diff --git a/docs/reference/native-authored-commands.md b/docs/reference/native-authored-commands.md index 39a940976..07110a241 100644 --- a/docs/reference/native-authored-commands.md +++ b/docs/reference/native-authored-commands.md @@ -38,7 +38,8 @@ and refuses replay; a missing ready mapping does not authorize a fresh start. `down --recover` explicitly retires a complete stored native generation whose foreground publisher and frontend admission owner are both dead on the same host -boot. It requires the current version 3 native publication, the exact stored Ready, +boot. New version 4 native publications use the kernel boot-session UUID, which +remains independent of calendar clock correction. Recovery requires the exact stored Ready, startup intent and private source envelope, and the original guest incarnation. Live owners, older version 2 dead publications, partial startup, pending writes, changed files and rebooted guests refuse. Env/profile changes, service subsets and @@ -47,6 +48,12 @@ nor acquires managed values or starts a provider. Omit `--profile` to recover the stored generation; explicit empty or named profile overrides both refuse. +The closed version 3 publication and version 1 recovery selector remain supported +with their original calendar boot qualifier; clock drift conservatively refuses +that legacy recovery. New version 4 owners use version 2 UUID selectors. Missing, +malformed or unavailable UUIDs refuse; no old record is migrated or given inferred +session authority. PID birth/executable, peer, inode, gate and run-lock checks remain. + Recovery commits a distinct private intent before calling the Rust cleanup owner. The original raw selectors and resource inventory remain fixed through retries; current cleanup phases may advance. Before retiring each frontend file, the owner @@ -69,4 +76,4 @@ this operation does not repair arbitrary partial lock or file publications. The native receipt and source paths are distinct from strict Compose v1 artifacts. No native hash substitutes for a normalized Compose hash. Source and fake-driver checks do not qualify an installed frontend, a live provider, the full authored -corpus, actual version 3 dead-owner recovery or resource overhead; those remain separate gates. +corpus, actual dead-owner recovery or resource overhead; those remain separate gates. diff --git a/packages/runtime-core/README.md b/packages/runtime-core/README.md index 3570fd6cf..2ed03adad 100644 --- a/packages/runtime-core/README.md +++ b/packages/runtime-core/README.md @@ -114,16 +114,21 @@ this same subset. Its private `native-graph-owner` record and authenticated v2 `native-graph-control` status/cleanup protocol bind the native review, exact process incarnation, directory, socket, file and retained per-run lock. Direct run/cleanup and foreground publication exclude each other under that lock. -New publications use a closed version3 owner record with independently captured -native host boot time. Live authentication rechecks that boot together with the -existing exact process and filesystem identities. The closed version2 live-owner -decoder remains available without inferring host boot from PID birth or guest -receipt boot. Version2 records reject the new field, and version3 records require -it. Native runtime receipts and the authenticated control/ready wire remain v2; +New publications use a closed version4 owner with `host_boot_uuid`, captured by +the native read-only `kern.bootsessionuuid` sysctl. Its fixed 37-byte response must +contain a nonzero ASCII UUID and terminal NUL; the reader canonicalizes lowercase. +Persisted UUIDs must already be lowercase. Unavailable or malformed readings refuse, +without a timestamp or shell fallback. Live authentication rechecks the session +together with the exact nonzero process incarnation and filesystem identities; +it does not compare process birth against mutable calendar boot time. +The closed version2 live-owner decoder remains available without a boot qualifier. +Version3 remains strictly qualified by its original `host_boot_micros`; it receives +no inferred UUID or migration. Each version rejects the other versions' qualifiers. +Native runtime receipts and the authenticated control/ready wire remain v2; Compose receipt and owner formats remain unchanged. This provenance alone grants no dead-owner recovery authority. The inactive read-only recovery selector admits only a complete Ready journal and -a dead version3 publication on the same native host boot. It captures the current +a dead version3 or version4 publication on the same qualified native host boot. It captures the current private owner bytes and inode; its raw SHA is a selector for later independent admission, not an external identity anchor from before that capture. After a durable intent, the saved inode and digest refuse replacement. Cleanup progress @@ -131,12 +136,24 @@ retains the original Ready selectors and exact inventory. Pending writes refuse; retired paths require the intent's preceding phase and unchanged archived inode. `graph native recovery-selection --run-id ID --json` exposes only the closed selection: run, original value-free Ready receipt, raw receipt/owner hashes and -native host boot time. The receipt contains review hashes, workload readiness, +the original boot qualifier. Version1 selections retain version3 calendar micros; +version2 selections carry version4 `host_boot_uuid`. Neither rewrites original +owner bytes, inode or digest. Version2 dead owners remain ineligible. Version3 +recovery conservatively refuses if its calendar qualifier has drifted. +The receipt contains review hashes, workload readiness, resource IDs/images/networks and terminal observations; it contains no environment keys/values, command argv, source bytes or publication process/path metadata. The selector creates no intent and connects no provider. It grants no cleanup or frontend recovery authority. +The UUID boundary addresses calendar correction without weakening reboot refusal. +[XNU calendar updates adjust boot time](https://github.com/apple-oss-distributions/xnu/blob/main/osfmk/kern/clock.c#L674-L752), +while the [read-only boot-session sysctl](https://github.com/apple-oss-distributions/xnu/blob/main/bsd/kern/kern_sysctl.c#L2735-L2762) +is [generated at boot](https://github.com/apple-oss-distributions/xnu/blob/main/iokit/Kernel/IOPMrootDomain.cpp#L3846-L3861). +Unrelated legacy filesystem chronology still uses its existing calendar helper. +Injected-reader and codec regressions qualify the ownership fences without changing +the host clock or rebooting. They do not establish actual provider recovery. + The separate explicit library recovery entrypoint re-admits both raw selectors and commits a bounded 64 KiB recovery intent before cleanup. It retains the publication gate, original operation lock and cleanup provider lease through diff --git a/packages/runtime-core/src/provider/graph/native/foreground.rs b/packages/runtime-core/src/provider/graph/native/foreground.rs index 55161dd75..9d51f4fd0 100644 --- a/packages/runtime-core/src/provider/graph/native/foreground.rs +++ b/packages/runtime-core/src/provider/graph/native/foreground.rs @@ -3,6 +3,7 @@ use super::*; use crate::provider::graph::foreground::{signals, transport}; use serde::{Deserialize, Serialize}; use std::{cell::Cell, io::Write}; +mod host_boot; mod owner; pub mod recovery; pub(super) use owner::DirectGuard; diff --git a/packages/runtime-core/src/provider/graph/native/foreground/host_boot.rs b/packages/runtime-core/src/provider/graph/native/foreground/host_boot.rs new file mode 100644 index 000000000..83457d9c6 --- /dev/null +++ b/packages/runtime-core/src/provider/graph/native/foreground/host_boot.rs @@ -0,0 +1,148 @@ +//! Stable native host boot authority. Calendar boot time is not an incarnation ID. +use super::{CandidateError, refused}; +use serde::{Deserialize, Deserializer, Serialize}; + +/// Persisted boot qualifiers are canonical, nonzero UUIDs. No timestamp fallback. +#[derive(Clone, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub(super) struct Session(String); +impl Session { + fn parse(value: &str) -> Option { + let bytes = value.as_bytes(); + if bytes.len() != 36 + || bytes.iter().enumerate().any(|(i, byte)| { + if matches!(i, 8 | 13 | 18 | 23) { + *byte != b'-' + } else { + !byte.is_ascii_digit() && !(b'a'..=b'f').contains(byte) + } + }) + || !bytes + .iter() + .any(|byte| matches!(byte, b'1'..=b'9' | b'a'..=b'f')) + { + return None; + } + Some(Self(value.into())) + } +} +impl<'de> Deserialize<'de> for Session { + fn deserialize>(reader: D) -> Result { + let value = String::deserialize(reader)?; + Self::parse(&value).ok_or_else(|| serde::de::Error::custom("Invalid host boot qualifier")) + } +} + +fn decode(buffer: &[u8; 37], length: usize) -> Result { + if length != buffer.len() || buffer[36] != 0 { + return Err(refused()); + } + let text = std::str::from_utf8(&buffer[..36]).map_err(|_| refused())?; + // XNU publishes uppercase UUID text; only the native reader normalizes it. + Session::parse(&text.to_ascii_lowercase()).ok_or_else(refused) +} + +pub(super) fn read() -> Result { + #[cfg(test)] + if let Some(value) = test::next() { + return value.ok_or_else(refused); + } + let mut buffer = [0u8; 37]; + let mut length = buffer.len(); + // SAFETY: the read-only sysctl writes at most this fixed initialized buffer; + // its size pointer is live, newp is null, and no pointer is retained. + if unsafe { + libc::sysctlbyname( + c"kern.bootsessionuuid".as_ptr(), + buffer.as_mut_ptr().cast(), + &mut length, + std::ptr::null_mut(), + 0, + ) + } != 0 + { + return Err(refused()); + } + decode(&buffer, length) +} + +#[cfg(test)] +pub(super) mod test { + use super::*; + use std::{cell::RefCell, collections::VecDeque}; + thread_local! { + static OVERRIDE: RefCell>>> = const { RefCell::new(None) }; + } + pub(super) fn next() -> Option> { + OVERRIDE.with(|cell| { + let mut state = cell.borrow_mut(); + let values = state.as_mut()?; + if values.len() > 1 { + values.pop_front() + } else { + values.front().cloned() + } + }) + } + pub(in super::super) struct Guard(Option>>); + impl Guard { + pub(in super::super) fn set(value: Option<&str>) -> Self { + Self::sequence(&[value]) + } + pub(in super::super) fn sequence(values: &[Option<&str>]) -> Self { + assert!(!values.is_empty()); + Self(OVERRIDE.with(|cell| { + cell.replace(Some( + values + .iter() + .map(|value| value.map(|v| Session::parse(v).unwrap())) + .collect(), + )) + })) + } + } + impl Drop for Guard { + fn drop(&mut self) { + OVERRIDE.with(|cell| cell.replace(self.0.take())); + } + } + #[test] + fn fixed_native_buffer_normalizes_only_valid_exact_uuid() { + let mut buffer = *b"12345678-ABCD-ABCD-ABCD-123456789ABC\0"; + assert!( + decode(&buffer, 37).unwrap() + == Session::parse("12345678-abcd-abcd-abcd-123456789abc").unwrap() + ); + for length in [0, 36, 38] { + assert!(decode(&buffer, length).is_err()); + } + buffer[36] = b'x'; + assert!(decode(&buffer, 37).is_err()); + for value in [ + "00000000-0000-0000-0000-000000000000", + "12345678-abcd-abcd-abcd-123456789abg", + "12345678_abcd-abcd-abcd-123456789abc", + ] { + let bytes: [u8; 37] = format!("{value}\0").into_bytes().try_into().unwrap(); + assert!(decode(&bytes, 37).is_err()); + } + buffer = *b"12345678-ABCD-ABCD-ABCD-123456789ABC\0"; + buffer[0] = 0xff; + assert!(decode(&buffer, 37).is_err()); + buffer[0] = 0; + assert!(decode(&buffer, 37).is_err()); + } + #[test] + fn persisted_uuid_is_closed_and_reader_unavailability_never_falls_back() { + for value in [ + serde_json::Value::Null, + serde_json::json!(1), + serde_json::json!("12345678-ABCD-ABCD-ABCD-123456789ABC"), + serde_json::json!("00000000-0000-0000-0000-000000000000"), + ] { + assert!(serde_json::from_value::(value).is_err()); + } + let _guard = Guard::set(None); + assert!(read().is_err()); + } +} diff --git a/packages/runtime-core/src/provider/graph/native/foreground/owner.rs b/packages/runtime-core/src/provider/graph/native/foreground/owner.rs index 0d1431a44..1f495fcd9 100644 --- a/packages/runtime-core/src/provider/graph/native/foreground/owner.rs +++ b/packages/runtime-core/src/provider/graph/native/foreground/owner.rs @@ -48,7 +48,7 @@ struct BootQualifiedRecord { host_boot_micros: u64, } impl BootQualifiedRecord { - fn into_record(self) -> (Record, Option) { + fn into_record(self) -> (Record, Option) { ( Record { version: self.version, @@ -60,11 +60,52 @@ impl BootQualifiedRecord { socket: self.socket, lock: self.lock, }, - Some(self.host_boot_micros), + Some(HostBoot::LegacyMicros(self.host_boot_micros)), ) } } -fn decode(bytes: &[u8]) -> Result<(Record, Option), CandidateError> { +/// Version4 has no calendar qualifier: exact process incarnation remains a separate fence. +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct SessionQualifiedRecord { + version: u32, + kind: Kind, + candidate: PathBuf, + review: native_input::Review, + process: ProcessIdentity, + parent: (u64, u64), + socket: (u64, u64), + lock: (u64, u64), + host_boot_uuid: host_boot::Session, +} +#[derive(Clone, PartialEq, Eq)] +pub(super) enum HostBoot { + LegacyMicros(u64), + Session(host_boot::Session), +} +impl HostBoot { + fn verify_with( + &self, + micros: impl FnOnce() -> Result, + session: impl FnOnce() -> Result, + ) -> Result<(), CandidateError> { + let equal = match self { + Self::LegacyMicros(expected) => micros().ok() == Some(*expected), + Self::Session(expected) => session().ok().as_ref() == Some(expected), + }; + if !equal { + return Err(refused()); + } + Ok(()) + } + fn verify(&self) -> Result<(), CandidateError> { + self.verify_with( + crate::provider::host_filesystem::host_boot_micros, + host_boot::read, + ) + } +} +fn decode(bytes: &[u8]) -> Result<(Record, Option), CandidateError> { let value: Value = serde_json::from_slice(bytes).map_err(|_| refused())?; match value.get("version").and_then(Value::as_u64) { Some(2) => { @@ -80,6 +121,26 @@ fn decode(bytes: &[u8]) -> Result<(Record, Option), CandidateError> { } Ok(record.into_record()) } + Some(4) => { + let record: SessionQualifiedRecord = + serde_json::from_slice(bytes).map_err(|_| refused())?; + if record.process.start_micros == 0 { + return Err(refused()); + } + Ok(( + Record { + version: record.version, + kind: record.kind, + candidate: record.candidate, + review: record.review, + process: record.process, + parent: record.parent, + socket: record.socket, + lock: record.lock, + }, + Some(HostBoot::Session(record.host_boot_uuid)), + )) + } _ => Err(refused()), } } @@ -160,16 +221,14 @@ impl<'a> DirectGuard<'a> { pub(super) struct Pin { root: PathBuf, record: Record, - host_boot_micros: Option, + host_boot: Option, bytes: Vec, file: (u64, u64), } impl Pin { fn verify_host_boot(&self) -> Result<(), CandidateError> { - if self.host_boot_micros.is_some_and(|boot| { - crate::provider::host_filesystem::host_boot_micros().ok() != Some(boot) - }) { - return Err(refused()); + if let Some(boot) = &self.host_boot { + boot.verify()?; } Ok(()) } @@ -183,7 +242,7 @@ impl Pin { state::check_private_directory(&root).map_err(|_| refused())?; let file = fs::symlink_metadata(root.join("owner.json")).map_err(|_| refused())?; let bytes = native_input::read_file(&root.join("owner.json"), 8192)?; - let (record, host_boot_micros) = decode(&bytes)?; + let (record, host_boot) = decode(&bytes)?; record .review .validate(record.review.scope()) @@ -194,7 +253,7 @@ impl Pin { let pin = Self { root, record, - host_boot_micros, + host_boot, bytes, file: id(&file), }; @@ -284,7 +343,11 @@ impl RecoverySelection { pub(super) fn fingerprint(&self) -> String { format!("{:x}", Sha256::digest(self.bytes.as_bytes())) } - fn record(&self, candidate: &Candidate, run: &str) -> Result<(Record, u64), CandidateError> { + fn record( + &self, + candidate: &Candidate, + run: &str, + ) -> Result<(Record, HostBoot), CandidateError> { if self.bytes.is_empty() || self.bytes.len() > 8192 { return Err(refused()); } @@ -295,14 +358,14 @@ impl RecoverySelection { .validate(record.review.scope()) .map_err(|_| refused())?; // SAFETY: geteuid has no preconditions; private state remains same-user. - if record.version != 3 + if !matches!(record.version, 3 | 4) || record.candidate != candidate.checkout || record.review.scope().run != run || record.process.uid != unsafe { libc::geteuid() } - || crate::provider::host_filesystem::host_boot_micros().ok() != Some(boot) { return Err(refused()); } + boot.verify()?; Ok((record, boot)) } } @@ -331,7 +394,7 @@ impl<'a> RecoveryLease<'a> { Some(value) => value, None => { let pin = Pin::read(candidate, run)?; - if pin.host_boot_micros.is_none() { + if pin.host_boot.is_none() { return Err(refused()); } RecoverySelection { @@ -359,7 +422,7 @@ impl<'a> RecoveryLease<'a> { .record(self.candidate, self.run) .map(|(record, _)| record.review) } - pub(super) fn host_boot_micros(&self) -> Result { + pub(super) fn host_boot(&self) -> Result { self.selection .record(self.candidate, self.run) .map(|(_, boot)| boot) @@ -484,8 +547,7 @@ impl Publication { review: &native_input::Review, ) -> Result { review.validate(review.scope()).map_err(|_| refused())?; - let host_boot_micros = - crate::provider::host_filesystem::host_boot_micros().map_err(|_| refused())?; + let host_boot_uuid = host_boot::read()?; let gate = super::super::super::publication_gate::Guard::acquire(candidate)?; let run = review.scope().run; if exists(&super::super::journal::directory(candidate, run)?)? { @@ -501,8 +563,8 @@ impl Publication { fs::set_permissions(root.join("control.sock"), fs::Permissions::from_mode(0o600)) .map_err(|_| refused())?; listener.set_nonblocking(true).map_err(|_| refused())?; - let record = BootQualifiedRecord { - version: 3, + let record = SessionQualifiedRecord { + version: 4, kind: Kind::NativeGraphOwner, candidate: candidate.checkout.clone(), review: review.clone(), @@ -510,13 +572,13 @@ impl Publication { parent: id(&fs::symlink_metadata(&root).map_err(|_| refused())?), socket: id(&fs::symlink_metadata(root.join("control.sock")).map_err(|_| refused())?), lock: lock.identity().map_err(|_| refused())?, - host_boot_micros, + host_boot_uuid, }; let bytes = serde_json::to_vec(&record).map_err(|_| refused())?; if bytes.len() > 8192 { return Err(refused()); } - let (record, host_boot_micros) = decode(&bytes)?; + let (record, host_boot) = decode(&bytes)?; let mut file = OpenOptions::new() .write(true) .create_new(true) @@ -530,7 +592,7 @@ impl Publication { let pin = Pin { root, record, - host_boot_micros, + host_boot, bytes, file: id(&file.metadata().map_err(|_| refused())?), }; @@ -583,3 +645,34 @@ impl Publication { .map_err(|_| refused()) } } + +#[cfg(test)] +mod boot_tests { + use super::*; + const UUID: &str = "12345678-abcd-abcd-abcd-123456789abc"; + fn session(value: &str) -> host_boot::Session { + serde_json::from_value(json!(value)).unwrap() + } + #[test] + fn session_authority_ignores_calendar_adjustment_but_not_reboot_or_unavailability() { + let boot = HostBoot::Session(session(UUID)); + for changed_wall in [1, u64::MAX] { + // Even an unavailable calendar reader cannot become V4 authority. + boot.verify_with(|| Ok(changed_wall), || Ok(session(UUID))) + .unwrap(); + boot.verify_with(|| Err(refused()), || Ok(session(UUID))) + .unwrap(); + } + assert!( + boot.verify_with( + || Ok(1), + || Ok(session("12345678-abcd-abcd-abcd-123456789abd")) + ) + .is_err() + ); + assert!(boot.verify_with(|| Ok(1), || Err(refused())).is_err()); + let legacy = HostBoot::LegacyMicros(1); + legacy.verify_with(|| Ok(1), || Err(refused())).unwrap(); + assert!(legacy.verify_with(|| Ok(2), || Ok(session(UUID))).is_err()); + } +} diff --git a/packages/runtime-core/src/provider/graph/native/foreground/recovery.rs b/packages/runtime-core/src/provider/graph/native/foreground/recovery.rs index 3bf34dbf1..f5a691d27 100644 --- a/packages/runtime-core/src/provider/graph/native/foreground/recovery.rs +++ b/packages/runtime-core/src/provider/graph/native/foreground/recovery.rs @@ -182,7 +182,15 @@ pub struct Selection { receipt: Receipt, receipt_sha256: String, owner_sha256: String, - host_boot_micros: u64, + #[serde(flatten)] + host_boot: SelectionBoot, +} +/// Exactly one original qualifier; the output version is selected from it. +#[derive(Serialize)] +#[serde(untagged)] +enum SelectionBoot { + Legacy { host_boot_micros: u64 }, + Session { host_boot_uuid: host_boot::Session }, } struct Admitted<'a> { candidate: &'a Candidate, @@ -198,18 +206,26 @@ struct Admitted<'a> { } impl Admitted<'_> { fn selection(&self) -> Result { + let (version, host_boot) = match self.lease.host_boot().map_err(|_| refused())? { + owner::HostBoot::LegacyMicros(host_boot_micros) => { + (1, SelectionBoot::Legacy { host_boot_micros }) + } + owner::HostBoot::Session(host_boot_uuid) => { + (2, SelectionBoot::Session { host_boot_uuid }) + } + }; Ok(Selection { - version: 1, + version, kind: SelectionKind::NativeGraphRecoverySelection, run: self.run.into(), receipt: self.original.clone(), receipt_sha256: self.receipt_sha256.clone(), owner_sha256: self.lease.selected().fingerprint(), - host_boot_micros: self.lease.host_boot_micros().map_err(|_| refused())?, + host_boot, }) } } -/// Select only a dead version3 complete Ready publication, or its unchanged +/// Select only a dead boot-qualified complete Ready publication, or its unchanged /// committed recovery intent. No file is created or repaired, and no provider /// operation, private acquisition, signal or cleanup is performed. pub fn select(candidate: &Candidate, run: &str) -> Result { diff --git a/packages/runtime-core/src/provider/graph/native/foreground/recovery/tests.rs b/packages/runtime-core/src/provider/graph/native/foreground/recovery/tests.rs index 9d67cca9a..8376a8034 100644 --- a/packages/runtime-core/src/provider/graph/native/foreground/recovery/tests.rs +++ b/packages/runtime-core/src/provider/graph/native/foreground/recovery/tests.rs @@ -245,9 +245,10 @@ fn dead_complete_publication_selects_original_raw_hash_without_creating_authorit selected.receipt_sha256, digest(&serde_json::to_vec(&selected.receipt).unwrap()) ); - assert_eq!( - selected.host_boot_micros, - crate::provider::host_filesystem::host_boot_micros().unwrap() + assert!( + serde_json::to_value(&selected).unwrap()["host_boot_uuid"] + == serde_json::to_value(host_boot::read().unwrap()).unwrap(), + "Selection must retain the observed boot qualifier" ); assert_eq!(fs::read(&state_path).unwrap(), original); assert_eq!(id(&state_path).unwrap(), state_id); @@ -328,7 +329,7 @@ fn selection_json_excludes_real_compiler_argv_environment_keys_values_and_owner_ .map(String::as_str) .collect::>(), [ - "host_boot_micros", + "host_boot_uuid", "kind", "owner_sha256", "receipt", @@ -377,11 +378,9 @@ fn dead_old_two_wrong_boot_and_unknown_publication_fields_refuse() { match case { "old-two" => { owner["version"] = json!(2); - owner.as_object_mut().unwrap().remove("host_boot_micros"); - } - "wrong-boot" => { - owner["host_boot_micros"] = json!(owner["host_boot_micros"].as_u64().unwrap() + 1) + owner.as_object_mut().unwrap().remove("host_boot_uuid"); } + "wrong-boot" => owner["host_boot_uuid"] = json!("00000000-0000-0000-0000-000000000001"), "unknown" => owner["cleanup_authority"] = json!(true), _ => unreachable!(), } @@ -390,6 +389,100 @@ fn dead_old_two_wrong_boot_and_unknown_publication_fields_refuse() { } } +#[test] +fn legacy_three_selection_remains_version_one_without_uuid_migration() { + let fixture = Fixture::new(); + fixture.dead(); + let path = fixture.owner_root().join("owner.json"); + let mut legacy: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + legacy["version"] = json!(3); + legacy.as_object_mut().unwrap().remove("host_boot_uuid"); + let micros = crate::provider::host_filesystem::host_boot_micros().unwrap(); + legacy["host_boot_micros"] = json!(micros); + fs::write(&path, legacy.to_string()).unwrap(); + let original = fs::read(&path).unwrap(); + let selected = serde_json::to_value(select(&fixture.candidate, RUN).unwrap()).unwrap(); + assert_eq!(selected["version"], 1); + assert_eq!(selected["host_boot_micros"], micros); + assert!(selected.get("host_boot_uuid").is_none()); + assert_eq!(fs::read(&path).unwrap(), original); + legacy["host_boot_micros"] = json!(micros + 1); + fs::write(&path, legacy.to_string()).unwrap(); + fixture.assert_refused_unchanged(); +} + +#[test] +fn recovery_session_entry_refusal_and_restoration_preserve_exact_evidence() { + let fixture = Fixture::new(); + fixture.dead(); + let intent = fixture.intent(); + fixture.store(&intent); + let lease = owner::RecoveryLease::acquire( + &fixture.candidate, + RUN, + Some(intent.publication.clone()), + false, + false, + ) + .unwrap(); + lease.verify(false, false).unwrap(); + for changed in [Some("12345678-abcd-abcd-abcd-123456789abc"), None] { + let _drift = host_boot::test::Guard::set(changed); + assert!(lease.verify(false, false).is_err()); + assert!(lease.review().is_err()); + assert!(lease.archive(true).is_err()); + assert!(select(&fixture.candidate, RUN).is_err()); + assert!(fixture.owner_root().join("owner.json").exists()); + assert!(fixture.owner_root().join("control.sock").exists()); + } + lease.verify(false, false).unwrap(); +} + +#[test] +fn recovery_session_final_observation_refuses_drift_without_changing_original_evidence() { + let fixture = Fixture::new(); + fixture.dead(); + let intent = fixture.intent(); + fixture.store(&intent); + let lease = owner::RecoveryLease::acquire( + &fixture.candidate, + RUN, + Some(intent.publication.clone()), + false, + false, + ) + .unwrap(); + let current = serde_json::to_value(host_boot::read().unwrap()) + .unwrap() + .as_str() + .unwrap() + .to_owned(); + let mut changed = current.clone(); + changed.replace_range(0..1, if current.starts_with('f') { "e" } else { "f" }); + let owner = fixture.owner_root().join("owner.json"); + let socket = fixture.owner_root().join("control.sock"); + let journal = fixture.journal_root().join("state.json"); + let saved_intent = fixture.journal_root().join(FILE); + let paths = [&owner, &socket, &journal, &saved_intent]; + let identities: Vec<_> = paths.iter().map(|path| id(path).unwrap()).collect(); + let bytes = [&owner, &journal, &saved_intent].map(|path| fs::read(path).unwrap()); + for last in [Some(changed.as_str()), None] { + let _drift = host_boot::test::Guard::sequence(&[Some(¤t), last]); + // First record read admits the original boot; final read must refuse. + assert!(lease.verify(false, false).is_err()); + for (path, expected) in paths.iter().zip(&identities) { + assert_eq!(id(path).unwrap(), *expected); + } + for (path, expected) in [&owner, &journal, &saved_intent].into_iter().zip(&bytes) { + assert!( + fs::read(path).unwrap() == *expected, + "Original evidence must remain unchanged" + ); + } + } + lease.verify(false, false).unwrap(); +} + #[test] fn partial_failed_missing_id_or_pending_state_never_creates_an_intent() { for case in [ @@ -430,7 +523,7 @@ fn replaced_selected_owner_or_published_socket_and_lock_refuse_without_repair() let fixture = Fixture::new(); fixture.dead(); if name == "owner.json" { - // Publication3 carries no original owner-file inode. Its first + // Publication4 carries no original owner-file inode. Its first // private snapshot becomes the saved intent's later inode anchor. fixture.store(&fixture.intent()); } diff --git a/packages/runtime-core/src/provider/graph/native/foreground/tests.rs b/packages/runtime-core/src/provider/graph/native/foreground/tests.rs index d22fe9723..6f4222abd 100644 --- a/packages/runtime-core/src/provider/graph/native/foreground/tests.rs +++ b/packages/runtime-core/src/provider/graph/native/foreground/tests.rs @@ -290,10 +290,17 @@ fn owner_codec_refuses_legacy_kind_unknown_fields_and_wrong_process_incarnation( ("/kind", json!("compose")), ("/candidate", json!("/foreign")), ("/process/start_micros", json!(0)), + ("/process/start_micros", json!(1)), ("/process/pid", json!(2_000_000)), - ("/host_boot_micros", json!(0)), - ("/host_boot_micros", json!(1)), - ("/host_boot_micros", json!(u64::MAX)), + ("/host_boot_uuid", json!(0)), + ( + "/host_boot_uuid", + json!("00000000-0000-0000-0000-000000000000"), + ), + ( + "/host_boot_uuid", + json!("00000000-0000-0000-0000-000000000001"), + ), ("/values", json!("private-canary")), ] { let fixture = Fixture::new(); @@ -318,24 +325,46 @@ fn owner_codec_refuses_legacy_kind_unknown_fields_and_wrong_process_incarnation( } #[test] -fn publication_three_binds_independent_host_boot_and_keeps_closed_live_version_two() { +fn publication_four_binds_session_and_preserves_closed_legacy_live_codecs() { let fixture = Fixture::new(); let review = fixture.receipt().review; let publication = owner::Publication::bind(&fixture.candidate, &review).unwrap(); let file = fixture.owner_root().join("owner.json"); let bytes = fs::read(&file).unwrap(); let qualified: Value = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(qualified["version"], 3); + assert_eq!(qualified["version"], 4); + assert!( + qualified["host_boot_uuid"] == serde_json::to_value(host_boot::read().unwrap()).unwrap(), + "Publication must retain the observed boot qualifier" + ); + assert!(qualified.get("host_boot_micros").is_none()); let boot = crate::provider::host_filesystem::host_boot_micros().unwrap(); - assert_eq!(qualified["host_boot_micros"], boot); - assert!(qualified["process"]["start_micros"].as_u64().unwrap() >= boot); + owner::Pin::load(&fixture.candidate, RUN).unwrap(); publication.verify().unwrap(); assert!(DirectGuard::acquire(&fixture.candidate, RUN).is_err()); + let mut legacy_three = qualified.clone(); + legacy_three["version"] = json!(3); + legacy_three + .as_object_mut() + .unwrap() + .remove("host_boot_uuid"); + legacy_three["host_boot_micros"] = json!(boot); + fs::write(&file, serde_json::to_vec(&legacy_three).unwrap()).unwrap(); + owner::Pin::load(&fixture.candidate, RUN) + .unwrap() + .connect() + .unwrap(); + assert!(publication.verify().is_err()); + // V3 remains strictly calendar-qualified rather than acquiring inferred UUID authority. + legacy_three["host_boot_micros"] = json!(boot + 1); + fs::write(&file, serde_json::to_vec(&legacy_three).unwrap()).unwrap(); + assert!(owner::Pin::load(&fixture.candidate, RUN).is_err()); + let mut old = qualified.clone(); old["version"] = json!(2); - old.as_object_mut().unwrap().remove("host_boot_micros"); + old.as_object_mut().unwrap().remove("host_boot_uuid"); fs::write(&file, serde_json::to_vec(&old).unwrap()).unwrap(); // Exact legacy live authentication remains available without inventing host boot. let legacy = owner::Pin::load(&fixture.candidate, RUN).unwrap(); @@ -357,6 +386,84 @@ fn publication_three_binds_independent_host_boot_and_keeps_closed_live_version_t assert!(!fixture.candidate.state_root.join("run/smolvm").exists()); } +#[test] +fn session_change_or_unavailable_reader_refuses_live_and_retired_fences() { + let fixture = Fixture::new(); + let review = fixture.receipt().review; + let first = "12345678-abcd-abcd-abcd-123456789abc"; + let _boot = host_boot::test::Guard::set(Some(first)); + let mut publication = owner::Publication::bind(&fixture.candidate, &review).unwrap(); + let pin = owner::Pin::load(&fixture.candidate, RUN).unwrap(); + pin.connect().unwrap(); + for changed in [Some("12345678-abcd-abcd-abcd-123456789abd"), None] { + let _drift = host_boot::test::Guard::set(changed); + assert!(pin.verify().is_err()); + assert!(pin.connect().is_err()); + assert!(publication.verify().is_err()); + assert!(publication.finish().is_err()); + assert!(fixture.owner_root().join("owner.json").exists()); + } + publication.finish().unwrap(); + pin.verify_retired().unwrap(); + let _drift = host_boot::test::Guard::set(None); + assert!(pin.verify_retired().is_err()); +} + +#[test] +fn session_codec_refuses_missing_mixed_downgraded_and_unknown_qualifiers() { + let fixture = Fixture::new(); + let publication = + owner::Publication::bind(&fixture.candidate, &fixture.receipt().review).unwrap(); + let file = fixture.owner_root().join("owner.json"); + let original: Value = serde_json::from_slice(&fs::read(&file).unwrap()).unwrap(); + for case in [ + "missing", + "null", + "uppercase", + "micros", + "v3", + "v2", + "future", + ] { + let mut bad = original.clone(); + match case { + "missing" => { + bad.as_object_mut().unwrap().remove("host_boot_uuid"); + } + "null" => bad["host_boot_uuid"] = Value::Null, + "uppercase" => bad["host_boot_uuid"] = json!("12345678-ABCD-ABCD-ABCD-123456789ABC"), + "micros" => bad["host_boot_micros"] = json!(1), + "v3" => bad["version"] = json!(3), + "v2" => bad["version"] = json!(2), + "future" => bad["version"] = json!(5), + _ => unreachable!(), + } + fs::write(&file, serde_json::to_vec(&bad).unwrap()).unwrap(); + assert!(owner::Pin::load(&fixture.candidate, RUN).is_err(), "{case}"); + assert!(publication.verify().is_err(), "{case}"); + } +} + +#[test] +fn uuid_drift_during_publication_and_peer_connect_refuses_final_fence() { + let first = "12345678-abcd-abcd-abcd-123456789abc"; + let changed = "12345678-abcd-abcd-abcd-123456789abd"; + let fixture = Fixture::new(); + { + let _drift = host_boot::test::Guard::sequence(&[Some(first), Some(changed)]); + assert!(owner::Publication::bind(&fixture.candidate, &fixture.receipt().review).is_err()); + assert!(fixture.owner_root().join("owner.json").exists()); + } + // Separate owned fixture keeps failed publication evidence intact. + let other = Fixture::new(); + let _boot = host_boot::test::Guard::set(Some(first)); + let _publication = owner::Publication::bind(&other.candidate, &other.receipt().review).unwrap(); + let pin = owner::Pin::load(&other.candidate, RUN).unwrap(); + let _drift = host_boot::test::Guard::sequence(&[Some(first), Some(changed)]); + assert!(pin.connect().is_err()); + assert!(other.owner_root().join("owner.json").exists()); +} + #[test] fn replaced_symlinked_hardlinked_and_public_owner_files_are_not_adopted() { for kind in ["replaced", "symlink", "hardlink", "public"] { diff --git a/src/backends/native-authored-recovery-protocol.ts b/src/backends/native-authored-recovery-protocol.ts index f0effc50a..c48cd4cb9 100644 --- a/src/backends/native-authored-recovery-protocol.ts +++ b/src/backends/native-authored-recovery-protocol.ts @@ -6,15 +6,19 @@ import { } from "./native-authored-graph-protocol.ts"; const HEX64 = /^[a-f0-9]{64}$/; -export type NativeAuthoredRecoverySelection = { - readonly version: 1; +const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/; +type RecoverySelectionFields = { readonly kind: "native-graph-recovery-selection"; readonly run: string; readonly receipt: NativeAuthoredReceipt; readonly receipt_sha256: string; readonly owner_sha256: string; - readonly host_boot_micros: number; }; +export type NativeAuthoredRecoverySelection = RecoverySelectionFields & + ( + | { readonly version: 1; readonly host_boot_micros: number } + | { readonly version: 2; readonly host_boot_uuid: string } + ); export type NativeAuthoredRecoveryResult = { readonly version: 1; readonly kind: "native-graph-live-owner-recovered"; @@ -61,6 +65,10 @@ export function parseNativeAuthoredRecoverySelection(opts: { }): NativeAuthoredRecoverySelection { const admitted = ready(opts.admitted); const value = opts.value; + const qualifier = + isRecord(value) && Object.hasOwn(value, "host_boot_uuid") + ? "host_boot_uuid" + : "host_boot_micros"; if ( !fields(value, [ "version", @@ -69,16 +77,13 @@ export function parseNativeAuthoredRecoverySelection(opts: { "receipt", "receipt_sha256", "owner_sha256", - "host_boot_micros", + qualifier, ]) || - value.version !== 1 || + (value.version !== 1 && value.version !== 2) || value.kind !== "native-graph-recovery-selection" || value.run !== admitted.review.provenance.run || !hash(value.receipt_sha256) || - !hash(value.owner_sha256) || - typeof value.host_boot_micros !== "number" || - !Number.isSafeInteger(value.host_boot_micros) || - value.host_boot_micros <= 0 + !hash(value.owner_sha256) ) { return refused(); } @@ -89,15 +94,32 @@ export function parseNativeAuthoredRecoverySelection(opts: { ) { return refused(); } - return { - version: 1, + const common: RecoverySelectionFields = { kind: "native-graph-recovery-selection", run: value.run, receipt, receipt_sha256: value.receipt_sha256, owner_sha256: value.owner_sha256, - host_boot_micros: value.host_boot_micros, }; + if (value.version === 1) { + if ( + typeof value.host_boot_micros !== "number" || + !Number.isSafeInteger(value.host_boot_micros) || + value.host_boot_micros <= 0 + ) { + return refused(); + } + return { ...common, version: 1, host_boot_micros: value.host_boot_micros }; + } + if ( + typeof value.host_boot_uuid !== "string" || + value.host_boot_uuid.length !== 36 || + !UUID.test(value.host_boot_uuid) || + value.host_boot_uuid === "00000000-0000-0000-0000-000000000000" + ) { + return refused(); + } + return { ...common, version: 2, host_boot_uuid: value.host_boot_uuid }; } /** Matching Removed is a cleanup result, not frontend retirement authority. diff --git a/tests/native-authored-project-recovery.test.ts b/tests/native-authored-project-recovery.test.ts index ecbc12139..22fca634e 100644 --- a/tests/native-authored-project-recovery.test.ts +++ b/tests/native-authored-project-recovery.test.ts @@ -100,7 +100,7 @@ function removed() { ), }); } -async function fixture() { +async function fixture(bootSession = false) { const root = await realpath( await mkdtemp(join(tmpdir(), "native-frontend-recovery-")) ); @@ -153,13 +153,15 @@ async function fixture() { const calls: string[][] = []; let cleanupCalls = 0; const selection = { - version: 1, + version: bootSession ? 2 : 1, kind: "native-graph-recovery-selection", run: "a".repeat(32), receipt: receipt(), receipt_sha256: "4".repeat(64), owner_sha256: "5".repeat(64), - host_boot_micros: 1_791_000_000_000_000, + ...(bootSession + ? { host_boot_uuid: "12345678-abcd-abcd-abcd-123456789abc" } + : { host_boot_micros: 1_791_000_000_000_000 }), }; const result = { version: 1, @@ -297,6 +299,21 @@ test("dead frontend recovery binds the stored run, holds startup, retires only a ).toBe(1); }); +test("frontend recovery preserves UUID selection through complete retry without changing Ready or control versions", async () => { + const current = await fixture(true); + await current.kill(); + await recoverNativeAuthoredProject(current.options); + const saved = JSON.parse(await Bun.file(current.paths.intent).text()); + expect(saved.record.native.version).toBe(2); + expect(saved.record.native.host_boot_uuid).toBe( + "12345678-abcd-abcd-abcd-123456789abc" + ); + expect(saved.record.native).not.toHaveProperty("host_boot_micros"); + expect(saved.record.native.receipt.version).toBe(2); + await recoverNativeAuthoredProject(current.options); + expect(current.cleanupCalls()).toBe(1); +}); + test("recovery refuses foreign Removed membership or nonnull current observations and retains frontend authority", async () => { for (const attack of ["id", "observation"]) { const current = await fixture(); diff --git a/tests/native-authored-recovery-protocol.test.ts b/tests/native-authored-recovery-protocol.test.ts index 21f0c5937..8bc208514 100644 --- a/tests/native-authored-recovery-protocol.test.ts +++ b/tests/native-authored-recovery-protocol.test.ts @@ -68,6 +68,14 @@ function selection() { host_boot_micros: 1_791_000_000_000_000, }; } +function sessionSelection() { + const { host_boot_micros: _legacy, ...common } = selection(); + return { + ...common, + version: 2, + host_boot_uuid: "12345678-abcd-abcd-abcd-123456789abc", + }; +} function result() { const removed = receipt(); removed.phase = "removed"; @@ -99,6 +107,79 @@ test("native recovery copies original selectors and binds exact Removed to durab .phase ).toBe("removed"); }); +test("session selection is closed version two and preserves original qualifier without migration", () => { + const admitted = parseNativeAuthoredReceipt(receipt()); + const expected = parseNativeAuthoredRecoverySelection({ + value: sessionSelection(), + admitted, + }); + expect(expected.version).toBe(2); + expect(expected).toHaveProperty( + "host_boot_uuid", + sessionSelection().host_boot_uuid + ); + expect(expected).not.toHaveProperty("host_boot_micros"); + expect( + parseNativeAuthoredRecoveryResult({ value: result(), expected }).version + ).toBe(1); + const canary = "private-boot-qualifier-canary"; + const { host_boot_uuid: _missing, ...missing } = sessionSelection(); + for (const value of [ + missing, + { ...sessionSelection(), version: 1 }, + { ...sessionSelection(), version: 3 }, + { ...sessionSelection(), host_boot_micros: 1 }, + { ...selection(), host_boot_uuid: sessionSelection().host_boot_uuid }, + ...[ + null, + 1, + canary, + "12345678-ABCD-ABCD-ABCD-123456789ABC", + "00000000-0000-0000-0000-000000000000", + "12345678-abcd-abcd-abcd-123456789abg", + "12345678-abcd-abcd-abcd-123456789abc\n", + "12345678-abcd-abcd-abcd-123456789abc ", + ].map((host_boot_uuid) => ({ ...sessionSelection(), host_boot_uuid })), + ]) { + try { + parseNativeAuthoredRecoverySelection({ value, admitted }); + throw new Error("fixture admitted forbidden qualifier"); + } catch (error) { + expect(String(error)).toContain("invalid"); + expect(String(error)).not.toContain(canary); + } + } +}); +test("selector dispatch refuses missing own fields before reading version getters", () => { + const admitted = parseNativeAuthoredReceipt(receipt()); + let calls = 0; + const prototype = { + get version() { + calls++; + return 2; + }, + }; + const ownPartial = Object.create(null); + Object.defineProperties(ownPartial, { + version: { + enumerable: true, + get() { + calls++; + return 2; + }, + }, + host_boot_uuid: { + enumerable: true, + value: sessionSelection().host_boot_uuid, + }, + }); + for (const value of [Object.create(prototype), ownPartial]) { + expect(() => + parseNativeAuthoredRecoverySelection({ value, admitted }) + ).toThrow("invalid"); + expect(calls).toBe(0); + } +}); test("native recovery selection refuses inherited unknown private or invalid boot/hash fields", () => { const admitted = parseNativeAuthoredReceipt(receipt()); const canary = "private-recovery-wire-canary";