diff --git a/packages/runtime-core/README.md b/packages/runtime-core/README.md index 3570fd6cf..473e09117 100644 --- a/packages/runtime-core/README.md +++ b/packages/runtime-core/README.md @@ -1372,3 +1372,58 @@ 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 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/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..174afd767 --- /dev/null +++ b/packages/runtime-core/src/provider/graph/native/persistent_data.rs @@ -0,0 +1,261 @@ +//! 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; + +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/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()); + } +} 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"