From ddeb34ad76ecd7acd8e98e026baff48f51367690 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sat, 26 Sep 2026 11:13:52 +0000 Subject: [PATCH 1/8] Fabric store: publish entries at a mode derived from the store's readers, not the umask Every object and head the CI principal wrote to /opt/gunbc/fabric-storage was 0600 (srv1 2026-09-26), so the serve principal could not read them. The cause is not an explicit mode: gunbc_file_write_create_new opens its staging file with no mode, and the fleet-converge steps that write the store run `umask 077` in their credential prelude in the same shell as gunbc. - extdeps.filesystem: Filesystem.WriteCreateNewWithMode; the one canonical create-new realization takes Option and sets the bits on the staged inode before the hard_link publishes it. None is today's behaviour exactly. - gunbc.managed_directory: managed_directory_entry_mode derives the entries' mode per (writer, reader) pair from the same dependents as the directory mode, and REFUSES other-read when the directory grants other-execute. - The fabric placement derives 0444 (briansrls reads ghrunner-written entries through other; neither area grants o+x), both writers bind it, and the CAS store gains DeclaredModeCreateOnly for heads. - The srv1 area ensure repairs existing entries: unprivileged find -user ghrunner -exec chmod u+r,g+r,o+r, additive and idempotent. Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/extdeps/access/posix.dag | 10 ++ dag/extdeps/filesystem/filesystem_io.dag | 17 +++ .../filesystem_rust_realization.dag | 2 +- dag/extdeps/tools/findutils.dag | 9 ++ dag/gunbc/durable_cas_file_store.dag | 17 +++ dag/gunbc/fabric/fabric_storage_client.dag | 17 ++- .../fabric/fabric_storage_file_store.dag | 40 ++++- dag/gunbc/fabric/fabric_storage_placement.dag | 70 ++++++++- dag/gunbc/fabric/fabric_storage_serve.dag | 10 +- .../fabric/fabric_writer_identity_observe.dag | 3 +- dag/gunbc/live_deploy/emit.dag | 34 ++++- dag/gunbc/managed_directory.dag | 77 ++++++++++ ...ic_storage_file_store_wet_witness_test.dag | 46 +++++- dag/test/claim/live_deploy/emit_test.dag | 27 +++- .../claim/managed_directory_witness_test.dag | 72 +++++++++ src/v1/05_emit.dag | 28 +++- src/v1/05_emit_rust.dag | 9 +- .../extdeps_filesystem_rust_realization.rs | 2 +- .../src/gunbc_file_transport_generated.rs | 25 ++- src/v1/stage0/src/v1_compiler_emit.rs | 67 ++++++++- src/v1/stage0/src/v1_compiler_emit_rust.rs | 15 +- src/v1/stage0/src/v1_interpreter.rs | 142 ++++++++++++++++-- src/v1/stage0/src/v1_rt.rs | 25 ++- 23 files changed, 719 insertions(+), 45 deletions(-) diff --git a/dag/extdeps/access/posix.dag b/dag/extdeps/access/posix.dag index 1cca0a3dc9c..21227b7ce8c 100644 --- a/dag/extdeps/access/posix.dag +++ b/dag/extdeps/access/posix.dag @@ -131,6 +131,16 @@ fn file_mode_octal(mode: FileMode) -> String { ], "") } +// The same mode as the NUMBER the kernel takes (chmod(2), open(2) mode argument): the four octal +// digits above weighted 8^3..8^0. file_mode_octal is the spelling for an argv; this is the value for +// an operation input, so neither consumer re-derives the digit layout. +fn file_mode_bits(mode: FileMode) -> Int { + file_mode_special_octal_digit(mode: mode) * 512 + + permission_bits_octal_digit(bits: mode.owner) * 64 + + permission_bits_octal_digit(bits: mode.group) * 8 + + permission_bits_octal_digit(bits: mode.other) +} + // THE SAME RELATION READ BACKWARD, which is why it lives here and not at the call site that wanted // it. file_mode_octal above renders a FileMode as chmod's numeric spelling; this reads that // spelling back. One grammar, two directions (DESIGN section 4) -- a parser authored beside a diff --git a/dag/extdeps/filesystem/filesystem_io.dag b/dag/extdeps/filesystem/filesystem_io.dag index 1ce06073810..8d71e0c0ee9 100644 --- a/dag/extdeps/filesystem/filesystem_io.dag +++ b/dag/extdeps/filesystem/filesystem_io.dag @@ -704,6 +704,23 @@ service Filesystem { transport file { path: "{path}", verb: "write_create_new" } } + // WriteCreateNew with the PUBLISHED MODE DECLARED rather than left to the process umask. The + // canonical realization (extdeps.filesystem.rust_realization) sets these bits on the staged inode + // before the hard_link publishes it, so the name never appears at any other mode. `mode` is the + // permission-bit value of a modeled extdeps.access.posix FileMode (file_mode_bits), never a + // literal; a value outside 0..=0o7777 refuses before anything is created. + operation WriteCreateNewWithMode { + input { path: String, content: String, mode: Int } + output { + success: Bool from "write_success" + bytes_written: Int from "bytes_written" + path: String from "path" + error: String from "error" + error_kind: String from "error_kind" + } + transport file { path: "{path}", verb: "write_create_new_with_mode" } + } + operation Read { input { path: String } output { diff --git a/dag/extdeps/filesystem/filesystem_rust_realization.dag b/dag/extdeps/filesystem/filesystem_rust_realization.dag index db913597472..4a87f4526cd 100644 --- a/dag/extdeps/filesystem/filesystem_rust_realization.dag +++ b/dag/extdeps/filesystem/filesystem_rust_realization.dag @@ -123,7 +123,7 @@ fn rust_file_create_staging_candidate_attempt_limit_def() -> String { ) } -data rust_file_write_create_new_fn_def: String = "fn gunbc_file_write_create_new(file_path: &str, content: &[u8]) -> std::io::Result<()> {\n use std::io::Write;\n static GUNBC_CREATE_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);\n let mut attempted: u32 = 0;\n let (mut staged, staging_path) = loop {\n if attempted >= GUNBC_CREATE_STAGING_CANDIDATE_ATTEMPT_LIMIT {\n return Err(std::io::Error::other(format!(\n \"gunbc create-new: StagingCandidateBudgetExhausted attempted={} limit={}\",\n attempted, GUNBC_CREATE_STAGING_CANDIDATE_ATTEMPT_LIMIT\n )));\n }\n attempted += 1;\n let seq = GUNBC_CREATE_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);\n let candidate = format!(\"{}.gunbc-create-{}-{}\", file_path, std::process::id(), seq);\n match std::fs::OpenOptions::new()\n .write(true)\n .create_new(true)\n .open(&candidate)\n {\n Ok(file) => break (file, candidate),\n Err(occupied) if occupied.kind() == std::io::ErrorKind::AlreadyExists => continue,\n Err(host) => return Err(host),\n }\n };\n if let Err(staging_err) = staged.write_all(content) {\n let _ = std::fs::remove_file(&staging_path);\n return Err(staging_err);\n }\n if let Err(sync_err) = staged.sync_all() {\n let _ = std::fs::remove_file(&staging_path);\n return Err(sync_err);\n }\n drop(staged);\n let published = std::fs::hard_link(&staging_path, file_path);\n let _ = std::fs::remove_file(&staging_path);\n published\n}\n" +data rust_file_write_create_new_fn_def: String = "fn gunbc_file_write_create_new(\n file_path: &str,\n content: &[u8],\n declared_mode: Option,\n) -> std::io::Result<()> {\n use std::io::Write;\n static GUNBC_CREATE_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);\n let mut attempted: u32 = 0;\n let (mut staged, staging_path) = loop {\n if attempted >= GUNBC_CREATE_STAGING_CANDIDATE_ATTEMPT_LIMIT {\n return Err(std::io::Error::other(format!(\n \"gunbc create-new: StagingCandidateBudgetExhausted attempted={} limit={}\",\n attempted, GUNBC_CREATE_STAGING_CANDIDATE_ATTEMPT_LIMIT\n )));\n }\n attempted += 1;\n let seq = GUNBC_CREATE_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);\n let candidate = format!(\"{}.gunbc-create-{}-{}\", file_path, std::process::id(), seq);\n match std::fs::OpenOptions::new()\n .write(true)\n .create_new(true)\n .open(&candidate)\n {\n Ok(file) => break (file, candidate),\n Err(occupied) if occupied.kind() == std::io::ErrorKind::AlreadyExists => continue,\n Err(host) => return Err(host),\n }\n };\n if let Some(declared) = declared_mode {\n #[cfg(unix)]\n let applied = {\n use std::os::unix::fs::PermissionsExt;\n staged.set_permissions(std::fs::Permissions::from_mode(declared))\n };\n #[cfg(not(unix))]\n let applied: std::io::Result<()> = Err(std::io::Error::new(\n std::io::ErrorKind::Unsupported,\n format!(\n \"gunbc create-new: declared mode {} is unavailable on this platform\",\n declared\n ),\n ));\n if let Err(mode_err) = applied {\n let _ = std::fs::remove_file(&staging_path);\n return Err(mode_err);\n }\n }\n if let Err(staging_err) = staged.write_all(content) {\n let _ = std::fs::remove_file(&staging_path);\n return Err(staging_err);\n }\n if let Err(sync_err) = staged.sync_all() {\n let _ = std::fs::remove_file(&staging_path);\n return Err(sync_err);\n }\n drop(staged);\n let published = std::fs::hard_link(&staging_path, file_path);\n let _ = std::fs::remove_file(&staging_path);\n published\n}\n" // THE SEED'S COPY, AS A COMMITTED GENERATED ARTIFACT. // diff --git a/dag/extdeps/tools/findutils.dag b/dag/extdeps/tools/findutils.dag index 7f8938f2c72..fbd5e9ad17f 100644 --- a/dag/extdeps/tools/findutils.dag +++ b/dag/extdeps/tools/findutils.dag @@ -65,6 +65,15 @@ fn find_symlink_target_glob_command( ) } +// RUN ONE COMMAND OVER THE REGULAR FILES DIRECTLY IN A DIRECTORY THAT ONE USER OWNS. -maxdepth 1 +// keeps it to that directory, -type f excludes symlinks (find does not follow them by default), and +// -user narrows it to entries the invoking principal can act on, so an entry another principal owns +// is never handed to a command that would refuse it. `-exec ... {} +` batches the paths into as few +// invocations as ARG_MAX admits; find exits nonzero when any invocation does. +fn find_owned_regular_files_exec_argv(dir: String, owner: String, command: List) -> List { + concat(concat([find_program as String, dir, "-maxdepth", "1", "-type", "f", "-user", owner, "-exec"], command), ["{}", "+"]) +} + // THE REGULAR FILES UNDER A ROOT, ONE ` ` LINE EACH (find(1) -printf: %s is the // size in bytes, %P the path with the starting point removed). A plain argv: no shell, and find's own // status is the one the caller reads. diff --git a/dag/gunbc/durable_cas_file_store.dag b/dag/gunbc/durable_cas_file_store.dag index a062ad0804a..35f23e3a20c 100644 --- a/dag/gunbc/durable_cas_file_store.dag +++ b/dag/gunbc/durable_cas_file_store.dag @@ -7,6 +7,7 @@ import std.list { distinct_by_key } import std.content_hash { ContentHash, content_hash_of_value, compare_content_hash, ContentHashEqual, ContentHashDifferent, ContentHashCrossFamilyIncomparable } import std.durable_compare_and_set { CasAttempt, cas_attempt, CasOutcome, CasCommitted, CasPreconditionFailed, CasStoreRefused, CasExpectation, ExpectSlotAbsent, ExpectSlotGeneration, CasGeneration, CasSlotVersion, CasReadableAbsent, CasReadablePresent, CasSlotObservation, CasObservedReadable, CasObservedUnreadable, CasUnreadableMalformed, CasUnreadableReadRefused, CasStoreFailure, CasSlotObservationRefused, CasGenerationPublicationRefused, cas_generation_exhausted, cas_generation_first, cas_generation_count, cas_generation_successor, CasSuccessorGeneration, CasSuccessorExhausted } import std.checked_arithmetic { int_inclusive_max } +import extdeps.access.posix { FileMode, file_mode_bits } import extdeps.filesystem.filesystem_io { Filesystem, FilesystemCreateNew, FilesystemCreated, FilesystemCreateTargetOccupied, FilesystemCreateRefused, FilesystemCreateKindUnrecognized, @@ -563,9 +564,16 @@ data cas_key_not_slot_addressable_detail: NonEmptyStr = "key is not slot-address // coproduct collapses to the single admitted publication operation. It is retired by that and by // nothing else; in particular it is NOT retired by a failure-atomic mode-carrying write, which would // be a different and larger capability than this population needs. +// DeclaredModeCreateOnly is DefaultAccessCreateOnly's publication -- staged, synced, hard-linked, +// so equally failure-atomic -- with the published mode declared by the store's own model rather than +// left to whatever umask the writing process runs under. A store with more than one declared reader +// needs it: under a `umask 077` step every generation publishes 0600 and the other reader is locked +// out (gunbc.fabric_storage_placement). It is not a confidentiality binding; that remains +// OwnerOnlyCreateOnly's, with its declared limitation above. type CasGenerationPublication = DefaultAccessCreateOnly | OwnerOnlyCreateOnly + | DeclaredModeCreateOnly { mode: FileMode } // THE ACTUATOR TAKES AN ADMITTED PUBLICATION, NOT A PATH AND SOME BYTES. // @@ -615,6 +623,15 @@ fn cas_commit_at(admitted: AdmittedCasPublication) -> CasOutcome { post: observe_cas_slot_state(root: root, key: attempt.key), ) } + DeclaredModeCreateOnly { mode } => { + let write = Filesystem.WriteCreateNewWithMode(path: path, content: content, mode: file_mode_bits(mode: mode)) + cas_outcome_from_create( + attempt: attempt, + target: target, + created: filesystem_create_new(path: write.path, success: write.success, error: write.error, error_kind: write.error_kind), + post: observe_cas_slot_state(root: root, key: attempt.key), + ) + } OwnerOnlyCreateOnly => { let write = Filesystem.WriteOwnerOnly(path: path, content: content) if write.success { diff --git a/dag/gunbc/fabric/fabric_storage_client.dag b/dag/gunbc/fabric/fabric_storage_client.dag index ee475e63399..09e7fd5e8b4 100644 --- a/dag/gunbc/fabric/fabric_storage_client.dag +++ b/dag/gunbc/fabric/fabric_storage_client.dag @@ -18,9 +18,9 @@ import gunbc.fabric_storage_wire { FabricIdentityRead, FabricIdentityUnproxied, FabricIdentityReadRefused, fabric_storage_unwire_identity, } import gunbc.fabric_storage_file_store { - FabricStorageFileRoot, fabric_storage_file_root, fabric_storage_file_head, fabric_storage_file_put, fabric_storage_file_advance, fabric_storage_file_closure, + FabricStorageFileRoot, fabric_storage_file_head, fabric_storage_file_put, fabric_storage_file_advance, fabric_storage_file_closure, } -import gunbc.fabric_storage_placement { FabricStoragePlacement, FabricStoragePlaced, FabricStorageUnplaced } +import gunbc.fabric_storage_placement { FabricStoragePlacement, FabricStoragePlaced, FabricStorageUnplaced, fabric_storage_placed_file_root, FabricStoragePlacedRootReady, FabricStoragePlacedRootRefused } import product.placement_supply { HostIdentity, host_identity_eq } // THE FABRIC DB AS A CALLER REACHES IT: std.fabric_storage's four operations, each bound to one of two @@ -44,12 +44,18 @@ type FabricStorageBinding = FabricStorageLocalFiles { root: FabricStorageFileRoot } | FabricStorageServed { endpoint: NonEmptyStr } | FabricStorageBindingUnplaced + | FabricStorageBindingRefused { detail: NonEmptyStr } fn fabric_storage_binding_for(placement: FabricStoragePlacement, executor: HostIdentity) -> FabricStorageBinding { match placement { FabricStorageUnplaced => FabricStorageBindingUnplaced FabricStoragePlaced { host: h, store_root: r, endpoint: e } => - if host_identity_eq(a: h, b: executor) { FabricStorageLocalFiles { root: fabric_storage_file_root(root: r) } } else { FabricStorageServed { endpoint: e } } + if host_identity_eq(a: h, b: executor) { + match fabric_storage_placed_file_root(store_root: r) { + FabricStoragePlacedRootReady { root } => FabricStorageLocalFiles { root: root } + FabricStoragePlacedRootRefused { detail } => FabricStorageBindingRefused { detail: detail } + } + } else { FabricStorageServed { endpoint: e } } } } @@ -108,6 +114,7 @@ fn expectation_request_words(expected: FabricHeadExpectation) -> String { fn fabric_storage_head(binding: FabricStorageBinding, name: NonEmptyStr) -> FabricHeadRead { match binding { FabricStorageBindingUnplaced => FabricHeadReadRefused { fault: FabricStoreUnreachable { detail: fabric_storage_unplaced_detail } } + FabricStorageBindingRefused { detail: d } => FabricHeadReadRefused { fault: FabricStoreUnreachable { detail: d } } FabricStorageLocalFiles { root: r } => fabric_storage_file_head(root: r, name: name) FabricStorageServed { endpoint: e } => match served_post(endpoint: e, operation: "head", request: name as String) { @@ -120,6 +127,7 @@ fn fabric_storage_head(binding: FabricStorageBinding, name: NonEmptyStr) -> Fabr fn fabric_storage_put(binding: FabricStorageBinding, object: FabricObject) -> FabricPut { match binding { FabricStorageBindingUnplaced => FabricPutRefused { fault: FabricStoreUnreachable { detail: fabric_storage_unplaced_detail } } + FabricStorageBindingRefused { detail: d } => FabricPutRefused { fault: FabricStoreUnreachable { detail: d } } FabricStorageLocalFiles { root: r } => fabric_storage_file_put(root: r, object: object) FabricStorageServed { endpoint: e } => match served_post(endpoint: e, operation: "put", request: fabric_object_preimage(object: object) as String) { @@ -132,6 +140,7 @@ fn fabric_storage_put(binding: FabricStorageBinding, object: FabricObject) -> Fa fn fabric_storage_advance(binding: FabricStorageBinding, name: NonEmptyStr, expected: FabricHeadExpectation, target: FabricObjectRef) -> FabricHeadAdvance { match binding { FabricStorageBindingUnplaced => FabricHeadAdvanceRefused { fault: FabricStoreUnreachable { detail: fabric_storage_unplaced_detail } } + FabricStorageBindingRefused { detail: d } => FabricHeadAdvanceRefused { fault: FabricStoreUnreachable { detail: d } } FabricStorageLocalFiles { root: r } => fabric_storage_file_advance(root: r, name: name, expected: expected, target: target) FabricStorageServed { endpoint: e } => match served_post(endpoint: e, operation: "advance", request: join([name as String, " ", expectation_request_words(expected: expected), " ", fabric_object_ref_wire(object: target) as String], "")) { @@ -144,6 +153,7 @@ fn fabric_storage_advance(binding: FabricStorageBinding, name: NonEmptyStr, expe fn fabric_storage_closure(binding: FabricStorageBinding, name: NonEmptyStr, bound: Nat) -> FabricClosureRead { match binding { FabricStorageBindingUnplaced => FabricClosureRefused { fault: FabricStoreUnreachable { detail: fabric_storage_unplaced_detail } } + FabricStorageBindingRefused { detail: d } => FabricClosureRefused { fault: FabricStoreUnreachable { detail: d } } FabricStorageLocalFiles { root: r } => fabric_storage_file_closure(root: r, name: name, bound: bound) FabricStorageServed { endpoint: e } => match served_post(endpoint: e, operation: "closure", request: join([name as String, " ", to_string(bound)], "")) { @@ -161,6 +171,7 @@ fn fabric_storage_closure(binding: FabricStorageBinding, name: NonEmptyStr, boun fn fabric_storage_presented_identity(binding: FabricStorageBinding) -> FabricIdentityRead { match binding { FabricStorageBindingUnplaced => FabricIdentityReadRefused { fault: FabricStoreUnreachable { detail: fabric_storage_unplaced_detail } } + FabricStorageBindingRefused { detail: d } => FabricIdentityReadRefused { fault: FabricStoreUnreachable { detail: d } } FabricStorageLocalFiles { root: _ } => FabricIdentityUnproxied FabricStorageServed { endpoint: e } => match served_post(endpoint: e, operation: "identity", request: "") { diff --git a/dag/gunbc/fabric/fabric_storage_file_store.dag b/dag/gunbc/fabric/fabric_storage_file_store.dag index 9db8e3bf309..2cf86809741 100644 --- a/dag/gunbc/fabric/fabric_storage_file_store.dag +++ b/dag/gunbc/fabric/fabric_storage_file_store.dag @@ -20,9 +20,10 @@ import std.fabric_storage { FabricClosureRead, FabricClosureBudgetExhausted, FabricClosureRefused, } import gunbc.durable_cas_file_store { - observe_cas_slot_state, file_compare_and_set, DefaultAccessCreateOnly, cas_key_is_slot_addressable, + observe_cas_slot_state, file_compare_and_set, DefaultAccessCreateOnly, DeclaredModeCreateOnly, CasGenerationPublication, cas_key_is_slot_addressable, admit_cas_attempt_for_derived_payload, DerivedPayloadAdmitted, DerivedPayloadKeyNotSlotAddressable, } +import extdeps.access.posix { FileMode, file_mode_bits } import extdeps.filesystem.filesystem_io { Filesystem, FilesystemCreateNew, FilesystemCreated, FilesystemCreateTargetOccupied, FilesystemCreateRefused, FilesystemCreateKindUnrecognized, @@ -48,15 +49,47 @@ import extdeps.filesystem.filesystem_io { // that defines the store's identity is a convergence decision, made by fabric_storage_file_root_ensure, // not a side effect of one write. +// THE ENTRY MODE IS THE PLACEMENT'S FACT, NOT THIS REALIZATION'S. Who reads the store's files is +// decided by who the placement declares as its writers and readers +// (gunbc.fabric_storage_placement fabric_storage_placed_file_root); a root built with no declared +// mode publishes at the writing process's umask, which is what a single-principal fixture wants and +// what a store with two principals must not rely on. type FabricStorageFileRoot { objects: NonEmptyStr heads: NonEmptyStr + entry_mode: FileMode? } fn fabric_storage_file_root(root: NonEmptyStr) -> FabricStorageFileRoot { + fabric_storage_file_root_with(root: root, entry_mode: none) +} + +fn fabric_storage_file_root_with(root: NonEmptyStr, entry_mode: FileMode?) -> FabricStorageFileRoot { FabricStorageFileRoot { objects: join([root as String, "/objects"], "") as NonEmptyStr, heads: join([root as String, "/heads"], "") as NonEmptyStr, + entry_mode: entry_mode, + } +} + +fn fabric_storage_head_publication(root: FabricStorageFileRoot) -> CasGenerationPublication { + match root.entry_mode { + Absent => DefaultAccessCreateOnly + Present { value: m } => DeclaredModeCreateOnly { mode: m } + } +} + +// ONE CREATE-NEW PUBLICATION EITHER WAY; the declared mode only decides the published bits. +fn fabric_storage_object_create(root: FabricStorageFileRoot, path: String, content: String) -> FilesystemCreateNew { + match root.entry_mode { + Absent => { + let w = Filesystem.WriteCreateNew(path: path, content: content) + filesystem_create_new(path: w.path, success: w.success, error: w.error, error_kind: w.error_kind) + } + Present { value: m } => { + let w = Filesystem.WriteCreateNewWithMode(path: path, content: content, mode: file_mode_bits(mode: m)) + filesystem_create_new(path: w.path, success: w.success, error: w.error, error_kind: w.error_kind) + } } } @@ -141,8 +174,7 @@ fn fabric_storage_file_put(root: FabricStorageFileRoot, object: FabricObject) -> let a = fabric_object_addressed(object: object) let r = a.ref let path = fabric_storage_object_path(root: root, object: r) - let write = Filesystem.WriteCreateNew(path: path, content: fabric_object_preimage(object: a.object) as String) - let created = filesystem_create_new(path: write.path, success: write.success, error: write.error, error_kind: write.error_kind) + let created = fabric_storage_object_create(root: root, path: path, content: fabric_object_preimage(object: a.object) as String) match created { FilesystemCreateTargetOccupied { path: _ } => fabric_storage_put_from_create(addressed: a, created: created, existing: fabric_storage_file_get(root: root, object: r)) FilesystemCreated { path: _ } => fabric_storage_put_from_create(addressed: a, created: created, existing: FabricObjectGetRefused { fault: FabricObjectMissing { object: r } }) @@ -205,7 +237,7 @@ fn fabric_storage_file_advance(root: FabricStorageFileRoot, name: NonEmptyStr, e match admit_cas_attempt_for_derived_payload(key: name, expected: fabric_storage_cas_expectation(reading: observed), proposed: fabric_object_ref_wire(object: target)) { DerivedPayloadKeyNotSlotAddressable { key: k } => FabricHeadAdvanceRefused { fault: FabricHeadNameRefused { name: k } } DerivedPayloadAdmitted { verified: v } => - match file_compare_and_set(root: root.heads, publication: DefaultAccessCreateOnly, verified: v) { + match file_compare_and_set(root: root.heads, publication: fabric_storage_head_publication(root: root), verified: v) { CasCommitted { committed: c } => FabricHeadAdvanced { object: target, generation: c.generation } CasStoreRefused { cause: c } => FabricHeadAdvanceRefused { fault: FabricStoreRefused { cause: c } } CasPreconditionFailed { expected: _, observed: _ } => diff --git a/dag/gunbc/fabric/fabric_storage_placement.dag b/dag/gunbc/fabric/fabric_storage_placement.dag index dc23ff67072..6710f3feab3 100644 --- a/dag/gunbc/fabric/fabric_storage_placement.dag +++ b/dag/gunbc/fabric/fabric_storage_placement.dag @@ -6,9 +6,13 @@ import gunbc.fleet_intent_network { operator_host_srv1, fleet_intent_network } import gunbc.roadmap_dashboard_instance { HostDashboardInstance, dashboard_instance_fabric_storage_root, srv1_live_dashboard_instance } import std.effect_grant { Read, Write, Execute } import gunbc.ownership { Ensured } -import gunbc.managed_directory { ManagedDirectory, DirectoryDependent, ManagedDirectoryAdmission, managed_directory_admit, srv1_dashboard_service_principal } +import gunbc.managed_directory { + ManagedDirectory, DirectoryDependent, ManagedDirectoryAdmission, managed_directory_admit, srv1_dashboard_service_principal, + EntriesUnmanaged, EntriesReadableByDependents, managed_directory_entry_mode, ManagedEntryModeDerived, ManagedEntryModeRefused, +} import gunbc.fleet_posix_accounts { fleet_posix_ci_runner_user } -import gunbc.fabric_storage_file_store { fabric_storage_file_root } +import gunbc.fabric_storage_file_store { FabricStorageFileRoot, fabric_storage_file_root, fabric_storage_file_root_with } +import extdeps.access.posix { FileMode, file_mode_bits } // WHERE THE FABRIC DB IS: one host holds the store's files and answers the endpoint. This is the // POLICY fact of the three (DESIGN 3) -- std.fabric_storage is the interface, the file store and the @@ -88,7 +92,23 @@ fn fabric_storage_placement() -> FabricStoragePlacement { // generation is a staged sibling hard-linked into its name (gunbc.durable_cas_file_store // DefaultAccessCreateOnly). So each writer needs Write and Execute on objects/ and heads/ and Read to // list and fetch, and only Execute on the root to reach them; no file is ever rewritten, so no file -// needs group write, and the umask's 0644 already lets each writer read the other's entries. +// needs group write. +// +// THE ENTRIES' MODE WAS LEFT TO THE UMASK, AND THE UMASK WAS NOT 022. The first cut of this row +// assumed each writer's umask would publish 0644. The CI job publishes from fleet-converge steps whose +// credential preludes run `umask 077` in the same shell as gunbc, so every object and head it wrote +// was 0600 and the service principal could not read them (observed on srv1 2026-09-26). The entry +// mode is therefore derived here from the same dependents (gunbc.managed_directory +// managed_directory_entry_mode) and handed to the store as a declared publication mode. +// +// IT DERIVES OTHER-READ, AND THAT IS CORRECT RATHER THAN LOOSE. An entry the CI principal creates is +// ghrunner:ghrunner (setgid), and the service principal is neither its owner nor a member of +// ghrunner's group (srv1 2026-09-26: briansrls's groups do not include ghrunner) -- so it reads that +// entry through OTHER or not at all. Adding the service principal to ghrunner's group would widen it +// to everything that group owns on the host, a privilege change for a store-local need. Other-read +// is instead admitted because both areas deny other-execute: no account outside owner and group can +// traverse to an entry, so o+r reaches exactly the one declared reader that needs it. The derivation +// refuses if an area ever granted o+x. Nothing is writable after publication: 0444. // // THE GROUP IS THE CI JOB PRINCIPAL'S OWN PRIMARY GROUP, not a new shared group, and that is derived // rather than preferred: with one owner and exactly one other writer, the smallest group that admits @@ -110,6 +130,50 @@ fn fabric_storage_directory(member: NonEmptyStr, path: NonEmptyStr, writes: Bool DirectoryDependent { who: fleet_posix_ci_runner_user, needs: needs }, ], ownership: Ensured, + entries: if writes { EntriesReadableByDependents { repaired_by: fleet_posix_ci_runner_user } } else { EntriesUnmanaged }, + } +} + +// ONE ENTRY MODE FOR THE STORE, OR A REFUSAL. Both writable areas derive it from their own +// dependents; they must agree, because the store publishes objects and heads under one declared +// mode, and a disagreement means the two areas' readers differ -- a modeling change this function +// refuses to paper over by picking one. +type FabricStorageEntryMode + = FabricStorageEntryModeDerived { mode: FileMode } + | FabricStorageEntryModeRefused { detail: NonEmptyStr } + +fn fabric_storage_entry_mode_of(objects: ManagedDirectory, heads: ManagedDirectory) -> FabricStorageEntryMode { + match managed_directory_entry_mode(d: objects) { + ManagedEntryModeRefused { member, path: _, reason } => FabricStorageEntryModeRefused { detail: join([member as String, ": ", reason as String], "") as NonEmptyStr } + ManagedEntryModeDerived { mode: o } => + match managed_directory_entry_mode(d: heads) { + ManagedEntryModeRefused { member, path: _, reason } => FabricStorageEntryModeRefused { detail: join([member as String, ": ", reason as String], "") as NonEmptyStr } + ManagedEntryModeDerived { mode: h } => + if file_mode_bits(mode: o) == file_mode_bits(mode: h) { FabricStorageEntryModeDerived { mode: o } } + else { FabricStorageEntryModeRefused { detail: "the objects and heads areas derive different entry modes" as NonEmptyStr } } + } + } +} + +fn fabric_storage_entry_mode() -> FabricStorageEntryMode { + let areas = fabric_storage_file_root(root: fabric_storage_store_root()) + fabric_storage_entry_mode_of( + objects: fabric_storage_directory(member: "fabric-storage-objects", path: areas.objects, writes: true), + heads: fabric_storage_directory(member: "fabric-storage-heads", path: areas.heads, writes: true), + ) +} + +// THE PLACED STORE'S FILES, WITH THE DERIVED ENTRY MODE, which is what every production writer on the +// placed host binds -- the served handler and the in-process client alike -- so an entry either +// writer publishes is readable by the other. +type FabricStoragePlacedRoot + = FabricStoragePlacedRootReady { root: FabricStorageFileRoot } + | FabricStoragePlacedRootRefused { detail: NonEmptyStr } + +fn fabric_storage_placed_file_root(store_root: NonEmptyStr) -> FabricStoragePlacedRoot { + match fabric_storage_entry_mode() { + FabricStorageEntryModeRefused { detail } => FabricStoragePlacedRootRefused { detail: detail } + FabricStorageEntryModeDerived { mode } => FabricStoragePlacedRootReady { root: fabric_storage_file_root_with(root: store_root, entry_mode: Present { value: mode }) } } } diff --git a/dag/gunbc/fabric/fabric_storage_serve.dag b/dag/gunbc/fabric/fabric_storage_serve.dag index 1fd55538d04..6b8bdb56f74 100644 --- a/dag/gunbc/fabric/fabric_storage_serve.dag +++ b/dag/gunbc/fabric/fabric_storage_serve.dag @@ -8,11 +8,11 @@ import std.fabric_storage { FabricPutRefused, FabricHeadReadRefused, FabricHeadAdvanceRefused, FabricClosureRefused, } import gunbc.fabric_storage_file_store { - FabricStorageFileRoot, fabric_storage_file_root, fabric_storage_file_head, fabric_storage_file_put, fabric_storage_file_advance, fabric_storage_file_closure, + FabricStorageFileRoot, fabric_storage_file_head, fabric_storage_file_put, fabric_storage_file_advance, fabric_storage_file_closure, } import gunbc.fabric_storage_wire { fabric_storage_wire_head, fabric_storage_wire_put, fabric_storage_wire_advance, fabric_storage_wire_closure, fabric_storage_wire_identity } import extdeps.tailscale.identity { tailnet_identity_of } -import gunbc.fabric_storage_placement { fabric_storage_placement, FabricStoragePlaced, FabricStorageUnplaced, fabric_storage_route_prefix } +import gunbc.fabric_storage_placement { fabric_storage_placement, FabricStoragePlaced, FabricStorageUnplaced, fabric_storage_route_prefix, fabric_storage_placed_file_root, FabricStoragePlacedRootReady, FabricStoragePlacedRootRefused } // THE FABRIC DB ENDPOINT: the store side of the served handler bound to std.fabric_storage. One // `gunbc serve` process on the placed host, compiled once, answers each request by running the @@ -170,6 +170,10 @@ fn fabric_storage_serve_handle_over(root: FabricStorageFileRoot, method: String, fn fabric_storage_serve_handle(method: String, path: String, body: String, tailscale_identity: String, release_revision: CommitSha) -> ServeWireResponse { match fabric_storage_placement() { FabricStorageUnplaced => text_response(status: 503, body: "fabric_storage/1 the fabric DB is unplaced\n") - FabricStoragePlaced { host: _, store_root: r, endpoint: _ } => fabric_storage_serve_handle_over(root: fabric_storage_file_root(root: r), method: method, path: path, body: body, tailscale_identity: tailscale_identity) + FabricStoragePlaced { host: _, store_root: r, endpoint: _ } => + match fabric_storage_placed_file_root(store_root: r) { + FabricStoragePlacedRootRefused { detail } => text_response(status: 503, body: join(["fabric_storage/1 the store's entry mode is refused: ", detail as String, "\n"], "")) + FabricStoragePlacedRootReady { root } => fabric_storage_serve_handle_over(root: root, method: method, path: path, body: body, tailscale_identity: tailscale_identity) + } } } diff --git a/dag/gunbc/fabric/fabric_writer_identity_observe.dag b/dag/gunbc/fabric/fabric_writer_identity_observe.dag index 914a1a6fa21..fd23bb09e26 100644 --- a/dag/gunbc/fabric/fabric_writer_identity_observe.dag +++ b/dag/gunbc/fabric/fabric_writer_identity_observe.dag @@ -7,7 +7,7 @@ import extdeps.filesystem.filesystem_io { Filesystem } import extdeps.tailscale.identity { TailnetIdentity, TailnetUser, TailnetIdentityAbsent } import product.placement_supply { HostIdentity, host_identity_eq } import gunbc.fabric_storage_placement { fabric_storage_placement, FabricStoragePlaced, FabricStorageUnplaced } -import gunbc.fabric_storage_client { FabricStorageBinding, FabricStorageLocalFiles, FabricStorageServed, FabricStorageBindingUnplaced, fabric_storage_binding_for, fabric_storage_presented_identity } +import gunbc.fabric_storage_client { FabricStorageBinding, FabricStorageLocalFiles, FabricStorageServed, FabricStorageBindingUnplaced, FabricStorageBindingRefused, fabric_storage_binding_for, fabric_storage_presented_identity } import gunbc.fabric_storage_wire { FabricIdentityRead, FabricIdentityPresented, FabricIdentityUnproxied, FabricIdentityReadRefused } import gunbc.hostname_read { hostname_short_read, HostnameShortCaptured, HostnameShortCaptureRefused } import gunbc.host_effect { LocalShell } @@ -52,6 +52,7 @@ fn binding_words(binding: FabricStorageBinding) -> String { FabricStorageLocalFiles { root: _ } => "local" FabricStorageServed { endpoint: e } => join(["served ", e as String], "") FabricStorageBindingUnplaced => "unplaced" + FabricStorageBindingRefused { detail: _ } => "refused" } } diff --git a/dag/gunbc/live_deploy/emit.dag b/dag/gunbc/live_deploy/emit.dag index d2aa35866fb..be873d08d5c 100644 --- a/dag/gunbc/live_deploy/emit.dag +++ b/dag/gunbc/live_deploy/emit.dag @@ -13,7 +13,11 @@ import gunbc.managed_directory { ManagedDirectoryAdmission, ManagedDirectoryAdmitted, ManagedDirectoryRefused, managed_directory_mode_octal, attempt_state_directory_at, + EntriesUnmanaged, EntriesReadableByDependents, + managed_directory_entry_mode, ManagedEntryModeDerived, ManagedEntryModeRefused, entry_mode_add_read_symbolic, } +import extdeps.tools.chmod { chmod_binary_path } +import extdeps.access.posix { PosixUser } import extdeps.tailscale.serve { tailscale_serve_enable_argv, tailscale_serve_off_argv, @@ -96,7 +100,7 @@ import gunbc.runner_microvm_slot_unit { microvm_controller_slice_unit_name, microvm_controller_slice_unit_path, microvm_controller_slice_unit_file, microvm_slot_controller_receipt_dir, } -import extdeps.tools.findutils { find_program, xargs_program } +import extdeps.tools.findutils { find_program, xargs_program, find_owned_regular_files_exec_argv } import extdeps.tools.gnu_coreutils { sort_program, cut_program, printf_program, install_program } import gunbc.live_deploy.slice_bounds { live_deploy_slice_memory_max, live_deploy_slice_memory_high, live_deploy_slice_memory_swap_max, @@ -1445,12 +1449,38 @@ fn ensure_managed_directory_command(admission: ManagedDirectoryAdmission) -> Str // dissolve-on = PosixUser (or a PosixGroup beside it) carries the group name, at which point this // projects it instead of reusing the user's. fn install_d_managed_command(dir: ManagedDirectory) -> String { - install_directory_owned_command( + let install = install_directory_owned_command( mode: managed_directory_mode_octal(d: dir) as NonEmptyStr, owner: dir.owner.name, group: dir.group_principal.name, path: dir.path as String, ) + match dir.entries { + EntriesUnmanaged => install + EntriesReadableByDependents { repaired_by } => join([install, " && ", managed_directory_entry_repair_command(dir: dir, repaired_by: repaired_by)], "") + } +} + +// THE ENTRIES' REPAIR RIDES THE SAME ENSURE AS THE DIRECTORY, so the next apply converges entries +// published before their mode was declared (the fabric store's CI-written 0600 objects and heads) +// with no one-off step. It adds exactly the derived read bits (chmod's additive symbolic form, so +// nothing anyone set is removed and a second run is a no-op), and it runs UNPRIVILEGED as the +// principal the apply runs as, over the entries that principal owns: those are the only ones it can +// chmod, and they are the ones the umask narrowed. A refused derivation lowers to the poison marker, +// never to a wider chmod. +data managed_directory_entry_mode_refused_poison: String = "__GUNBC_DEPLOY_REFUSED__ managed host directory entries NOT REPAIRED: gunbc.managed_directory managed_directory_entry_mode refused. member=" + +fn managed_directory_entry_repair_command(dir: ManagedDirectory, repaired_by: PosixUser) -> String { + match managed_directory_entry_mode(d: dir) { + ManagedEntryModeRefused { member, path, reason } => + join([managed_directory_entry_mode_refused_poison, member as String, " path=", path as String, " reason=", reason as String], "") + ManagedEntryModeDerived { mode } => + shell_command_text_of_argv(argv: find_owned_regular_files_exec_argv( + dir: dir.path as String, + owner: repaired_by.name as String, + command: [chmod_binary_path as String, entry_mode_add_read_symbolic(mode: mode)], + )) + } } // THE PUBLICATION PRINCIPAL EXISTS BEFORE ANY MEMBER NAMES IT. Creation is idempotent at the shell diff --git a/dag/gunbc/managed_directory.dag b/dag/gunbc/managed_directory.dag index 86b1d829b5b..3e146abd395 100644 --- a/dag/gunbc/managed_directory.dag +++ b/dag/gunbc/managed_directory.dag @@ -127,8 +127,20 @@ type ManagedDirectory { group_principal: PosixUser dependents: List ownership: Ownership + entries: ManagedDirectoryEntries } +// WHETHER THE DIRECTORY'S ENTRIES ARE PART OF WHAT IS MANAGED. Most managed directories are a mode on +// a path and nothing more, and for them the entries are the writer's business. A directory whose +// dependents READ EACH OTHER'S ENTRIES is different: an entry at a mode its declared readers cannot +// open is the directory failing its dependents exactly as a wrong directory mode would, so the entry +// mode is derived from the same dependents (managed_directory_entry_mode) and the ensure repairs the +// entries it can. `repaired_by` is the principal the ensure runs as; it can chmod only the entries it +// owns, so the repair touches exactly those and needs no elevation. +type ManagedDirectoryEntries + = EntriesUnmanaged + | EntriesReadableByDependents { repaired_by: PosixUser } + type DependentClass = OwnerClass | GroupClass | OtherClass fn dependent_class_eq(a: DependentClass, b: DependentClass) -> Bool { @@ -206,6 +218,70 @@ fn managed_directory_permissions(d: ManagedDirectory) -> FilePermissions { } } +// ── THE ENTRIES' MODE, DERIVED FROM THE SAME DEPENDENTS ────────────────────────────────────────── +// +// An entry is owned by the dependent that CREATED it, not by the directory's owner, and its group is +// the directory's group when setgid is set (managed_directory_permissions) and the creator's own +// group otherwise. So a reader's class is decided per (writer, reader) pair: the entry's owner when +// it is the writer, its group when its gid is the entry's group, and OTHER otherwise -- which is +// where a directory OWNER lands when another writer created the entry and the owner is not in that +// writer's group. Each class gets Read exactly when some such pair puts a declared reader in it. +// Nothing gets Write or Execute: these entries are create-only, never rewritten or run. +// +// OTHER-READ IS ADMITTED ONLY BEHIND A DIRECTORY THAT DENIES OTHER TRAVERSAL. A derived o+r reaches +// every account on the host that can reach the entry, and the entry's own directory is on every +// path to it, so when that directory grants no other-execute the o+r reaches exactly the declared +// reader that needed it (the directory's owner) and nobody else. If the directory ever granted o+x +// the same bit would publish the entries to every account; the derivation REFUSES rather than +// either widening or quietly tightening away a declared reader's access. +type ManagedEntryMode + = ManagedEntryModeDerived { mode: FileMode } + | ManagedEntryModeRefused { member: NonEmptyStr, path: FilePath, reason: NonEmptyStr } + +fn entry_class_of(d: ManagedDirectory, writer: PosixUser, reader: PosixUser) -> DependentClass { + let g = if d.group_principal.gid != d.owner.gid { d.group_principal.gid } else { writer.gid } + dependent_class_of(owner: writer, group_gid: g, who: reader) +} + +fn entry_class_reads(d: ManagedDirectory, cls: DependentClass) -> Bool { + let writers = filter(xs: d.dependents, predicate: dep => needs_verb(needs: dep.needs, v: Write)) + let readers = filter(xs: d.dependents, predicate: dep => needs_verb(needs: dep.needs, v: Read)) + any(writers, w => any(readers, r => dependent_class_eq(a: entry_class_of(d: d, writer: w.who, reader: r.who), b: cls))) +} + +fn entry_read_bits(d: ManagedDirectory, cls: DependentClass) -> PermissionBits { + PermissionBits { read: entry_class_reads(d: d, cls: cls), write: false, execute: false } +} + +fn managed_directory_entry_mode(d: ManagedDirectory) -> ManagedEntryMode { + let mode = FileMode { + owner: entry_read_bits(d: d, cls: OwnerClass), + group: entry_read_bits(d: d, cls: GroupClass), + other: entry_read_bits(d: d, cls: OtherClass), + setuid: false, + setgid: false, + sticky: false, + } + if mode.other.read && managed_directory_permissions(d: d).mode.other.execute { + ManagedEntryModeRefused { + member: d.member, + path: d.path, + reason: "a declared reader reaches these entries only through other-read, and the directory grants other-execute, so the same bit would publish every entry to every account" as NonEmptyStr, + } + } else { + ManagedEntryModeDerived { mode: mode } + } +} + +// chmod(1)'s additive symbolic spelling of the read bits: `u+r,g+r,o+r` for the classes that read. +// Additive, so a repair never removes a bit anyone set, and idempotent. +fn entry_mode_add_read_symbolic(mode: FileMode) -> String { + join(concat(concat( + if mode.owner.read { ["u+r"] } else { [] }, + if mode.group.read { ["g+r"] } else { [] }), + if mode.other.read { ["o+r"] } else { [] }), ",") +} + // ── ADMISSION: A WRITER THE OWNER AND GROUP DO NOT COVER REFUSES ───────────────────────────────── // // The derivation above is total: a dependent in the other class that needs Write gets o+w, because @@ -288,6 +364,7 @@ fn attempt_state_directory_at(path: FilePath) -> ManagedDirectory { DirectoryDependent { who: service, needs: [Read, Write, Execute] }, ], ownership: Owned, + entries: EntriesUnmanaged, } } diff --git a/dag/test/claim/fabric/fabric_storage_file_store_wet_witness_test.dag b/dag/test/claim/fabric/fabric_storage_file_store_wet_witness_test.dag index d2216292b37..6b0b88f2e59 100644 --- a/dag/test/claim/fabric/fabric_storage_file_store_wet_witness_test.dag +++ b/dag/test/claim/fabric/fabric_storage_file_store_wet_witness_test.dag @@ -1,6 +1,9 @@ module test.claim.fabric.fabric_storage_file_store_wet_witness import extdeps.shell +import extdeps.access.posix { FileMode, PermissionBits, file_mode_octal } +import extdeps.tools.coreutils_stat { coreutils_stat_path_mode, PathModeObserved, PathModeUnobservable } +import gunbc.durable_cas_file_store { cas_file_slot_path } import extdeps.filesystem.filesystem_io { Filesystem } import std.types { Bool, NonEmptyStr, String } import std.durable_compare_and_set { cas_generation_count } @@ -19,7 +22,7 @@ import extdeps.tailscale.identity { TailnetUser, TailnetIdentityAbsent } import gunbc.fabric_storage_wire { fabric_storage_unwire_advance } import gunbc.fabric_storage_client { served_reply_from, ServedAnswered, ServedFault } import gunbc.fabric_storage_file_store { - FabricStorageFileRoot, fabric_storage_file_root, fabric_storage_file_root_ensure, FabricStorageRootReady, FabricStorageRootRefused, + FabricStorageFileRoot, fabric_storage_file_root, fabric_storage_file_root_with, fabric_storage_file_root_ensure, FabricStorageRootReady, FabricStorageRootRefused, fabric_storage_file_put, fabric_storage_file_get, fabric_storage_file_head, fabric_storage_file_advance, fabric_storage_file_closure, fabric_storage_object_path, } @@ -189,3 +192,44 @@ test fn the_identity_route_echoes_the_presented_login_and_touches_no_head_by_rea let untouched = match fabric_storage_file_head(root: s, name: "p") { FabricHeadObserved { reading: r } => match r { FabricHeadAbsent => true FabricHeadAt { object: _, generation: _ } => false } FabricHeadReadRefused { fault: _ } => false } echoed && absent && untouched } + +// THE DECLARED ENTRY MODE REACHES BOTH PUBLICATIONS. A store rooted with a declared mode publishes +// its object (Filesystem.WriteCreateNewWithMode) and its head generation (the CAS's +// DeclaredModeCreateOnly) at exactly that mode, read back with stat from the files themselves. 0444 +// is the placement's derived mode (test.claim.live_deploy.emit the_placed_fabric_store_derives_entry_mode_0444) +// and differs from what any umask can produce from create-new's 0666, so a realization that dropped +// the declared bits and fell back to the umask is red here whatever umask the runner has. The +// umask-077 case that failed on srv1 is exercised directly in the seed's +// a_declared_mode_is_published_regardless_of_the_umask. +fn read_only_by_all() -> FileMode { + FileMode { + owner: PermissionBits { read: true, write: false, execute: false }, + group: PermissionBits { read: true, write: false, execute: false }, + other: PermissionBits { read: true, write: false, execute: false }, + setuid: false, setgid: false, sticky: false, + } +} + +fn observed_mode_octal(path: String) -> String { + match coreutils_stat_path_mode(path: path as NonEmptyStr) { + PathModeObserved { path: _, mode } => file_mode_octal(mode: mode) + PathModeUnobservable { path: _, reason } => join(["unobservable: ", reason], "") + } +} + +test fn a_declared_entry_mode_is_the_published_mode_of_objects_and_heads() -> Bool { + let base = fresh_store() + let s = fabric_storage_file_root_with(root: shell.Mktemp.DirWithTemplate(template: "/tmp/gunbc_fabric_storage_mode.XXXXXX").path as NonEmptyStr, entry_mode: Present { value: read_only_by_all() }) + let ready = match fabric_storage_file_root_ensure(root: s) { FabricStorageRootReady => true FabricStorageRootRefused { area: _, detail: _ } => false } + let a = event(body: "a", parent: none) + let put = match fabric_storage_file_put(root: s, object: a) { FabricObjectStored { object: _ } => true FabricPutRefused { fault: _ } => false } + let object_mode = observed_mode_octal(path: fabric_storage_object_path(root: s, object: fabric_object_ref_of(object: a))) + let head_mode = match fabric_storage_file_advance(root: s, name: "p", expected: ExpectHeadAbsent, target: fabric_object_ref_of(object: a)) { + FabricHeadAdvanced { object: _, generation: g } => observed_mode_octal(path: cas_file_slot_path(root: s.heads, key: "p", generation: g)) + _ => "not advanced" + } + // The control: the SAME operations on a store with no declared mode do not publish 0444. + let _b = fabric_storage_file_put(root: base, object: a) + let undeclared_mode = observed_mode_octal(path: fabric_storage_object_path(root: base, object: fabric_object_ref_of(object: a))) + ready && put && object_mode == "0444" && head_mode == "0444" && undeclared_mode != "0444" +} diff --git a/dag/test/claim/live_deploy/emit_test.dag b/dag/test/claim/live_deploy/emit_test.dag index 60e699ea985..971a20880a8 100644 --- a/dag/test/claim/live_deploy/emit_test.dag +++ b/dag/test/claim/live_deploy/emit_test.dag @@ -10,7 +10,8 @@ import test.claim.live_deploy.apply_emission_projection { pipeline_steps_text, a import test.claim.live_deploy.deploy_release_fixture { deploy_witness_executable_digest, deploy_witness_release_revision, deploy_witness_other_release_revision, deploy_witness_full_install_plan, deploy_witness_release_target } import std.types { Bool, String, List, NonEmptyStr, Int } import std.os.types { Apt } -import gunbc.fabric_storage_placement { fabric_storage_listen_port, fabric_storage_https_port } +import gunbc.fabric_storage_placement { fabric_storage_listen_port, fabric_storage_https_port, fabric_storage_entry_mode, FabricStorageEntryModeDerived, FabricStorageEntryModeRefused } +import extdeps.access.posix { file_mode_octal } import gunbc.fabric_storage_file_store { fabric_storage_file_root } import extdeps.systemd.unit_file { serialize_systemd_unit_file } import v2.std.collection { Present, Absent } @@ -1322,6 +1323,30 @@ test fn the_fabric_storage_areas_are_ensured_writable_by_the_claim_principal() - && string_contains(s: heads, pattern: "2770") && string_contains(s: heads, pattern: "ghrunner") && !string_contains(s: heads, pattern: "REFUSED") } +// THE SAME ENSURE REPAIRS THE AREAS' EXISTING ENTRIES, read off the command the srv1 spec lowers: +// the CI job principal publishes from `umask 077` steps, so every entry it wrote before the store +// declared its entry mode is 0600 and the service principal cannot read it (srv1 2026-09-26). The +// ensure adds the derived read bits to the entries the applying principal owns, unprivileged. Red +// if the areas stop managing their entries, or the derivation refuses. +test fn the_fabric_storage_area_ensure_repairs_the_ci_principals_entries() -> Bool { + let files = fabric_storage_file_root(root: dashboard_instance_fabric_storage_root(instance: srv1_live_dashboard_instance()) as String as NonEmptyStr) + let objects = srv1_fabric_area_ensure(path: files.objects as String) + let heads = srv1_fabric_area_ensure(path: files.heads as String) + string_contains(s: objects, pattern: "-user ghrunner -exec /bin/chmod u+r,g+r,o+r") && !string_contains(s: objects, pattern: "REFUSED") + && string_contains(s: heads, pattern: "-user ghrunner -exec /bin/chmod u+r,g+r,o+r") && !string_contains(s: heads, pattern: "REFUSED") +} + +// THE PLACEMENT'S DERIVED ENTRY MODE, from the real store directories: 0444, the other-read being +// the service principal's only route to an entry the CI principal created. The per-rule cases, and +// the refusal when a directory grants o+x, are witnessed on synthetic principals in +// test.claim.managed_directory_witness. +test fn the_placed_fabric_store_derives_entry_mode_0444() -> Bool { + match fabric_storage_entry_mode() { + FabricStorageEntryModeDerived { mode } => file_mode_octal(mode: mode) == "0444" + FabricStorageEntryModeRefused { detail: _ } => false + } +} + // THE LOG IS NOT DEPLOYMENT STATE: no retract may remove the store. Moving this root back into the // owned roster (where the event-log mirror root sat, torn down with rm -rf) turns this red. test fn no_retract_can_remove_the_fabric_storage_store() -> Bool { diff --git a/dag/test/claim/managed_directory_witness_test.dag b/dag/test/claim/managed_directory_witness_test.dag index ecec9e12077..bc0754259f8 100644 --- a/dag/test/claim/managed_directory_witness_test.dag +++ b/dag/test/claim/managed_directory_witness_test.dag @@ -20,6 +20,10 @@ import gunbc.managed_directory { managed_directory_mode_octal, ManagedDirectoryAdmission, ManagedDirectoryAdmitted, ManagedDirectoryRefused, managed_directory_admit, + EntriesUnmanaged, + entry_class_of, + managed_directory_entry_mode, ManagedEntryModeDerived, ManagedEntryModeRefused, + entry_mode_add_read_symbolic, } import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } @@ -62,6 +66,7 @@ fn dir_with(dependents: List) -> ManagedDirectory { group_principal: owner_user(), dependents: dependents, ownership: Owned, + entries: EntriesUnmanaged, } } @@ -211,6 +216,7 @@ fn writers_dir(group: PosixUser) -> ManagedDirectory { DirectoryDependent { who: stranger_user(), needs: [Read, Write, Execute] }, ], ownership: Owned, + entries: EntriesUnmanaged, } } @@ -235,3 +241,69 @@ test fn witness_uncovered_writer_refuses_naming_it() -> Bool { test fn witness_uncovered_writer_would_have_derived_world_write() -> Bool { managed_directory_permissions(d: writers_dir(group: owner_user())).mode.other.write } + +// ── ENTRY MODE ─────────────────────────────────────────────────────────────────────────────── +// +// writers_dir(group: stranger_user()) is the two-writer store shape: the owner and a second writer +// whose own group is the directory's (setgid). An entry the SECOND writer creates is theirs, in their +// group, so the directory's owner -- not a member of that group -- can read it only through OTHER. +// The derivation must say so (0444), must say it only because a declared reader needs it (a +// single-writer directory derives owner-read alone), and must REFUSE the moment the directory lets +// other traverse, because the same o+r would then reach every account. + +fn entry_mode_octal_or_refused(d: ManagedDirectory) -> String { + match managed_directory_entry_mode(d: d) { + ManagedEntryModeDerived { mode } => file_mode_octal(mode: mode) + ManagedEntryModeRefused { member: _, path: _, reason: _ } => "refused" + } +} + +test fn witness_owner_reads_second_writers_entry_through_other() -> Bool { + class_label(c: entry_class_of(d: writers_dir(group: stranger_user()), writer: stranger_user(), reader: owner_user())) == "other" +} + +test fn witness_second_writer_reads_owners_entry_through_group() -> Bool { + class_label(c: entry_class_of(d: writers_dir(group: stranger_user()), writer: owner_user(), reader: stranger_user())) == "group" +} + +test fn witness_two_writer_store_entries_derive_0444() -> Bool { + entry_mode_octal_or_refused(d: writers_dir(group: stranger_user())) == "0444" +} + +test fn witness_entry_repair_adds_exactly_the_derived_read_bits() -> Bool { + match managed_directory_entry_mode(d: writers_dir(group: stranger_user())) { + ManagedEntryModeDerived { mode } => entry_mode_add_read_symbolic(mode: mode) == "u+r,g+r,o+r" + ManagedEntryModeRefused { member: _, path: _, reason: _ } => false + } +} + +test fn witness_single_writer_entries_derive_owner_read_only() -> Bool { + entry_mode_octal_or_refused(d: dir_with(dependents: [DirectoryDependent { who: owner_user(), needs: [Read, Write, Execute] }])) == "0400" +} + +// THE RED. Same store, plus one principal outside owner and group that needs only to TRAVERSE: the +// directory now derives o+x, so the entries' o+r would publish them to that principal and to every +// other account. The derivation must refuse, and the control above is the same store without it. +fn store_other_can_traverse() -> ManagedDirectory { + ManagedDirectory { + member: "synthetic-store", + path: "/synthetic/store", + owner: owner_user(), + group_principal: stranger_user(), + dependents: [ + DirectoryDependent { who: owner_user(), needs: [Read, Write, Execute] }, + DirectoryDependent { who: stranger_user(), needs: [Read, Write, Execute] }, + DirectoryDependent { who: PosixUser { name: "third", uid: 4003, gid: 4003, shell: LoginShellUnobserved }, needs: [Execute] }, + ], + ownership: Owned, + entries: EntriesUnmanaged, + } +} + +test fn witness_other_traverse_directory_grants_o_x() -> Bool { + managed_directory_permissions(d: store_other_can_traverse()).mode.other.execute +} + +test fn witness_entry_other_read_refuses_when_other_can_traverse() -> Bool { + entry_mode_octal_or_refused(d: store_other_can_traverse()) == "refused" +} diff --git a/src/v1/05_emit.dag b/src/v1/05_emit.dag index e8104641721..6e21c82c13c 100644 --- a/src/v1/05_emit.dag +++ b/src/v1/05_emit.dag @@ -1139,6 +1139,7 @@ type FileVerb | FileWrite | FileWriteOwnerOnly | FileWriteCreateNew + | FileWriteCreateNewWithMode | FileDelete | FileList @@ -1227,7 +1228,8 @@ fn file_result_channel_of_key(key: String) -> FileResultChannel? { // put a fact extdeps owns into the compiler (section 3). // // Total by construction over the admitted set: `file_emission_verb_refusal` has -// already rejected any verb that is not delete, list, write_owner_only or write_create_new, so the +// already rejected any verb that is not delete, list, write_owner_only, write_create_new or +// write_create_new_with_mode, so the // final arm is the floor beneath that wall rather than a second opinion. fn bind_file_verb(t: Node, op_node: Node, source_indices: Map) -> FileVerb { match file_transport_declared_verb(t: t, source_indices: source_indices) { @@ -1235,6 +1237,7 @@ fn bind_file_verb(t: Node, op_node: Node, source_indices: Map if file_operation_has_content_input(op_node: op_node, source_indices: source_indices) { FileWrite } @@ -2510,6 +2513,7 @@ type FileEmissionRefusal | FileVerbNotModeled { verb: String } | FilePathNotStaticallyRenderable | FileWriteMissingContentInput { verb: String } + | FileWriteMissingModeInput { verb: String } | FileOutputKeyNotModeled { key: String } | FileOutputShapeNotModeled @@ -2520,12 +2524,15 @@ fn file_emission_refusal_fact(refusal: FileEmissionRefusal) -> String { "' has no file realization handler, so no operation carrying `transport file` is emitted for it") FileVerbNotModeled { verb: v } => concat("file transport verb '", v, - "' is not a modeled action -- the modeled verbs are delete, list, write_owner_only and write_create_new, and an absent verb means write when the operation declares a `content` input and read otherwise") + "' is not a modeled action -- the modeled verbs are delete, list, write_owner_only, write_create_new and write_create_new_with_mode, and an absent verb means write when the operation declares a `content` input and read otherwise") FilePathNotStaticallyRenderable => "the file transport `path:` must be a string literal or a string interpolation over the operation inputs; no other expression shape has a rendering" FileWriteMissingContentInput { verb: v } => concat("file transport verb '", v, "' writes a payload, and the operation declares no `content` input to write -- the payload is an input to the operation, never a value the emitter may invent") + FileWriteMissingModeInput { verb: v } => + concat("file transport verb '", v, + "' publishes with a declared mode, and the operation declares no `mode` input -- the mode is the caller's modeled fact, never a value the emitter may invent") FileOutputKeyNotModeled { key: k } => concat("file transport output key '", k, "' has no modeled channel -- the modeled channels are write_success, read_success, delete_success, list_success, success, bytes_written, bytes, byte_count, path, error, content and entries") @@ -2540,6 +2547,11 @@ data file_transport_verb_write_owner_only: String = "write_owner_only" data file_transport_verb_write_create_new: String = "write_create_new" +// THE SAME CREATE-NEW PUBLICATION, WITH THE PUBLISHED MODE DECLARED BY THE CALLER instead of left to +// the process umask (extdeps.filesystem.io WriteCreateNewWithMode). One canonical realization serves +// both verbs; this one passes the declared bits, the other passes none. +data file_transport_verb_write_create_new_with_mode: String = "write_create_new_with_mode" + fn file_transport_declared_verb(t: Node, source_indices: Map) -> String? { match transport_verb(t: t, source_indices: source_indices) { Present { value: v } => match v.expr_data { @@ -2550,8 +2562,12 @@ fn file_transport_declared_verb(t: Node, source_indices: Map) -> Bool { + op_node.params |> any(p => param_node_name_at(n: p, source_indices: source_indices) == name) +} + fn file_operation_has_content_input(op_node: Node, source_indices: Map) -> Bool { - op_node.params |> any(p => param_node_name_at(n: p, source_indices: source_indices) == "content") + file_operation_has_input(op_node: op_node, name: "content", source_indices: source_indices) } // The channels the interpreter's map_file_outputs answers, read back as the emitter's obligation. @@ -2608,6 +2624,12 @@ fn file_emission_verb_refusal(op_node: Node, verb: String?, source_indices: Map< } else if v == file_transport_verb_write_owner_only || v == file_transport_verb_write_create_new { if file_operation_has_content_input(op_node: op_node, source_indices: source_indices) { none } else { Present { value: FileWriteMissingContentInput { verb: v } } } + } else if v == file_transport_verb_write_create_new_with_mode { + if !file_operation_has_content_input(op_node: op_node, source_indices: source_indices) { + Present { value: FileWriteMissingContentInput { verb: v } } + } else if !file_operation_has_input(op_node: op_node, name: "mode", source_indices: source_indices) { + Present { value: FileWriteMissingModeInput { verb: v } } + } else { none } } else { Present { value: FileVerbNotModeled { verb: v } } } diff --git a/src/v1/05_emit_rust.dag b/src/v1/05_emit_rust.dag index 268181fd707..46a6cec8e7e 100644 --- a/src/v1/05_emit_rust.dag +++ b/src/v1/05_emit_rust.dag @@ -259,7 +259,7 @@ import v1.compiler.emit { ShellChanStderrTruncated, ShellChanStderrTotalBytes, ShellChanStderrRetainedBytes, ShellChannelNotRealizedByTarget, shell_emission_refusal_fact, shell_result_channel_key, bind_operation_transport, transport_binding_refusal_fact, - FileVerb, FileRead, FileWrite, FileWriteOwnerOnly, FileWriteCreateNew, FileDelete, FileList, + FileVerb, FileRead, FileWrite, FileWriteOwnerOnly, FileWriteCreateNew, FileWriteCreateNewWithMode, FileDelete, FileList, FileResultField, FileResultChannel, FileChanSuccess, FileChanByteCount, FileChanPath, FileChanError, FileChanErrorKind, FileChanContent } @@ -16225,6 +16225,7 @@ fn file_verb_action_expr(verb: FileVerb) -> String { FileWrite => file_write_expr FileWriteOwnerOnly => file_write_owner_only_expr FileWriteCreateNew => file_write_create_new_expr + FileWriteCreateNewWithMode => file_write_create_new_with_mode_expr FileDelete => file_delete_match_expr FileList => file_list_match_expr } @@ -16320,7 +16321,11 @@ data file_write_expr: String = "{\n let payload_bytes = content.len() as i64; // policy -- a stringified error and a byte count -- and the realization deliberately does not // return it, because doing so would stringify std::io::Error inside the realization and discard // the host's structured failure before any consumer asked for that loss. -data file_write_create_new_expr: String = "{\n let payload_bytes = content.len() as i64;\n match v1_rt::gunbc_file_write_create_new(&file_path, content.as_bytes()) {\n Ok(()) => (true, String::new(), String::new(), payload_bytes, String::new()),\n Err(create_new_err) => (false, String::new(), format!(\"{}\", create_new_err), 0i64, file_io_error_kind(&create_new_err)),\n }\n};" +data file_write_create_new_expr: String = "{\n let payload_bytes = content.len() as i64;\n match v1_rt::gunbc_file_write_create_new(&file_path, content.as_bytes(), None) {\n Ok(()) => (true, String::new(), String::new(), payload_bytes, String::new()),\n Err(create_new_err) => (false, String::new(), format!(\"{}\", create_new_err), 0i64, file_io_error_kind(&create_new_err)),\n }\n};" + +// `mode` is the declared permission-bit value (extdeps.access.posix file_mode_bits). A value that is +// not a mode REFUSES as InvalidInput before anything is created; it is never truncated into one. +data file_write_create_new_with_mode_expr: String = "{\n let payload_bytes = content.len() as i64;\n let declared_mode = u32::try_from(mode).ok().filter(|m| *m <= 0o7777);\n let create_result = match declared_mode {\n Some(m) => v1_rt::gunbc_file_write_create_new(&file_path, content.as_bytes(), Some(m)),\n None => Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!(\"write_create_new_with_mode refused: {} is not a permission-bit value\", mode))),\n };\n match create_result {\n Ok(()) => (true, String::new(), String::new(), payload_bytes, String::new()),\n Err(create_new_err) => (false, String::new(), format!(\"{}\", create_new_err), 0i64, file_io_error_kind(&create_new_err)),\n }\n};" data file_write_owner_only_expr: String = "{\n let payload_bytes = content.len() as i64;\n let owner_only_result = (|| -> std::io::Result<()> {\n #[cfg(unix)]\n {\n use std::io::Write;\n use std::os::unix::fs::OpenOptionsExt;\n let mut owner_only_file = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(&file_path)?;\n owner_only_file.write_all(content.as_bytes())?;\n return Ok(());\n }\n #[cfg(not(unix))]\n {\n return Err(std::io::Error::new(std::io::ErrorKind::Unsupported, \"write_owner_only refused: owner-only mode-at-creation is unavailable on this platform\"));\n }\n })();\n match owner_only_result {\n Ok(()) => (true, String::new(), String::new(), payload_bytes, String::new()),\n Err(owner_only_err) => (false, String::new(), format!(\"{}\", owner_only_err), 0i64, file_io_error_kind(&owner_only_err)),\n }\n};" diff --git a/src/v1/stage0/src/extdeps_filesystem_rust_realization.rs b/src/v1/stage0/src/extdeps_filesystem_rust_realization.rs index b6ae2463b9d..f4442a778e1 100644 --- a/src/v1/stage0/src/extdeps_filesystem_rust_realization.rs +++ b/src/v1/stage0/src/extdeps_filesystem_rust_realization.rs @@ -25,7 +25,7 @@ pub fn rust_file_create_staging_candidate_attempt_limit_def() -> String { pub fn rust_file_write_create_new_fn_def() -> String { thread_local! { static CACHED: String = { - "fn gunbc_file_write_create_new(file_path: &str, content: &[u8]) -> std::io::Result<()> {\n use std::io::Write;\n static GUNBC_CREATE_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);\n let mut attempted: u32 = 0;\n let (mut staged, staging_path) = loop {\n if attempted >= GUNBC_CREATE_STAGING_CANDIDATE_ATTEMPT_LIMIT {\n return Err(std::io::Error::other(format!(\n \"gunbc create-new: StagingCandidateBudgetExhausted attempted={} limit={}\",\n attempted, GUNBC_CREATE_STAGING_CANDIDATE_ATTEMPT_LIMIT\n )));\n }\n attempted += 1;\n let seq = GUNBC_CREATE_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);\n let candidate = format!(\"{}.gunbc-create-{}-{}\", file_path, std::process::id(), seq);\n match std::fs::OpenOptions::new()\n .write(true)\n .create_new(true)\n .open(&candidate)\n {\n Ok(file) => break (file, candidate),\n Err(occupied) if occupied.kind() == std::io::ErrorKind::AlreadyExists => continue,\n Err(host) => return Err(host),\n }\n };\n if let Err(staging_err) = staged.write_all(content) {\n let _ = std::fs::remove_file(&staging_path);\n return Err(staging_err);\n }\n if let Err(sync_err) = staged.sync_all() {\n let _ = std::fs::remove_file(&staging_path);\n return Err(sync_err);\n }\n drop(staged);\n let published = std::fs::hard_link(&staging_path, file_path);\n let _ = std::fs::remove_file(&staging_path);\n published\n}\n".to_string() + "fn gunbc_file_write_create_new(\n file_path: &str,\n content: &[u8],\n declared_mode: Option,\n) -> std::io::Result<()> {\n use std::io::Write;\n static GUNBC_CREATE_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);\n let mut attempted: u32 = 0;\n let (mut staged, staging_path) = loop {\n if attempted >= GUNBC_CREATE_STAGING_CANDIDATE_ATTEMPT_LIMIT {\n return Err(std::io::Error::other(format!(\n \"gunbc create-new: StagingCandidateBudgetExhausted attempted={} limit={}\",\n attempted, GUNBC_CREATE_STAGING_CANDIDATE_ATTEMPT_LIMIT\n )));\n }\n attempted += 1;\n let seq = GUNBC_CREATE_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);\n let candidate = format!(\"{}.gunbc-create-{}-{}\", file_path, std::process::id(), seq);\n match std::fs::OpenOptions::new()\n .write(true)\n .create_new(true)\n .open(&candidate)\n {\n Ok(file) => break (file, candidate),\n Err(occupied) if occupied.kind() == std::io::ErrorKind::AlreadyExists => continue,\n Err(host) => return Err(host),\n }\n };\n if let Some(declared) = declared_mode {\n #[cfg(unix)]\n let applied = {\n use std::os::unix::fs::PermissionsExt;\n staged.set_permissions(std::fs::Permissions::from_mode(declared))\n };\n #[cfg(not(unix))]\n let applied: std::io::Result<()> = Err(std::io::Error::new(\n std::io::ErrorKind::Unsupported,\n format!(\n \"gunbc create-new: declared mode {} is unavailable on this platform\",\n declared\n ),\n ));\n if let Err(mode_err) = applied {\n let _ = std::fs::remove_file(&staging_path);\n return Err(mode_err);\n }\n }\n if let Err(staging_err) = staged.write_all(content) {\n let _ = std::fs::remove_file(&staging_path);\n return Err(staging_err);\n }\n if let Err(sync_err) = staged.sync_all() {\n let _ = std::fs::remove_file(&staging_path);\n return Err(sync_err);\n }\n drop(staged);\n let published = std::fs::hard_link(&staging_path, file_path);\n let _ = std::fs::remove_file(&staging_path);\n published\n}\n".to_string() }; } CACHED.with(|c: &String| c.clone()) diff --git a/src/v1/stage0/src/gunbc_file_transport_generated.rs b/src/v1/stage0/src/gunbc_file_transport_generated.rs index 28ff535d950..c97dc4621b8 100644 --- a/src/v1/stage0/src/gunbc_file_transport_generated.rs +++ b/src/v1/stage0/src/gunbc_file_transport_generated.rs @@ -3,7 +3,11 @@ pub const GUNBC_CREATE_STAGING_CANDIDATE_ATTEMPT_LIMIT: u32 = 1024; -pub fn gunbc_file_write_create_new(file_path: &str, content: &[u8]) -> std::io::Result<()> { +pub fn gunbc_file_write_create_new( + file_path: &str, + content: &[u8], + declared_mode: Option, +) -> std::io::Result<()> { use std::io::Write; static GUNBC_CREATE_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let mut attempted: u32 = 0; @@ -27,6 +31,25 @@ pub fn gunbc_file_write_create_new(file_path: &str, content: &[u8]) -> std::io:: Err(host) => return Err(host), } }; + if let Some(declared) = declared_mode { + #[cfg(unix)] + let applied = { + use std::os::unix::fs::PermissionsExt; + staged.set_permissions(std::fs::Permissions::from_mode(declared)) + }; + #[cfg(not(unix))] + let applied: std::io::Result<()> = Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + format!( + "gunbc create-new: declared mode {} is unavailable on this platform", + declared + ), + )); + if let Err(mode_err) = applied { + let _ = std::fs::remove_file(&staging_path); + return Err(mode_err); + } + } if let Err(staging_err) = staged.write_all(content) { let _ = std::fs::remove_file(&staging_path); return Err(staging_err); diff --git a/src/v1/stage0/src/v1_compiler_emit.rs b/src/v1/stage0/src/v1_compiler_emit.rs index 2d5e4b453af..53709d150ce 100644 --- a/src/v1/stage0/src/v1_compiler_emit.rs +++ b/src/v1/stage0/src/v1_compiler_emit.rs @@ -2594,6 +2594,7 @@ pub enum FileVerb { FileWrite, FileWriteOwnerOnly, FileWriteCreateNew, + FileWriteCreateNewWithMode, FileDelete, FileList, } @@ -2707,7 +2708,11 @@ pub fn bind_file_verb( if (v.clone() == file_transport_verb_write_create_new()) { FileVerb::FileWriteCreateNew } else { - FileVerb::FileWriteOwnerOnly + if (v.clone() == file_transport_verb_write_create_new_with_mode()) { + FileVerb::FileWriteCreateNewWithMode + } else { + FileVerb::FileWriteOwnerOnly + } } } } @@ -5274,6 +5279,7 @@ pub enum FileEmissionRefusal { FileVerbNotModeled { verb: String }, FilePathNotStaticallyRenderable, FileWriteMissingContentInput { verb: String }, + FileWriteMissingModeInput { verb: String }, FileOutputKeyNotModeled { key: String }, FileOutputShapeNotModeled, } @@ -5281,9 +5287,10 @@ pub enum FileEmissionRefusal { pub fn file_emission_refusal_fact(refusal: Rc) -> String { match (*refusal.clone()).clone() { FileEmissionRefusal::FileTargetNotModeled { target_name: t, .. } => v1_rt::concat(v1_rt::concat("file transport emission is modeled for the rust target only; target '".to_string(), t.clone()), "' has no file realization handler, so no operation carrying `transport file` is emitted for it".to_string()), - FileEmissionRefusal::FileVerbNotModeled { verb: v, .. } => v1_rt::concat(v1_rt::concat("file transport verb '".to_string(), v.clone()), "' is not a modeled action -- the modeled verbs are delete, list, write_owner_only and write_create_new, and an absent verb means write when the operation declares a `content` input and read otherwise".to_string()), + FileEmissionRefusal::FileVerbNotModeled { verb: v, .. } => v1_rt::concat(v1_rt::concat("file transport verb '".to_string(), v.clone()), "' is not a modeled action -- the modeled verbs are delete, list, write_owner_only, write_create_new and write_create_new_with_mode, and an absent verb means write when the operation declares a `content` input and read otherwise".to_string()), FileEmissionRefusal::FilePathNotStaticallyRenderable => "the file transport `path:` must be a string literal or a string interpolation over the operation inputs; no other expression shape has a rendering".to_string(), FileEmissionRefusal::FileWriteMissingContentInput { verb: v, .. } => v1_rt::concat(v1_rt::concat("file transport verb '".to_string(), v.clone()), "' writes a payload, and the operation declares no `content` input to write -- the payload is an input to the operation, never a value the emitter may invent".to_string()), + FileEmissionRefusal::FileWriteMissingModeInput { verb: v, .. } => v1_rt::concat(v1_rt::concat("file transport verb '".to_string(), v.clone()), "' publishes with a declared mode, and the operation declares no `mode` input -- the mode is the caller's modeled fact, never a value the emitter may invent".to_string()), FileEmissionRefusal::FileOutputKeyNotModeled { key: k, .. } => v1_rt::concat(v1_rt::concat("file transport output key '".to_string(), k.clone()), "' has no modeled channel -- the modeled channels are write_success, read_success, delete_success, list_success, success, bytes_written, bytes, byte_count, path, error, content and entries".to_string()), FileEmissionRefusal::FileOutputShapeNotModeled => "the file transport operation's declared output must be a product of named fields; the emitted realization answers per FIELD, so a return shape with no fields to answer for has no rendering".to_string(), } @@ -5325,6 +5332,15 @@ pub fn file_transport_verb_write_create_new() -> String { CACHED.with(|c: &String| c.clone()) } +pub fn file_transport_verb_write_create_new_with_mode() -> String { + thread_local! { + static CACHED: String = { + "write_create_new_with_mode".to_string() + }; + } + CACHED.with(|c: &String| c.clone()) +} + pub fn file_transport_declared_verb( t: Rc, source_indices: Rc>>, @@ -5345,15 +5361,16 @@ pub fn file_transport_declared_verb( } } -pub fn file_operation_has_content_input( +pub fn file_operation_has_input( op_node: Rc, + name: String, source_indices: Rc>>, ) -> bool { { let mut __found = false; for p in op_node.params.clone().iter().cloned() { if (crate::v1_std_core::param_node_name_at(p.clone(), source_indices.clone()) - == "content".to_string()) + == name.clone()) { __found = true; break; @@ -5363,6 +5380,17 @@ pub fn file_operation_has_content_input( } } +pub fn file_operation_has_content_input( + op_node: Rc, + source_indices: Rc>>, +) -> bool { + file_operation_has_input( + op_node.clone(), + "content".to_string(), + source_indices.clone(), + ) +} + pub fn is_modeled_file_output_channel(key: String) -> bool { match file_result_channel_of_key(key.clone()) { Some(_) => true, @@ -5453,9 +5481,32 @@ pub fn file_emission_verb_refusal( })) } } else { - Some(Rc::new(FileEmissionRefusal::FileVerbNotModeled { - verb: v.clone(), - })) + if (v.clone() == file_transport_verb_write_create_new_with_mode()) { + if !file_operation_has_content_input( + op_node.clone(), + source_indices.clone(), + ) { + Some(Rc::new(FileEmissionRefusal::FileWriteMissingContentInput { + verb: v.clone(), + })) + } else { + if !file_operation_has_input( + op_node.clone(), + "mode".to_string(), + source_indices.clone(), + ) { + Some(Rc::new(FileEmissionRefusal::FileWriteMissingModeInput { + verb: v.clone(), + })) + } else { + std::option::Option::None + } + } + } else { + Some(Rc::new(FileEmissionRefusal::FileVerbNotModeled { + verb: v.clone(), + })) + } } } } @@ -8425,6 +8476,8 @@ pub struct FileWriteOwnerOnly; #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct FileWriteCreateNew; #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct FileWriteCreateNewWithMode; +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct FileDelete; #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct FileList; diff --git a/src/v1/stage0/src/v1_compiler_emit_rust.rs b/src/v1/stage0/src/v1_compiler_emit_rust.rs index f1eb3449a19..6105287bd73 100644 --- a/src/v1/stage0/src/v1_compiler_emit_rust.rs +++ b/src/v1/stage0/src/v1_compiler_emit_rust.rs @@ -145,7 +145,8 @@ use crate::v1_compiler_emit::FileResultChannel::{ FileChanSuccess, }; use crate::v1_compiler_emit::FileVerb::{ - FileDelete, FileList, FileRead, FileWrite, FileWriteCreateNew, FileWriteOwnerOnly, + FileDelete, FileList, FileRead, FileWrite, FileWriteCreateNew, FileWriteCreateNewWithMode, + FileWriteOwnerOnly, }; use crate::v1_compiler_emit::ShellEmissionRefusal::ShellChannelNotRealizedByTarget; use crate::v1_compiler_emit::ShellResultChannel::{ @@ -37508,6 +37509,7 @@ pub fn file_verb_action_expr(verb: FileVerb) -> String { FileVerb::FileWrite => file_write_expr(), FileVerb::FileWriteOwnerOnly => file_write_owner_only_expr(), FileVerb::FileWriteCreateNew => file_write_create_new_expr(), + FileVerb::FileWriteCreateNewWithMode => file_write_create_new_with_mode_expr(), FileVerb::FileDelete => file_delete_match_expr(), FileVerb::FileList => file_list_match_expr(), } @@ -37703,7 +37705,16 @@ pub fn file_write_expr() -> String { pub fn file_write_create_new_expr() -> String { thread_local! { static CACHED: String = { - "{\n let payload_bytes = content.len() as i64;\n match v1_rt::gunbc_file_write_create_new(&file_path, content.as_bytes()) {\n Ok(()) => (true, String::new(), String::new(), payload_bytes, String::new()),\n Err(create_new_err) => (false, String::new(), format!(\"{}\", create_new_err), 0i64, file_io_error_kind(&create_new_err)),\n }\n};".to_string() + "{\n let payload_bytes = content.len() as i64;\n match v1_rt::gunbc_file_write_create_new(&file_path, content.as_bytes(), None) {\n Ok(()) => (true, String::new(), String::new(), payload_bytes, String::new()),\n Err(create_new_err) => (false, String::new(), format!(\"{}\", create_new_err), 0i64, file_io_error_kind(&create_new_err)),\n }\n};".to_string() + }; + } + CACHED.with(|c: &String| c.clone()) +} + +pub fn file_write_create_new_with_mode_expr() -> String { + thread_local! { + static CACHED: String = { + "{\n let payload_bytes = content.len() as i64;\n let declared_mode = u32::try_from(mode).ok().filter(|m| *m <= 0o7777);\n let create_result = match declared_mode {\n Some(m) => v1_rt::gunbc_file_write_create_new(&file_path, content.as_bytes(), Some(m)),\n None => Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!(\"write_create_new_with_mode refused: {} is not a permission-bit value\", mode))),\n };\n match create_result {\n Ok(()) => (true, String::new(), String::new(), payload_bytes, String::new()),\n Err(create_new_err) => (false, String::new(), format!(\"{}\", create_new_err), 0i64, file_io_error_kind(&create_new_err)),\n }\n};".to_string() }; } CACHED.with(|c: &String| c.clone()) diff --git a/src/v1/stage0/src/v1_interpreter.rs b/src/v1/stage0/src/v1_interpreter.rs index 90bbc4fd692..5f3887e9823 100644 --- a/src/v1/stage0/src/v1_interpreter.rs +++ b/src/v1/stage0/src/v1_interpreter.rs @@ -15789,7 +15789,7 @@ mod write_file_create_new_tests { std::fs::write(name, format!("occupant {seq}")).expect("plant a candidate"); } - let refusal = super::write_file_create_new(&target, b"a fresh repository") + let refusal = super::write_file_create_new(&target, b"a fresh repository", None) .expect_err("an exhausted candidate budget must refuse"); // NOT AlreadyExists: the target is absent, and conflating the two is the defect this // whole module exists to remove. @@ -15854,7 +15854,7 @@ mod write_file_create_new_tests { let planted = format!("{}.gunbc-create-{}-0", target, std::process::id()); std::fs::write(&planted, b"a stale internal candidate").expect("plant the first candidate"); - super::write_file_create_new(&target, b"a fresh repository") + super::write_file_create_new(&target, b"a fresh repository", None) .expect("an occupied staging candidate must be skipped, not refused"); assert_eq!( std::fs::read(&path).expect("target must be published"), @@ -15883,7 +15883,7 @@ mod write_file_create_new_tests { std::fs::write(&collided, b"a leftover from an earlier attempt") .expect("plant the leftover"); - super::write_file_create_new(&target, b"a fresh repository") + super::write_file_create_new(&target, b"a fresh repository", None) .expect("a leftover staging file must not refuse a create whose TARGET is absent"); assert_eq!( std::fs::read(&path).expect("target must exist"), @@ -15910,7 +15910,7 @@ mod write_file_create_new_tests { .map(|_| { let t = target.clone(); let p = payload.clone(); - std::thread::spawn(move || super::write_file_create_new(&t, &p)) + std::thread::spawn(move || super::write_file_create_new(&t, &p, None)) }) .collect(); let results: Vec<_> = handles @@ -15952,7 +15952,7 @@ mod write_file_create_new_tests { let path = dir.join("repo.json"); std::fs::write(&path, b"SOMEONE ELSE'S BYTES").expect("seed the path"); - let err = super::write_file_create_new(path.to_str().unwrap(), b"a fresh repository") + let err = super::write_file_create_new(path.to_str().unwrap(), b"a fresh repository", None) .expect_err("a path that exists must refuse, not be truncated"); assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists); @@ -16005,7 +16005,7 @@ mod write_file_create_new_tests { }; libc::setrlimit(libc::RLIMIT_FSIZE, &lim); } - let _ = super::write_file_create_new(&target_s, &content); + let _ = super::write_file_create_new(&target_s, &content, None); unsafe { libc::_exit(0) }; } let mut status: libc::c_int = 0; @@ -16085,7 +16085,7 @@ mod write_file_create_new_tests { let dir = std::env::temp_dir().join(format!("gunbc-create-new-ok-{}", std::process::id())); std::fs::create_dir_all(&dir).expect("temp dir"); let path = dir.join("repo.json"); - super::write_file_create_new(path.to_str().unwrap(), b"a fresh repository") + super::write_file_create_new(path.to_str().unwrap(), b"a fresh repository", None) .expect("an absent path is created"); assert_eq!( std::fs::read(&path).expect("written"), @@ -16093,6 +16093,74 @@ mod write_file_create_new_tests { ); std::fs::remove_dir_all(&dir).ok(); } + + // THE DECLARED MODE IS THE PUBLISHED MODE, WHATEVER THE UMASK. A fleet-converge step runs + // `umask 077` in its credential prelude and then invokes gunbc in the same shell, so every + // create-new it performs used to publish 0600 -- a fabric-store object the other declared + // reader could not open. The child sets exactly that umask and publishes with a declared mode + // the umask would narrow; the published inode must carry the declared bits. RED against the + // umask-only construction: it publishes 0600 here. + // + // The paired control is the Absent arm under the same umask: a caller that declares no mode + // keeps today's behaviour exactly (0666 & !umask), so widening the signature changed nothing + // for the callers that did not ask. + fn create_new_mode_under_umask(label: &str, umask: libc::mode_t, declared: Option) -> u32 { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join(format!( + "gunbc-create-new-mode-{label}-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).expect("temp dir"); + let target = dir.join("object"); + let target_s = target.to_str().unwrap().to_string(); + let pid = unsafe { libc::fork() }; + assert!(pid >= 0, "fork failed"); + if pid == 0 { + unsafe { libc::umask(umask) }; + let code = match super::write_file_create_new(&target_s, b"an object", declared) { + Ok(()) => 0, + Err(_) => 1, + }; + unsafe { libc::_exit(code) }; + } + let mut status: libc::c_int = 0; + unsafe { libc::waitpid(pid, &mut status, 0) }; + assert!( + libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0, + "the create in the child must succeed" + ); + let mode = std::fs::metadata(&target) + .expect("published") + .permissions() + .mode() + & 0o7777; + std::fs::remove_dir_all(&dir).ok(); + mode + } + + #[test] + fn a_declared_mode_is_published_regardless_of_the_umask() { + assert_eq!( + create_new_mode_under_umask("declared", 0o077, Some(0o644)), + 0o644 + ); + assert_eq!( + create_new_mode_under_umask("declared-narrow", 0o022, Some(0o640)), + 0o640 + ); + } + + #[test] + fn an_absent_mode_keeps_the_umask_derived_mode() { + assert_eq!( + create_new_mode_under_umask("absent-077", 0o077, None), + 0o600 + ); + assert_eq!( + create_new_mode_under_umask("absent-022", 0o022, None), + 0o644 + ); + } } struct FileResult { @@ -16274,7 +16342,7 @@ fn dispatch_file( OutputChannel::ShellTrace, &format!("[file] write_create_new {} ({} bytes)", path, byte_count), ); - return match write_file_create_new(&path, content.as_bytes()) { + return match write_file_create_new(&path, content.as_bytes(), None) { Ok(()) => Ok(FileResult { success: true, byte_count, @@ -16297,10 +16365,66 @@ fn dispatch_file( }), }; } + // THE DECLARED-MODE ARM. Same canonical realization as write_create_new; the only + // difference is that the staged inode is given the caller's declared permission bits + // before publication, so the published mode is the model's and not the process umask's. + // `mode` is the permission-bit VALUE (extdeps.access.posix file_mode_bits), refused + // rather than truncated when it is not a mode. + "write_create_new_with_mode" => { + let content = match param_env.lookup(ctx.sym("content")) { + Some(v) => format!("{}", v), + None => { + return Err(InterpError::TypeError { + msg: format!( + "file write_create_new_with_mode operation missing `content` argument for {}", + path + ), + }) + } + }; + let mode = match param_env.lookup(ctx.sym("mode")) { + Some(Value::Int(n)) if (0..=0o7777).contains(n) => *n as u32, + other => { + return Err(InterpError::TypeError { + msg: format!( + "file write_create_new_with_mode for {} needs an Int `mode` within 0..=0o7777, got {:?}", + path, + other.map(|v| format!("{}", v)) + ), + }) + } + }; + let byte_count = content.len() as i64; + trace_emit( + OutputChannel::ShellTrace, + &format!( + "[file] write_create_new_with_mode {} ({} bytes, mode {:o})", + path, byte_count, mode + ), + ); + return match write_file_create_new(&path, content.as_bytes(), Some(mode)) { + Ok(()) => Ok(FileResult { + success: true, + byte_count, + path, + error: String::new(), + error_kind: String::new(), + content: String::new(), + }), + Err(e) => Ok(FileResult { + success: false, + byte_count: 0, + path, + error: format!("{}", e), + error_kind: io_error_kind_name(&e), + content: String::new(), + }), + }; + } other => { return Err(InterpError::TypeError { msg: format!( - "file transport verb '{other}' is not a known action (delete, list, write_owner_only, write_create_new)" + "file transport verb '{other}' is not a known action (delete, list, write_owner_only, write_create_new, write_create_new_with_mode)" ), }) } diff --git a/src/v1/stage0/src/v1_rt.rs b/src/v1/stage0/src/v1_rt.rs index 5fd1c1b5da8..24155a63b59 100644 --- a/src/v1/stage0/src/v1_rt.rs +++ b/src/v1/stage0/src/v1_rt.rs @@ -1256,7 +1256,11 @@ pub fn hash_combine(a: Hash, b: Hash) -> Hash { pub const GUNBC_CREATE_STAGING_CANDIDATE_ATTEMPT_LIMIT: u32 = 1024; -pub fn gunbc_file_write_create_new(file_path: &str, content: &[u8]) -> std::io::Result<()> { +pub fn gunbc_file_write_create_new( + file_path: &str, + content: &[u8], + declared_mode: Option, +) -> std::io::Result<()> { use std::io::Write; static GUNBC_CREATE_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let mut attempted: u32 = 0; @@ -1280,6 +1284,25 @@ pub fn gunbc_file_write_create_new(file_path: &str, content: &[u8]) -> std::io:: Err(host) => return Err(host), } }; + if let Some(declared) = declared_mode { + #[cfg(unix)] + let applied = { + use std::os::unix::fs::PermissionsExt; + staged.set_permissions(std::fs::Permissions::from_mode(declared)) + }; + #[cfg(not(unix))] + let applied: std::io::Result<()> = Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + format!( + "gunbc create-new: declared mode {} is unavailable on this platform", + declared + ), + )); + if let Err(mode_err) = applied { + let _ = std::fs::remove_file(&staging_path); + return Err(mode_err); + } + } if let Err(staging_err) = staged.write_all(content) { let _ = std::fs::remove_file(&staging_path); return Err(staging_err); From fd2495c5212390f900c9626ecf060175d137ac1f Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sat, 26 Sep 2026 11:40:56 +0000 Subject: [PATCH 2/8] Move annotations to module-item grain; match the ensure claim on the quoted argv Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/extdeps/filesystem/filesystem_io.dag | 11 ++++++----- .../fabric_storage_file_store_wet_witness_test.dag | 4 ++-- dag/test/claim/live_deploy/emit_test.dag | 4 ++-- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/dag/extdeps/filesystem/filesystem_io.dag b/dag/extdeps/filesystem/filesystem_io.dag index 8d71e0c0ee9..0ae0c5360b2 100644 --- a/dag/extdeps/filesystem/filesystem_io.dag +++ b/dag/extdeps/filesystem/filesystem_io.dag @@ -668,6 +668,12 @@ data filesystem_absence_establishment_adoption_standing: String = "RUNG: structu // is declared on the two operations that have a consumer for it (Read and WriteCreateNew, both // consumed by extdeps.realization.materialization_store_local); an operation gains it when a // consumer needs it, since the transport answers it for every verb. +// +// WriteCreateNewWithMode: WriteCreateNew with the PUBLISHED MODE DECLARED rather than left to the process umask. The +// canonical realization (extdeps.filesystem.rust_realization) sets these bits on the staged inode +// before the hard_link publishes it, so the name never appears at any other mode. `mode` is the +// permission-bit value of a modeled extdeps.access.posix FileMode (file_mode_bits), never a +// literal; a value outside 0..=0o7777 refuses before anything is created. service Filesystem { operation Write { @@ -704,11 +710,6 @@ service Filesystem { transport file { path: "{path}", verb: "write_create_new" } } - // WriteCreateNew with the PUBLISHED MODE DECLARED rather than left to the process umask. The - // canonical realization (extdeps.filesystem.rust_realization) sets these bits on the staged inode - // before the hard_link publishes it, so the name never appears at any other mode. `mode` is the - // permission-bit value of a modeled extdeps.access.posix FileMode (file_mode_bits), never a - // literal; a value outside 0..=0o7777 refuses before anything is created. operation WriteCreateNewWithMode { input { path: String, content: String, mode: Int } output { diff --git a/dag/test/claim/fabric/fabric_storage_file_store_wet_witness_test.dag b/dag/test/claim/fabric/fabric_storage_file_store_wet_witness_test.dag index 6b0b88f2e59..da82f575c83 100644 --- a/dag/test/claim/fabric/fabric_storage_file_store_wet_witness_test.dag +++ b/dag/test/claim/fabric/fabric_storage_file_store_wet_witness_test.dag @@ -200,7 +200,8 @@ test fn the_identity_route_echoes_the_presented_login_and_touches_no_head_by_rea // and differs from what any umask can produce from create-new's 0666, so a realization that dropped // the declared bits and fell back to the umask is red here whatever umask the runner has. The // umask-077 case that failed on srv1 is exercised directly in the seed's -// a_declared_mode_is_published_regardless_of_the_umask. +// a_declared_mode_is_published_regardless_of_the_umask. The control inside the claim runs the SAME +// operations on a store with no declared mode and requires that it does not publish 0444. fn read_only_by_all() -> FileMode { FileMode { owner: PermissionBits { read: true, write: false, execute: false }, @@ -228,7 +229,6 @@ test fn a_declared_entry_mode_is_the_published_mode_of_objects_and_heads() -> Bo FabricHeadAdvanced { object: _, generation: g } => observed_mode_octal(path: cas_file_slot_path(root: s.heads, key: "p", generation: g)) _ => "not advanced" } - // The control: the SAME operations on a store with no declared mode do not publish 0444. let _b = fabric_storage_file_put(root: base, object: a) let undeclared_mode = observed_mode_octal(path: fabric_storage_object_path(root: base, object: fabric_object_ref_of(object: a))) ready && put && object_mode == "0444" && head_mode == "0444" && undeclared_mode != "0444" diff --git a/dag/test/claim/live_deploy/emit_test.dag b/dag/test/claim/live_deploy/emit_test.dag index 971a20880a8..085e221f475 100644 --- a/dag/test/claim/live_deploy/emit_test.dag +++ b/dag/test/claim/live_deploy/emit_test.dag @@ -1332,8 +1332,8 @@ test fn the_fabric_storage_area_ensure_repairs_the_ci_principals_entries() -> Bo let files = fabric_storage_file_root(root: dashboard_instance_fabric_storage_root(instance: srv1_live_dashboard_instance()) as String as NonEmptyStr) let objects = srv1_fabric_area_ensure(path: files.objects as String) let heads = srv1_fabric_area_ensure(path: files.heads as String) - string_contains(s: objects, pattern: "-user ghrunner -exec /bin/chmod u+r,g+r,o+r") && !string_contains(s: objects, pattern: "REFUSED") - && string_contains(s: heads, pattern: "-user ghrunner -exec /bin/chmod u+r,g+r,o+r") && !string_contains(s: heads, pattern: "REFUSED") + string_contains(s: objects, pattern: "'-user' 'ghrunner' '-exec' '/bin/chmod' 'u+r,g+r,o+r' '{}' '+'") && !string_contains(s: objects, pattern: "REFUSED") + && string_contains(s: heads, pattern: "'-user' 'ghrunner' '-exec' '/bin/chmod' 'u+r,g+r,o+r' '{}' '+'") && !string_contains(s: heads, pattern: "REFUSED") } // THE PLACEMENT'S DERIVED ENTRY MODE, from the real store directories: 0444, the other-read being From cb64dcf37600ba895fe677c472a16146db66e937 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sat, 26 Sep 2026 12:31:06 +0000 Subject: [PATCH 3/8] Repair runs as its own deploy step (no inline &&); import filter, replace bare get in touched files Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/extdeps/filesystem/filesystem_io.dag | 1 + dag/gunbc/durable_cas_file_store.dag | 7 ++++--- dag/gunbc/live_deploy/emit.dag | 23 ++++++++++++++++++----- dag/test/claim/live_deploy/emit_test.dag | 16 ++++++++++++++-- 4 files changed, 37 insertions(+), 10 deletions(-) diff --git a/dag/extdeps/filesystem/filesystem_io.dag b/dag/extdeps/filesystem/filesystem_io.dag index 0ae0c5360b2..74bd69e1c88 100644 --- a/dag/extdeps/filesystem/filesystem_io.dag +++ b/dag/extdeps/filesystem/filesystem_io.dag @@ -1,5 +1,6 @@ module extdeps.filesystem.filesystem_io +import v2.std.algebra { filter } import extdeps.external_authority { ExternalAuthority } import extdeps.uri { Uri, Https } import std.types { Bool, FilePath, Int, List, String } diff --git a/dag/gunbc/durable_cas_file_store.dag b/dag/gunbc/durable_cas_file_store.dag index 35f23e3a20c..29979a6c83a 100644 --- a/dag/gunbc/durable_cas_file_store.dag +++ b/dag/gunbc/durable_cas_file_store.dag @@ -7,6 +7,7 @@ import std.list { distinct_by_key } import std.content_hash { ContentHash, content_hash_of_value, compare_content_hash, ContentHashEqual, ContentHashDifferent, ContentHashCrossFamilyIncomparable } import std.durable_compare_and_set { CasAttempt, cas_attempt, CasOutcome, CasCommitted, CasPreconditionFailed, CasStoreRefused, CasExpectation, ExpectSlotAbsent, ExpectSlotGeneration, CasGeneration, CasSlotVersion, CasReadableAbsent, CasReadablePresent, CasSlotObservation, CasObservedReadable, CasObservedUnreadable, CasUnreadableMalformed, CasUnreadableReadRefused, CasStoreFailure, CasSlotObservationRefused, CasGenerationPublicationRefused, cas_generation_exhausted, cas_generation_first, cas_generation_count, cas_generation_successor, CasSuccessorGeneration, CasSuccessorExhausted } import std.checked_arithmetic { int_inclusive_max } +import v2.std.algebra { skip } import extdeps.access.posix { FileMode, file_mode_bits } import extdeps.filesystem.filesystem_io { Filesystem, @@ -828,9 +829,9 @@ fn cas_name_is_digits(s: String) -> Bool { fn cas_key_of_slot_name(name: String) -> NonEmptyStr? { let parts = name.split(delimiter: ".") if count(parts) < 2 { none } else { - let last = match parts |> get(count(parts) - 1) { - null => "" - p => p + let last = match first(skip(parts, count(parts) - 1)) { + Absent => "" + Present { value: p } => p } if !cas_name_is_digits(s: last) { none } else { let key = join(parts |> take(count(parts) - 1), ".") diff --git a/dag/gunbc/live_deploy/emit.dag b/dag/gunbc/live_deploy/emit.dag index be873d08d5c..61dd06f8c08 100644 --- a/dag/gunbc/live_deploy/emit.dag +++ b/dag/gunbc/live_deploy/emit.dag @@ -1422,7 +1422,9 @@ fn ensure_dependency_steps(dep: DeploymentDependencyStep, privileged: Bool) -> L EnsuredHostDirectory { path, owner } => [ deploy_raw(command: install_d_owned_for_user(user: owner, path: path as String)), ] - EnsuredManagedHostDirectory { admission } => [deploy_raw(command: ensure_managed_directory_command(admission: admission))] + EnsuredManagedHostDirectory { admission } => + concat([deploy_raw(command: ensure_managed_directory_command(admission: admission))], + map(managed_directory_entry_repair_commands(admission: admission), c => deploy_raw(command: c))) } } @@ -1449,15 +1451,26 @@ fn ensure_managed_directory_command(admission: ManagedDirectoryAdmission) -> Str // dissolve-on = PosixUser (or a PosixGroup beside it) carries the group name, at which point this // projects it instead of reusing the user's. fn install_d_managed_command(dir: ManagedDirectory) -> String { - let install = install_directory_owned_command( + install_directory_owned_command( mode: managed_directory_mode_octal(d: dir) as NonEmptyStr, owner: dir.owner.name, group: dir.group_principal.name, path: dir.path as String, ) - match dir.entries { - EntriesUnmanaged => install - EntriesReadableByDependents { repaired_by } => join([install, " && ", managed_directory_entry_repair_command(dir: dir, repaired_by: repaired_by)], "") +} + +// THE REPAIR IS ITS OWN STEP, never text glued to the install: the deploy's pipeline already +// sequences steps and fails the apply at the first one that refuses, so a second command is a +// second step rather than a shell `&&` spelled in a string. A refused admission repairs nothing -- +// its install step is already the poison marker. +fn managed_directory_entry_repair_commands(admission: ManagedDirectoryAdmission) -> List { + match admission { + ManagedDirectoryRefused { member: _, path: _, writer: _ } => [] + ManagedDirectoryAdmitted { dir } => + match dir.entries { + EntriesUnmanaged => [] + EntriesReadableByDependents { repaired_by } => [managed_directory_entry_repair_command(dir: dir, repaired_by: repaired_by)] + } } } diff --git a/dag/test/claim/live_deploy/emit_test.dag b/dag/test/claim/live_deploy/emit_test.dag index 085e221f475..fb982707868 100644 --- a/dag/test/claim/live_deploy/emit_test.dag +++ b/dag/test/claim/live_deploy/emit_test.dag @@ -113,6 +113,7 @@ import gunbc.host_layout { srv1_gunbc_approval_broker_root, srv1_gunbc_repo_root import gunbc.live_deploy.spec { approval_broker_serve_binary_is_per_release_poison } import gunbc.live_deploy.emit { ensure_managed_directory_command, + managed_directory_entry_repair_commands, approval_broker_tree_receipt_path_for, approval_broker_release_dir, approval_broker_release_binary_path, @@ -1328,10 +1329,21 @@ test fn the_fabric_storage_areas_are_ensured_writable_by_the_claim_principal() - // declared its entry mode is 0600 and the service principal cannot read it (srv1 2026-09-26). The // ensure adds the derived read bits to the entries the applying principal owns, unprivileged. Red // if the areas stop managing their entries, or the derivation refuses. +fn srv1_fabric_area_repair(path: String) -> String { + fold(deployment_spec_srv1().steps, init: "", f: (acc, st) => match st { + Dependency { step: dep } => match dep.subject { + EnsuredManagedHostDirectory { admission } => + if (ensured_subject_identity(subject: dep.subject) as String) == path { join(managed_directory_entry_repair_commands(admission: admission), "\n") } else { acc } + _ => acc + } + ArtifactStep { step: _ } => acc + }) +} + test fn the_fabric_storage_area_ensure_repairs_the_ci_principals_entries() -> Bool { let files = fabric_storage_file_root(root: dashboard_instance_fabric_storage_root(instance: srv1_live_dashboard_instance()) as String as NonEmptyStr) - let objects = srv1_fabric_area_ensure(path: files.objects as String) - let heads = srv1_fabric_area_ensure(path: files.heads as String) + let objects = srv1_fabric_area_repair(path: files.objects as String) + let heads = srv1_fabric_area_repair(path: files.heads as String) string_contains(s: objects, pattern: "'-user' 'ghrunner' '-exec' '/bin/chmod' 'u+r,g+r,o+r' '{}' '+'") && !string_contains(s: objects, pattern: "REFUSED") && string_contains(s: heads, pattern: "'-user' 'ghrunner' '-exec' '/bin/chmod' 'u+r,g+r,o+r' '{}' '+'") && !string_contains(s: heads, pattern: "REFUSED") } From ad16a42d87ab7154c72c29120117294fb777f8b1 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sat, 26 Sep 2026 13:11:49 +0000 Subject: [PATCH 4/8] Import skip in the fabric file store; retire its bare-provider roster row as ImportsFixed Co-Authored-By: Claude Opus 5.5 (1M context) --- dag/gunbc/fabric/fabric_storage_file_store.dag | 1 + src/v2/workflow/floor_unimported_bare_provider_debt_roster.dag | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/dag/gunbc/fabric/fabric_storage_file_store.dag b/dag/gunbc/fabric/fabric_storage_file_store.dag index 2cf86809741..c01623060b3 100644 --- a/dag/gunbc/fabric/fabric_storage_file_store.dag +++ b/dag/gunbc/fabric/fabric_storage_file_store.dag @@ -24,6 +24,7 @@ import gunbc.durable_cas_file_store { admit_cas_attempt_for_derived_payload, DerivedPayloadAdmitted, DerivedPayloadKeyNotSlotAddressable, } import extdeps.access.posix { FileMode, file_mode_bits } +import v2.std.algebra { skip } import extdeps.filesystem.filesystem_io { Filesystem, FilesystemCreateNew, FilesystemCreated, FilesystemCreateTargetOccupied, FilesystemCreateRefused, FilesystemCreateKindUnrecognized, diff --git a/src/v2/workflow/floor_unimported_bare_provider_debt_roster.dag b/src/v2/workflow/floor_unimported_bare_provider_debt_roster.dag index 01425ae9b51..80ccf1ac303 100644 --- a/src/v2/workflow/floor_unimported_bare_provider_debt_roster.dag +++ b/src/v2/workflow/floor_unimported_bare_provider_debt_roster.dag @@ -456,7 +456,7 @@ data unimported_bare_provider_dispositions: List Date: Sat, 26 Sep 2026 14:03:59 +0000 Subject: [PATCH 5/8] os_install actuator witness: bind the first step's Optional instead of passing it as a step A latent main type error the floor now compiles because this change reaches its closure. Co-Authored-By: Claude Opus 5.5 (1M context) --- ...nstall_actuator_selection_witness_test.dag | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/dag/test/claim/os_install_actuator_selection_witness_test.dag b/dag/test/claim/os_install_actuator_selection_witness_test.dag index bd1bf945578..9f0a35508c4 100644 --- a/dag/test/claim/os_install_actuator_selection_witness_test.dag +++ b/dag/test/claim/os_install_actuator_selection_witness_test.dag @@ -61,22 +61,23 @@ data srv3_resolved_image_fixture: SeededImageResolution = SeededImageResolved { } test fn unsatisfiable_selection_refuses_the_nbd_proxy_serve_step() -> Bool { - match actuator_step_realization_under( - step: os_install_actuator_steps( - mechanism: NbdProxyVirtualMediaInstall, - export: srv3_nbd_proxy_export_placeholder, - ).first(), - selection: unsatisfiable_actuator_selection_fixture, - image: srv3_resolved_image_fixture, - ) { - ActuatorStepRefused { cause: c } => - match c { - ActuatorStepActuatorHostUnsatisfiable { step: _, reason: why } => - string_contains(s: why, pattern: "fixture: no candidate host") - ActuatorStepRealizationAbsent { step: _ } => false - ActuatorStepImageUnresolved { step: _, reason: _ } => false + match os_install_actuator_steps(mechanism: NbdProxyVirtualMediaInstall, export: srv3_nbd_proxy_export_placeholder).first() { + Absent => false + Present { value: first_step } => + match actuator_step_realization_under( + step: first_step, + selection: unsatisfiable_actuator_selection_fixture, + image: srv3_resolved_image_fixture, + ) { + ActuatorStepRefused { cause: c } => + match c { + ActuatorStepActuatorHostUnsatisfiable { step: _, reason: why } => + string_contains(s: why, pattern: "fixture: no candidate host") + ActuatorStepRealizationAbsent { step: _ } => false + ActuatorStepImageUnresolved { step: _, reason: _ } => false + } + ActuatorStepReady { intent: _ } => false } - ActuatorStepReady { intent: _ } => false } } From a68e2bb5ef5c9572b78e5bb15a11ef55d271b7b5 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sat, 26 Sep 2026 15:35:59 +0000 Subject: [PATCH 6/8] Enroll the two wet fabric-store claims the floor now plans (route gap + local wet schedule) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/v2/workflow/floor_route_gap.dag | 6 ++++++ src/v2/workflow/local_repo_wet_terminal.dag | 18 ++++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/v2/workflow/floor_route_gap.dag b/src/v2/workflow/floor_route_gap.dag index 3b1be935035..71cede6b169 100644 --- a/src/v2/workflow/floor_route_gap.dag +++ b/src/v2/workflow/floor_route_gap.dag @@ -1387,6 +1387,10 @@ fn floor_route_gap_expectation_chunk_17() -> List { tail: Cons { head: FloorRouteGapExpectation { identity: "test.claim.fabric.fabric_storage_file_store_wet_witness.a_lost_race_through_the_served_handler_decodes_as_the_same_lost_race_by_real_execution", operation: "DirWithTemplate", ground: NoMockResponse {} }, tail: Cons { + head: FloorRouteGapExpectation { identity: "test.claim.fabric.fabric_storage_file_store_wet_witness.a_declared_entry_mode_is_the_published_mode_of_objects_and_heads", operation: "DirWithTemplate", ground: NoMockResponse {} }, + tail: Cons { + head: FloorRouteGapExpectation { identity: "test.claim.fabric.fabric_storage_file_store_wet_witness.the_identity_route_echoes_the_presented_login_and_touches_no_head_by_real_execution", operation: "DirWithTemplate", ground: NoMockResponse {} }, + tail: Cons { head: FloorRouteGapExpectation { identity: "test.claim.fabric.fabric_partition_read_wet_witness.an_empty_partition_reports_an_absent_head_and_no_events_by_real_execution", operation: "DirWithTemplate", ground: NoMockResponse {} }, tail: Cons { head: FloorRouteGapExpectation { identity: "test.claim.fabric.fabric_partition_read_wet_witness.a_two_event_partition_reports_both_events_oldest_first_by_real_execution", operation: "DirWithTemplate", ground: NoMockResponse {} }, @@ -1414,6 +1418,8 @@ fn floor_route_gap_expectation_chunk_17() -> List { } } } + } + } } // The four test.claim.shell_spawn_refused_real_execution_witness identities. Their subject IS the diff --git a/src/v2/workflow/local_repo_wet_terminal.dag b/src/v2/workflow/local_repo_wet_terminal.dag index 63fd1baa635..3d3a35a4ec3 100644 --- a/src/v2/workflow/local_repo_wet_terminal.dag +++ b/src/v2/workflow/local_repo_wet_terminal.dag @@ -188,14 +188,16 @@ fn local_repo_wet_requirement(candidate: String) -> WetEvidenceRequirement { // too-small host produces an OOM kill and not a verdict -- which is `SelectedButNotExecuted`, // carrying no evidence in either direction. // -// FIFTEEN FABRIC DB MEMBERS (test.claim.fabric.fabric_storage_file_store_wet_witness, +// SEVENTEEN FABRIC DB MEMBERS (test.claim.fabric.fabric_storage_file_store_wet_witness, // test.claim.fabric.fabric_event_log_wet_witness and // test.claim.fabric.fabric_partition_read_wet_witness). Their effects are a temp directory and files // created inside it -- the fabric DB's object and head areas -- with no network and nothing outside // the directory. They are the file store's refusal arms (lost race, missing object, a hole, a // corrupt object, the bound, a navigating name), the served handler's store side, the read-only partition readout, and the event log's route over it. They replaced // the three seat-ledger append members when the log moved off git (2026-09-19). Measured wet -// through `gunbc run` over this tree before scheduling: all fifteen held. +// through `gunbc run` over this tree before scheduling: all fifteen held. Two more joined on +// gunbc#12342 -- the declared entry mode's published-mode claim, and the served identity route, +// which had never been planned until a change to its file made the floor plan it -- both held wet. // // THREE DEVBOOT TEXT-BLOB MEMBERS (test.claim.devboot_text_blob_real_execution), the same // local-repo effects: a temp directory holding a bare store or a worktree repository, a symlink @@ -1265,6 +1267,18 @@ fn local_repo_wet_schedule() -> List { function: "a_lost_race_through_the_served_handler_decodes_as_the_same_lost_race_by_real_execution", expectation: ExpectedToHold {} }, + WetScheduledClaim { + identity: WitnessIdentity { module_path: "test.claim.fabric.fabric_storage_file_store_wet_witness", function: "a_declared_entry_mode_is_the_published_mode_of_objects_and_heads" }, + entry: "dag/test/claim/fabric/fabric_storage_file_store_wet_witness_test.dag", + function: "a_declared_entry_mode_is_the_published_mode_of_objects_and_heads", + expectation: ExpectedToHold {} + }, + WetScheduledClaim { + identity: WitnessIdentity { module_path: "test.claim.fabric.fabric_storage_file_store_wet_witness", function: "the_identity_route_echoes_the_presented_login_and_touches_no_head_by_real_execution" }, + entry: "dag/test/claim/fabric/fabric_storage_file_store_wet_witness_test.dag", + function: "the_identity_route_echoes_the_presented_login_and_touches_no_head_by_real_execution", + expectation: ExpectedToHold {} + }, WetScheduledClaim { identity: WitnessIdentity { module_path: "test.claim.fabric.fabric_partition_read_wet_witness", function: "an_empty_partition_reports_an_absent_head_and_no_events_by_real_execution" }, entry: "dag/test/claim/fabric/fabric_partition_read_wet_witness_test.dag", From 7ad4dbf95555c95c5ad056fafe1a8edf91a3aa79 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sat, 26 Sep 2026 20:59:41 +0000 Subject: [PATCH 7/8] Resync the stage0 emit_rust mirror after merging main (installed by claim_executor --regen-round-cost) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/v1/stage0/src/v1_compiler_emit_rust.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/v1/stage0/src/v1_compiler_emit_rust.rs b/src/v1/stage0/src/v1_compiler_emit_rust.rs index d58dddc2bef..48906fb09fc 100644 --- a/src/v1/stage0/src/v1_compiler_emit_rust.rs +++ b/src/v1/stage0/src/v1_compiler_emit_rust.rs @@ -145,7 +145,8 @@ use crate::v1_compiler_emit::FileResultChannel::{ FileChanSuccess, }; use crate::v1_compiler_emit::FileVerb::{ - FileDelete, FileList, FileRead, FileWrite, FileWriteCreateNew, FileWriteOwnerOnly, + FileDelete, FileList, FileRead, FileWrite, FileWriteCreateNew, FileWriteCreateNewWithMode, + FileWriteOwnerOnly, }; use crate::v1_compiler_emit::ShellEmissionRefusal::ShellChannelNotRealizedByTarget; use crate::v1_compiler_emit::ShellResultChannel::{ @@ -37501,6 +37502,7 @@ pub fn file_verb_action_expr(verb: FileVerb) -> String { FileVerb::FileWrite => file_write_expr(), FileVerb::FileWriteOwnerOnly => file_write_owner_only_expr(), FileVerb::FileWriteCreateNew => file_write_create_new_expr(), + FileVerb::FileWriteCreateNewWithMode => file_write_create_new_with_mode_expr(), FileVerb::FileDelete => file_delete_match_expr(), FileVerb::FileList => file_list_match_expr(), } @@ -37696,7 +37698,16 @@ pub fn file_write_expr() -> String { pub fn file_write_create_new_expr() -> String { thread_local! { static CACHED: String = { - "{\n let payload_bytes = content.len() as i64;\n match v1_rt::gunbc_file_write_create_new(&file_path, content.as_bytes()) {\n Ok(()) => (true, String::new(), String::new(), payload_bytes, String::new()),\n Err(create_new_err) => (false, String::new(), format!(\"{}\", create_new_err), 0i64, file_io_error_kind(&create_new_err)),\n }\n};".to_string() + "{\n let payload_bytes = content.len() as i64;\n match v1_rt::gunbc_file_write_create_new(&file_path, content.as_bytes(), None) {\n Ok(()) => (true, String::new(), String::new(), payload_bytes, String::new()),\n Err(create_new_err) => (false, String::new(), format!(\"{}\", create_new_err), 0i64, file_io_error_kind(&create_new_err)),\n }\n};".to_string() + }; + } + CACHED.with(|c: &String| c.clone()) +} + +pub fn file_write_create_new_with_mode_expr() -> String { + thread_local! { + static CACHED: String = { + "{\n let payload_bytes = content.len() as i64;\n let declared_mode = u32::try_from(mode).ok().filter(|m| *m <= 0o7777);\n let create_result = match declared_mode {\n Some(m) => v1_rt::gunbc_file_write_create_new(&file_path, content.as_bytes(), Some(m)),\n None => Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!(\"write_create_new_with_mode refused: {} is not a permission-bit value\", mode))),\n };\n match create_result {\n Ok(()) => (true, String::new(), String::new(), payload_bytes, String::new()),\n Err(create_new_err) => (false, String::new(), format!(\"{}\", create_new_err), 0i64, file_io_error_kind(&create_new_err)),\n }\n};".to_string() }; } CACHED.with(|c: &String| c.clone()) From b0b78514550ba0672da03d26fc3e53725cbbcec5 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sat, 26 Sep 2026 21:48:35 +0000 Subject: [PATCH 8/8] Resync the stage0 emit_rust mirror after merging main again (installed by --regen-round-cost; the next round converges with 0 stages) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/v1/stage0/src/v1_compiler_emit_rust.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/v1/stage0/src/v1_compiler_emit_rust.rs b/src/v1/stage0/src/v1_compiler_emit_rust.rs index d6a2233b3a0..3d3933087aa 100644 --- a/src/v1/stage0/src/v1_compiler_emit_rust.rs +++ b/src/v1/stage0/src/v1_compiler_emit_rust.rs @@ -37703,6 +37703,7 @@ pub fn file_write_create_new_expr() -> String { } CACHED.with(|c: &String| c.clone()) } + pub fn file_write_create_new_with_mode_expr() -> String { thread_local! { static CACHED: String = {