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()); + } +}