From 94e8a7855a19d95ec61a8ee27d409d382bfc8022 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 22:07:20 -0400 Subject: [PATCH 1/4] feat: define native persistent data identity codec --- packages/runtime-core/README.md | 25 ++ .../src/provider/graph/native/mod.rs | 1 + .../provider/graph/native/persistent_data.rs | 259 ++++++++++++++++ .../graph/native/persistent_data/tests.rs | 288 ++++++++++++++++++ 4 files changed, 573 insertions(+) create mode 100644 packages/runtime-core/src/provider/graph/native/persistent_data.rs create mode 100644 packages/runtime-core/src/provider/graph/native/persistent_data/tests.rs diff --git a/packages/runtime-core/README.md b/packages/runtime-core/README.md index 3570fd6cf..1fc9ed1c5 100644 --- a/packages/runtime-core/README.md +++ b/packages/runtime-core/README.md @@ -1372,3 +1372,28 @@ machine name and both disks' current identities and declared sizes. Every sample and the acquired startup lease recheck the selected owner; changed ownership refuses, and a reserve-qualified request cannot enter VM create or boot. Stopped, missing or unproved capacity retains fresh-allocation requirements or refuses. + +### Inactive native persistent-data codec + +`provider::graph::native::persistent_data` defines one closed, private-candidate +version-1 owner record and a pure comparison. Its persistent binding contains a +64-hex project namespace, logical storage name, 32-hex owner, exact guest owner, +boot UUID and backing-disk identity, and the default local-volume policy. An +enrolled record retains the exact volume name, UTC `CreatedAt` bytes and directory +device/inode. Runtime run, plan and generation IDs are absent, so independent +compute generations can compare the same explicitly selected data identity. + +Pending intent is distinct from enrolled observation and always refuses the +comparison, even when supplied an exact volume observation. Missing observations, +unknown/duplicate fields, malformed identities, unsupported versions/policies, +and scope, guest, birth or directory changes refuse. Copied labels do not make a +replacement volume match its original birth. Guest boot/disk rollover remains a +refusal requiring a future separately owned handoff; no migration is inferred. + +This codec is inactive: no command reads or writes it and no create, adoption, +promotion, repair or deletion path is added. Decoding an enrolled assertion does +not prove a durable enrollment commit, fresh observation, contents or effect +authority. It is separate from dependency-cache provenance and does not qualify +persistent databases, initializer replay, SQL retention or the full NC05 corpus. +Future enrollment and runtime owners must establish those commit/freshness and +same-data lifecycle gates before activation. diff --git a/packages/runtime-core/src/provider/graph/native/mod.rs b/packages/runtime-core/src/provider/graph/native/mod.rs index 76d7cf022..585c9c485 100644 --- a/packages/runtime-core/src/provider/graph/native/mod.rs +++ b/packages/runtime-core/src/provider/graph/native/mod.rs @@ -4,6 +4,7 @@ use crate::{project::native::NativeInputs, provider::native_input}; #[cfg(target_os = "macos")] pub mod foreground; mod journal; +pub mod persistent_data; mod runtime; pub mod selection; pub use journal::{Phase, Receipt}; diff --git a/packages/runtime-core/src/provider/graph/native/persistent_data.rs b/packages/runtime-core/src/provider/graph/native/persistent_data.rs new file mode 100644 index 000000000..c1fda6c0b --- /dev/null +++ b/packages/runtime-core/src/provider/graph/native/persistent_data.rs @@ -0,0 +1,259 @@ +//! Inactive, data-only persistent-volume identity codec for the private native candidate. +//! +//! A decoded enrolled record is an assertion to compare, not proof of durable enrollment, +//! freshness, or permission to create, adopt, start, repair or delete anything. No runtime +//! generation, graph run, plan, dependency-cache completion or application data is encoded. +//! Future enrollment must own its commit point and obtain the original identities itself. + +use crate::CandidateError; +use serde::{Deserialize, Serialize}; + +pub use crate::provider::identity::DiskIdentity; + +const LIMIT: usize = 4096; + +fn refused() -> CandidateError { + CandidateError::new( + "native_persistent_data_identity", + "Persistent data enrollment is incomplete, malformed or changed; no data effects were authorized.", + ) +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Scope { + pub namespace: String, + pub storage: String, + pub owner: String, +} + +/// Conservative same-guest fence. A different boot or backing disk requires a future +/// separately owned handoff; this codec never infers that two guests share the same data. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GuestIdentity { + pub owner: String, + pub boot_id: String, + pub storage: DiskIdentity, +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Local { + Local, +} +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct NoOptions {} + +/// Only the existing default local-volume policy is representable. Unknown driver, +/// scope or options refuse rather than importing provider/cache semantics. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Policy { + pub driver: Local, + pub scope: Local, + pub options: NoOptions, +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Binding { + pub scope: Scope, + pub guest: GuestIdentity, + pub policy: Policy, +} +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DirectoryIdentity { + pub device: u64, + pub inode: u64, +} +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct VolumeIdentity { + pub name: String, + pub created_at: String, + pub directory: DirectoryIdentity, +} + +/// Complete already-acquired observation. Constructing or deserializing this object +/// does not establish that an engine or guest actually supplied these facts. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Observation { + pub binding: Binding, + pub volume: VolumeIdentity, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +enum Kind { + NativePersistentDataOwner, +} +#[derive(Clone, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case", deny_unknown_fields)] +enum Enrollment { + Pending { intent: String, volume_name: String }, + Enrolled { volume: VolumeIdentity }, +} +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Record { + version: u8, + kind: Kind, + binding: Binding, + enrollment: Enrollment, +} + +/// Closed validated record; no public constructor/promoter or effect capability. +#[derive(Clone, Serialize)] +#[serde(transparent)] +pub struct Owner(Record); + +/// Decode only, with the byte bound applied before deserialization allocations. +/// Duplicate/unknown fields, missing/null values and unsupported versions refuse. +pub fn decode(bytes: &[u8]) -> Result { + if bytes.is_empty() || bytes.len() > LIMIT { + return Err(refused()); + } + let record: Record = serde_json::from_slice(bytes).map_err(|_| refused())?; + let enrollment_valid = match &record.enrollment { + Enrollment::Pending { + intent, + volume_name, + } => super::super::hex(intent, 32) && volume_name_valid(volume_name), + Enrollment::Enrolled { volume } => volume_valid(volume), + }; + if record.version != 1 || !binding_valid(&record.binding) || !enrollment_valid { + return Err(refused()); + } + Ok(Owner(record)) +} + +pub struct CompareOptions<'a> { + pub record: &'a Owner, + pub expected: &'a Binding, + pub observed: Option<&'a Observation>, +} + +/// Pure equality fence independent of any caller's runtime-generation reference. +/// Pending enrollment never matches, including an otherwise exact current observation. +/// Success is not durable-record, observation freshness, lease or execution authority. +pub fn compare(options: CompareOptions<'_>) -> Result<(), CandidateError> { + let Enrollment::Enrolled { volume } = &options.record.0.enrollment else { + return Err(refused()); + }; + let observed = options.observed.ok_or_else(refused)?; + if !binding_valid(options.expected) + || !binding_valid(&observed.binding) + || !volume_valid(&observed.volume) + || options.record.0.binding != *options.expected + || observed.binding != *options.expected + || observed.volume != *volume + { + return Err(refused()); + } + Ok(()) +} + +fn binding_valid(binding: &Binding) -> bool { + let guest = &binding.guest; + let disk = &guest.storage; + super::super::hex(&binding.scope.namespace, 64) + && super::super::hex(&binding.scope.owner, 32) + && logical_name(&binding.scope.storage) + && super::super::hex(&guest.owner, 32) + && uuid(&guest.boot_id) + && disk.inode > 0 + && disk.bytes > 0 + && uuid(&disk.uuid) +} +fn logical_name(value: &str) -> bool { + !value.is_empty() + && value.len() <= 63 + && value.bytes().enumerate().all(|(index, byte)| { + byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || (index > 0 && matches!(byte, b'-' | b'_' | b'.')) + }) +} +fn volume_name_valid(value: &str) -> bool { + !value.is_empty() + && value.len() <= 255 + && value.bytes().enumerate().all(|(index, byte)| { + byte.is_ascii_alphanumeric() || (index > 0 && matches!(byte, b'-' | b'_' | b'.')) + }) +} +fn uuid(value: &str) -> bool { + value.len() == 36 + && value != "00000000-0000-0000-0000-000000000000" + && value.bytes().enumerate().all(|(index, byte)| { + if [8, 13, 18, 23].contains(&index) { + byte == b'-' + } else { + byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte) + } + }) +} +fn volume_valid(volume: &VolumeIdentity) -> bool { + volume_name_valid(&volume.name) && volume.directory.inode > 0 && timestamp(&volume.created_at) +} + +// Closed UTC RFC3339Nano spelling. Equality retains the supplied engine bytes; +// alternate spellings are not silently normalized into a replacement's birth. +fn timestamp(value: &str) -> bool { + let bytes = value.as_bytes(); + if !(20..=30).contains(&bytes.len()) + || bytes.last() != Some(&b'Z') + || [4, 7].iter().any(|index| bytes[*index] != b'-') + || bytes[10] != b'T' + || [13, 16].iter().any(|index| bytes[*index] != b':') + || !(bytes.len() == 20 || (bytes.len() >= 22 && bytes[19] == b'.')) + || bytes.iter().enumerate().any(|(index, byte)| { + ![4, 7, 10, 13, 16, bytes.len() - 1].contains(&index) + && !(index == 19 && bytes.len() > 20) + && !byte.is_ascii_digit() + }) + { + return false; + } + let number = |start: usize, end: usize| { + bytes[start..end] + .iter() + .fold(0_u32, |n, b| n * 10 + u32::from(*b - b'0')) + }; + let year = number(0, 4); + let month = number(5, 7); + let day = number(8, 10); + let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + let days = match month { + 2 => { + if leap { + 29 + } else { + 28 + } + } + 4 | 6 | 9 | 11 => 30, + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + _ => 0, + }; + let zero_birth = year == 1 + && month == 1 + && day == 1 + && number(11, 13) == 0 + && number(14, 16) == 0 + && number(17, 19) == 0 + && (bytes.len() == 20 || bytes[20..bytes.len() - 1].iter().all(|b| *b == b'0')); + year > 0 + && !zero_birth + && day > 0 + && day <= days + && number(11, 13) < 24 + && number(14, 16) < 60 + && number(17, 19) < 60 +} + +#[cfg(test)] +mod tests; diff --git a/packages/runtime-core/src/provider/graph/native/persistent_data/tests.rs b/packages/runtime-core/src/provider/graph/native/persistent_data/tests.rs new file mode 100644 index 000000000..d3f2bff7a --- /dev/null +++ b/packages/runtime-core/src/provider/graph/native/persistent_data/tests.rs @@ -0,0 +1,288 @@ +use super::*; +use serde_json::{Value, json}; + +fn input(pending: bool) -> Value { + json!({ + "version": 1, "kind": "native-persistent-data-owner", + "binding": { + "scope": {"namespace": "a".repeat(64), "storage": "db_data", "owner": "b".repeat(32)}, + "guest": { + "owner": "c".repeat(32), "boot_id": "11111111-2222-3333-4444-555555555555", + "storage": {"device": 0, "inode": 25, "bytes": 8192, "uuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"} + }, + "policy": {"driver": "local", "scope": "local", "options": {}} + }, + "enrollment": if pending { + json!({"status": "pending", "intent": "d".repeat(32), "volume_name": "hkp-owned-db_data"}) + } else { + json!({"status": "enrolled", "volume": { + "name": "hkp-owned-db_data", "created_at": "2026-10-08T12:34:56.123456789Z", + "directory": {"device": 0, "inode": 91} + }}) + } + }) +} +fn owner(value: &Value) -> Owner { + decode(&serde_json::to_vec(value).unwrap()).unwrap() +} +fn observation(value: &Value) -> Observation { + serde_json::from_value(json!({ + "binding": value["binding"], "volume": value["enrollment"]["volume"] + })) + .unwrap() +} +fn matches(record: &Owner, expected: &Binding, observed: Option<&Observation>) -> bool { + compare(CompareOptions { + record, + expected, + observed, + }) + .is_ok() +} +fn rejects(value: Value) { + let error = decode(&serde_json::to_vec(&value).unwrap()).err().unwrap(); + assert_eq!(error.code, "native_persistent_data_identity"); + assert!( + !serde_json::to_string(&error) + .unwrap() + .contains("private-canary") + ); +} + +#[test] +fn independent_runtime_generations_share_only_the_explicit_persistent_binding() { + let raw = input(false); + let record = owner(&raw); + let observed = observation(&raw); + for run in ["1".repeat(32), "2".repeat(32)] { + // The caller may have a different run reference; it is neither compared nor encoded. + let runtime_scope = crate::provider::native_input::Scope { + namespace: &observed.binding.scope.namespace, + run: &run, + }; + let mut expected = observed.binding.clone(); + expected.scope.namespace = runtime_scope.namespace.into(); + assert!(matches(&record, &expected, Some(&observed))); + let encoded = serde_json::to_string(&record).unwrap(); + assert!(!encoded.contains(runtime_scope.run)); + assert!(!encoded.contains("generation")); + assert!(!encoded.contains("\"run\"")); + assert!(!encoded.contains("\"plan\"")); + } + assert_eq!(serde_json::to_value(record).unwrap(), raw); +} + +#[test] +fn pending_is_roundtrippable_but_never_enrolled_even_with_exact_observation() { + let pending = input(true); + let record = owner(&pending); + let observed = observation(&input(false)); + assert_eq!(serde_json::to_value(&record).unwrap(), pending); + assert!(!matches(&record, &observed.binding, Some(&observed))); + assert!(!matches(&record, &observed.binding, None)); + assert!(!matches(&owner(&input(false)), &observed.binding, None)); + let mut mixed = pending; + mixed["enrollment"]["volume"] = input(false)["enrollment"]["volume"].clone(); + rejects(mixed); +} + +#[test] +fn every_observed_identity_change_refuses_including_copied_labels_new_birth() { + let raw = input(false); + let record = owner(&raw); + let expected = observation(&raw).binding; + for (pointer, value) in [ + ("/binding/scope/namespace", json!("f".repeat(64))), + ("/binding/scope/storage", json!("other_data")), + ("/binding/scope/owner", json!("f".repeat(32))), + ("/binding/guest/owner", json!("f".repeat(32))), + ( + "/binding/guest/boot_id", + json!("ffffffff-2222-3333-4444-555555555555"), + ), + ("/binding/guest/storage/device", json!(1)), + ("/binding/guest/storage/inode", json!(26)), + ("/binding/guest/storage/bytes", json!(16384)), + ( + "/binding/guest/storage/uuid", + json!("ffffffff-bbbb-cccc-dddd-eeeeeeeeeeee"), + ), + ("/enrollment/volume/name", json!("hkp-other-db_data")), + ( + "/enrollment/volume/created_at", + json!("2026-10-08T12:34:56.123456790Z"), + ), + ("/enrollment/volume/directory/device", json!(1)), + ("/enrollment/volume/directory/inode", json!(92)), + ] { + let mut changed = raw.clone(); + *changed.pointer_mut(pointer).unwrap() = value; + let observed = observation(&changed); + assert!( + !matches(&record, &expected, Some(&observed)), + "changed field {pointer}" + ); + if pointer.starts_with("/binding/") { + assert!( + !matches(&record, &observed.binding, Some(&observation(&raw))), + "expected field {pointer}" + ); + } + } +} + +#[test] +fn strict_wire_refuses_versions_kinds_missing_null_unknown_fields_and_policy() { + for (pointer, value) in [ + ("/version", json!(2)), + ("/version", json!(1.0)), + ("/kind", json!("cache-provenance")), + ("/binding/scope/namespace", json!("A".repeat(64))), + ("/binding/scope/owner", json!("b".repeat(31))), + ("/binding/scope/storage", json!("../private-canary")), + ( + "/binding/guest/boot_id", + json!("00000000-0000-0000-0000-000000000000"), + ), + ( + "/binding/guest/boot_id", + json!("11111111-2222-3333-4444-555555555555\n"), + ), + ("/binding/guest/storage/uuid", json!("missing")), + ("/binding/guest/storage/bytes", json!(0)), + ("/binding/guest/storage/inode", json!(0)), + ("/binding/policy/driver", json!("foreign")), + ("/binding/policy/scope", json!("global")), + ("/binding/policy/options", json!({"type":"bind"})), + ("/enrollment/status", json!("adopted")), + ("/enrollment/volume/name", json!("/private-canary")), + ("/enrollment/volume/directory/inode", json!(0)), + ] { + let mut changed = input(false); + *changed.pointer_mut(pointer).unwrap() = value; + rejects(changed); + } + for pointer in [ + "", + "/binding", + "/binding/scope", + "/binding/guest", + "/binding/guest/storage", + "/binding/policy", + "/binding/policy/options", + "/enrollment", + "/enrollment/volume", + "/enrollment/volume/directory", + ] { + let mut changed = input(false); + changed + .pointer_mut(pointer) + .unwrap() + .as_object_mut() + .unwrap() + .insert("private-canary".into(), json!(true)); + rejects(changed); + } + for key in ["version", "kind", "binding", "enrollment"] { + let mut missing = input(false); + missing.as_object_mut().unwrap().remove(key); + rejects(missing); + let mut null = input(false); + null[key] = Value::Null; + rejects(null); + } + for (parent, key) in [ + ("/binding", "scope"), + ("/binding", "guest"), + ("/binding", "policy"), + ("/binding/scope", "namespace"), + ("/binding/scope", "storage"), + ("/binding/scope", "owner"), + ("/binding/guest", "owner"), + ("/binding/guest", "boot_id"), + ("/binding/guest", "storage"), + ("/binding/guest/storage", "device"), + ("/binding/guest/storage", "inode"), + ("/binding/guest/storage", "bytes"), + ("/binding/guest/storage", "uuid"), + ("/binding/policy", "driver"), + ("/binding/policy", "scope"), + ("/binding/policy", "options"), + ("/enrollment", "status"), + ("/enrollment", "volume"), + ("/enrollment/volume", "name"), + ("/enrollment/volume", "created_at"), + ("/enrollment/volume", "directory"), + ("/enrollment/volume/directory", "device"), + ("/enrollment/volume/directory", "inode"), + ] { + let mut missing = input(false); + missing + .pointer_mut(parent) + .unwrap() + .as_object_mut() + .unwrap() + .remove(key); + rejects(missing); + let mut null = input(false); + null.pointer_mut(parent).unwrap()[key] = Value::Null; + rejects(null); + } + let mut null = input(false); + null["binding"]["policy"]["options"] = Value::Null; + rejects(null); + let mut invalid_pending = input(true); + invalid_pending["enrollment"]["intent"] = json!("invalid"); + rejects(invalid_pending); +} + +#[test] +fn duplicate_fields_and_preallocation_bound_refuse() { + let raw = serde_json::to_string(&input(false)).unwrap(); + for (needle, duplicate) in [ + ("\"version\":1", "\"version\":1,\"version\":1"), + ( + "\"status\":\"enrolled\"", + "\"status\":\"enrolled\",\"status\":\"enrolled\"", + ), + ( + "\"storage\":\"db_data\"", + "\"storage\":\"db_data\",\"storage\":\"db_data\"", + ), + ("\"inode\":91", "\"inode\":91,\"inode\":91"), + ] { + assert!(raw.contains(needle)); + assert!(decode(raw.replacen(needle, duplicate, 1).as_bytes()).is_err()); + } + assert!(decode(&[]).is_err()); + assert!(decode(&vec![b' '; LIMIT + 1]).is_err()); + assert!(decode(b"\xff").is_err()); + assert!(decode(format!("{raw} {raw}").as_bytes()).is_err()); +} + +#[test] +fn malformed_or_unavailable_birth_refuses_without_normalizing_identity() { + for birth in [ + "", + "private-canary", + "0001-01-01T00:00:00Z", + "0001-01-01T00:00:00.000000000Z", + "2026-02-29T00:00:00Z", + "2026-10-08T24:00:00Z", + "2026-10-08T12:34:56.Z", + "2026-10-08T12:34:56.1234567890Z", + "2026-10-08T12:34:56+00:00", + ] { + let mut changed = input(false); + changed["enrollment"]["volume"]["created_at"] = json!(birth); + rejects(changed); + } + let mut changed = input(false); + changed["enrollment"]["volume"]["created_at"] = Value::Null; + rejects(changed); + for birth in ["2000-02-29T00:00:00Z", "2026-10-08T12:34:56.0Z"] { + let mut changed = input(false); + changed["enrollment"]["volume"]["created_at"] = json!(birth); + assert!(decode(&serde_json::to_vec(&changed).unwrap()).is_ok()); + } +} From 90e3373697ba5531def571981b806fe13023cac2 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Thu, 8 Oct 2026 22:34:47 -0400 Subject: [PATCH 2/4] feat(native): add inactive persistent data enrollment owner --- packages/runtime-core/README.md | 34 +- .../provider/graph/native/persistent_data.rs | 2 + .../native/persistent_data/enrollment.rs | 547 +++++++++++++++++ .../persistent_data/enrollment/tests.rs | 562 ++++++++++++++++++ scripts/lib/tla-runtime-models.ts | 37 ++ tests/models/tla/README.md | 27 + .../Enrollment.tla | 75 +++ .../native-persistent-enrollment/negative.cfg | 8 + .../pending-read.cfg | 8 + .../native-persistent-enrollment/positive.cfg | 8 + .../retire-data.cfg | 8 + .../wrong-birth.cfg | 8 + tests/tla-runtime-models.test.ts | 24 + 13 files changed, 1346 insertions(+), 2 deletions(-) create mode 100644 packages/runtime-core/src/provider/graph/native/persistent_data/enrollment.rs create mode 100644 packages/runtime-core/src/provider/graph/native/persistent_data/enrollment/tests.rs create mode 100644 tests/models/tla/native-persistent-enrollment/Enrollment.tla create mode 100644 tests/models/tla/native-persistent-enrollment/negative.cfg create mode 100644 tests/models/tla/native-persistent-enrollment/pending-read.cfg create mode 100644 tests/models/tla/native-persistent-enrollment/positive.cfg create mode 100644 tests/models/tla/native-persistent-enrollment/retire-data.cfg create mode 100644 tests/models/tla/native-persistent-enrollment/wrong-birth.cfg diff --git a/packages/runtime-core/README.md b/packages/runtime-core/README.md index 1fc9ed1c5..473e09117 100644 --- a/packages/runtime-core/README.md +++ b/packages/runtime-core/README.md @@ -1390,10 +1390,40 @@ and scope, guest, birth or directory changes refuse. Copied labels do not make a replacement volume match its original birth. Guest boot/disk rollover remains a refusal requiring a future separately owned handoff; no migration is inferred. -This codec is inactive: no command reads or writes it and no create, adoption, -promotion, repair or deletion path is added. Decoding an enrolled assertion does +This codec alone is data-only: no command reads or writes it. Decoding an enrolled assertion does not prove a durable enrollment commit, fresh observation, contents or effect authority. It is separate from dependency-cache provenance and does not qualify persistent databases, initializer replay, SQL retention or the full NC05 corpus. Future enrollment and runtime owners must establish those commit/freshness and same-data lifecycle gates before activation. + +### Inactive persistent-data enrollment owner + +`persistent_data::enrollment` adds an inactive private-filesystem lifecycle with no +production transport adapter. The owner uses a stable namespace/storage slot, +independent of compute generations, under an explicitly supplied existing private +host directory outside application data. An exclusive slot/lock and pending +record are synchronized before the sole original-create attempt. Promotion +requires that attempt's captured volume identity, matching fresh observations, +and unchanged guest, root, lock and original record identities/bytes. Existing +volume names, slots, incomplete staging and pending attempts refuse; no later +invocation recreates or promotes an interrupted attempt. + +The sealed transport requires atomic exclusive creation under an existing guest +effect owner. An absence probe followed by Docker's idempotent volume create API +does not satisfy this contract. Only an atomic stand-in implements it here. No +command, engine/provider adapter, adoption, repair, deletion or global default is +activated. Existing-only retained reads acquire the existing lock, compare the +exact binding/birth/directory, and leave record bytes and generation references +unchanged. Pending or missing/foreign state cannot become enrollment by reading. + +Before rename, failures retain pending or incomplete staging. After rename, a +publication/directory-sync failure is uncertain: the enrolled pathname may exist, +but the operation returns no successful durable commit. A later retained read is +a fresh data-only comparison and cannot retroactively prove that failed operation +completed, or authorize runtime effects by itself. The lock coordinates these +writers; unsynchronized same-user external mutations are not atomically frozen. +Local synchronous filesystem I/O and trusted transport deadline obligations are +not process cancellation or crash-durability proof. Real private-filesystem and +stand-in tests qualify sequencing/refusal; persistent SQL and full NC05 runtime +retention remain open. diff --git a/packages/runtime-core/src/provider/graph/native/persistent_data.rs b/packages/runtime-core/src/provider/graph/native/persistent_data.rs index c1fda6c0b..174afd767 100644 --- a/packages/runtime-core/src/provider/graph/native/persistent_data.rs +++ b/packages/runtime-core/src/provider/graph/native/persistent_data.rs @@ -257,3 +257,5 @@ fn timestamp(value: &str) -> bool { #[cfg(test)] mod tests; + +pub mod enrollment; diff --git a/packages/runtime-core/src/provider/graph/native/persistent_data/enrollment.rs b/packages/runtime-core/src/provider/graph/native/persistent_data/enrollment.rs new file mode 100644 index 000000000..e8016fb2d --- /dev/null +++ b/packages/runtime-core/src/provider/graph/native/persistent_data/enrollment.rs @@ -0,0 +1,547 @@ +//! Inactive durable enrollment owner. No production engine implements its transport. +//! +//! The transport must atomically create a previously absent volume under its retained +//! guest authority. Docker's idempotent volumes/create response, an absence probe, copied +//! labels or empty contents cannot meet that obligation. Errors never retry creation, +//! adopt an observed volume, delete data or recover an interrupted enrollment. + +use super::{Binding, CompareOptions, Enrollment, Kind, Observation, Owner, Record}; +use crate::{CandidateError, provider::state, reject_aliased_state}; +use sha2::{Digest, Sha256}; +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Instant; + +// Sealing prevents callers from passing an arbitrary observation callback as create +// authority. A future crate-owned adapter requires its own effect qualification. +pub(crate) mod sealed { + pub trait Transport {} +} + +/// Trusted bounded transport under the existing guest/effect owner. No production +/// implementation exists. Each method must honor the supplied aggregate deadline; +/// `create_new` must refuse existing names atomically, including competing creators. +pub trait Transport: sealed::Transport { + fn verify(&mut self, expected: &Binding, deadline: Instant) -> Result<(), CandidateError>; + fn inspect( + &mut self, + name: &str, + deadline: Instant, + ) -> Result, CandidateError>; + fn create_new( + &mut self, + request: &CreateRequest, + deadline: Instant, + ) -> Result; +} + +/// Immutable original-create request, assembled only after the pending intent is durable. +/// It contains stable storage ownership, never a compute generation/run/plan reference. +pub struct CreateRequest { + binding: Binding, + name: String, + intent: String, +} +impl CreateRequest { + pub fn binding(&self) -> &Binding { + &self.binding + } + pub fn name(&self) -> &str { + &self.name + } + pub fn intent(&self) -> &str { + &self.intent + } +} + +pub struct EnrollOptions<'a> { + /// Existing private host directory external to application data; never initialized here. + pub state_root: &'a Path, + pub binding: &'a Binding, + /// Fresh owner-supplied nonce; no runtime generation is encoded or inferred. + pub intent: &'a str, + pub deadline: Instant, + pub cancelled: &'a AtomicBool, +} +pub struct ReadOptions<'a> { + pub state_root: &'a Path, + pub binding: &'a Binding, + pub deadline: Instant, + pub cancelled: &'a AtomicBool, +} + +/// Exclusively enroll one original create. The pending file and its parent are synced +/// before the sole create call. Promotion requires that call's captured identity plus +/// fresh matching observation and unchanged original private files/locks/guest fences. +/// Before rename, failure preserves pending (or incomplete staging); after rename, +/// publication/sync failure is uncertain and cannot return successful enrollment. +/// Nothing resumes or promotes an interrupted attempt in a subsequent invocation. +pub fn enroll_new( + options: EnrollOptions<'_>, + transport: &mut T, +) -> Result { + enroll(options, transport, &mut SystemSync) +} + +/// Observe existing enrollment under its existing lock. Missing root, lock, record, +/// pending intent or staging residue refuses without creating or repairing any file. +/// Success is a fresh retained-read comparison, not engine execution/deletion authority. +pub fn read_retained( + options: ReadOptions<'_>, + transport: &mut T, +) -> Result { + let binding = snapshot(options.binding)?; + let guard = Guard { + deadline: options.deadline, + cancelled: options.cancelled, + }; + guard.check()?; + let files = Files::existing(options.state_root, &binding)?; + let record = RecordPin::read(&files.slot.path.join("owner.json"))?; + let owner = super::decode(&record.bytes)?; + let Enrollment::Enrolled { volume } = &owner.0.enrollment else { + return Err(refused()); + }; + if volume.name != volume_name(&binding) { + return Err(refused()); + } + files.verify(Some(&record))?; + guard.check()?; + transport + .verify(&binding, guard.deadline) + .map_err(|_| refused())?; + files.verify(Some(&record))?; + guard.check()?; + let observed = transport + .inspect(&volume.name, guard.deadline) + .map_err(|_| refused())?; + super::compare(CompareOptions { + record: &owner, + expected: &binding, + observed: observed.as_ref(), + })?; + transport + .verify(&binding, guard.deadline) + .map_err(|_| refused())?; + files.verify(Some(&record))?; + guard.check()?; + Ok(owner) +} + +fn snapshot(binding: &Binding) -> Result { + if !super::binding_valid(binding) { + return Err(refused()); + } + Ok(binding.clone()) +} +fn refused() -> CandidateError { + CandidateError::new( + "native_persistent_data_enrollment", + "Persistent data enrollment is incomplete, ambiguous or changed; retained data was not adopted or deleted.", + ) +} +fn volume_name(binding: &Binding) -> String { + format!( + "hkp-{}-{}-{}", + binding.scope.namespace, binding.scope.owner, binding.scope.storage + ) +} +fn slot_name(binding: &Binding) -> String { + let mut digest = Sha256::new(); + digest.update(binding.scope.namespace.as_bytes()); + digest.update([0]); + digest.update(binding.scope.storage.as_bytes()); + format!("persistent-{:x}", digest.finalize()) +} + +struct Guard<'a> { + deadline: Instant, + cancelled: &'a AtomicBool, +} +impl Guard<'_> { + fn check(&self) -> Result<(), CandidateError> { + if self.cancelled.load(Ordering::SeqCst) || Instant::now() >= self.deadline { + return Err(refused()); + } + Ok(()) + } +} + +fn enroll( + options: EnrollOptions<'_>, + transport: &mut T, + sync: &mut S, +) -> Result { + let binding = snapshot(options.binding)?; + if !super::super::super::hex(options.intent, 32) { + return Err(refused()); + } + let request = CreateRequest { + name: volume_name(&binding), + binding, + intent: options.intent.into(), + }; + let guard = Guard { + deadline: options.deadline, + cancelled: options.cancelled, + }; + guard.check()?; + transport + .verify(&request.binding, guard.deadline) + .map_err(|_| refused())?; + guard.check()?; + if transport + .inspect(&request.name, guard.deadline) + .map_err(|_| refused())? + .is_some() + { + return Err(refused()); + } + guard.check()?; + let files = Files::fresh(options.state_root, &request.binding, sync)?; + let pending = RecordPin::create( + &files.slot.path.join("owner.json"), + &Owner(Record { + version: 1, + kind: Kind::NativePersistentDataOwner, + binding: request.binding.clone(), + enrollment: Enrollment::Pending { + intent: request.intent.clone(), + volume_name: request.name.clone(), + }, + }), + sync, + Step::PendingFile, + )?; + sync.sync(&files.slot.file, Step::PendingDirectory)?; + fresh(&files, &pending, &request.binding, &guard, transport)?; + if transport + .inspect(&request.name, guard.deadline) + .map_err(|_| refused())? + .is_some() + { + return Err(refused()); + } + // The last absence observation grants no create authority: the sealed adapter must + // still refuse a competing existing name atomically inside create_new. + fresh(&files, &pending, &request.binding, &guard, transport)?; + let captured = transport + .create_new(&request, guard.deadline) + .map_err(|_| refused())?; + let enrolled = Owner(Record { + version: 1, + kind: Kind::NativePersistentDataOwner, + binding: request.binding.clone(), + enrollment: Enrollment::Enrolled { + volume: captured.volume.clone(), + }, + }); + super::compare(CompareOptions { + record: &enrolled, + expected: &request.binding, + observed: Some(&captured), + })?; + if captured.volume.name != request.name { + return Err(refused()); + } + fresh(&files, &pending, &request.binding, &guard, transport)?; + let observed = transport + .inspect(&request.name, guard.deadline) + .map_err(|_| refused())?; + super::compare(CompareOptions { + record: &enrolled, + expected: &request.binding, + observed: observed.as_ref(), + })?; + fresh(&files, &pending, &request.binding, &guard, transport)?; + let staged = RecordPin::create( + &files.slot.path.join("owner.next"), + &enrolled, + sync, + Step::EnrolledFile, + )?; + files.verify_record(&pending)?; + staged.verify()?; + transport + .verify(&request.binding, guard.deadline) + .map_err(|_| refused())?; + let observed = transport + .inspect(&request.name, guard.deadline) + .map_err(|_| refused())?; + super::compare(CompareOptions { + record: &enrolled, + expected: &request.binding, + observed: observed.as_ref(), + })?; + transport + .verify(&request.binding, guard.deadline) + .map_err(|_| refused())?; + files.verify_record(&pending)?; + staged.verify()?; + guard.check()?; + fs::rename(&staged.path, &pending.path).map_err(|_| refused())?; + // No arbitrary writer is admitted by this private nonblocking lock. As with other + // provider state, unsynchronized same-UID external writers are not atomically frozen. + let committed = staged.at(pending.path); + files.verify(Some(&committed))?; + sync.sync(&files.slot.file, Step::CommittedDirectory)?; + transport + .verify(&request.binding, guard.deadline) + .map_err(|_| refused())?; + let observed = transport + .inspect(&request.name, guard.deadline) + .map_err(|_| refused())?; + super::compare(CompareOptions { + record: &enrolled, + expected: &request.binding, + observed: observed.as_ref(), + })?; + transport + .verify(&request.binding, guard.deadline) + .map_err(|_| refused())?; + files.verify(Some(&committed))?; + guard.check()?; + Ok(enrolled) +} + +fn fresh( + files: &Files, + record: &RecordPin, + binding: &Binding, + guard: &Guard<'_>, + transport: &mut T, +) -> Result<(), CandidateError> { + guard.check()?; + files.verify(Some(record))?; + transport + .verify(binding, guard.deadline) + .map_err(|_| refused())?; + files.verify(Some(record))?; + guard.check() +} + +struct Directory { + path: PathBuf, + file: File, +} +impl Directory { + fn open(path: &Path) -> Result { + if !path.is_absolute() || fs::canonicalize(path).map_err(|_| refused())? != path { + return Err(refused()); + } + reject_aliased_state(path).map_err(|_| refused())?; + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(path) + .map_err(|_| refused())?; + let directory = Self { + path: path.into(), + file, + }; + directory.verify()?; + Ok(directory) + } + fn verify(&self) -> Result<(), CandidateError> { + reject_aliased_state(&self.path).map_err(|_| refused())?; + let fd = self.file.metadata().map_err(|_| refused())?; + let path = fs::symlink_metadata(&self.path).map_err(|_| refused())?; + if !fd.is_dir() || !safe(&fd, 0o700, false) || !same(&fd, &path) { + return Err(refused()); + } + Ok(()) + } +} +struct Files { + root: Directory, + slot: Directory, + lock: state::Lock, +} +impl Files { + fn fresh( + root: &Path, + binding: &Binding, + sync: &mut S, + ) -> Result { + let root = Directory::open(root)?; + let path = root.path.join(slot_name(binding)); + root.verify()?; + fs::DirBuilder::new() + .mode(0o700) + .create(&path) + .map_err(|_| refused())?; + let slot = Directory::open(&path)?; + root.verify()?; + sync.sync(&root.file, Step::SlotDirectory)?; + let lock_file = OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW) + .open(path.join("operation.lock")) + .map_err(|_| refused())?; + sync.sync(&lock_file, Step::LockFile)?; + let lock = state::Lock::from_file(lock_file).map_err(|_| refused())?; + let files = Self { root, slot, lock }; + files.verify(None)?; + Ok(files) + } + fn existing(root: &Path, binding: &Binding) -> Result { + let root = Directory::open(root)?; + let slot = Directory::open(&root.path.join(slot_name(binding)))?; + let lock = state::Lock::acquire_existing(&slot.path).map_err(|_| refused())?; + let files = Self { root, slot, lock }; + files.verify(None)?; + Ok(files) + } + fn verify_record(&self, record: &RecordPin) -> Result<(), CandidateError> { + self.root.verify()?; + self.slot.verify()?; + let lock = + fs::symlink_metadata(self.slot.path.join("operation.lock")).map_err(|_| refused())?; + if !lock.is_file() + || lock.len() != 0 + || !safe(&lock, 0o600, true) + || (lock.dev(), lock.ino()) != self.lock.identity().map_err(|_| refused())? + { + return Err(refused()); + } + record.verify() + } + fn verify(&self, record: Option<&RecordPin>) -> Result<(), CandidateError> { + absent(&self.slot.path.join("owner.next"))?; + match record { + Some(record) => self.verify_record(record), + None => { + self.root.verify()?; + self.slot.verify()?; + let lock = fs::symlink_metadata(self.slot.path.join("operation.lock")) + .map_err(|_| refused())?; + if !lock.is_file() + || lock.len() != 0 + || !safe(&lock, 0o600, true) + || (lock.dev(), lock.ino()) != self.lock.identity().map_err(|_| refused())? + { + return Err(refused()); + } + Ok(()) + } + } + } +} +fn absent(path: &Path) -> Result<(), CandidateError> { + match fs::symlink_metadata(path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + _ => Err(refused()), + } +} +fn safe(metadata: &fs::Metadata, mode: u32, single: bool) -> bool { + // SAFETY: geteuid has no preconditions. + metadata.uid() == unsafe { libc::geteuid() } + && metadata.mode() & 0o777 == mode + && (!single || metadata.nlink() == 1) +} +fn same(fd: &fs::Metadata, path: &fs::Metadata) -> bool { + fd.dev() == path.dev() && fd.ino() == path.ino() && fd.file_type() == path.file_type() +} +struct RecordPin { + path: PathBuf, + file: File, + bytes: Vec, +} +impl RecordPin { + fn create( + path: &Path, + owner: &Owner, + sync: &mut S, + step: Step, + ) -> Result { + let bytes = serde_json::to_vec(owner).map_err(|_| refused())?; + super::decode(&bytes)?; + let mut file = OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW) + .open(path) + .map_err(|_| refused())?; + file.write_all(&bytes).map_err(|_| refused())?; + sync.sync(&file, step)?; + let pin = Self { + path: path.into(), + file, + bytes, + }; + pin.verify()?; + Ok(pin) + } + fn read(path: &Path) -> Result { + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(path) + .map_err(|_| refused())?; + let bytes = read_bytes(&file)?; + super::decode(&bytes)?; + let pin = Self { + path: path.into(), + file, + bytes, + }; + pin.verify()?; + Ok(pin) + } + fn at(self, path: PathBuf) -> Self { + Self { path, ..self } + } + fn verify(&self) -> Result<(), CandidateError> { + let fd = self.file.metadata().map_err(|_| refused())?; + let path = fs::symlink_metadata(&self.path).map_err(|_| refused())?; + if !same(&fd, &path) || read_bytes(&self.file)? != self.bytes { + return Err(refused()); + } + Ok(()) + } +} +fn read_bytes(mut file: &File) -> Result, CandidateError> { + let m = file.metadata().map_err(|_| refused())?; + if !m.is_file() || !safe(&m, 0o600, true) || m.len() > super::LIMIT as u64 { + return Err(refused()); + } + file.seek(SeekFrom::Start(0)).map_err(|_| refused())?; + let mut bytes = Vec::new(); + file.take(super::LIMIT as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|_| refused())?; + if bytes.is_empty() || bytes.len() > super::LIMIT { + return Err(refused()); + } + Ok(bytes) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Step { + SlotDirectory, + LockFile, + PendingFile, + PendingDirectory, + EnrolledFile, + CommittedDirectory, +} +trait Sync { + fn sync(&mut self, file: &File, step: Step) -> Result<(), CandidateError>; +} +struct SystemSync; +impl Sync for SystemSync { + fn sync(&mut self, file: &File, _step: Step) -> Result<(), CandidateError> { + file.sync_all().map_err(|_| refused()) + } +} + +#[cfg(test)] +mod tests; diff --git a/packages/runtime-core/src/provider/graph/native/persistent_data/enrollment/tests.rs b/packages/runtime-core/src/provider/graph/native/persistent_data/enrollment/tests.rs new file mode 100644 index 000000000..b79bece11 --- /dev/null +++ b/packages/runtime-core/src/provider/graph/native/persistent_data/enrollment/tests.rs @@ -0,0 +1,562 @@ +use super::super::{DirectoryIdentity, VolumeIdentity}; +use super::*; +use serde_json::{Value, json}; +use std::cell::RefCell; +use std::collections::BTreeMap; +use std::os::unix::fs::{PermissionsExt, symlink}; +use std::rc::Rc; +use std::sync::atomic::AtomicU64; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +static NEXT: AtomicU64 = AtomicU64::new(0); +struct Fixture(PathBuf); +impl Fixture { + fn new() -> Self { + let base = fs::canonicalize(std::env::temp_dir()).unwrap(); + let path = base.join(format!( + "native-persistent-enroll-{}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(), + NEXT.fetch_add(1, Ordering::SeqCst) + )); + fs::DirBuilder::new().mode(0o700).create(&path).unwrap(); + Self(path) + } + fn slot(&self) -> PathBuf { + self.0.join(slot_name(&binding())) + } + fn record(&self) -> Value { + serde_json::from_slice(&fs::read(self.slot().join("owner.json")).unwrap()).unwrap() + } +} +impl Drop for Fixture { + fn drop(&mut self) { + fs::remove_dir_all(&self.0).unwrap(); + } +} +fn binding() -> Binding { + serde_json::from_value(json!({ + "scope":{"namespace":"a".repeat(64),"storage":"db_data","owner":"b".repeat(32)}, + "guest":{"owner":"c".repeat(32),"boot_id":"11111111-2222-3333-4444-555555555555", + "storage":{"device":0,"inode":25,"bytes":8192,"uuid":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"}}, + "policy":{"driver":"local","scope":"local","options":{}} + })).unwrap() +} +fn observation() -> Observation { + Observation { + binding: binding(), + volume: VolumeIdentity { + name: volume_name(&binding()), + created_at: "2026-10-08T12:34:56.123456789Z".into(), + directory: DirectoryIdentity { + device: 0, + inode: 91, + }, + }, + } +} +#[derive(Clone, Copy, PartialEq, Eq)] +enum Event { + Verify(usize), + Inspect(usize), + Create, +} +struct Fake { + current: BTreeMap, + verifies: usize, + inspects: usize, + creates: usize, + fail_create: bool, + replace_after_create: bool, + fail: Option, + hook: Option>, +} +impl Fake { + fn new() -> Self { + Self { + current: BTreeMap::new(), + verifies: 0, + inspects: 0, + creates: 0, + fail_create: false, + replace_after_create: false, + fail: None, + hook: None, + } + } + fn event(&mut self, event: Event) { + if let Some(hook) = &mut self.hook { + hook(event); + } + } +} +impl sealed::Transport for Fake {} +impl Transport for Fake { + fn verify(&mut self, expected: &Binding, deadline: Instant) -> Result<(), CandidateError> { + self.verifies += 1; + self.event(Event::Verify(self.verifies)); + if self.fail == Some(Event::Verify(self.verifies)) { + return Err(canary()); + } + if *expected != binding() || Instant::now() >= deadline { + return Err(refused()); + } + Ok(()) + } + fn inspect( + &mut self, + name: &str, + deadline: Instant, + ) -> Result, CandidateError> { + self.inspects += 1; + self.event(Event::Inspect(self.inspects)); + if self.fail == Some(Event::Inspect(self.inspects)) { + return Err(canary()); + } + if Instant::now() >= deadline { + return Err(refused()); + } + Ok(self.current.get(name).cloned()) + } + fn create_new( + &mut self, + request: &CreateRequest, + deadline: Instant, + ) -> Result { + self.creates += 1; + self.event(Event::Create); + if Instant::now() >= deadline || self.current.contains_key(request.name()) { + return Err(refused()); + } + assert!(request.binding() == &binding()); + assert_eq!(request.intent(), "d".repeat(32)); + let created = observation(); + self.current.insert(request.name().into(), created.clone()); + if self.replace_after_create { + self.current + .get_mut(request.name()) + .unwrap() + .volume + .directory + .inode += 1; + } + if self.fail_create { + return Err(canary()); + } + Ok(created) + } +} +fn canary() -> CandidateError { + CandidateError::new("adapter-private-canary", "private-value-canary/path/volume") + .with_cause_code("private-cause-canary".into()) +} +struct FaultSync { + fail: Option, + seen: Rc>>, +} +impl Sync for FaultSync { + fn sync(&mut self, file: &File, step: Step) -> Result<(), CandidateError> { + self.seen.borrow_mut().push(step); + if self.fail == Some(step) { + return Err(refused()); + } + file.sync_all().map_err(|_| refused()) + } +} +fn options<'a>( + fixture: &'a Fixture, + binding: &'a Binding, + cancelled: &'a AtomicBool, +) -> EnrollOptions<'a> { + EnrollOptions { + state_root: &fixture.0, + binding, + intent: "dddddddddddddddddddddddddddddddd", + deadline: Instant::now() + Duration::from_secs(10), + cancelled, + } +} +fn read(fixture: &Fixture, binding: &Binding, fake: &mut Fake) -> Result { + read_retained( + ReadOptions { + state_root: &fixture.0, + binding, + deadline: Instant::now() + Duration::from_secs(10), + cancelled: &AtomicBool::new(false), + }, + fake, + ) +} +fn enroll_fixture(fixture: &Fixture, fake: &mut Fake) -> Owner { + enroll_new(options(fixture, &binding(), &AtomicBool::new(false)), fake).unwrap() +} + +#[test] +fn durable_pending_precedes_the_original_effect_and_retained_reads_do_not_rebind_generations() { + let fixture = Fixture::new(); + let seen = Rc::new(RefCell::new(Vec::new())); + let at_create = seen.clone(); + let slot = fixture.slot(); + let mut fake = Fake::new(); + fake.hook = Some(Box::new(move |event| { + if event == Event::Create { + assert!( + *at_create.borrow() + == vec![ + Step::SlotDirectory, + Step::LockFile, + Step::PendingFile, + Step::PendingDirectory + ] + ); + let bytes = fs::read(slot.join("owner.json")).unwrap(); + let pending = super::super::decode(&bytes).unwrap(); + assert!( + super::super::compare(CompareOptions { + record: &pending, + expected: &binding(), + observed: Some(&observation()) + }) + .is_err() + ); + assert_eq!( + fs::metadata(slot.join("owner.json")).unwrap().mode() & 0o777, + 0o600 + ); + } + })); + let enrolled = enroll( + options(&fixture, &binding(), &AtomicBool::new(false)), + &mut fake, + &mut FaultSync { fail: None, seen }, + ) + .unwrap(); + let original = fs::read(fixture.slot().join("owner.json")).unwrap(); + let original_inode = fs::metadata(fixture.slot().join("owner.json")) + .unwrap() + .ino(); + assert_eq!(fake.creates, 1); + assert_eq!(serde_json::to_value(enrolled).unwrap(), fixture.record()); + fake.hook = None; + for run in ["1".repeat(32), "2".repeat(32)] { + let scope = crate::provider::native_input::Scope { + namespace: &binding().scope.namespace, + run: &run, + }; + let mut expected = binding(); + expected.scope.namespace = scope.namespace.into(); + assert!(read(&fixture, &expected, &mut fake).is_ok()); + } + assert_eq!(fake.creates, 1); + assert_eq!( + fs::read(fixture.slot().join("owner.json")).unwrap(), + original + ); + assert_eq!( + fs::metadata(fixture.slot().join("owner.json")) + .unwrap() + .ino(), + original_inode + ); + assert!(!String::from_utf8(original).unwrap().contains("\"run\"")); +} + +#[test] +fn preexisting_names_slots_and_pending_never_gain_enrollment_or_a_second_effect() { + let fixture = Fixture::new(); + let mut fake = Fake::new(); + fake.current.insert(volume_name(&binding()), observation()); + assert!( + enroll_new( + options(&fixture, &binding(), &AtomicBool::new(false)), + &mut fake + ) + .is_err() + ); + assert_eq!(fake.creates, 0); + assert!(!fixture.slot().exists()); + fake.current.clear(); + fake.fail_create = true; + assert!( + enroll_new( + options(&fixture, &binding(), &AtomicBool::new(false)), + &mut fake + ) + .is_err() + ); + let original = fs::read(fixture.slot().join("owner.json")).unwrap(); + assert_eq!(fixture.record()["enrollment"]["status"], "pending"); + assert!(read(&fixture, &binding(), &mut fake).is_err()); + fake.current.clear(); // Even apparent engine absence cannot recover an old attempt. + fake.fail_create = false; + assert!( + enroll_new( + options(&fixture, &binding(), &AtomicBool::new(false)), + &mut fake + ) + .is_err() + ); + assert_eq!(fake.creates, 1); + assert_eq!( + fs::read(fixture.slot().join("owner.json")).unwrap(), + original + ); + assert!(fixture.slot().join("operation.lock").is_file()); +} + +#[test] +fn adapter_errors_are_normalized_before_return_including_ambiguous_original_create() { + for event in [ + Event::Verify(1), + Event::Inspect(1), + Event::Create, + Event::Inspect(3), + ] { + let fixture = Fixture::new(); + let mut fake = Fake::new(); + if event == Event::Create { + fake.fail_create = true; + } else { + fake.fail = Some(event); + } + let error = enroll_new( + options(&fixture, &binding(), &AtomicBool::new(false)), + &mut fake, + ) + .err() + .unwrap(); + assert_eq!( + serde_json::to_value(&error).unwrap(), + json!({ + "code": "native_persistent_data_enrollment", + "message": "Persistent data enrollment is incomplete, ambiguous or changed; retained data was not adopted or deleted." + }) + ); + assert!(!serde_json::to_string(&error).unwrap().contains("private-")); + if fake.creates > 0 { + assert_eq!(fixture.record()["enrollment"]["status"], "pending"); + assert_eq!(fake.current.len(), 1); + } + } + let fixture = Fixture::new(); + let mut fake = Fake::new(); + enroll_fixture(&fixture, &mut fake); + fake.fail = Some(Event::Inspect(fake.inspects + 1)); + let before = fs::read(fixture.slot().join("owner.json")).unwrap(); + let error = read(&fixture, &binding(), &mut fake).err().unwrap(); + assert_eq!( + serde_json::to_value(&error).unwrap(), + serde_json::to_value(refused()).unwrap() + ); + assert_eq!(fs::read(fixture.slot().join("owner.json")).unwrap(), before); + assert_eq!(fake.creates, 1); +} + +#[test] +fn pending_sync_failure_prevents_create_and_final_sync_failure_is_uncertain() { + for step in [ + Step::PendingFile, + Step::PendingDirectory, + Step::EnrolledFile, + Step::CommittedDirectory, + ] { + let fixture = Fixture::new(); + let mut fake = Fake::new(); + let result = enroll( + options(&fixture, &binding(), &AtomicBool::new(false)), + &mut fake, + &mut FaultSync { + fail: Some(step), + seen: Rc::new(RefCell::new(Vec::new())), + }, + ); + assert!(result.is_err()); + assert_eq!( + fake.creates, + usize::from(matches!( + step, + Step::EnrolledFile | Step::CommittedDirectory + )) + ); + let status = if step == Step::CommittedDirectory { + "enrolled" + } else { + "pending" + }; + assert_eq!(fixture.record()["enrollment"]["status"], status); + // An enrolled pathname after failed final sync is an observation of uncertain + // publication, not successful-return evidence or permission to repeat creation. + assert!( + enroll_new( + options(&fixture, &binding(), &AtomicBool::new(false)), + &mut fake + ) + .is_err() + ); + } +} + +#[test] +fn captured_original_identity_cannot_be_replaced_by_copied_labels_or_a_new_directory() { + let fixture = Fixture::new(); + let mut fake = Fake::new(); + fake.replace_after_create = true; + assert!( + enroll_new( + options(&fixture, &binding(), &AtomicBool::new(false)), + &mut fake + ) + .is_err() + ); + assert_eq!(fake.creates, 1); + assert_eq!(fixture.record()["enrollment"]["status"], "pending"); + assert_eq!( + fake.current.values().next().unwrap().volume.directory.inode, + 92 + ); + assert!(!fixture.slot().join("owner.next").exists()); +} + +#[test] +fn invalid_input_expiration_and_cancel_refuse_before_any_file_or_create() { + let fixture = Fixture::new(); + let mut fake = Fake::new(); + let cancelled = AtomicBool::new(false); + let mut invalid = binding(); + invalid.scope.storage = "../unowned".into(); + assert!(enroll_new(options(&fixture, &invalid, &cancelled), &mut fake).is_err()); + let valid = binding(); + let mut expired = options(&fixture, &valid, &cancelled); + expired.deadline = Instant::now(); + assert!(enroll_new(expired, &mut fake).is_err()); + cancelled.store(true, Ordering::SeqCst); + assert!(enroll_new(options(&fixture, &valid, &cancelled), &mut fake).is_err()); + assert_eq!(fake.verifies, 0); + assert_eq!(fake.creates, 0); + assert_eq!(fs::read_dir(&fixture.0).unwrap().count(), 0); +} + +#[test] +fn post_effect_record_lock_and_root_replacements_refuse_without_adopting_or_deleting() { + for target in ["owner.json", "operation.lock", "root"] { + let fixture = Fixture::new(); + let slot = fixture.slot(); + let root = fixture.0.clone(); + let mut fake = Fake::new(); + fake.hook = Some(Box::new(move |event| { + if event == Event::Create { + if target == "root" { + fs::rename(&root, root.with_extension("old")).unwrap(); + fs::DirBuilder::new().mode(0o700).create(&root).unwrap(); + fs::rename(root.with_extension("old"), root.join("retained-original")).unwrap(); + } else { + let path = slot.join(target); + let bytes = fs::read(&path).unwrap(); + fs::rename(&path, slot.join(format!("{target}.old"))).unwrap(); + let mut replacement = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&path) + .unwrap(); + replacement.write_all(&bytes).unwrap(); + } + } + })); + assert!( + enroll_new( + options(&fixture, &binding(), &AtomicBool::new(false)), + &mut fake + ) + .is_err() + ); + assert_eq!(fake.creates, 1); + assert_eq!(fake.current.len(), 1); + assert!(!fixture.slot().join("owner.next").exists()); + } +} + +#[test] +fn retained_read_is_existing_only_and_refuses_missing_lock_staging_birth_or_guest_drift() { + let fixture = Fixture::new(); + let mut fake = Fake::new(); + assert!(read(&fixture, &binding(), &mut fake).is_err()); + assert_eq!(fs::read_dir(&fixture.0).unwrap().count(), 0); + enroll_fixture(&fixture, &mut fake); + let original = fs::read(fixture.slot().join("owner.json")).unwrap(); + let lock = fixture.slot().join("operation.lock"); + fs::rename(&lock, fixture.slot().join("retained.lock")).unwrap(); + assert!(read(&fixture, &binding(), &mut fake).is_err()); + assert!(!lock.exists()); + fs::rename(fixture.slot().join("retained.lock"), &lock).unwrap(); + fs::write(fixture.slot().join("owner.next"), b"incomplete").unwrap(); + assert!(read(&fixture, &binding(), &mut fake).is_err()); + fs::remove_file(fixture.slot().join("owner.next")).unwrap(); + fake.current + .get_mut(&volume_name(&binding())) + .unwrap() + .volume + .directory + .inode += 1; + assert!(read(&fixture, &binding(), &mut fake).is_err()); + fake.current.insert(volume_name(&binding()), observation()); + let mut expected = binding(); + expected.guest.boot_id = "ffffffff-2222-3333-4444-555555555555".into(); + assert!(read(&fixture, &expected, &mut fake).is_err()); + expected = binding(); + expected.scope.owner = "f".repeat(32); + assert!(read(&fixture, &expected, &mut fake).is_err()); + assert_eq!( + fs::read(fixture.slot().join("owner.json")).unwrap(), + original + ); + assert_eq!(fake.creates, 1); +} + +#[test] +fn private_file_rules_lock_exclusion_and_final_cancel_fences_are_real() { + let fixture = Fixture::new(); + let mut fake = Fake::new(); + enroll_fixture(&fixture, &mut fake); + let lock = state::Lock::acquire_existing(&fixture.slot()).unwrap(); + assert!(read(&fixture, &binding(), &mut fake).is_err()); + drop(lock); + let path = fixture.slot().join("owner.json"); + let original = fs::read(&path).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + assert!(read(&fixture, &binding(), &mut fake).is_err()); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + fs::hard_link(&path, fixture.slot().join("alias")).unwrap(); + assert!(read(&fixture, &binding(), &mut fake).is_err()); + fs::remove_file(fixture.slot().join("alias")).unwrap(); + fs::rename(&path, fixture.slot().join("original")).unwrap(); + symlink(fixture.slot().join("original"), &path).unwrap(); + assert!(read(&fixture, &binding(), &mut fake).is_err()); + fs::remove_file(&path).unwrap(); + fs::rename(fixture.slot().join("original"), &path).unwrap(); + let cancelled = Rc::new(AtomicBool::new(false)); + let from_probe = cancelled.clone(); + let last = fake.inspects + 1; + fake.hook = Some(Box::new(move |event| { + if event == Event::Inspect(last) { + from_probe.store(true, Ordering::SeqCst); + } + })); + assert!( + read_retained( + ReadOptions { + state_root: &fixture.0, + binding: &binding(), + deadline: Instant::now() + Duration::from_secs(10), + cancelled: &cancelled + }, + &mut fake + ) + .is_err() + ); + assert_eq!(fs::read(&path).unwrap(), original); + assert_eq!(fake.creates, 1); +} diff --git a/scripts/lib/tla-runtime-models.ts b/scripts/lib/tla-runtime-models.ts index e2deafcea..a0af68274 100644 --- a/scripts/lib/tla-runtime-models.ts +++ b/scripts/lib/tla-runtime-models.ts @@ -40,6 +40,43 @@ const laterIncompleteMissingLockStages = [ "owner", ] as const; const contracts: readonly ModelContract[] = [ + { + name: "native-persistent-enrollment", + module: "Enrollment", + states: 38, + invariant: "NoExistingAdoption", + action: "CreateOriginal", + fields: ['phase = "created"', "volume = 2", "unsafeAdoption = TRUE"], + additionalControls: [ + { + name: "wrong-birth", + negative: true, + invariant: "OriginalBirthAtCommit", + action: "PublishEnrollment", + fields: ["captured = 1", "volume = 2", "unsafeCommit = TRUE"], + alternativeWitnesses: [ + { + action: "PublishEnrollment", + fields: ["captured = 2", "volume = 2", "unsafeCommit = TRUE"], + }, + ], + }, + { + name: "pending-read", + negative: true, + invariant: "NoPendingMatch", + action: "ReadRetained", + fields: ['record = "pending"', "matched = TRUE", "unsafeRead = TRUE"], + }, + { + name: "retire-data", + negative: true, + invariant: "RetirementPreservesData", + action: "RetireCompute", + fields: ["run = 2", "volume = 0", "unsafeRetirement = TRUE"], + }, + ], + }, { name: "native-frontend-recovery", module: "Recovery", diff --git a/tests/models/tla/README.md b/tests/models/tla/README.md index a24e36b4a..b3de2e0a3 100644 --- a/tests/models/tla/README.md +++ b/tests/models/tla/README.md @@ -1,5 +1,32 @@ # Runtime state models +`native-persistent-enrollment/Enrollment.tla` follows the inactive Rust +`provider::graph::native::persistent_data::enrollment` owner. Its finite domain is +one storage slot, absent/original/replacement volume identities and two independent +compute references. Pending publication, exclusive creation, captured original +birth, enrolled rename, final directory synchronization and failure are distinct +steps. Failure retains the intent or an uncertain enrolled pathname and loses the +original invocation's promotion ability; read-only reopen cannot create or promote. +An uncertain enrolled pathname may compare as data, but does not establish the +original operation's successful return or runtime effect authority. + +| Model operation | Source boundary and qualification | +| --- | --- | +| `ReserveIntent` | Exclusive private slot/lock, `owner.json` pending write and file/parent sync precede `create_new`. Real filesystem sync-failure controls require create count zero. | +| `CreateOriginal` / `CaptureBirth` | Sealed trusted transport requires atomic exclusive creation and returns its original identity directly to the invocation. No production adapter is implemented; a Docker absence check plus idempotent create is insufficient. The stand-in refuses existing names and retains ambiguous-create pending state. | +| `ReplaceVolume` / `PublishEnrollment` | Captured binding/birth/directory must equal fresh observations; root/lock/record/staged inode and bytes remain exact before rename. Copied-label replacement and post-effect file/lock/root controls refuse. | +| `ConfirmDirectorySync` / `CrashOrFailure` | Commit return follows enrolled rename and parent sync, then final guest/observation/files/deadline fences. Failed final sync returns uncertainty even if the enrolled pathname is visible. No reopen recovers an original-create capability. | +| `ReadRetained` / `RetireCompute` | Existing-only locked read never rewrites binding or stable identity when caller compute references change. `RetireCompute` expresses the future runtime obligation to retain data; no runtime teardown owner is activated or qualified here. | + +The positive exploration exhausts 38 distinct states. Four guard-removal +controls must fail `NoExistingAdoption`, `OriginalBirthAtCommit`, `NoPendingMatch` +and `RetirementPreservesData` at their corresponding named action with the exact +same-state witness. Registry/checker contracts reject incomplete exploration and +unrelated failures. This model does not prove fsync/crash durability, real guest or +volume observations, atomic ownership against unsynchronized external writers, +transport cancellation, SQL contents/retention or NC05 application acceptance. +No fairness or eventual recovery is asserted. + Run `bun run test:models` with Java 17 available and `TLA2TOOLS_JAR` pointing to TLA+ 1.7.4's `tla2tools.jar`. The runner checks the SHA-256 before executing Java: diff --git a/tests/models/tla/native-persistent-enrollment/Enrollment.tla b/tests/models/tla/native-persistent-enrollment/Enrollment.tla new file mode 100644 index 000000000..b40941813 --- /dev/null +++ b/tests/models/tla/native-persistent-enrollment/Enrollment.tla @@ -0,0 +1,75 @@ +-------------------------- MODULE Enrollment -------------------------- +EXTENDS Naturals +CONSTANTS EnforceExclusiveCreate, EnforceOriginalCommit, + EnforcePendingRead, KeepData +VARIABLES phase, record, volume, captured, alive, returned, matched, run, + unsafeAdoption, unsafeCommit, unsafeRead, unsafeRetirement +vars == <> + +Init == /\ phase = "idle" /\ record = "none" + /\ volume \in {0, 2} /\ captured = 0 /\ alive = TRUE + /\ returned = FALSE /\ matched = FALSE /\ run = 1 + /\ unsafeAdoption = FALSE /\ unsafeCommit = FALSE + /\ unsafeRead = FALSE /\ unsafeRetirement = FALSE +ReserveIntent == /\ phase = "idle" /\ alive + /\ phase' = "pending" /\ record' = "pending" + /\ UNCHANGED <> +CreateOriginal == /\ phase = "pending" /\ alive + /\ (~EnforceExclusiveCreate \/ volume = 0) + /\ volume' = (IF volume = 0 THEN 1 ELSE volume) + /\ phase' = "created" + /\ unsafeAdoption' = (volume # 0) + /\ UNCHANGED <> +CaptureBirth == /\ phase = "created" /\ alive + /\ captured' = volume /\ phase' = "captured" + /\ UNCHANGED <> +ReplaceVolume == /\ phase \in {"created", "captured", "done", "crashed"} + /\ volume = 1 /\ volume' = 2 + /\ UNCHANGED <> +PublishEnrollment == /\ phase = "captured" /\ alive + /\ (~EnforceOriginalCommit \/ (captured = 1 /\ volume = captured)) + /\ phase' = "published" /\ record' = "enrolled" + /\ unsafeCommit' = (captured # 1 \/ volume # captured) + /\ UNCHANGED <> +ConfirmDirectorySync == /\ phase = "published" /\ alive + /\ phase' = "done" /\ returned' = TRUE + /\ UNCHANGED <> +CrashOrFailure == /\ phase \in {"pending", "created", "captured", "published"} + /\ alive /\ alive' = FALSE /\ phase' = "crashed" + /\ UNCHANGED <> +ReadRetained == /\ phase \in {"done", "crashed"} /\ ~matched + /\ (IF EnforcePendingRead + THEN record = "enrolled" /\ captured > 0 /\ volume = captured + ELSE record # "none" /\ volume > 0) + /\ matched' = TRUE /\ unsafeRead' = (record # "enrolled") + /\ UNCHANGED <> +RetireCompute == /\ phase \in {"done", "crashed"} /\ run = 1 + /\ run' = 2 /\ volume' = (IF KeepData THEN volume ELSE 0) + /\ unsafeRetirement' = (~KeepData /\ volume > 0) + /\ UNCHANGED <> +Next == ReserveIntent \/ CreateOriginal \/ CaptureBirth \/ ReplaceVolume \/ + PublishEnrollment \/ ConfirmDirectorySync \/ CrashOrFailure \/ + ReadRetained \/ RetireCompute +Spec == Init /\ [][Next]_vars +TypeOK == /\ phase \in {"idle", "pending", "created", "captured", "published", "done", "crashed"} + /\ record \in {"none", "pending", "enrolled"} + /\ volume \in 0..2 /\ captured \in 0..2 /\ run \in 1..2 + /\ alive \in BOOLEAN /\ returned \in BOOLEAN /\ matched \in BOOLEAN + /\ unsafeAdoption \in BOOLEAN /\ unsafeCommit \in BOOLEAN + /\ unsafeRead \in BOOLEAN /\ unsafeRetirement \in BOOLEAN +NoExistingAdoption == ~unsafeAdoption +OriginalBirthAtCommit == ~unsafeCommit +NoPendingMatch == ~unsafeRead +RetirementPreservesData == ~unsafeRetirement +NoUnconfirmedReturn == returned => record = "enrolled" /\ phase = "done" +============================================================================= diff --git a/tests/models/tla/native-persistent-enrollment/negative.cfg b/tests/models/tla/native-persistent-enrollment/negative.cfg new file mode 100644 index 000000000..9a3a71a44 --- /dev/null +++ b/tests/models/tla/native-persistent-enrollment/negative.cfg @@ -0,0 +1,8 @@ +CONSTANTS EnforceExclusiveCreate = FALSE + EnforceOriginalCommit = TRUE + EnforcePendingRead = TRUE + KeepData = TRUE +SPECIFICATION Spec +INVARIANTS TypeOK NoExistingAdoption OriginalBirthAtCommit NoPendingMatch + RetirementPreservesData NoUnconfirmedReturn +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/native-persistent-enrollment/pending-read.cfg b/tests/models/tla/native-persistent-enrollment/pending-read.cfg new file mode 100644 index 000000000..8c94710d4 --- /dev/null +++ b/tests/models/tla/native-persistent-enrollment/pending-read.cfg @@ -0,0 +1,8 @@ +CONSTANTS EnforceExclusiveCreate = TRUE + EnforceOriginalCommit = TRUE + EnforcePendingRead = FALSE + KeepData = TRUE +SPECIFICATION Spec +INVARIANTS TypeOK NoExistingAdoption OriginalBirthAtCommit NoPendingMatch + RetirementPreservesData NoUnconfirmedReturn +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/native-persistent-enrollment/positive.cfg b/tests/models/tla/native-persistent-enrollment/positive.cfg new file mode 100644 index 000000000..2354f591f --- /dev/null +++ b/tests/models/tla/native-persistent-enrollment/positive.cfg @@ -0,0 +1,8 @@ +CONSTANTS EnforceExclusiveCreate = TRUE + EnforceOriginalCommit = TRUE + EnforcePendingRead = TRUE + KeepData = TRUE +SPECIFICATION Spec +INVARIANTS TypeOK NoExistingAdoption OriginalBirthAtCommit NoPendingMatch + RetirementPreservesData NoUnconfirmedReturn +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/native-persistent-enrollment/retire-data.cfg b/tests/models/tla/native-persistent-enrollment/retire-data.cfg new file mode 100644 index 000000000..2fa4241fe --- /dev/null +++ b/tests/models/tla/native-persistent-enrollment/retire-data.cfg @@ -0,0 +1,8 @@ +CONSTANTS EnforceExclusiveCreate = TRUE + EnforceOriginalCommit = TRUE + EnforcePendingRead = TRUE + KeepData = FALSE +SPECIFICATION Spec +INVARIANTS TypeOK NoExistingAdoption OriginalBirthAtCommit NoPendingMatch + RetirementPreservesData NoUnconfirmedReturn +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/native-persistent-enrollment/wrong-birth.cfg b/tests/models/tla/native-persistent-enrollment/wrong-birth.cfg new file mode 100644 index 000000000..91cf21515 --- /dev/null +++ b/tests/models/tla/native-persistent-enrollment/wrong-birth.cfg @@ -0,0 +1,8 @@ +CONSTANTS EnforceExclusiveCreate = TRUE + EnforceOriginalCommit = FALSE + EnforcePendingRead = TRUE + KeepData = TRUE +SPECIFICATION Spec +INVARIANTS TypeOK NoExistingAdoption OriginalBirthAtCommit NoPendingMatch + RetirementPreservesData NoUnconfirmedReturn +CHECK_DEADLOCK FALSE diff --git a/tests/tla-runtime-models.test.ts b/tests/tla-runtime-models.test.ts index 46b8a32df..896fbb367 100644 --- a/tests/tla-runtime-models.test.ts +++ b/tests/tla-runtime-models.test.ts @@ -80,6 +80,30 @@ test("runtime model config selection rejects paths and arbitrary filenames", () } }); +test("persistent enrollment accepts both genuine changed-birth witnesses only", () => { + const control = runtimeModels + .find((model) => model.name === "native-persistent-enrollment") + ?.additionalControls.find((entry) => entry.name === "wrong-birth"); + expect(control).toBeDefined(); + for (const captured of [1, 2]) { + const output = tlaWitness({ + invariant: "OriginalBirthAtCommit", + action: "PublishEnrollment", + fields: [`captured = ${captured}`, "volume = 2", "unsafeCommit = TRUE"], + }); + expect(control?.verify({ negative: true, exitCode: 12, output })).toBe( + true + ); + expect( + control?.verify({ + negative: true, + exitCode: 12, + output: output.replace("volume = 2", "volume = 1"), + }) + ).toBe(false); + } +}); + test("storage witness controls distinguish missing bytes from foreign metadata", () => { const model = runtimeModels.find( (entry) => entry.name === "native-storage-witness" From e494143a61b7b864a84d8b5e9a1cafb3442ea6b8 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Fri, 9 Oct 2026 00:22:11 -0400 Subject: [PATCH 3/4] feat(native): add inactive persistent root witness helper --- packages/relay-guest/README.md | 20 ++ packages/relay-guest/src/main.rs | 51 ++++ packages/runtime-core/src/provider/mod.rs | 2 + .../src/provider/storage_root_witness.rs | 260 ++++++++++++++++++ .../provider/storage_root_witness/linux.rs | 174 ++++++++++++ .../storage_root_witness/linux/tests.rs | 184 +++++++++++++ .../provider/storage_root_witness/tests.rs | 224 +++++++++++++++ 7 files changed, 915 insertions(+) create mode 100644 packages/runtime-core/src/provider/storage_root_witness.rs create mode 100644 packages/runtime-core/src/provider/storage_root_witness/linux.rs create mode 100644 packages/runtime-core/src/provider/storage_root_witness/linux/tests.rs create mode 100644 packages/runtime-core/src/provider/storage_root_witness/tests.rs diff --git a/packages/relay-guest/README.md b/packages/relay-guest/README.md index 7c13b2e99..d3a78ea81 100644 --- a/packages/relay-guest/README.md +++ b/packages/relay-guest/README.md @@ -169,3 +169,23 @@ Argument validation has a standalone integration-test target, avoiding the share host modules' tests: `cargo test --locked --manifest-path packages/relay-guest/Cargo.toml --test options`. Its same-port address-isolation test runs only on Linux. Host argument tests do not qualify guest networking, authentication, TLS or owned cleanup. +## Persistent root witness (inactive integration) + +The explicit `--storage-root-witness` mode accepts exactly one bounded private +pipe record and no other arguments. It derives the root from a canonical native +persistent volume name under `/var/lib/docker/volumes`; callers cannot supply an +arbitrary path. Root, seed and verify are closed operations. The seed operation +uses `XATTR_CREATE` for one random `user.hack.storage.<64hex>` name and a 32-byte +value, synchronizes directory metadata, and rereads the exact value. Verify never +creates or repairs a witness. Every ancestor is opened without following symlinks; +retained descriptors and a fresh canonical traversal must agree before and after +each observation. No permissions, ownership or capabilities are changed. + +This helper supplies an observation, not enrollment or cleanup authority. Only +the invocation holding the durable original-create intent may authorize seed, +and its expected witness must be committed independently before compute use. +Pending or unknown completion cannot be promoted from helper output. Copying an +entire root with its xattrs is outside this proof. The helper has not been built, +installed or executed for persistence; graph control-only helper availability, +durable witness enrollment and real SQL retention remain open. Ordinary persistent +startup stays refused before provider connection. diff --git a/packages/relay-guest/src/main.rs b/packages/relay-guest/src/main.rs index a4ffd24a2..6aa2e9d1e 100644 --- a/packages/relay-guest/src/main.rs +++ b/packages/relay-guest/src/main.rs @@ -5,6 +5,9 @@ mod error; pub use error::CandidateError; #[path = "../../runtime-core/src/provider/private_input.rs"] mod private_input; +#[cfg(target_os = "linux")] +#[path = "../../runtime-core/src/provider/storage_root_witness.rs"] +mod storage_root_witness; // This shared module also supplies host-only authority/effect APIs. Keep them // compiled and checked by runtime-core without requiring guest-side callers. #[allow(dead_code)] @@ -71,6 +74,54 @@ fn run(options: Options) -> Result<(), CandidateError> { } fn main() { let raw_args: Vec<_> = std::env::args_os().skip(1).collect(); + if raw_args + .first() + .is_some_and(|arg| arg == "--storage-root-witness") + { + if raw_args.len() != 1 { + eprintln!("hack-relay-guest: persistent root witness refused"); + std::process::exit(64); + } + #[cfg(target_os = "linux")] + { + if !storage_root_witness::arguments(&raw_args) { + eprintln!("hack-relay-guest: persistent root witness refused"); + std::process::exit(64); + } + let result = (|| { + // SAFETY: F_GETFD observes FD0 before this branch takes its sole + // ownership. No Stdin handle or retained duplicate is created. + if unsafe { libc::fcntl(0, libc::F_GETFD) } < 0 { + return Err(CandidateError::new( + "storage_root_witness_refused", + "Persistent root witness was refused; values omitted.", + )); + } + // SAFETY: the invocation transfers this private pipe FD exactly once. + let fd = unsafe { OwnedFd::from_raw_fd(0) }; + let bytes = private_input::receive( + fd, + Duration::from_secs(5), + storage_root_witness::MAX_REQUEST, + )?; + let request = storage_root_witness::Request::parse(&bytes)?; + storage_root_witness::execute(request) + })(); + match result { + Ok(observation) => print!("{}", observation.encode()), + Err(_) => { + eprintln!("hack-relay-guest: persistent root witness refused"); + std::process::exit(1); + } + } + return; + } + #[cfg(not(target_os = "linux"))] + { + eprintln!("hack-relay-guest: persistent root witness refused"); + std::process::exit(1); + } + } if raw_args .first() .is_some_and(|arg| arg == "--await-release" || arg == "--check-release") diff --git a/packages/runtime-core/src/provider/mod.rs b/packages/runtime-core/src/provider/mod.rs index 38b30c497..be73ceb13 100644 --- a/packages/runtime-core/src/provider/mod.rs +++ b/packages/runtime-core/src/provider/mod.rs @@ -52,6 +52,8 @@ pub mod relay_owner; pub mod resources; #[cfg(target_os = "macos")] pub mod shared_https_recovery; +#[cfg(test)] +mod storage_root_witness; pub mod storage_usage; #[cfg(all(test, target_os = "macos"))] mod test_executable; diff --git a/packages/runtime-core/src/provider/storage_root_witness.rs b/packages/runtime-core/src/provider/storage_root_witness.rs new file mode 100644 index 000000000..a1addebc3 --- /dev/null +++ b/packages/runtime-core/src/provider/storage_root_witness.rs @@ -0,0 +1,260 @@ +//! Inactive Linux root-witness helper contract. A descriptor supplies bytes, not +//! enrollment authority. Only the durable original-create owner may select Seed. +//! Retained reads select Verify and never repair an absent or changed witness. +//! Copying a whole root together with its xattrs is outside this continuity proof. +use crate::CandidateError; +use zeroize::Zeroize; + +const MAGIC: &str = "hack-storage-root-v1"; +pub(crate) const MAX_REQUEST: usize = 1024; +pub(crate) fn arguments(args: &[std::ffi::OsString]) -> bool { + args.len() == 1 && args[0] == "--storage-root-witness" +} + +fn refused() -> CandidateError { + CandidateError::new( + "storage_root_witness_refused", + "Persistent root witness was refused; values omitted.", + ) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) struct Root { + pub(crate) device: u64, + pub(crate) inode: u64, + pub(crate) uid: u32, + pub(crate) gid: u32, +} +impl Root { + fn valid(&self) -> bool { + self.inode != 0 + } +} + +pub(crate) struct Witness { + name: [u8; 64], + value: [u8; 32], +} +impl Drop for Witness { + fn drop(&mut self) { + self.value.zeroize(); + } +} +impl Witness { + fn parse(name: &str, value: &str) -> Result { + let suffix = name + .strip_prefix("user.hack.storage.") + .ok_or_else(refused)?; + if !hex(suffix, 64) + || !hex(value, 64) + || suffix.bytes().all(|byte| byte == b'0') + || value.bytes().all(|byte| byte == b'0') + { + return Err(refused()); + } + let mut witness = Self { + name: [0; 64], + value: [0; 32], + }; + witness.name.copy_from_slice(suffix.as_bytes()); + for (index, chunk) in value.as_bytes().chunks_exact(2).enumerate() { + let digit = |byte: u8| { + if byte <= b'9' { + byte - b'0' + } else { + byte - b'a' + 10 + } + }; + witness.value[index] = digit(chunk[0]) * 16 + digit(chunk[1]); + } + Ok(witness) + } +} + +pub(crate) enum Operation { + Root, + Seed { root: Root, witness: Witness }, + Verify { root: Root, witness: Witness }, +} +pub(crate) struct Request { + volume: String, + operation: Operation, +} +fn hex(value: &str, length: usize) -> bool { + value.len() == length + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} +fn volume_valid(value: &str) -> bool { + let Some(value) = value.strip_prefix("hkp-") else { + return false; + }; + if value.len() < 99 || value.len() > 161 || !value.is_ascii() { + return false; + } + let logical = &value[98..]; + hex(&value[..64], 64) + && value.as_bytes()[64] == b'-' + && hex(&value[65..97], 32) + && value.as_bytes()[97] == b'-' + && !logical.is_empty() + && logical.len() <= 63 + && (logical.as_bytes()[0].is_ascii_lowercase() || logical.as_bytes()[0].is_ascii_digit()) + && logical.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"._-".contains(&byte) + }) +} +fn number(value: &str) -> Result { + if value.is_empty() + || value.len() > 20 + || (value.len() > 1 && value.starts_with('0')) + || !value.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err(refused()); + } + value.parse().map_err(|_| refused()) +} +impl Request { + /// Exact ASCII newline record from a bounded private pipe. Unknown keys, + /// modes, alternate spelling, argv data and trailing records are refused. + pub(crate) fn parse(bytes: &[u8]) -> Result { + if bytes.len() > MAX_REQUEST || !bytes.is_ascii() { + return Err(refused()); + } + let text = std::str::from_utf8(bytes).map_err(|_| refused())?; + let fields: Vec<_> = text.split('\n').collect(); + if fields.first() != Some(&MAGIC) || fields.last() != Some(&"") { + return Err(refused()); + } + let volume = *fields.get(2).ok_or_else(refused)?; + if !volume_valid(volume) { + return Err(refused()); + } + let operation = match fields.get(1).copied() { + Some("root") if fields.len() == 4 => Operation::Root, + Some(mode @ ("seed" | "verify")) if fields.len() == 10 => { + let root = Root { + device: number(fields[3])?, + inode: number(fields[4])?, + uid: number(fields[5])?.try_into().map_err(|_| refused())?, + gid: number(fields[6])?.try_into().map_err(|_| refused())?, + }; + if !root.valid() { + return Err(refused()); + } + let witness = Witness::parse(fields[7], fields[8])?; + if mode == "seed" { + Operation::Seed { root, witness } + } else { + Operation::Verify { root, witness } + } + } + _ => return Err(refused()), + }; + Ok(Self { + volume: volume.into(), + operation, + }) + } +} +pub(crate) enum Observation { + Root(Root), + Seeded, + Verified, +} +impl Observation { + /// Observation only. Neither this reply nor a successful helper exit can + /// promote pending storage or authorize compute use without the owner commit. + pub(crate) fn encode(&self) -> String { + match self { + Self::Root(root) => format!( + "root:{}:{}:{}:{}\n", + root.device, root.inode, root.uid, root.gid + ), + Self::Seeded => "seeded\n".into(), + Self::Verified => "verified\n".into(), + } + } +} + +trait Kernel { + type Directory; + fn open(&mut self, volume: &str) -> Result; + fn check(&mut self, held: &Self::Directory) -> Result; + fn effective_identity(&self) -> (u32, u32); + fn create(&mut self, held: &Self::Directory, witness: &Witness) -> Result<(), CandidateError>; + fn sync(&mut self, held: &Self::Directory) -> Result<(), CandidateError>; + fn read( + &mut self, + held: &Self::Directory, + witness: &Witness, + ) -> Result<[u8; 32], CandidateError>; + fn close(&mut self, held: Self::Directory) -> Result<(), CandidateError>; +} +fn matches(value: &[u8; 32], expected: &[u8; 32]) -> bool { + value + .iter() + .zip(expected) + .fold(0, |difference, (left, right)| difference | (left ^ right)) + == 0 +} +fn run(request: Request, kernel: &mut impl Kernel) -> Result { + fn execute(request: Request, kernel: &mut impl Kernel) -> Result { + let held = kernel.open(&request.volume)?; + let selected = kernel.check(&held)?; + if !selected.valid() { + return Err(refused()); + } + let seed = matches!(&request.operation, Operation::Seed { .. }); + let outcome = match request.operation { + Operation::Root => { + if kernel.check(&held)? != selected { + return Err(refused()); + } + Observation::Root(selected) + } + Operation::Seed { root, witness } | Operation::Verify { root, witness } => { + if selected != root || kernel.effective_identity() != (root.uid, root.gid) { + return Err(refused()); + } + // The mode was already captured before any kernel await/effect. + // Seed never replaces an xattr; Verify never invokes create. + if seed { + kernel.create(&held, &witness)?; + kernel.sync(&held)?; + } + for _ in 0..2 { + if kernel.check(&held)? != root + || !matches(&kernel.read(&held, &witness)?, &witness.value) + { + return Err(refused()); + } + if kernel.check(&held)? != root { + return Err(refused()); + } + } + if seed { + Observation::Seeded + } else { + Observation::Verified + } + } + }; + kernel.close(held)?; + Ok(outcome) + } + execute(request, kernel).map_err(|_| refused()) +} + +#[cfg(target_os = "linux")] +#[path = "storage_root_witness/linux.rs"] +mod linux; +#[cfg(target_os = "linux")] +pub(crate) fn execute(request: Request) -> Result { + run(request, &mut linux::Linux) +} + +#[cfg(test)] +#[path = "storage_root_witness/tests.rs"] +mod tests; diff --git a/packages/runtime-core/src/provider/storage_root_witness/linux.rs b/packages/runtime-core/src/provider/storage_root_witness/linux.rs new file mode 100644 index 000000000..ff8d30792 --- /dev/null +++ b/packages/runtime-core/src/provider/storage_root_witness/linux.rs @@ -0,0 +1,174 @@ +//! Fixed Linux filesystem boundary. Every ancestor is opened without following +//! symlinks and retained until completion. Fresh canonical traversal and each +//! retained FD must still identify the original directory chain at every check. +use super::*; +use std::{ + ffi::CString, + fs::File, + os::{ + fd::{AsRawFd, FromRawFd, IntoRawFd}, + unix::fs::MetadataExt, + }, +}; + +pub(super) struct Directory { + components: Vec, + files: Vec, + roots: Vec, +} +pub(super) struct Linux; +fn root(file: &File) -> Result { + let info = file.metadata().map_err(|_| refused())?; + if !info.is_dir() || info.ino() == 0 { + return Err(refused()); + } + Ok(Root { + device: info.dev(), + inode: info.ino(), + uid: info.uid(), + gid: info.gid(), + }) +} +fn open(volume: &str) -> Result { + if !volume_valid(volume) { + return Err(refused()); + } + open_components(&["var", "lib", "docker", "volumes", volume, "_data"]) +} +fn open_components(components: &[&str]) -> Result { + if components.iter().any(|part| { + part.is_empty() + || *part == "." + || *part == ".." + || part.contains('/') + || part.contains('\0') + }) { + return Err(refused()); + } + let flags = libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC; + // SAFETY: a fixed NUL-terminated root path and read-only directory flags. + let first = unsafe { libc::open(c"/".as_ptr(), flags) }; + if first < 0 { + return Err(refused()); + } + // SAFETY: successful open returned one new owned FD, adopted exactly once. + let mut files = vec![unsafe { File::from_raw_fd(first) }]; + let mut roots = vec![root(&files[0])?]; + for component in components { + let name = CString::new(*component).map_err(|_| refused())?; + // SAFETY: live retained parent FD and exact single validated component. + let fd = unsafe { + libc::openat( + files.last().ok_or_else(refused)?.as_raw_fd(), + name.as_ptr(), + flags, + ) + }; + if fd < 0 { + return Err(refused()); + } + // SAFETY: successful openat returned one new owned FD, adopted once. + let child = unsafe { File::from_raw_fd(fd) }; + // SAFETY: F_GETFD only observes flags on this live owned FD. + let fd_flags = unsafe { libc::fcntl(fd, libc::F_GETFD) }; + if fd_flags < 0 || fd_flags & libc::FD_CLOEXEC == 0 { + return Err(refused()); + } + roots.push(root(&child)?); + files.push(child); + } + Ok(Directory { + components: components.iter().map(|part| (*part).into()).collect(), + files, + roots, + }) +} +fn close(held: Directory) -> Result<(), CandidateError> { + let mut failed = false; + for file in held.files.into_iter().rev() { + let fd = file.into_raw_fd(); + // SAFETY: into_raw_fd transferred the owned FD; close is called once. + // A failed close is uncertain. Never retry an FD that could be reused. + if unsafe { libc::close(fd) } != 0 { + failed = true; + } + } + if failed { Err(refused()) } else { Ok(()) } +} +fn selected(held: &Directory) -> Result<&File, CandidateError> { + held.files.last().ok_or_else(refused) +} +fn name(witness: &Witness) -> Result { + let mut bytes = b"user.hack.storage.".to_vec(); + bytes.extend_from_slice(&witness.name); + CString::new(bytes).map_err(|_| refused()) +} +impl Kernel for Linux { + type Directory = Directory; + fn open(&mut self, volume: &str) -> Result { + open(volume) + } + fn check(&mut self, held: &Directory) -> Result { + let before = held.files.iter().map(root).collect::, _>>()?; + if before != held.roots { + return Err(refused()); + } + let components: Vec<_> = held.components.iter().map(String::as_str).collect(); + let current = open_components(&components)?; + let same = current.roots == held.roots; + close(current)?; + let after = held.files.iter().map(root).collect::, _>>()?; + if !same || after != held.roots { + return Err(refused()); + } + root(selected(held)?) + } + fn effective_identity(&self) -> (u32, u32) { + // SAFETY: these functions only read the calling process credentials. + unsafe { (libc::geteuid(), libc::getegid()) } + } + fn create(&mut self, held: &Directory, witness: &Witness) -> Result<(), CandidateError> { + let name = name(witness)?; + // SAFETY: borrowed live root FD, NUL-terminated name, exact32 initialized + // bytes. XATTR_CREATE refuses an existing name; no replace operation exists. + let result = unsafe { + libc::fsetxattr( + selected(held)?.as_raw_fd(), + name.as_ptr(), + witness.value.as_ptr().cast(), + witness.value.len(), + libc::XATTR_CREATE, + ) + }; + if result == 0 { Ok(()) } else { Err(refused()) } + } + fn sync(&mut self, held: &Directory) -> Result<(), CandidateError> { + selected(held)?.sync_all().map_err(|_| refused()) + } + fn read(&mut self, held: &Directory, witness: &Witness) -> Result<[u8; 32], CandidateError> { + let name = name(witness)?; + let mut value = [0_u8; 32]; + // SAFETY: borrowed live FD/name and exact writable32-byte output buffer. + // Missing, shorter and oversized xattrs all refuse; no partial result passes. + let count = unsafe { + libc::fgetxattr( + selected(held)?.as_raw_fd(), + name.as_ptr(), + value.as_mut_ptr().cast(), + value.len(), + ) + }; + if count != value.len() as isize { + value.zeroize(); + return Err(refused()); + } + Ok(value) + } + fn close(&mut self, held: Directory) -> Result<(), CandidateError> { + close(held) + } +} + +#[cfg(test)] +#[path = "linux/tests.rs"] +mod tests; diff --git a/packages/runtime-core/src/provider/storage_root_witness/linux/tests.rs b/packages/runtime-core/src/provider/storage_root_witness/linux/tests.rs new file mode 100644 index 000000000..3bd2be0b8 --- /dev/null +++ b/packages/runtime-core/src/provider/storage_root_witness/linux/tests.rs @@ -0,0 +1,184 @@ +//! Real private-filesystem controls only. No Docker/provider paths are touched. +use super::*; +use std::{ + fs, + os::unix::fs::{DirBuilderExt, symlink}, + path::{Component, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, +}; +struct Private { + root: PathBuf, + data: PathBuf, +} +impl Private { + fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let root = std::env::temp_dir().canonicalize().unwrap().join(format!( + "native-root-witness-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::DirBuilder::new().mode(0o700).create(&root).unwrap(); + let data = root.join("_data"); + fs::DirBuilder::new().mode(0o700).create(&data).unwrap(); + Self { root, data } + } + fn directory(&self) -> Result { + let components = self + .data + .components() + .filter_map(|part| match part { + Component::RootDir => None, + Component::Normal(part) => Some(part.to_str().unwrap()), + _ => panic!("Canonical private fixture path required"), + }) + .collect::>(); + open_components(&components) + } + fn request(&self, mode: &str, root: Root) -> Request { + let bytes = format!( + "{MAGIC}\n{mode}\nhkp-{}-{}-database\n{}\n{}\n{}\n{}\nuser.hack.storage.{}\n{}\n", + "a".repeat(64), + "b".repeat(32), + root.device, + root.inode, + root.uid, + root.gid, + "c".repeat(64), + "01".repeat(32) + ); + Request::parse(bytes.as_bytes()).unwrap() + } +} +impl Drop for Private { + fn drop(&mut self) { + fs::remove_dir_all(&self.root).unwrap(); + } +} +impl Kernel for &Private { + type Directory = Directory; + fn open(&mut self, volume: &str) -> Result { + if !volume_valid(volume) { + return Err(refused()); + } + self.directory() + } + fn check(&mut self, held: &Directory) -> Result { + Linux.check(held) + } + fn effective_identity(&self) -> (u32, u32) { + Linux.effective_identity() + } + fn create(&mut self, held: &Directory, witness: &Witness) -> Result<(), CandidateError> { + Linux.create(held, witness) + } + fn sync(&mut self, held: &Directory) -> Result<(), CandidateError> { + Linux.sync(held) + } + fn read(&mut self, held: &Directory, witness: &Witness) -> Result<[u8; 32], CandidateError> { + Linux.read(held, witness) + } + fn close(&mut self, held: Directory) -> Result<(), CandidateError> { + Linux.close(held) + } +} +fn observed(fixture: &Private) -> Root { + let held = fixture.directory().unwrap(); + let value = Linux.check(&held).unwrap(); + close(held).unwrap(); + value +} +#[test] +fn real_xattr_create_is_exclusive_fsynced_and_retained_reads_do_not_repair() { + let fixture = Private::new(); + let root = observed(&fixture); + assert!(run(fixture.request("verify", root), &mut &fixture).is_err()); + assert_eq!( + run(fixture.request("seed", root), &mut &fixture) + .unwrap() + .encode(), + "seeded\n" + ); + let before = fs::symlink_metadata(&fixture.data).unwrap(); + assert_eq!( + run(fixture.request("verify", root), &mut &fixture) + .unwrap() + .encode(), + "verified\n" + ); + assert!(run(fixture.request("seed", root), &mut &fixture).is_err()); + let after = fs::symlink_metadata(&fixture.data).unwrap(); + assert_eq!( + ( + before.ino(), + before.mtime(), + before.mtime_nsec(), + before.ctime(), + before.ctime_nsec() + ), + ( + after.ino(), + after.mtime(), + after.mtime_nsec(), + after.ctime(), + after.ctime_nsec() + ) + ); + let held = fixture.directory().unwrap(); + let witness = Witness::parse( + &format!("user.hack.storage.{}", "c".repeat(64)), + &"01".repeat(32), + ) + .unwrap(); + let name = name(&witness).unwrap(); + let changed = [2_u8; 32]; + // SAFETY: only this private fixture's retained FD/name and exact32-byte buffer. + assert_eq!( + unsafe { + libc::fsetxattr( + selected(&held).unwrap().as_raw_fd(), + name.as_ptr(), + changed.as_ptr().cast(), + changed.len(), + libc::XATTR_REPLACE, + ) + }, + 0 + ); + selected(&held).unwrap().sync_all().unwrap(); + assert!(run(fixture.request("verify", root), &mut &fixture).is_err()); + // SAFETY: removes only the xattr seeded on this exact owned private root FD. + assert_eq!( + unsafe { libc::fremovexattr(selected(&held).unwrap().as_raw_fd(), name.as_ptr()) }, + 0 + ); + assert!(run(fixture.request("verify", root), &mut &fixture).is_err()); + close(held).unwrap(); +} +#[test] +fn retained_fd_path_replacement_and_symlinks_refuse_without_seeding_the_new_root() { + let fixture = Private::new(); + let root = observed(&fixture); + run(fixture.request("seed", root), &mut &fixture).unwrap(); + let held = fixture.directory().unwrap(); + let original = fixture.root.join("original"); + fs::rename(&fixture.data, &original).unwrap(); + fs::DirBuilder::new() + .mode(0o700) + .create(&fixture.data) + .unwrap(); + assert!(Linux.check(&held).is_err()); + assert!(run(fixture.request("verify", root), &mut &fixture).is_err()); + let new_root = observed(&fixture); + assert!(run(fixture.request("verify", new_root), &mut &fixture).is_err()); + fs::remove_dir(&fixture.data).unwrap(); + symlink(&original, &fixture.data).unwrap(); + assert!(fixture.directory().is_err()); + let witness = Witness::parse( + &format!("user.hack.storage.{}", "c".repeat(64)), + &"01".repeat(32), + ) + .unwrap(); + assert_eq!(Linux.read(&held, &witness).unwrap(), [1; 32]); + close(held).unwrap(); +} diff --git a/packages/runtime-core/src/provider/storage_root_witness/tests.rs b/packages/runtime-core/src/provider/storage_root_witness/tests.rs new file mode 100644 index 000000000..e9574a657 --- /dev/null +++ b/packages/runtime-core/src/provider/storage_root_witness/tests.rs @@ -0,0 +1,224 @@ +use super::*; + +fn volume() -> String { + format!("hkp-{}-{}-database", "a".repeat(64), "b".repeat(32)) +} +fn root() -> Root { + Root { + device: 0, + inode: 42, + uid: 501, + gid: 20, + } +} +fn request(mode: &str) -> Vec { + let volume = volume(); + if mode == "root" { + return format!("{MAGIC}\nroot\n{volume}\n").into_bytes(); + } + format!( + "{MAGIC}\n{mode}\n{volume}\n0\n42\n501\n20\nuser.hack.storage.{}\n{}\n", + "c".repeat(64), + "01".repeat(32) + ) + .into_bytes() +} +#[derive(Default)] +struct Fake { + calls: Vec<&'static str>, + fail: Option<&'static str>, + checks: usize, + change_at: Option, + value: Option<[u8; 32]>, + wrong_uid: bool, + change_second_read: bool, + reads: usize, +} +impl Fake { + fn call(&mut self, name: &'static str) -> Result<(), CandidateError> { + self.calls.push(name); + if self.fail == Some(name) { + Err(CandidateError::new( + "private-path-value", + "private canary /owned/path", + )) + } else { + Ok(()) + } + } +} +impl Kernel for Fake { + type Directory = Root; + fn open(&mut self, _: &str) -> Result { + self.call("open")?; + Ok(root()) + } + fn check(&mut self, _: &Root) -> Result { + self.call("check")?; + self.checks += 1; + let mut selected = root(); + if self.change_at == Some(self.checks) { + selected.inode += 1; + } + Ok(selected) + } + fn effective_identity(&self) -> (u32, u32) { + (if self.wrong_uid { 502 } else { 501 }, 20) + } + fn create(&mut self, _: &Root, witness: &Witness) -> Result<(), CandidateError> { + self.call("create")?; + if self.value.is_some() { + return Err(refused()); + } + self.value = Some(witness.value); + Ok(()) + } + fn sync(&mut self, _: &Root) -> Result<(), CandidateError> { + self.call("sync") + } + fn read(&mut self, _: &Root, _: &Witness) -> Result<[u8; 32], CandidateError> { + self.call("read")?; + self.reads += 1; + let mut value = self.value.ok_or_else(refused)?; + if self.change_second_read && self.reads == 2 { + value[0] ^= 1; + } + Ok(value) + } + fn close(&mut self, _: Root) -> Result<(), CandidateError> { + self.call("close") + } +} +#[test] +fn original_seed_syncs_and_rereads_while_retained_verification_is_read_only() { + let mut fake = Fake::default(); + assert_eq!( + run(Request::parse(&request("root")).unwrap(), &mut fake) + .unwrap() + .encode(), + "root:0:42:501:20\n" + ); + fake.calls.clear(); + assert_eq!( + run(Request::parse(&request("seed")).unwrap(), &mut fake) + .unwrap() + .encode(), + "seeded\n" + ); + assert_eq!( + fake.calls, + [ + "open", "check", "create", "sync", "check", "read", "check", "check", "read", "check", + "close" + ] + ); + fake.calls.clear(); + assert_eq!( + run(Request::parse(&request("verify")).unwrap(), &mut fake) + .unwrap() + .encode(), + "verified\n" + ); + assert!(!fake.calls.contains(&"create")); + assert!(!fake.calls.contains(&"sync")); + fake.calls.clear(); + assert!(run(Request::parse(&request("seed")).unwrap(), &mut fake).is_err()); + assert_eq!(fake.value, Some([1; 32])); +} +#[test] +fn missing_changed_second_read_root_drift_and_errors_never_repair_or_escape() { + for change_at in 1..=5 { + let mut fake = Fake { + change_at: Some(change_at), + value: Some([1; 32]), + ..Fake::default() + }; + assert!(run(Request::parse(&request("verify")).unwrap(), &mut fake).is_err()); + assert!(!fake.calls.contains(&"create")); + } + for mut fake in [ + Fake::default(), + Fake { + value: Some([2; 32]), + ..Fake::default() + }, + Fake { + value: Some([1; 32]), + change_second_read: true, + ..Fake::default() + }, + Fake { + value: Some([1; 32]), + wrong_uid: true, + ..Fake::default() + }, + ] { + assert!(run(Request::parse(&request("verify")).unwrap(), &mut fake).is_err()); + assert!(!fake.calls.contains(&"create")); + } + for fail in ["open", "check", "create", "sync", "read", "close"] { + let mut fake = Fake { + fail: Some(fail), + ..Fake::default() + }; + let error = match run(Request::parse(&request("seed")).unwrap(), &mut fake) { + Err(error) => error, + Ok(_) => panic!("Fault must refuse"), + }; + assert_eq!(error.code, "storage_root_witness_refused"); + assert_eq!( + error.message, + "Persistent root witness was refused; values omitted." + ); + assert_eq!( + fake.calls.iter().filter(|call| **call == "create").count(), + usize::from(["create", "sync", "read", "close"].contains(&fail)) + ); + } +} +#[test] +fn descriptor_codec_refuses_extra_missing_noncanonical_or_unsafe_requests() { + assert!(arguments(&["--storage-root-witness".into()])); + for args in [ + vec![], + vec!["--storage-root-witness".into(), "seed".into()], + vec!["--slot".into(), "0".into(), "--storage-root-witness".into()], + vec!["--storage-root-witness".into(), "--check-release".into()], + vec!["--storage-root-witness\n".into()], + ] { + assert!(!arguments(&args)); + } + let good = String::from_utf8(request("seed")).unwrap(); + for value in [ + good.replace("hack-storage-root-v1", "hack-storage-root-v2"), + good.replace("\nseed\n", "\nrepair\n"), + format!("{good}extra\n"), + good.trim_end().into(), + good.replace("\n42\n", "\n0\n"), + good.replace("\n42\n", "\n042\n"), + good.replace("\n501\n", "\n4294967296\n"), + good.replace("user.hack.storage.", "trusted.hack.storage."), + good.replace(&"01".repeat(32), &"00".repeat(32)), + good.replace("database", "../database"), + good.replace("database", "database\0"), + good.replace("database", "dátabase"), + ] { + assert!(Request::parse(value.as_bytes()).is_err()); + } + assert!(Request::parse(&[b'x'; MAX_REQUEST + 1]).is_err()); + for logical in ["a", "0", "a.b-c_d", &"a".repeat(63)] { + let input = format!( + "{MAGIC}\nroot\nhkp-{}-{}-{logical}\n", + "a".repeat(64), + "b".repeat(32) + ); + assert!(Request::parse(input.as_bytes()).is_ok()); + } + for logical in ["", ".", "..", "A", "a/b", &"a".repeat(64)] { + assert!(!volume_valid(&format!( + "hkp-{}-{}-{logical}", + "a".repeat(64), + "b".repeat(32) + ))); + } +} From e1e6da928ea182e5695c8cd07caf3d8bdcb38073 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Fri, 9 Oct 2026 01:17:35 -0400 Subject: [PATCH 4/4] feat(native): bind inactive persistent enrollment to root witnesses --- packages/relay-guest/src/main.rs | 2 + packages/runtime-core/README.md | 26 +- packages/runtime-core/src/provider/agent.rs | 15 +- .../src/provider/graph/native/journal.rs | 30 +- .../provider/graph/native/persistent_data.rs | 5 + .../graph/native/persistent_data/engine.rs | 4 + .../persistent_data/engine/witnessed.rs | 125 +++++ .../native/persistent_data/enrollment.rs | 23 +- .../persistent_data/enrollment/witnessed.rs | 286 +++++++++++ .../enrollment/witnessed/tests.rs | 357 ++++++++++++++ .../graph/native/persistent_data/tool.rs | 443 ++++++++++++++++++ .../graph/native/persistent_data/witnessed.rs | 188 ++++++++ .../provider/graph/native/runtime/tests.rs | 14 + .../src/provider/graph/startup/runtime.rs | 97 ++-- .../runtime-core/src/provider/guest_tool.rs | 238 ++++++++++ .../runtime-core/src/provider/lifecycle.rs | 26 +- packages/runtime-core/src/provider/mod.rs | 6 +- .../src/provider/storage_root_witness.rs | 85 ++++ .../provider/storage_root_witness/tests.rs | 43 ++ .../native-authored-graph-protocol.ts | 38 +- ...ative-authored-persistent-data-protocol.ts | 57 +++ tests/native-authored-graph-protocol.test.ts | 57 +++ 22 files changed, 2088 insertions(+), 77 deletions(-) create mode 100644 packages/runtime-core/src/provider/graph/native/persistent_data/engine/witnessed.rs create mode 100644 packages/runtime-core/src/provider/graph/native/persistent_data/enrollment/witnessed.rs create mode 100644 packages/runtime-core/src/provider/graph/native/persistent_data/enrollment/witnessed/tests.rs create mode 100644 packages/runtime-core/src/provider/graph/native/persistent_data/tool.rs create mode 100644 packages/runtime-core/src/provider/graph/native/persistent_data/witnessed.rs create mode 100644 packages/runtime-core/src/provider/guest_tool.rs diff --git a/packages/relay-guest/src/main.rs b/packages/relay-guest/src/main.rs index 6aa2e9d1e..147a38f23 100644 --- a/packages/relay-guest/src/main.rs +++ b/packages/relay-guest/src/main.rs @@ -7,6 +7,8 @@ pub use error::CandidateError; mod private_input; #[cfg(target_os = "linux")] #[path = "../../runtime-core/src/provider/storage_root_witness.rs"] +// The host also uses request encoding/reply decoding from this shared source. +#[allow(dead_code)] mod storage_root_witness; // This shared module also supplies host-only authority/effect APIs. Keep them // compiled and checked by runtime-core without requiring guest-side callers. diff --git a/packages/runtime-core/README.md b/packages/runtime-core/README.md index 269cee130..700e15403 100644 --- a/packages/runtime-core/README.md +++ b/packages/runtime-core/README.md @@ -1526,9 +1526,33 @@ created only during the original enrollment with `XATTR_CREATE` on a retained nofollow root descriptor, synchronized, and independently reread with exact descriptor/path/root checks. Retained startup only reads; missing/changed witness refuses without repair. Whole-root/xattr copying remains outside the claimed -guarantee. This native helper/transport is not implemented or qualified here. +guarantee. The metadata-only checkpoint does not implement or qualify that helper +boundary; the inactive witnessed continuation below adds its source. These runtime/SQL gates and replacement safety are not yet qualified by this source implementation. Stock PostgreSQL parity remains a separate NC05 gate requiring authored ownership/user or a specifically qualified capability policy. This slice guesses no UID/GID, changes no volume permissions, and adds no capability to make a stock image work. +# Witnessed enrollment continuation (inactive) + +The separate persistent owner version2 stores its expected root xattr in the private +pending intent before the single original volume create. It captures the original +volume and root UID/GID/device/inode, seeds with `XATTR_CREATE`, independently verifies +the root, and only then publishes enrollment under the original private lock. A missing +or changed witness refuses retained reads without repair. Version1 metadata records +are not upgraded or treated as continuity proof. Ambiguous create/seed retains the +intent; publication after rename can be uncertain even when an enrolled file is visible. + +Native receipt4 may describe a separate pinned guest tool installation, including the +original directory and helper identities. This control path shares the existing relay +artifact/upload boundary and uses private descriptor input under the original operation +deadline. Dependency-free legacy graphs continue to install no guest artifact. Tool +receipt parsing grants neither installation nor storage authority, and this continuation +does not enable ordinary persistent startup. The Linux private-filesystem controls, +installed helper/guest transport, and real Bun/SQLite retention still require qualification. +The shared artifact reader now requires a current-UID regular file with one link, +no group/world write permission, and matching held-FD/path identity and bytes. Legacy +relay graphs with dependencies use that stricter reader and its fixed +`guest_tool_artifact` refusal; dependency-free graphs do not read an artifact. +Whole-root copies retaining the exact xattr remain outside this witness guarantee. +Stock PostgreSQL remains a separate ownership/capability acceptance gate. diff --git a/packages/runtime-core/src/provider/agent.rs b/packages/runtime-core/src/provider/agent.rs index b3bbd0210..1aed6bdba 100644 --- a/packages/runtime-core/src/provider/agent.rs +++ b/packages/runtime-core/src/provider/agent.rs @@ -136,6 +136,19 @@ pub(super) fn exec_until( script: &str, arguments: &[&str], deadline: Instant, +) -> Result { + exec_input_until(path, script, arguments, None, deadline) +} + +/// Private input follows the same absolute deadline as connect, dispatch and reply. +/// A timed-out exec is uncertain; no late successful reply restores authority. +#[cfg(feature = "native-config-plan")] +pub(super) fn exec_input_until( + path: &Path, + script: &str, + arguments: &[&str], + input: Option<&str>, + deadline: Instant, ) -> Result { let mut stream = connect_until(path, deadline)?; let remaining = deadline @@ -147,7 +160,7 @@ pub(super) fn exec_until( } let mut command = vec!["/bin/sh", "-c", script, "hack-local"]; command.extend_from_slice(arguments); - let body = json!({"method":"vm_exec","command":command,"env":[["PATH","/opt/hack-engine:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]],"workdir":"/","timeout_ms":milliseconds,"interactive":false,"tty":false,"background":false,"stdin_data":null}); + let body = json!({"method":"vm_exec","command":command,"env":[["PATH","/opt/hack-engine:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]],"workdir":"/","timeout_ms":milliseconds,"interactive":false,"tty":false,"background":false,"stdin_data":input}); let bytes = encode(&body)?; let timeout = deadline .checked_duration_since(Instant::now()) diff --git a/packages/runtime-core/src/provider/graph/native/journal.rs b/packages/runtime-core/src/provider/graph/native/journal.rs index 6a3436fb9..28bb0807a 100644 --- a/packages/runtime-core/src/provider/graph/native/journal.rs +++ b/packages/runtime-core/src/provider/graph/native/journal.rs @@ -69,6 +69,8 @@ pub struct Receipt { #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub(super) data_mounts: BTreeMap>, #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) data_tool: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] failure: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub(super) terminal: BTreeMap, @@ -91,6 +93,8 @@ struct ReceiptWire { data: Option>, #[serde(default, deserialize_with = "present_map")] data_mounts: Option>>, + #[serde(default, deserialize_with = "present_map")] + data_tool: Option, #[serde(default)] failure: Option, #[serde(default)] @@ -104,7 +108,8 @@ fn present_map<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>( impl TryFrom for Receipt { type Error = &'static str; fn try_from(wire: ReceiptWire) -> Result { - if (wire.version == 2 && (wire.data.is_some() || wire.data_mounts.is_some())) + if (wire.version == 2 + && (wire.data.is_some() || wire.data_mounts.is_some() || wire.data_tool.is_some())) || (wire.version == 4 && (wire.data.is_none() || wire.data_mounts.is_none())) { return Err("Native receipt storage fields do not match its wire version."); @@ -120,6 +125,7 @@ impl TryFrom for Receipt { resources: wire.resources, data: wire.data.unwrap_or_default(), data_mounts: wire.data_mounts.unwrap_or_default(), + data_tool: wire.data_tool, failure: wire.failure, terminal: wire.terminal, }) @@ -134,6 +140,7 @@ impl Receipt { || self.readiness != expected.readiness || self.data != expected.data || self.data_mounts != expected.data_mounts + || self.data_tool != expected.data_tool || self.resources.keys().ne(expected.resources.keys()) || self.resources.iter().any(|(key, resource)| { let prior = &expected.resources[key]; @@ -150,6 +157,9 @@ impl Receipt { } Ok(()) } + pub(in crate::provider::graph::native) fn persistent(&self) -> bool { + self.version == 4 + } pub fn phase(&self) -> &Phase { &self.phase } @@ -202,6 +212,7 @@ impl Receipt { resources: config.resources.clone(), data: config.data.clone(), data_mounts: config.data_mounts.clone(), + data_tool: None, failure: None, terminal: BTreeMap::new(), }; @@ -348,7 +359,10 @@ impl Receipt { fn validate_data(&self) -> Result<(), CandidateError> { if self.version == 2 { - return if self.data.is_empty() && self.data_mounts.is_empty() { + return if self.data.is_empty() + && self.data_mounts.is_empty() + && self.data_tool.is_none() + { Ok(()) } else { Err(refused()) @@ -357,6 +371,18 @@ impl Receipt { if self.data.is_empty() || self.data_mounts.is_empty() { return Err(refused()); } + if let Some(tool) = &self.data_tool { + tool.validate()?; + if (self.phase == Phase::ReadyObserved + || self + .resources + .values() + .any(|resource| resource.kind == Kind::Container && resource.id.is_some())) + && tool.helper.is_none() + { + return Err(refused()); + } + } let mut used = BTreeSet::new(); for (service, mounts) in &self.data_mounts { if !self.readiness.contains_key(service) || mounts.is_empty() { diff --git a/packages/runtime-core/src/provider/graph/native/persistent_data.rs b/packages/runtime-core/src/provider/graph/native/persistent_data.rs index 4d8f14607..b90acef5d 100644 --- a/packages/runtime-core/src/provider/graph/native/persistent_data.rs +++ b/packages/runtime-core/src/provider/graph/native/persistent_data.rs @@ -261,3 +261,8 @@ mod tests; pub(super) mod engine; pub mod enrollment; +pub mod witnessed; + +// Inactive APIs are deliberately not selected by ordinary startup until qualification. +#[allow(dead_code)] +pub(super) mod tool; diff --git a/packages/runtime-core/src/provider/graph/native/persistent_data/engine.rs b/packages/runtime-core/src/provider/graph/native/persistent_data/engine.rs index 808559d8d..3b67f0538 100644 --- a/packages/runtime-core/src/provider/graph/native/persistent_data/engine.rs +++ b/packages/runtime-core/src/provider/graph/native/persistent_data/engine.rs @@ -9,6 +9,10 @@ use reqwest::Method; use serde_json::{Value, json}; use std::{collections::BTreeMap, io::Read, sync::atomic::AtomicBool, time::Instant}; +// This installed transport is intentionally not called by the gated runtime yet. +#[allow(dead_code)] +mod witnessed; + #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "status", rename_all = "snake_case", deny_unknown_fields)] enum State { diff --git a/packages/runtime-core/src/provider/graph/native/persistent_data/engine/witnessed.rs b/packages/runtime-core/src/provider/graph/native/persistent_data/engine/witnessed.rs new file mode 100644 index 000000000..a115a5912 --- /dev/null +++ b/packages/runtime-core/src/provider/graph/native/persistent_data/engine/witnessed.rs @@ -0,0 +1,125 @@ +//! Installed tool + original Engine lease transport. No ordinary runtime caller is +//! enabled. A helper observation never grants durable promotion by itself. +use super::*; +use crate::provider::graph::native::persistent_data::{ + tool::Installed, + witnessed::{ExpectedWitness, RootIdentity}, +}; +use crate::provider::storage_root_witness::{Observation as Reply, Request}; +use enrollment::Transport as _; + +pub(in crate::provider::graph::native) struct WitnessAdapter<'a, 'guest> { + base: Adapter<'a, 'guest>, + tool: &'a Installed, +} +impl<'a, 'guest> WitnessAdapter<'a, 'guest> { + pub(in crate::provider::graph::native) fn new( + engine: &'a Engine<'guest>, + tool: &'a Installed, + fresh: &'a dyn Fn() -> Result<(), CandidateError>, + ) -> Self { + Self { + base: Adapter::new(engine, fresh), + tool, + } + } + fn exact(&mut self, captured: &Observation, deadline: Instant) -> Result<(), CandidateError> { + self.base.verify(&captured.binding, deadline)?; + if self.base.inspect(&captured.volume.name, deadline)?.as_ref() != Some(captured) { + return Err(refused()); + } + self.base.check(deadline) + } + fn invoke(&self, request: Request, deadline: Instant) -> Result { + self.tool + .invoke(self.base.engine, request, deadline, self.base.fresh) + } +} +impl enrollment::sealed::Transport for WitnessAdapter<'_, '_> {} +impl enrollment::Transport for WitnessAdapter<'_, '_> { + fn verify(&mut self, expected: &Binding, deadline: Instant) -> Result<(), CandidateError> { + self.base.verify(expected, deadline) + } + fn inspect( + &mut self, + name: &str, + deadline: Instant, + ) -> Result, CandidateError> { + self.base.inspect(name, deadline) + } + fn create_new( + &mut self, + request: &enrollment::CreateRequest, + deadline: Instant, + ) -> Result { + self.base.create_new(request, deadline) + } +} +impl enrollment::witnessed::Transport for WitnessAdapter<'_, '_> { + fn root( + &mut self, + volume: &Observation, + deadline: Instant, + ) -> Result { + self.exact(volume, deadline)?; + let Reply::Root(root) = self.invoke(Request::root(&volume.volume.name)?, deadline)? else { + return Err(refused()); + }; + if (root.device, root.inode) + != ( + volume.volume.directory.device, + volume.volume.directory.inode, + ) + { + return Err(refused()); + } + self.exact(volume, deadline)?; + Ok(root.into()) + } + fn seed( + &mut self, + request: &enrollment::witnessed::SeedRequest, + deadline: Instant, + ) -> Result<(), CandidateError> { + let observed = request.captured(); + self.exact(observed, deadline)?; + let witness = request.witness(); + let reply = self.invoke( + Request::bound( + &observed.volume.name, + true, + request.root().into(), + &witness.name, + &witness.value, + )?, + deadline, + )?; + if !matches!(reply, Reply::Seeded) { + return Err(refused()); + } + self.exact(observed, deadline) + } + fn verify_witness( + &mut self, + volume: &Observation, + root: RootIdentity, + witness: &ExpectedWitness, + deadline: Instant, + ) -> Result<(), CandidateError> { + self.exact(volume, deadline)?; + let reply = self.invoke( + Request::bound( + &volume.volume.name, + false, + root.into(), + &witness.name, + &witness.value, + )?, + deadline, + )?; + if !matches!(reply, Reply::Verified) { + return Err(refused()); + } + self.exact(volume, deadline) + } +} diff --git a/packages/runtime-core/src/provider/graph/native/persistent_data/enrollment.rs b/packages/runtime-core/src/provider/graph/native/persistent_data/enrollment.rs index 799179c53..458f6d01e 100644 --- a/packages/runtime-core/src/provider/graph/native/persistent_data/enrollment.rs +++ b/packages/runtime-core/src/provider/graph/native/persistent_data/enrollment.rs @@ -141,7 +141,7 @@ fn snapshot(binding: &Binding) -> Result { } Ok(binding.clone()) } -fn refused() -> CandidateError { +pub(super) fn refused() -> CandidateError { CandidateError::new( "native_persistent_data_enrollment", "Persistent data enrollment is incomplete, ambiguous or changed; retained data was not adopted or deleted.", @@ -505,6 +505,17 @@ impl RecordPin { ) -> Result { let bytes = serde_json::to_vec(owner).map_err(|_| refused())?; super::decode(&bytes)?; + Self::create_bytes(path, bytes, sync, step) + } + fn create_bytes( + path: &Path, + bytes: Vec, + sync: &mut S, + step: Step, + ) -> Result { + if bytes.is_empty() || bytes.len() > super::LIMIT { + return Err(refused()); + } let mut file = OpenOptions::new() .read(true) .write(true) @@ -524,13 +535,19 @@ impl RecordPin { Ok(pin) } fn read(path: &Path) -> Result { + Self::read_validated(path, |bytes| super::decode(bytes).map(|_| ())) + } + fn read_validated( + path: &Path, + validate: impl FnOnce(&[u8]) -> Result<(), CandidateError>, + ) -> Result { let file = OpenOptions::new() .read(true) .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) .open(path) .map_err(|_| refused())?; let bytes = read_bytes(&file)?; - super::decode(&bytes)?; + validate(&bytes)?; let pin = Self { path: path.into(), file, @@ -588,3 +605,5 @@ impl Sync for SystemSync { #[cfg(test)] mod tests; + +pub mod witnessed; diff --git a/packages/runtime-core/src/provider/graph/native/persistent_data/enrollment/witnessed.rs b/packages/runtime-core/src/provider/graph/native/persistent_data/enrollment/witnessed.rs new file mode 100644 index 000000000..c35bf5ef6 --- /dev/null +++ b/packages/runtime-core/src/provider/graph/native/persistent_data/enrollment/witnessed.rs @@ -0,0 +1,286 @@ +//! Witnessed enrollment is a separate owner wire, with no v1 migration. Expected +//! bytes are durable before original create and XATTR_CREATE. Pending or an uncertain +//! seed is never resumed. Retained reads only verify; they cannot repair a witness. + +use super::*; +use crate::provider::graph::native::persistent_data::witnessed::{ + self, ExpectedWitness, RootIdentity, +}; + +/// Sealed original-effect transport, retaining the same continuously fenced guest +/// mutation lease through owner publication. A successful seed reply alone cannot +/// enroll data. All errors at this boundary are fixed and omit supplied values. +pub trait Transport: super::Transport { + fn root( + &mut self, + volume: &Observation, + deadline: Instant, + ) -> Result; + fn seed(&mut self, request: &SeedRequest, deadline: Instant) -> Result<(), CandidateError>; + fn verify_witness( + &mut self, + volume: &Observation, + root: RootIdentity, + witness: &ExpectedWitness, + deadline: Instant, + ) -> Result<(), CandidateError>; +} +/// Issued only after this invocation's durable pending intent and captured sole +/// original create. Its fields cannot be reconstructed by an external caller. +pub struct SeedRequest { + captured: Observation, + root: RootIdentity, + witness: ExpectedWitness, + intent: String, +} +impl SeedRequest { + pub fn captured(&self) -> &Observation { + &self.captured + } + pub fn root(&self) -> RootIdentity { + self.root + } + pub fn witness(&self) -> &ExpectedWitness { + &self.witness + } + pub fn intent(&self) -> &str { + &self.intent + } +} +pub struct EnrollOptions<'a> { + pub base: super::EnrollOptions<'a>, + pub witness: &'a ExpectedWitness, +} +pub fn enroll_new( + options: EnrollOptions<'_>, + transport: &mut T, +) -> Result { + enroll(options, transport, &mut SystemSync) +} +pub fn read_retained( + options: ReadOptions<'_>, + transport: &mut T, +) -> Result { + let binding = snapshot(options.binding)?; + let guard = Guard { + deadline: options.deadline, + cancelled: options.cancelled, + }; + guard.check()?; + let files = Files::existing(options.state_root, &binding)?; + let record = RecordPin::read_validated(&files.slot.path.join("owner.json"), |bytes| { + witnessed::decode(bytes).map(|_| ()) + })?; + let owner = witnessed::decode(&record.bytes)?; + if !matches!(owner.0.enrollment, witnessed::Enrollment::Enrolled { .. }) { + return Err(refused()); + } + fresh(&files, &record, &binding, &guard, transport)?; + verify_current(&owner, &binding, &guard, transport)?; + fresh(&files, &record, &binding, &guard, transport)?; + Ok(owner) +} +pub struct BindingSelectionOptions<'a> { + pub state_root: &'a Path, + pub namespace: &'a str, + pub storage: &'a str, +} +/// Read-only stable binding selection. Pending/staging/v1/foreign scope refuses; +/// the resulting binding still requires a separately acquired retained witness read. +pub fn existing_binding( + options: BindingSelectionOptions<'_>, +) -> Result, CandidateError> { + if !super::super::super::hex(options.namespace, 64) + || !super::super::logical_name(options.storage) + { + return Err(refused()); + } + let root = Directory::open(options.state_root)?; + let path = root.path.join(slot_key(options.namespace, options.storage)); + match fs::symlink_metadata(&path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + root.verify()?; + return Ok(None); + } + Err(_) => return Err(refused()), + Ok(_) => {} + } + let slot = Directory::open(&path)?; + let lock = state::Lock::acquire_existing(&slot.path).map_err(|_| refused())?; + let files = Files { root, slot, lock }; + files.verify(None)?; + let record = RecordPin::read_validated(&files.slot.path.join("owner.json"), |bytes| { + witnessed::decode(bytes).map(|_| ()) + })?; + let owner = witnessed::decode(&record.bytes)?; + if owner.0.binding.scope.namespace != options.namespace + || owner.0.binding.scope.storage != options.storage + || !matches!(owner.0.enrollment, witnessed::Enrollment::Enrolled { .. }) + { + return Err(refused()); + } + files.verify(Some(&record))?; + Ok(Some(owner.0.binding.clone())) +} +fn encode(owner: &witnessed::Owner) -> Result, CandidateError> { + let bytes = serde_json::to_vec(owner).map_err(|_| refused())?; + witnessed::decode(&bytes)?; + Ok(bytes) +} +fn verify_current( + owner: &witnessed::Owner, + binding: &Binding, + guard: &Guard<'_>, + transport: &mut T, +) -> Result<(), CandidateError> { + let witnessed::Enrollment::Enrolled { + volume, + root, + witness, + } = &owner.0.enrollment + else { + return Err(refused()); + }; + guard.check()?; + transport + .verify(binding, guard.deadline) + .map_err(|_| refused())?; + guard.check()?; + let observed = transport + .inspect(&volume.name, guard.deadline) + .map_err(|_| refused())? + .ok_or_else(refused)?; + guard.check()?; + let observed_root = transport + .root(&observed, guard.deadline) + .map_err(|_| refused())?; + witnessed::matches(owner, binding, Some(&observed), observed_root)?; + guard.check()?; + transport + .verify_witness(&observed, *root, witness, guard.deadline) + .map_err(|_| refused())?; + guard.check()?; + let after = transport + .inspect(&volume.name, guard.deadline) + .map_err(|_| refused())?; + witnessed::matches(owner, binding, after.as_ref(), observed_root)?; + transport + .verify(binding, guard.deadline) + .map_err(|_| refused())?; + guard.check() +} +fn enroll( + options: EnrollOptions<'_>, + transport: &mut T, + sync: &mut S, +) -> Result { + let binding = snapshot(options.base.binding)?; + let pending_owner = witnessed::pending( + binding.clone(), + options.base.intent.into(), + options.witness.clone(), + ); + let pending_bytes = encode(&pending_owner)?; // validate all inputs before any file or effect + let guard = Guard { + deadline: options.base.deadline, + cancelled: options.base.cancelled, + }; + guard.check()?; + transport + .verify(&binding, guard.deadline) + .map_err(|_| refused())?; + guard.check()?; + let name = volume_name(&binding); + if transport + .inspect(&name, guard.deadline) + .map_err(|_| refused())? + .is_some() + { + return Err(refused()); + } + guard.check()?; + let files = Files::fresh(options.base.state_root, &binding, sync)?; + let pending = RecordPin::create_bytes( + &files.slot.path.join("owner.json"), + pending_bytes, + sync, + Step::PendingFile, + )?; + sync.sync(&files.slot.file, Step::PendingDirectory)?; + fresh(&files, &pending, &binding, &guard, transport)?; + if transport + .inspect(&name, guard.deadline) + .map_err(|_| refused())? + .is_some() + { + return Err(refused()); + } + fresh(&files, &pending, &binding, &guard, transport)?; + let create = CreateRequest { + binding: binding.clone(), + name, + intent: options.base.intent.into(), + }; + let captured = transport + .create_new(&create, guard.deadline) + .map_err(|_| refused())?; + fresh(&files, &pending, &binding, &guard, transport)?; + if captured.binding != binding + || captured.volume.name != create.name + || !super::super::volume_valid(&captured.volume) + { + return Err(refused()); + } + let root = transport + .root(&captured, guard.deadline) + .map_err(|_| refused())?; + let enrolled = witnessed::enrolled( + binding.clone(), + captured.volume.clone(), + root, + options.witness.clone(), + ); + encode(&enrolled)?; + fresh(&files, &pending, &binding, &guard, transport)?; + let observed = transport + .inspect(&create.name, guard.deadline) + .map_err(|_| refused())?; + witnessed::matches(&enrolled, &binding, observed.as_ref(), root)?; + fresh(&files, &pending, &binding, &guard, transport)?; + transport + .seed( + &SeedRequest { + captured, + root, + witness: options.witness.clone(), + intent: create.intent, + }, + guard.deadline, + ) + .map_err(|_| refused())?; + fresh(&files, &pending, &binding, &guard, transport)?; + verify_current(&enrolled, &binding, &guard, transport)?; + fresh(&files, &pending, &binding, &guard, transport)?; + let staged = RecordPin::create_bytes( + &files.slot.path.join("owner.next"), + encode(&enrolled)?, + sync, + Step::EnrolledFile, + )?; + files.verify_record(&pending)?; + staged.verify()?; + verify_current(&enrolled, &binding, &guard, transport)?; + files.verify_record(&pending)?; + staged.verify()?; + guard.check()?; + fs::rename(&staged.path, &pending.path).map_err(|_| refused())?; + let committed = staged.at(pending.path); + files.verify(Some(&committed))?; + sync.sync(&files.slot.file, Step::CommittedDirectory)?; + verify_current(&enrolled, &binding, &guard, transport)?; + fresh(&files, &committed, &binding, &guard, transport)?; + Ok(enrolled) +} + +#[cfg(test)] +mod tests; diff --git a/packages/runtime-core/src/provider/graph/native/persistent_data/enrollment/witnessed/tests.rs b/packages/runtime-core/src/provider/graph/native/persistent_data/enrollment/witnessed/tests.rs new file mode 100644 index 000000000..4eb3b4d14 --- /dev/null +++ b/packages/runtime-core/src/provider/graph/native/persistent_data/enrollment/witnessed/tests.rs @@ -0,0 +1,357 @@ +use super::super::super::{DirectoryIdentity, VolumeIdentity}; +use super::*; +use serde_json::{Value, json}; +use std::{ + os::unix::fs::PermissionsExt, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +struct Fixture(PathBuf); +impl Fixture { + fn new() -> Self { + let path = fs::canonicalize(std::env::temp_dir()) + .unwrap() + .join(format!( + "witness-enroll-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::DirBuilder::new().mode(0o700).create(&path).unwrap(); + Self(path) + } + fn record(&self) -> Value { + serde_json::from_slice( + &fs::read(self.0.join(slot_name(&binding())).join("owner.json")).unwrap(), + ) + .unwrap() + } +} +impl Drop for Fixture { + fn drop(&mut self) { + fs::remove_dir_all(&self.0).unwrap(); + } +} +fn binding() -> Binding { + serde_json::from_value(json!({"scope":{"namespace":"a".repeat(64),"storage":"db","owner":"b".repeat(32)},"guest":{"owner":"c".repeat(32),"boot_id":"11111111-2222-3333-4444-555555555555","storage":{"device":0,"inode":25,"bytes":8192,"uuid":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"}},"policy":{"driver":"local","scope":"local","options":{}}})).unwrap() +} +fn witness() -> ExpectedWitness { + ExpectedWitness { + name: format!("user.hack.storage.{}", "d".repeat(64)), + value: "e".repeat(64), + } +} +fn root() -> RootIdentity { + RootIdentity { + device: 0, + inode: 91, + uid: 0, + gid: 0, + } +} +fn observation() -> Observation { + Observation { + binding: binding(), + volume: VolumeIdentity { + name: volume_name(&binding()), + created_at: "2026-10-08T12:34:56.123456789Z".into(), + directory: DirectoryIdentity { + device: 0, + inode: 91, + }, + }, + } +} +struct Fake { + state_root: PathBuf, + current: Option, + created: usize, + seeds: usize, + reads: usize, + witness_present: bool, + seed_error: bool, + replace_record: bool, + changed_root: bool, +} +impl Fake { + fn new(f: &Fixture) -> Self { + Self { + state_root: f.0.clone(), + current: None, + created: 0, + seeds: 0, + reads: 0, + witness_present: false, + seed_error: false, + replace_record: false, + changed_root: false, + } + } +} +impl super::super::sealed::Transport for Fake {} +impl super::super::Transport for Fake { + fn verify(&mut self, expected: &Binding, _: Instant) -> Result<(), CandidateError> { + if expected != &binding() { + return Err(refused()); + } + Ok(()) + } + fn inspect(&mut self, _: &str, _: Instant) -> Result, CandidateError> { + Ok(self.current.clone()) + } + fn create_new( + &mut self, + request: &CreateRequest, + _: Instant, + ) -> Result { + let bytes = fs::read( + self.state_root + .join(slot_name(&binding())) + .join("owner.json"), + ) + .unwrap(); + let owner = witnessed::decode(&bytes).unwrap(); + let witnessed::Enrollment::Pending { + intent, + volume_name, + witness: expected, + } = &owner.0.enrollment + else { + panic!("missing durable pending witness"); + }; + assert_eq!(intent, request.intent()); + assert_eq!(volume_name, request.name()); + assert!(expected == &witness()); + assert!(self.current.is_none()); + self.created += 1; + self.current = Some(observation()); + Ok(observation()) + } +} +impl Transport for Fake { + fn root(&mut self, _: &Observation, _: Instant) -> Result { + let mut r = root(); + if self.changed_root { + r.uid = 1; + } + Ok(r) + } + fn seed(&mut self, request: &SeedRequest, _: Instant) -> Result<(), CandidateError> { + assert!(request.captured() == &observation()); + assert!(request.witness() == &witness()); + assert!(request.root() == root()); + assert!(!self.witness_present); + self.seeds += 1; + self.witness_present = true; + if self.seed_error { + return Err(CandidateError::new("private-canary", "private-canary")); + } + Ok(()) + } + fn verify_witness( + &mut self, + _: &Observation, + _: RootIdentity, + expected: &ExpectedWitness, + _: Instant, + ) -> Result<(), CandidateError> { + self.reads += 1; + if self.replace_record { + self.replace_record = false; + let file = self + .state_root + .join(slot_name(&binding())) + .join("owner.json"); + let bytes = fs::read(&file).unwrap(); + fs::rename(&file, file.with_extension("old")).unwrap(); + fs::write(&file, bytes).unwrap(); + fs::set_permissions(&file, fs::Permissions::from_mode(0o600)).unwrap(); + } + if !self.witness_present || expected != &witness() { + return Err(refused()); + } + Ok(()) + } +} +fn options<'a>( + f: &'a Fixture, + b: &'a Binding, + w: &'a ExpectedWitness, + cancelled: &'a AtomicBool, +) -> EnrollOptions<'a> { + EnrollOptions { + base: super::super::EnrollOptions { + state_root: &f.0, + binding: b, + intent: "f0000000000000000000000000000000", + deadline: Instant::now() + Duration::from_secs(5), + cancelled, + }, + witness: w, + } +} +fn read_options<'a>(f: &'a Fixture, b: &'a Binding, c: &'a AtomicBool) -> ReadOptions<'a> { + ReadOptions { + state_root: &f.0, + binding: b, + deadline: Instant::now() + Duration::from_secs(5), + cancelled: c, + } +} + +#[test] +fn original_create_and_seed_follow_durable_expected_witness_and_retained_read_never_seeds() { + let f = Fixture::new(); + let b = binding(); + let w = witness(); + let c = AtomicBool::new(false); + let mut fake = Fake::new(&f); + let owner = enroll_new(options(&f, &b, &w, &c), &mut fake).unwrap(); + assert_eq!((fake.created, fake.seeds), (1, 1)); + assert!(fake.reads >= 3); + let bytes = fs::read(f.0.join(slot_name(&b)).join("owner.json")).unwrap(); + assert_eq!(serde_json::to_vec(&owner).unwrap(), bytes); + assert!(read_retained(read_options(&f, &b, &c), &mut fake).is_ok()); + assert!( + existing_binding(BindingSelectionOptions { + state_root: &f.0, + namespace: &b.scope.namespace, + storage: &b.scope.storage + }) + .unwrap() + .is_some_and(|selected| selected == b) + ); + assert_eq!((fake.created, fake.seeds), (1, 1)); + fake.witness_present = false; // identical labels/birth/dev+ino on a replacement cannot pass + assert!(read_retained(read_options(&f, &b, &c), &mut fake).is_err()); + assert_eq!((fake.created, fake.seeds), (1, 1)); + fake.witness_present = true; + fake.changed_root = true; + assert!(read_retained(read_options(&f, &b, &c), &mut fake).is_err()); + assert_eq!((fake.created, fake.seeds), (1, 1)); +} +#[test] +fn ambiguous_seed_keeps_pending_and_never_promotes_or_retries() { + let f = Fixture::new(); + let b = binding(); + let w = witness(); + let c = AtomicBool::new(false); + let mut fake = Fake::new(&f); + fake.seed_error = true; + let error = enroll_new(options(&f, &b, &w, &c), &mut fake) + .err() + .unwrap(); + assert!( + !serde_json::to_string(&error) + .unwrap() + .contains("private-canary") + ); + assert_eq!(f.record()["enrollment"]["status"], "pending"); + assert!( + existing_binding(BindingSelectionOptions { + state_root: &f.0, + namespace: &b.scope.namespace, + storage: &b.scope.storage + }) + .is_err() + ); + let bytes = fs::read(f.0.join(slot_name(&b)).join("owner.json")).unwrap(); + assert!(read_retained(read_options(&f, &b, &c), &mut fake).is_err()); + assert!(enroll_new(options(&f, &b, &w, &c), &mut fake).is_err()); + assert_eq!((fake.created, fake.seeds), (1, 1)); + assert_eq!( + fs::read(f.0.join(slot_name(&b)).join("owner.json")).unwrap(), + bytes + ); +} +#[test] +fn preexisting_metadata_or_record_replacement_cannot_enroll() { + let b = binding(); + let w = witness(); + let c = AtomicBool::new(false); + let f = Fixture::new(); + let mut fake = Fake::new(&f); + fake.current = Some(observation()); + assert!(enroll_new(options(&f, &b, &w, &c), &mut fake).is_err()); + assert_eq!((fake.created, fake.seeds), (0, 0)); + assert!(!f.0.join(slot_name(&b)).exists()); + let f = Fixture::new(); + let mut fake = Fake::new(&f); + fake.replace_record = true; + assert!(enroll_new(options(&f, &b, &w, &c), &mut fake).is_err()); + assert_eq!((fake.created, fake.seeds), (1, 1)); + assert_eq!(f.record()["enrollment"]["status"], "pending"); +} +#[test] +fn final_directory_sync_failure_is_uncertain_and_cannot_report_enrollment() { + struct Fail; + impl Sync for Fail { + fn sync(&mut self, file: &File, step: Step) -> Result<(), CandidateError> { + if step == Step::CommittedDirectory { + return Err(refused()); + } + file.sync_all().map_err(|_| refused()) + } + } + let f = Fixture::new(); + let b = binding(); + let w = witness(); + let c = AtomicBool::new(false); + let mut fake = Fake::new(&f); + assert!(enroll(options(&f, &b, &w, &c), &mut fake, &mut Fail).is_err()); + assert_eq!(f.record()["enrollment"]["status"], "enrolled"); // rename may have happened; success/durability was withheld + assert_eq!((fake.created, fake.seeds), (1, 1)); +} +#[test] +fn pending_parent_sync_must_succeed_before_original_create_or_seed() { + struct Fail; + impl Sync for Fail { + fn sync(&mut self, file: &File, step: Step) -> Result<(), CandidateError> { + if step == Step::PendingDirectory { + return Err(refused()); + } + file.sync_all().map_err(|_| refused()) + } + } + let f = Fixture::new(); + let b = binding(); + let w = witness(); + let c = AtomicBool::new(false); + let mut fake = Fake::new(&f); + assert!(enroll(options(&f, &b, &w, &c), &mut fake, &mut Fail).is_err()); + assert_eq!((fake.created, fake.seeds), (0, 0)); + assert_eq!(f.record()["enrollment"]["status"], "pending"); +} +#[test] +fn v1_and_missing_or_forged_witness_fields_cannot_decode_as_v2() { + let record = witnessed::enrolled(binding(), observation().volume, root(), witness()); + let raw = serde_json::to_value(&record).unwrap(); + for pointer in [ + "/version", + "/enrollment/witness/name", + "/enrollment/witness/value", + "/enrollment/root/inode", + ] { + let mut wrong = raw.clone(); + *wrong.pointer_mut(pointer).unwrap() = Value::Null; + assert!(witnessed::decode(&serde_json::to_vec(&wrong).unwrap()).is_err()); + } + let mut wrong = raw.clone(); + wrong["version"] = json!(1); + assert!(witnessed::decode(&serde_json::to_vec(&wrong).unwrap()).is_err()); + let mut wrong = raw.clone(); + wrong["run"] = json!("a".repeat(32)); + assert!(witnessed::decode(&serde_json::to_vec(&wrong).unwrap()).is_err()); + let bytes = serde_json::to_string(&record).unwrap(); + assert!( + witnessed::decode( + bytes + .replacen("\"version\":2", "\"version\":2,\"version\":2", 1) + .as_bytes() + ) + .is_err() + ); + assert!(super::super::super::decode(bytes.as_bytes()).is_err()); +} diff --git a/packages/runtime-core/src/provider/graph/native/persistent_data/tool.rs b/packages/runtime-core/src/provider/graph/native/persistent_data/tool.rs new file mode 100644 index 000000000..2bc234036 --- /dev/null +++ b/packages/runtime-core/src/provider/graph/native/persistent_data/tool.rs @@ -0,0 +1,443 @@ +//! Receipt4 tool installation under the original provider lease. It uses the same +//! artifact/upload boundary as relay startup, but requires no host dependency or +//! authored Compose. No ordinary caller is enabled until persistence qualification. +//! Ambiguous upload leaves the native intent; this module has no delete or retry path. +use super::super::{Phase, Receipt, journal}; +use super::*; +use crate::{ + Candidate, + provider::{engine::Engine, guest_tool, storage_root_witness as helper}, +}; +use base64::Engine as _; +use std::{path::Path, time::Instant}; +use zeroize::Zeroize; + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(in crate::provider::graph::native) struct Identity { + pub device: u64, + pub inode: u64, + pub helper_device: u64, + pub helper_inode: u64, +} +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(in crate::provider::graph::native) struct Reference { + pub version: u8, + pub artifact: String, + pub bytes: u64, + pub root: Option, + pub helper: Option, +} +impl std::fmt::Debug for Reference { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("PersistentWitnessTool") + } +} +impl Reference { + pub(in crate::provider::graph::native) fn validate(&self) -> Result<(), CandidateError> { + if self.version != 1 + || !super::super::super::hex(&self.artifact, 64) + || self.bytes == 0 + || self.bytes > 2 * 1024 * 1024 + || self.root.as_ref().is_some_and(|r| r.inode == 0) + || self + .helper + .as_ref() + .is_some_and(|h| h.inode == 0 || self.root.is_none()) + { + return Err(refused()); + } + Ok(()) + } +} +pub(in crate::provider::graph::native) struct Installed { + run: String, + owner: String, + guest: GuestIdentity, + reference: Reference, +} +fn refused() -> CandidateError { + super::enrollment::refused() +} +fn check( + engine: &Engine<'_>, + deadline: Instant, + fresh: &dyn Fn() -> Result<(), CandidateError>, +) -> Result<(), CandidateError> { + fresh().map_err(|_| refused())?; + engine.guest().verify().map_err(|_| refused())?; + if Instant::now() >= deadline { + return Err(refused()); + } + Ok(()) +} +fn identity(text: &str) -> Result { + let parts: Vec<_> = text + .strip_suffix('\n') + .ok_or_else(refused)? + .split(':') + .collect(); + if parts.len() != 4 { + return Err(refused()); + } + let number = |value: &str| -> Result { + if value.is_empty() + || value.len() > 20 + || (value.len() > 1 && value.starts_with('0')) + || !value.bytes().all(|b| b.is_ascii_digit()) + { + return Err(refused()); + } + value.parse().map_err(|_| refused()) + }; + let root = Identity { + device: number(parts[0])?, + inode: number(parts[1])?, + helper_device: number(parts[2])?, + helper_inode: number(parts[3])?, + }; + if root.inode == 0 || root.helper_inode == 0 { + return Err(refused()); + } + Ok(root) +} +pub(in crate::provider::graph::native) struct InstallOptions<'a, 'guest> { + pub candidate: &'a Candidate, + pub engine: &'a Engine<'guest>, + pub receipt: &'a mut Receipt, + pub root: &'a Path, + pub artifact: &'a Path, + pub digest: &'a str, + pub deadline: Instant, + pub fresh: &'a dyn Fn() -> Result<(), CandidateError>, +} +pub(in crate::provider::graph::native) fn install( + opts: InstallOptions<'_, '_>, +) -> Result { + let InstallOptions { + candidate, + engine, + receipt, + root, + artifact, + digest, + deadline, + fresh, + } = opts; + if !receipt.persistent() + || receipt.phase != Phase::Preparing + || receipt.data_tool.is_some() + || journal::directory(candidate, receipt.review.scope().run)? != root + { + return Err(refused()); + } + check(engine, deadline, fresh)?; + let guest = engine + .guest() + .persistent_identity() + .map_err(|_| refused())?; + if receipt.owner != guest.owner || receipt.boot != guest.boot_id { + return Err(refused()); + } + let (saved, saved_root) = journal::load( + candidate, + receipt.review.scope().run, + &receipt.owner, + &receipt.boot, + )?; + if saved_root != root + || serde_json::to_vec(&saved).map_err(|_| refused())? + != serde_json::to_vec(receipt).map_err(|_| refused())? + { + return Err(refused()); + } + let artifact = guest_tool::Artifact::read(artifact, digest).map_err(|_| refused())?; + let reference = Reference { + version: 1, + artifact: artifact.digest().into(), + bytes: artifact.bytes().len() as u64, + root: None, + helper: None, + }; + reference.validate()?; + receipt.data_tool = Some(reference.clone()); + journal::save(root, receipt)?; // durable intent before any guest installation effect + check(engine, deadline, fresh)?; + artifact.verify().map_err(|_| refused())?; + let run = receipt.review.scope().run.to_owned(); + let owner = receipt.owner.clone(); + let prepared = engine + .guest() + .execute_input_until(guest_tool::PREPARE, &[&run, &owner], None, deadline, true) + .map_err(|_| refused())?; + check(engine, deadline, fresh)?; + let root_parts: Vec<_> = prepared + .strip_suffix('\n') + .ok_or_else(refused)? + .split(':') + .collect(); + if root_parts.len() != 2 { + return Err(refused()); + } + // Capture the exact original directory before any chunk. No matching later root is adopted. + let number = |value: &str| -> Result { + if value.is_empty() + || value.len() > 20 + || (value.len() > 1 && value.starts_with('0')) + || !value.bytes().all(|b| b.is_ascii_digit()) + { + return Err(refused()); + } + value.parse().map_err(|_| refused()) + }; + let captured = DirectoryIdentity { + device: number(root_parts[0])?, + inode: number(root_parts[1])?, + }; + if captured.inode == 0 { + return Err(refused()); + } + let mut reference = reference; + reference.root = Some(DirectoryIdentity { + device: captured.device, + inode: captured.inode, + }); + receipt.data_tool = Some(reference.clone()); + journal::save(root, receipt)?; + for (index, chunk) in artifact.bytes().chunks(24 * 1024).enumerate() { + check(engine, deadline, fresh)?; + artifact.verify().map_err(|_| refused())?; + let script = format!( + "test \"$(stat -c %d:%i /storage/hack-graph-startup/$1)\" = \"$3\"\n{}", + guest_tool::APPEND + ); + engine + .guest() + .execute_input_until( + &script, + &[ + &run, + &(index * 24 * 1024).to_string(), + &format!("{}:{}", captured.device, captured.inode), + ], + Some(&base64::engine::general_purpose::STANDARD.encode(chunk)), + deadline, + true, + ) + .map_err(|_| refused())?; + check(engine, deadline, fresh)?; + } + artifact.verify().map_err(|_| refused())?; + check(engine, deadline, fresh)?; + let script = format!( + "test \"$(stat -c %d:%i /storage/hack-graph-startup/$1)\" = \"$3\"\n{}", + guest_tool::PUBLISH + ); + engine + .guest() + .execute_input_until( + &script, + &[ + &run, + digest, + &format!("{}:{}", captured.device, captured.inode), + ], + None, + deadline, + true, + ) + .map_err(|_| refused())?; + check(engine, deadline, fresh)?; + let observed = engine + .guest() + .execute_until( + INSPECT, + &[&run, &owner, digest, &reference.bytes.to_string()], + deadline, + ) + .map_err(|_| refused())?; + check(engine, deadline, fresh)?; + let actual = identity(&observed)?; + if (actual.device, actual.inode) != (captured.device, captured.inode) { + return Err(refused()); + } + reference.root = Some(DirectoryIdentity { + device: actual.device, + inode: actual.inode, + }); + reference.helper = Some(DirectoryIdentity { + device: actual.helper_device, + inode: actual.helper_inode, + }); + receipt.data_tool = Some(reference.clone()); + journal::save(root, receipt)?; + check(engine, deadline, fresh)?; + artifact.verify().map_err(|_| refused())?; + let installed = Installed { + run, + owner, + guest, + reference, + }; + installed.verify(engine, deadline, fresh)?; + Ok(installed) +} +impl Installed { + fn verify( + &self, + engine: &Engine<'_>, + deadline: Instant, + fresh: &dyn Fn() -> Result<(), CandidateError>, + ) -> Result<(), CandidateError> { + self.reference.validate()?; + check(engine, deadline, fresh)?; + if engine + .guest() + .persistent_identity() + .map_err(|_| refused())? + != self.guest + { + return Err(refused()); + } + let output = engine + .guest() + .execute_until( + INSPECT, + &[ + &self.run, + &self.owner, + &self.reference.artifact, + &self.reference.bytes.to_string(), + ], + deadline, + ) + .map_err(|_| refused())?; + check(engine, deadline, fresh)?; + let identity = identity(&output)?; + if self.reference.root + != Some(DirectoryIdentity { + device: identity.device, + inode: identity.inode, + }) + || self.reference.helper + != Some(DirectoryIdentity { + device: identity.helper_device, + inode: identity.helper_inode, + }) + { + return Err(refused()); + } + Ok(()) + } + fn identity_string(&self) -> Result { + let root = self.reference.root.as_ref().ok_or_else(refused)?; + let helper = self.reference.helper.as_ref().ok_or_else(refused)?; + Ok(format!( + "{}:{}:{}:{}", + root.device, root.inode, helper.device, helper.inode + )) + } + pub(in crate::provider::graph::native) fn invoke( + &self, + engine: &Engine<'_>, + request: helper::Request, + deadline: Instant, + fresh: &dyn Fn() -> Result<(), CandidateError>, + ) -> Result { + self.verify(engine, deadline, fresh)?; + let seed = request.is_seed(); + let mut input = String::from_utf8(request.encode()).map_err(|_| refused())?; + check(engine, deadline, fresh)?; + let result = engine.guest().execute_input_until( + INVOKE, + &[ + &self.run, + &self.owner, + &self.reference.artifact, + &self.reference.bytes.to_string(), + &self.identity_string()?, + ], + Some(&input), + deadline, + seed, + ); + input.zeroize(); + check(engine, deadline, fresh)?; + let output = result.map_err(|_| refused())?; + self.verify(engine, deadline, fresh)?; + helper::Observation::decode(output.as_bytes()).map_err(|_| refused()) + } +} +const INSPECT: &str = r#" +base=/storage/hack-graph-startup +root="$base/$1" +test ! -L "$base"; test "$(stat -c %u:%g:%a "$base")" = 0:0:700 +test ! -L "$root"; test "$(stat -c %u:%g:%a "$root")" = 0:0:700 +test ! -L "$root/owner"; test "$(stat -c %u:%g:%a:%h:%s "$root/owner")" = 0:0:444:1:33 +test "$(cat "$root/owner")" = "$2" +test ! -e "$root/helper.pending"; test ! -L "$root/helper.pending" +test ! -L "$root/helper"; test -f "$root/helper" +test "$(stat -c %u:%g:%a:%h:%s "$root/helper")" = "0:0:555:1:$4" +test "$(sha256sum "$root/helper" | cut -d' ' -f1)" = "$3" +printf '%s:%s\n' "$(stat -c %d:%i "$root")" "$(stat -c %d:%i "$root/helper")" +"#; +const INVOKE: &str = r#" +base=/storage/hack-graph-startup +root="$base/$1" +test ! -L "$base"; test "$(stat -c %u:%g:%a "$base")" = 0:0:700 +test ! -L "$root"; test "$(stat -c %u:%g:%a "$root")" = 0:0:700 +test ! -L "$root/owner"; test "$(stat -c %u:%g:%a:%h:%s "$root/owner")" = 0:0:444:1:33 +test "$(cat "$root/owner")" = "$2" +test ! -L "$root/helper"; test -f "$root/helper" +test "$(stat -c %u:%g:%a:%h:%s "$root/helper")" = "0:0:555:1:$4" +test "$(sha256sum "$root/helper" | cut -d' ' -f1)" = "$3" +test "$(stat -c %d:%i "$root"):$(stat -c %d:%i "$root/helper")" = "$5" +exec "$root/helper" --storage-root-witness +"#; + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn installed_tool_is_closed_and_original_root_and_helper_are_distinct_observations() { + let reference = Reference { + version: 1, + artifact: "a".repeat(64), + bytes: 8192, + root: None, + helper: None, + }; + assert!(reference.validate().is_ok()); + let mut wrong = reference.clone(); + wrong.helper = Some(DirectoryIdentity { + device: 0, + inode: 2, + }); + assert!(wrong.validate().is_err()); + let mut valid = reference.clone(); + valid.root = Some(DirectoryIdentity { + device: 0, + inode: 1, + }); + valid.helper = Some(DirectoryIdentity { + device: 0, + inode: 2, + }); + assert!(valid.validate().is_ok()); + for text in [ + "0:0:0:2\n", + "0:1:0:0\n", + "00:1:0:2\n", + "0:1:0:2\nextra\n", + "0:1:0:2", + ] { + assert!(identity(text).is_err()); + } + let actual = identity("0:1:0:2\n").unwrap(); + assert_eq!((actual.inode, actual.helper_inode), (1, 2)); + let mut unknown = serde_json::to_value(valid).unwrap(); + unknown["secret"] = serde_json::json!("private-canary"); + assert!(serde_json::from_value::(unknown).is_err()); + } +} diff --git a/packages/runtime-core/src/provider/graph/native/persistent_data/witnessed.rs b/packages/runtime-core/src/provider/graph/native/persistent_data/witnessed.rs new file mode 100644 index 000000000..eae6c1ea6 --- /dev/null +++ b/packages/runtime-core/src/provider/graph/native/persistent_data/witnessed.rs @@ -0,0 +1,188 @@ +//! Closed witnessed owner assertion. Version 1 remains metadata-only and is never +//! upgraded here. Version 2 binds a private expected root witness before creation. +//! Decode/compare cannot supply original-create, durable commit or helper authority. +//! Whole-root copies retaining the xattr remain outside this continuity guarantee. + +use super::{Binding, Observation, VolumeIdentity, binding_valid, volume_valid}; +use crate::CandidateError; +use crate::provider::storage_root_witness::{Request, Root}; +use serde::{Deserialize, Serialize}; +use zeroize::Zeroize; + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExpectedWitness { + pub name: String, + pub value: String, +} +impl Drop for ExpectedWitness { + fn drop(&mut self) { + self.value.zeroize(); + } +} +impl ExpectedWitness { + pub(super) fn valid(&self, volume: &str, root: Root) -> bool { + Request::bound(volume, false, root, &self.name, &self.value).is_ok() + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RootIdentity { + pub device: u64, + pub inode: u64, + pub uid: u32, + pub gid: u32, +} +impl From for Root { + fn from(root: RootIdentity) -> Self { + Self { + device: root.device, + inode: root.inode, + uid: root.uid, + gid: root.gid, + } + } +} +impl From for RootIdentity { + fn from(root: Root) -> Self { + Self { + device: root.device, + inode: root.inode, + uid: root.uid, + gid: root.gid, + } + } +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case", deny_unknown_fields)] +pub(super) enum Enrollment { + Pending { + intent: String, + volume_name: String, + witness: ExpectedWitness, + }, + Enrolled { + volume: VolumeIdentity, + root: RootIdentity, + witness: ExpectedWitness, + }, +} +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +enum Kind { + NativePersistentDataWitnessOwner, +} +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct Record { + version: u8, + kind: Kind, + pub(super) binding: Binding, + pub(super) enrollment: Enrollment, +} +#[derive(Clone, Serialize)] +#[serde(transparent)] +pub struct Owner(pub(super) Record); + +fn refused() -> CandidateError { + super::refused() +} +pub fn decode(bytes: &[u8]) -> Result { + if bytes.is_empty() || bytes.len() > super::LIMIT { + return Err(refused()); + } + let record: Record = serde_json::from_slice(bytes).map_err(|_| refused())?; + let name = super::enrollment::volume_name(&record.binding); + let valid = match &record.enrollment { + Enrollment::Pending { + intent, + volume_name, + witness, + } => { + super::super::super::hex(intent, 32) + && volume_name == &name + && witness.valid( + &name, + Root { + device: 0, + inode: 1, + uid: 0, + gid: 0, + }, + ) + } + Enrollment::Enrolled { + volume, + root, + witness, + } => { + volume_valid(volume) + && volume.name == name + && root.inode != 0 + && (root.device, root.inode) == (volume.directory.device, volume.directory.inode) + && witness.valid(&name, (*root).into()) + } + }; + if record.version != 2 || !binding_valid(&record.binding) || !valid { + return Err(refused()); + } + Ok(Owner(record)) +} +pub(super) fn pending(binding: Binding, intent: String, witness: ExpectedWitness) -> Owner { + Owner(Record { + version: 2, + kind: Kind::NativePersistentDataWitnessOwner, + enrollment: Enrollment::Pending { + volume_name: super::enrollment::volume_name(&binding), + intent, + witness, + }, + binding, + }) +} +pub(super) fn enrolled( + binding: Binding, + volume: VolumeIdentity, + root: RootIdentity, + witness: ExpectedWitness, +) -> Owner { + Owner(Record { + version: 2, + kind: Kind::NativePersistentDataWitnessOwner, + binding, + enrollment: Enrollment::Enrolled { + volume, + root, + witness, + }, + }) +} +pub(super) fn matches( + owner: &Owner, + binding: &Binding, + observed: Option<&Observation>, + root: RootIdentity, +) -> Result<(), CandidateError> { + let Enrollment::Enrolled { + volume, + root: expected, + .. + } = &owner.0.enrollment + else { + return Err(refused()); + }; + let observed = observed.ok_or_else(refused)?; + if !binding_valid(binding) + || observed.binding != *binding + || owner.0.binding != *binding + || observed.volume != *volume + || *expected != root + { + return Err(refused()); + } + // Validate the constructed assertion as strictly as a serialized one. + decode(&serde_json::to_vec(owner).map_err(|_| refused())?)?; + Ok(()) +} diff --git a/packages/runtime-core/src/provider/graph/native/runtime/tests.rs b/packages/runtime-core/src/provider/graph/native/runtime/tests.rs index adbb0c044..6ee3a15f8 100644 --- a/packages/runtime-core/src/provider/graph/native/runtime/tests.rs +++ b/packages/runtime-core/src/provider/graph/native/runtime/tests.rs @@ -133,6 +133,8 @@ fn persistent_journal_has_distinct_version_and_exact_data_membership_without_vol json!({"data":null}), json!({"data_mounts":null}), json!({"data":null,"data_mounts":null}), + json!({"data_tool":null}), + json!({"data_tool":{"version":1,"artifact":"a".repeat(64),"bytes":8192,"root":null,"helper":null}}), ] { let mut wire = image_only.clone(); wire.as_object_mut() @@ -159,6 +161,18 @@ fn persistent_journal_has_distinct_version_and_exact_data_membership_without_vol persistent_wire["version"] = json!(4); receipt = serde_json::from_value(persistent_wire).unwrap(); receipt.validate(RUN, OWNER).unwrap(); + let mut installed = serde_json::to_value(&receipt).unwrap(); + installed["data_tool"] = json!({"version":1,"artifact":"a".repeat(64),"bytes":8192,"root":{"device":0,"inode":1},"helper":{"device":0,"inode":2}}); + let with_tool: Receipt = serde_json::from_value(installed.clone()).unwrap(); + with_tool.validate(RUN, OWNER).unwrap(); + assert!(with_tool.check_binding(&receipt).is_err()); + installed["data_tool"]["helper"]["inode"] = json!(0); + assert!( + serde_json::from_value::(installed) + .unwrap() + .validate(RUN, OWNER) + .is_err() + ); assert_eq!(receipt.resources.len(), 2); assert!( receipt diff --git a/packages/runtime-core/src/provider/graph/startup/runtime.rs b/packages/runtime-core/src/provider/graph/startup/runtime.rs index a21a2babf..5a032d372 100644 --- a/packages/runtime-core/src/provider/graph/startup/runtime.rs +++ b/packages/runtime-core/src/provider/graph/startup/runtime.rs @@ -2,6 +2,7 @@ use super::*; mod jobs; mod rebind; +use crate::provider::guest_tool::{APPEND, PREPARE, PUBLISH}; use crate::provider::{ host_endpoint::HostEndpoint, lifecycle::{RelayChild, RelayLaunch}, @@ -65,11 +66,7 @@ pub(super) fn retired_dependency_rebind_archive_complete( rebind::archive::retired_archive_complete(root, original, boot) } use sha2::{Digest, Sha256}; -use std::{ - io::Read, - os::unix::fs::{MetadataExt, OpenOptionsExt}, - time::Instant, -}; +use std::{io::Read, time::Instant}; /// One explicit host dependency for one service. The endpoint is a live captured /// generation, not a port number or implicit host-gateway route. @@ -385,32 +382,7 @@ impl HostRelayRuntime { { return Err(stage_refused("graph_startup_input")); } - let bytes = if dependencies.is_empty() { - Vec::new() - } else { - let mut file = fs::OpenOptions::new() - .read(true) - .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) - .open(artifact) - .map_err(state::io)?; - let metadata = file.metadata().map_err(state::io)?; - if !metadata.is_file() || metadata.len() > 2 * 1024 * 1024 || metadata.nlink() != 1 { - return Err(stage_refused("graph_startup_artifact_file")); - } - let mut bytes = Vec::new(); - file.by_ref() - .take(2 * 1024 * 1024 + 1) - .read_to_end(&mut bytes) - .map_err(state::io)?; - if bytes.len() as u64 != metadata.len() - || format!("{:x}", Sha256::digest(&bytes)) != expected_sha256 - || bytes.get(..6) != Some(b"\x7fELF\x02\x01") - || bytes.get(18..20) != Some(&[183, 0]) - { - return Err(stage_refused("graph_startup_artifact_identity")); - } - bytes - }; + let bytes = relay_artifact(artifact, expected_sha256, !dependencies.is_empty())?; // The CLI constructs this relay before acquiring its foreground run // lock. Fence pending retirement before any new listener or reservation, // in the recovery path's gate-before-Engine order. This local guard is @@ -1037,36 +1009,6 @@ impl Driver for HostRelayRuntime { } } // Arguments are validated receipt IDs/digests. Artifact chunks are non-secret. -const PREPARE: &str = r#" -umask 077 -base=/storage/hack-graph-startup -test ! -L "$base" -if test ! -e "$base"; then mkdir -m 700 "$base"; fi -test "$(stat -c %u:%g:%a "$base")" = 0:0:700 -root="$base/$1" -test ! -e "$root"; test ! -L "$root" -mkdir -m 700 "$root" -printf '%s\n' "$2" > "$root/owner" -chmod 444 "$root/owner" -: > "$root/helper.pending" -stat -c %d:%i "$root" -"#; -const APPEND: &str = r#" -root="/storage/hack-graph-startup/$1" -test ! -L "$root"; test "$(stat -c %u:%g:%a "$root")" = 0:0:700 -test ! -L "$root/helper.pending"; test -f "$root/helper.pending" -test "$(stat -c %u:%g:%h:%s "$root/helper.pending")" = "0:0:1:$2" -base64 -d >> "$root/helper.pending" -"#; -const PUBLISH: &str = r#" -root="/storage/hack-graph-startup/$1" -test ! -L "$root"; test ! -L "$root/helper.pending" -test -f "$root/helper.pending"; test "$(stat -c %u:%g:%h "$root/helper.pending")" = 0:0:1 -test "$(sha256sum "$root/helper.pending" | cut -d' ' -f1)" = "$2" -test ! -e "$root/helper"; test ! -L "$root/helper" -chmod 555 "$root/helper.pending"; mv "$root/helper.pending" "$root/helper" -sync -f "$root" -"#; const GATE: &str = r#" root="/storage/hack-graph-startup/$1" test ! -L "$root"; test "$(stat -c %u:%g:%a "$root")" = 0:0:700 @@ -1090,9 +1032,42 @@ chmod 444 "$root/pending"; sync -f "$root/pending" mv "$root/pending" "$root/release"; sync -f "$root" "#; +fn relay_artifact( + artifact: &Path, + expected_sha256: &str, + required: bool, +) -> Result, CandidateError> { + if required { + Ok( + crate::provider::guest_tool::Artifact::read(artifact, expected_sha256)? + .bytes() + .to_vec(), + ) + } else { + Ok(Vec::new()) + } +} + #[cfg(test)] mod route_tests { use super::*; + use std::os::unix::fs::MetadataExt; + #[test] + fn dependency_free_constructor_skips_artifact_but_required_artifact_uses_fixed_refusal() { + let fixture = super::super::super::tests::Fixture::new(); + let absent = fixture.0.join("absent-guest-artifact"); + assert_eq!( + relay_artifact(&absent, &"a".repeat(64), false).unwrap(), + Vec::::new() + ); + assert_eq!( + relay_artifact(&absent, &"a".repeat(64), true) + .unwrap_err() + .code, + "guest_tool_artifact" + ); + assert!(!absent.exists()); + } #[test] fn exited_listener_accepts_only_successful_completed_service() { let exited = json!({"State":{"Running":false,"ExitCode":0,"OOMKilled":false}}); diff --git a/packages/runtime-core/src/provider/guest_tool.rs b/packages/runtime-core/src/provider/guest_tool.rs new file mode 100644 index 000000000..ce0267d8b --- /dev/null +++ b/packages/runtime-core/src/provider/guest_tool.rs @@ -0,0 +1,238 @@ +//! One pinned guest artifact and the existing graph startup upload protocol. +//! Caller-supplied digest/provenance is required; no artifact lookup or build occurs. +use crate::CandidateError; +use sha2::{Digest, Sha256}; +use std::{ + fs::{self, File, OpenOptions}, + io::Read, + os::unix::fs::{MetadataExt, OpenOptionsExt}, + path::{Path, PathBuf}, +}; + +pub(in crate::provider) struct Artifact { + path: PathBuf, + file: File, + bytes: Vec, + digest: String, + metadata: fs::Metadata, +} +fn refused() -> CandidateError { + CandidateError::new( + "guest_tool_artifact", + "Guest tool artifact identity changed; values omitted.", + ) +} +impl Artifact { + pub(in crate::provider) fn read(path: &Path, digest: &str) -> Result { + if digest.len() != 64 + || !digest + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + { + return Err(refused()); + } + let mut file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(path) + .map_err(|_| refused())?; + let m = file.metadata().map_err(|_| refused())?; + // SAFETY: geteuid has no arguments or retained pointers. + if !m.is_file() + || m.nlink() != 1 + || m.uid() != unsafe { libc::geteuid() } + || m.mode() & 0o022 != 0 + || m.len() > 2 * 1024 * 1024 + { + return Err(refused()); + } + let mut bytes = Vec::new(); + file.by_ref() + .take(2 * 1024 * 1024 + 1) + .read_to_end(&mut bytes) + .map_err(|_| refused())?; + if bytes.len() as u64 != m.len() + || format!("{:x}", Sha256::digest(&bytes)) != digest + || bytes.get(..6) != Some(b"\x7fELF\x02\x01") + || bytes.get(18..20) != Some(&[183, 0]) + { + return Err(refused()); + } + let artifact = Self { + path: path.into(), + file, + bytes, + digest: digest.into(), + metadata: m, + }; + artifact.verify()?; + Ok(artifact) + } + pub(in crate::provider) fn verify(&self) -> Result<(), CandidateError> { + let fd = self.file.metadata().map_err(|_| refused())?; + let path = fs::symlink_metadata(&self.path).map_err(|_| refused())?; + let original = &self.metadata; + if ( + fd.dev(), + fd.ino(), + fd.uid(), + fd.mode(), + fd.len(), + fd.nlink(), + ) != ( + original.dev(), + original.ino(), + original.uid(), + original.mode(), + original.len(), + original.nlink(), + ) || !path.is_file() + || fd.dev() != path.dev() + || fd.ino() != path.ino() + || fd.uid() != path.uid() + || fd.mode() != path.mode() + || fd.nlink() != 1 + || fd.len() != self.bytes.len() as u64 + || path.len() != fd.len() + { + return Err(refused()); + } + use std::io::{Seek, SeekFrom}; + let mut file = &self.file; + file.seek(SeekFrom::Start(0)).map_err(|_| refused())?; + let mut bytes = Vec::new(); + file.take(2 * 1024 * 1024 + 1) + .read_to_end(&mut bytes) + .map_err(|_| refused())?; + if bytes != self.bytes || format!("{:x}", Sha256::digest(&bytes)) != self.digest { + return Err(refused()); + } + let after = self.file.metadata().map_err(|_| refused())?; + let path = fs::symlink_metadata(&self.path).map_err(|_| refused())?; + if !path.is_file() + || ( + after.dev(), + after.ino(), + after.len(), + after.uid(), + after.mode(), + after.nlink(), + ) != ( + fd.dev(), + fd.ino(), + fd.len(), + fd.uid(), + fd.mode(), + fd.nlink(), + ) + || (path.dev(), path.ino(), path.len(), path.mode()) + != (fd.dev(), fd.ino(), fd.len(), fd.mode()) + { + return Err(refused()); + } + Ok(()) + } + pub(in crate::provider) fn bytes(&self) -> &[u8] { + &self.bytes + } + #[cfg(feature = "native-config-plan")] + pub(in crate::provider) fn digest(&self) -> &str { + &self.digest + } +} + +pub(in crate::provider) const PREPARE: &str = r#" +umask 077 +base=/storage/hack-graph-startup +test ! -L "$base" +if test ! -e "$base"; then mkdir -m 700 "$base"; fi +test "$(stat -c %u:%g:%a "$base")" = 0:0:700 +root="$base/$1" +test ! -e "$root"; test ! -L "$root" +mkdir -m 700 "$root" +printf '%s\n' "$2" > "$root/owner" +chmod 444 "$root/owner" +: > "$root/helper.pending" +stat -c %d:%i "$root" +"#; +pub(in crate::provider) const APPEND: &str = r#" +root="/storage/hack-graph-startup/$1" +test ! -L "$root"; test "$(stat -c %u:%g:%a "$root")" = 0:0:700 +test ! -L "$root/helper.pending"; test -f "$root/helper.pending" +test "$(stat -c %u:%g:%h:%s "$root/helper.pending")" = "0:0:1:$2" +base64 -d >> "$root/helper.pending" +"#; +pub(in crate::provider) const PUBLISH: &str = r#" +root="/storage/hack-graph-startup/$1" +test ! -L "$root"; test ! -L "$root/helper.pending" +test -f "$root/helper.pending"; test "$(stat -c %u:%g:%h "$root/helper.pending")" = 0:0:1 +test "$(sha256sum "$root/helper.pending" | cut -d' ' -f1)" = "$2" +test ! -e "$root/helper"; test ! -L "$root/helper" +chmod 555 "$root/helper.pending"; mv "$root/helper.pending" "$root/helper" +sync -f "$root" +"#; + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::{DirBuilderExt, PermissionsExt}; + #[test] + fn pinned_artifact_refuses_path_replacement_and_mode_or_byte_drift() { + struct Fixture(PathBuf); + impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + let path = fs::canonicalize(std::env::temp_dir()) + .unwrap() + .join(format!( + "guest-artifact-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::DirBuilder::new().mode(0o700).create(&path).unwrap(); + let fixture = Fixture(path); + let path = fixture.0.join("guest-tool"); + let mut bytes = vec![0_u8; 32]; + bytes[..6].copy_from_slice(b"\x7fELF\x02\x01"); + bytes[18..20].copy_from_slice(&[183, 0]); + fs::write(&path, &bytes).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + let digest = format!("{:x}", Sha256::digest(&bytes)); + let artifact = Artifact::read(&path, &digest).unwrap(); + assert!(artifact.verify().is_ok()); + for mode in [0o620, 0o602, 0o666] { + fs::set_permissions(&path, fs::Permissions::from_mode(mode)).unwrap(); + let error = Artifact::read(&path, &digest).err().unwrap(); + assert_eq!(error.code, "guest_tool_artifact"); + assert_eq!( + error.message, + "Guest tool artifact identity changed; values omitted." + ); + } + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + let linked = fixture.0.join("linked"); + fs::hard_link(&path, &linked).unwrap(); + assert!(Artifact::read(&path, &digest).is_err()); + fs::remove_file(linked).unwrap(); + let symlink = fixture.0.join("symlink"); + std::os::unix::fs::symlink(&path, &symlink).unwrap(); + assert!(Artifact::read(&symlink, &digest).is_err()); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + assert!(artifact.verify().is_err()); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + fs::rename(&path, fixture.0.join("original")).unwrap(); + fs::write(&path, &bytes).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + assert!(artifact.verify().is_err()); + let current = Artifact::read(&path, &digest).unwrap(); + bytes[31] = 1; + fs::write(&path, &bytes).unwrap(); + assert!(current.verify().is_err()); + assert!(Artifact::read(&path, &digest).is_err()); + } +} diff --git a/packages/runtime-core/src/provider/lifecycle.rs b/packages/runtime-core/src/provider/lifecycle.rs index 6a4aa52cf..654a460d7 100644 --- a/packages/runtime-core/src/provider/lifecycle.rs +++ b/packages/runtime-core/src/provider/lifecycle.rs @@ -556,19 +556,33 @@ impl<'a> OwnedGuest<'a> { script: &str, arguments: &[&str], deadline: Instant, + ) -> Result { + self.execute_input_until(script, arguments, None, deadline, false) + } + + #[cfg(feature = "native-config-plan")] + pub(in crate::provider) fn execute_input_until( + &self, + script: &str, + arguments: &[&str], + input: Option<&str>, + deadline: Instant, + allocation: bool, ) -> Result { self.verify()?; + if allocation { + self.before_effect()?; + } let script = format!( "set -eu\ntest \"$(cat /proc/sys/kernel/random/boot_id)\" = \"$1\"\ntest \"$(cat /storage/.hack-local-owner)\" = \"$2\"\ntest \"$(findmnt -n -o FSTYPE --mountpoint /storage)\" = ext4\nshift 2\n{script}" ); let mut args = vec![self.boot_id(), self.incarnation()]; args.extend_from_slice(arguments); - let result = agent::exec_until( - &socket(self.candidate, &self.owner, "agent.sock")?, - &script, - &args, - deadline, - ); + let path = socket(self.candidate, &self.owner, "agent.sock")?; + let result = match input { + Some(input) => agent::exec_input_until(&path, &script, &args, Some(input), deadline), + None => agent::exec_until(&path, &script, &args, deadline), + }; self.verify()?; result } diff --git a/packages/runtime-core/src/provider/mod.rs b/packages/runtime-core/src/provider/mod.rs index be73ceb13..38765267d 100644 --- a/packages/runtime-core/src/provider/mod.rs +++ b/packages/runtime-core/src/provider/mod.rs @@ -26,6 +26,8 @@ pub use engine::{EngineInfo, info as engine_info}; #[cfg(all(test, target_os = "macos", target_arch = "aarch64"))] mod gateway_probe_test; pub mod guest_storage; +#[cfg(any(target_os = "macos", feature = "native-config-plan"))] +mod guest_tool; pub mod host_endpoint; pub mod hostname_authority; pub mod http_probe; @@ -52,7 +54,9 @@ pub mod relay_owner; pub mod resources; #[cfg(target_os = "macos")] pub mod shared_https_recovery; -#[cfg(test)] +#[cfg(any(test, feature = "native-config-plan"))] +// Host clients and the guest executable use different parts of this shared codec. +#[allow(dead_code)] mod storage_root_witness; pub mod storage_usage; #[cfg(all(test, target_os = "macos"))] diff --git a/packages/runtime-core/src/provider/storage_root_witness.rs b/packages/runtime-core/src/provider/storage_root_witness.rs index a1addebc3..8974ab8ed 100644 --- a/packages/runtime-core/src/provider/storage_root_witness.rs +++ b/packages/runtime-core/src/provider/storage_root_witness.rs @@ -116,6 +116,60 @@ fn number(value: &str) -> Result { value.parse().map_err(|_| refused()) } impl Request { + pub(crate) fn root(volume: &str) -> Result { + Self::parse(format!("{MAGIC}\nroot\n{volume}\n").as_bytes()) + } + pub(crate) fn bound( + volume: &str, + seed: bool, + root: Root, + name: &str, + value: &str, + ) -> Result { + Self::parse( + format!( + "{MAGIC}\n{}\n{volume}\n{}\n{}\n{}\n{}\n{name}\n{value}\n", + if seed { "seed" } else { "verify" }, + root.device, + root.inode, + root.uid, + root.gid + ) + .as_bytes(), + ) + } + pub(crate) fn is_seed(&self) -> bool { + matches!(&self.operation, Operation::Seed { .. }) + } + pub(crate) fn encode(&self) -> Vec { + match &self.operation { + Operation::Root => format!("{MAGIC}\nroot\n{}\n", self.volume).into_bytes(), + Operation::Seed { root, witness } | Operation::Verify { root, witness } => { + let mut value: String = witness + .value + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + let name = std::str::from_utf8(&witness.name).unwrap_or(""); + let bytes = format!( + "{MAGIC}\n{}\n{}\n{}\n{}\n{}\n{}\nuser.hack.storage.{name}\n{value}\n", + if matches!(&self.operation, Operation::Seed { .. }) { + "seed" + } else { + "verify" + }, + self.volume, + root.device, + root.inode, + root.uid, + root.gid + ) + .into_bytes(); + value.zeroize(); + bytes + } + } + } /// Exact ASCII newline record from a bounded private pipe. Unknown keys, /// modes, alternate spelling, argv data and trailing records are refused. pub(crate) fn parse(bytes: &[u8]) -> Result { @@ -164,6 +218,37 @@ pub(crate) enum Observation { Verified, } impl Observation { + pub(crate) fn decode(bytes: &[u8]) -> Result { + if bytes == b"seeded\n" { + return Ok(Self::Seeded); + } + if bytes == b"verified\n" { + return Ok(Self::Verified); + } + if bytes.len() > 100 || !bytes.is_ascii() { + return Err(refused()); + } + let text = std::str::from_utf8(bytes).map_err(|_| refused())?; + let parts: Vec<_> = text + .strip_prefix("root:") + .and_then(|text| text.strip_suffix('\n')) + .ok_or_else(refused)? + .split(':') + .collect(); + if parts.len() != 4 { + return Err(refused()); + } + let root = Root { + device: number(parts[0])?, + inode: number(parts[1])?, + uid: number(parts[2])?.try_into().map_err(|_| refused())?, + gid: number(parts[3])?.try_into().map_err(|_| refused())?, + }; + if !root.valid() { + return Err(refused()); + } + Ok(Self::Root(root)) + } /// Observation only. Neither this reply nor a successful helper exit can /// promote pending storage or authorize compute use without the owner commit. pub(crate) fn encode(&self) -> String { diff --git a/packages/runtime-core/src/provider/storage_root_witness/tests.rs b/packages/runtime-core/src/provider/storage_root_witness/tests.rs index e9574a657..168cd4e8b 100644 --- a/packages/runtime-core/src/provider/storage_root_witness/tests.rs +++ b/packages/runtime-core/src/provider/storage_root_witness/tests.rs @@ -1,5 +1,48 @@ use super::*; +#[test] +fn host_request_and_reply_codecs_share_the_exact_guest_mode_and_reject_trailing_data() { + let volume = format!("hkp-{}-{}-db", "a".repeat(64), "b".repeat(32)); + let root = Root { + device: 0, + inode: 1, + uid: 0, + gid: 0, + }; + for seed in [false, true] { + let request = Request::bound( + &volume, + seed, + root, + &format!("user.hack.storage.{}", "c".repeat(64)), + &"d".repeat(64), + ) + .unwrap(); + let bytes = request.encode(); + assert!(Request::parse(&bytes).unwrap().is_seed() == seed); + let mut extra = bytes.clone(); + extra.extend_from_slice(b"extra\n"); + assert!(Request::parse(&extra).is_err()); + } + let request = Request::root(&volume).unwrap(); + assert!(matches!( + Request::parse(&request.encode()).unwrap().operation, + Operation::Root + )); + assert!( + matches!(Observation::decode(b"root:0:1:0:0\n").unwrap(), Observation::Root(selected) if selected == root) + ); + for bytes in [ + b"root:0:0:0:0\n".as_slice(), + b"root:00:1:0:0\n", + b"root:0:1:0:0\nextra\n", + b"seeded\nextra", + b"verified\n\n", + ] { + assert!(Observation::decode(bytes).is_err()); + } +} + fn volume() -> String { format!("hkp-{}-{}-database", "a".repeat(64), "b".repeat(32)) } diff --git a/src/backends/native-authored-graph-protocol.ts b/src/backends/native-authored-graph-protocol.ts index 6f7f41db8..7a8fae493 100644 --- a/src/backends/native-authored-graph-protocol.ts +++ b/src/backends/native-authored-graph-protocol.ts @@ -3,7 +3,9 @@ import { isRecord } from "../lib/guards.ts"; import { type NativePersistentMount, type NativePersistentReference, + type NativePersistentTool, parseNativePersistentData, + parseNativePersistentTool, } from "./native-authored-persistent-data-protocol.ts"; const HEX32 = /^[a-f0-9]{32}$/; @@ -71,6 +73,7 @@ export type NativeAuthoredReceipt = { readonly phase: Phase; readonly readiness: Readonly>; readonly resources: Readonly>; + readonly data_tool?: NativePersistentTool; readonly data?: Readonly>; readonly data_mounts?: Readonly< Record @@ -326,7 +329,7 @@ export function parseNativeAuthoredReceipt( "readiness", "resources", ], - ["failure", "terminal", "data", "data_mounts"] + ["failure", "terminal", "data", "data_mounts", "data_tool"] ) || (value.version !== 2 && value.version !== 4) || value.kind !== "native-graph-runtime" || @@ -403,7 +406,10 @@ export function parseNativeAuthoredReceipt( : undefined; const hasData = Object.hasOwn(value, "data") || Object.hasOwn(value, "data_mounts"); - if ((value.version === 2 && hasData) || (value.version === 4 && !hasData)) { + if ( + (value.version === 2 && (hasData || Object.hasOwn(value, "data_tool"))) || + (value.version === 4 && !hasData) + ) { return refused(); } const data = @@ -423,6 +429,7 @@ export function parseNativeAuthoredReceipt( ), }) : undefined; + const tool = parseReceiptTool({ value, resources }); return { version: value.version, kind: "native-graph-runtime", @@ -433,10 +440,30 @@ export function parseNativeAuthoredReceipt( readiness, resources, ...data, + ...(tool ? { data_tool: tool } : {}), ...(failure ? { failure } : {}), ...(terminal ? { terminal } : {}), }; } +function parseReceiptTool(opts: { + readonly value: Record; + readonly resources: Readonly>; +}): NativePersistentTool | undefined { + const tool = Object.hasOwn(opts.value, "data_tool") + ? parseNativePersistentTool(opts.value.data_tool) + : undefined; + if ( + tool && + (opts.value.phase === "ready-observed" || + Object.values(opts.resources).some( + (item) => item.kind === "container" && item.id !== null + )) && + tool.helper === null + ) { + return refused(); + } + return tool; +} function parseFailure( value: unknown, readiness: Readonly> @@ -507,7 +534,12 @@ export function nativeAuthoredReceiptBinding( ): string { return JSON.stringify({ ...(receipt.version === 4 - ? { version: 4, data: receipt.data, data_mounts: receipt.data_mounts } + ? { + version: 4, + data: receipt.data, + data_mounts: receipt.data_mounts, + ...(receipt.data_tool ? { data_tool: receipt.data_tool } : {}), + } : {}), owner: receipt.owner, boot: receipt.boot, diff --git a/src/backends/native-authored-persistent-data-protocol.ts b/src/backends/native-authored-persistent-data-protocol.ts index 1e28d0711..415107e9b 100644 --- a/src/backends/native-authored-persistent-data-protocol.ts +++ b/src/backends/native-authored-persistent-data-protocol.ts @@ -361,3 +361,60 @@ export function parseNativePersistentData(opts: ParseOptions): { } return { data: Object.freeze(data), data_mounts: Object.freeze(mounts) }; } + +/** Inactive graph4 tool assertion; parsing cannot install or invoke this artifact. */ +export type NativePersistentTool = { + readonly version: 1; + readonly artifact: string; + readonly bytes: number; + readonly root: { readonly device: number; readonly inode: number } | null; + readonly helper: { readonly device: number; readonly inode: number } | null; +}; +export function parseNativePersistentTool( + value: unknown +): NativePersistentTool { + const tool = record(value, "version,artifact,bytes,root,helper"); + if ( + tool.version !== 1 || + typeof tool.artifact !== "string" || + tool.artifact.length !== 64 || + !HEX64.test(tool.artifact) || + typeof tool.bytes !== "number" || + !Number.isSafeInteger(tool.bytes) || + tool.bytes <= 0 || + tool.bytes > 2 * 1024 * 1024 + ) { + return refused(); + } + function identity( + value: unknown + ): { readonly device: number; readonly inode: number } | null { + if (value === null) { + return null; + } + const id = record(value, "device,inode"); + if ( + typeof id.device !== "number" || + !Number.isSafeInteger(id.device) || + id.device < 0 || + typeof id.inode !== "number" || + !Number.isSafeInteger(id.inode) || + id.inode <= 0 + ) { + return refused(); + } + return Object.freeze({ device: id.device, inode: id.inode }); + } + const root = identity(tool.root); + const helper = identity(tool.helper); + if (helper !== null && root === null) { + return refused(); + } + return Object.freeze({ + version: 1, + artifact: tool.artifact, + bytes: tool.bytes, + root, + helper, + }); +} diff --git a/tests/native-authored-graph-protocol.test.ts b/tests/native-authored-graph-protocol.test.ts index d8e7f3aed..f529f6518 100644 --- a/tests/native-authored-graph-protocol.test.ts +++ b/tests/native-authored-graph-protocol.test.ts @@ -8,6 +8,7 @@ import { parseNativeAuthoredReview, parseNativeAuthoredSnapshot, } from "../src/backends/native-authored-graph-protocol.ts"; +import { parseNativePersistentTool } from "../src/backends/native-authored-persistent-data-protocol.ts"; import { parseNativeAuthoredRecoverySelection } from "../src/backends/native-authored-recovery-protocol.ts"; const run = "a".repeat(32); @@ -102,6 +103,62 @@ function persistentReceipt() { }, }; } +test("inactive storage tool codec is graph4-only and cannot claim readiness before installed identity", () => { + const pending = { + version: 1 as const, + artifact: "a".repeat(64), + bytes: 8192, + root: null, + helper: null, + }; + expect(parseNativePersistentTool(pending)).toEqual(pending); + expect(() => + parseNativeAuthoredReceipt({ ...receipt(), data_tool: pending }) + ).toThrow(); + expect(() => + parseNativeAuthoredReceipt({ ...receipt(), data_tool: null }) + ).toThrow(); + expect(() => + parseNativeAuthoredReceipt({ ...persistentReceipt(), data_tool: pending }) + ).toThrow(); + const installed = { + ...pending, + root: { device: 0, inode: 1 }, + helper: { device: 0, inode: 2 }, + }; + const qualifiedAssertion = parseNativeAuthoredReceipt({ + ...persistentReceipt(), + data_tool: installed, + }); + expect(qualifiedAssertion.data_tool).toEqual(installed); + const changed = parseNativeAuthoredReceipt({ + ...persistentReceipt(), + data_tool: { ...installed, artifact: "b".repeat(64) }, + }); + expect(nativeAuthoredReceiptBinding(changed)).not.toBe( + nativeAuthoredReceiptBinding(qualifiedAssertion) + ); + for (const wrong of [ + null, + { ...pending, version: 2 }, + { ...pending, extra: true }, + { ...pending, helper: { device: 0, inode: 2 } }, + { ...pending, artifact: `${"a".repeat(64)}\n` }, + { ...installed, helper: { device: 0, inode: 0 } }, + ]) { + expect(() => parseNativePersistentTool(wrong)).toThrow(); + } + let calls = 0; + const getter = Object.defineProperty({ ...pending }, "root", { + enumerable: true, + get() { + calls += 1; + return null; + }, + }); + expect(() => parseNativePersistentTool(getter)).toThrow(); + expect(calls).toBe(0); +}); test("persistent graph4 keeps stable data across independent compute membership and excludes graph2/3 recovery", () => { const first = parseNativeAuthoredReceipt(persistentReceipt()); const later = persistentReceipt();