diff --git a/docs/reference/native-authored-commands.md b/docs/reference/native-authored-commands.md index 553ec7011..8edceb778 100644 --- a/docs/reference/native-authored-commands.md +++ b/docs/reference/native-authored-commands.md @@ -15,10 +15,21 @@ HACK_NATIVE_HOME=/absolute/private/candidate-home \ ./dist/hack --path /absolute/project up ``` -The native planner admits image-only workloads, exec readiness, initializer jobs -and dependencies with the ordinary project network and outbound mode. Source -root must remain `.`; source acquisition, mounts, storage, authored networks, file inputs, routing, endpoints -and host effects remain outside this bounded frontend. Unsupported intent must +The native planner admits image workloads, exec readiness, initializer jobs +and dependencies with the ordinary project network and outbound mode. It also +admits one read-only project source mount per selected workload, using the existing +`host-mounted` source mode and root `.`. The provider pool must already contain +the exact explicitly approved unfiltered live project share. That existing +virtiofs share grants the guest writable access to the whole tree; only the +individual workload bind is read-only. This command does not enroll the share or +change pool mounts. Host edits remain visible. A selected +directory allows descendant edits; a selected regular file allows in-place edits +but refuses replacement of its inode. Selected path/ancestor aliases, identity +or permission changes refuse, while exact owned shutdown remains possible after +the host source is moved or deleted and preserves host data. + +Writable/other mounts, source acquisition, storage, authored networks, file inputs, +routing, endpoints and host effects remain outside this bounded frontend. Unsupported intent must refuse before managed value resolution and provider work. Early typed input capability refusals retain `E_NATIVE_PROJECT_UNSUPPORTED` without exposing compiler diagnostics or creating native source/start/run authority. `--detach`, @@ -77,6 +88,10 @@ and an interrupted completed-history hardlink archive remain retained refusals; 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 +Image-only graph receipts remain v2; source-bearing graph receipts use a closed v3 +binding, distinct from the existing foreground publication-owner v3. Ready/control +envelopes remain v2. Dead-owner recovery of source-bearing receipts is not admitted +by the separately qualified image-only recovery path. 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 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 700e15403..2da697961 100644 --- a/packages/runtime-core/README.md +++ b/packages/runtime-core/README.md @@ -8,12 +8,14 @@ provider, image acquisition or receipt effects. This feature's compiler path dependency requires the repository's pinned Rust 1.97.1. The default runtime package keeps its declared Rust 1.85 minimum; enabling the adapter requires Rust 1.97.1. -The adapter accepts at most 32 selected services/jobs with immutable images, exec readiness, +The adapter accepts at most 32 selected pinned-image services/jobs, exec readiness, explicit exec/shell commands, entrypoint clearing, init, exact shutdown intent and working directories. Jobs require successful completion; services with readiness require health, and other services require startup. Workload names, including dots, -remain exact. Omitted process fields preserve image/backend defaults. Source and -worktree declarations and local resolution remain intent only. +remain exact. Omitted process fields preserve image/backend defaults. One project-relative +read-only live source bind per workload is supported through an already-approved +pool share. Other source acquisition and worktree declarations and local resolution +remain intent only. An explicit entrypoint requires an authored command, matching the bounded NC03 renderer; image CMD inheritance under an entrypoint override remains unqualified. @@ -68,6 +70,56 @@ lowerer retains logical storage mount intent without fabricating provider volume It checks owner shape and deadline, never real guest ownership, image presence, combined capacity, private staging or engine effects. +The explicit native consumer additionally admits at most one compiler-normalized +read-only project source bind per selected workload. The authored source mode is +`host-mounted` with root `.`: content changes remain live, including atomic edits +of descendants inside a selected directory. A selected regular file may change +in place; replacing that file itself changes the selected identity and refuses. +Writable source binds and custom source roots remain unsupported. Source-bearing +projects cannot combine live source with persistent storage; the separate storage +intent contract remains inactive before provider admission. +This does not create an immutable snapshot or publish a new source revision. + +Source consumption requires the provider pool to already contain the exact +explicitly approved unfiltered project share. The consumer never approves that +whole-tree writable share, changes pool mounts or enrolls a project implicitly. +Individual workload binds are read-only and `rprivate`. Selection pins the source +root, every selected path and its ancestors by device, inode, type, UID and full +mode; aliases, hardlinked selected files and permission changes refuse. Startup +and active observations recheck those host paths, the existing provider share, +virtiofs mapping and configured/runtime bind around engine work. Host editors are +not locked: these are bounded replacement checks, not an atomic host filesystem +fence. Descendant edits under a selected directory follow the approved live-share +policy; its whole tree can include local configuration. + +Source-bearing native graph receipts use a closed v3 source binding; image-only +v2 receipts keep their prior fields and serialization. This graph v3 is separate +from the foreground publication-owner v3 and does not change the v2 ready/control +envelopes. Retained inspection/startup cannot recapture or adopt a new selected +path. Exact owned cleanup continues after the host source is moved or deleted: +it verifies the original provider share and read-only container bind, stops and +removes only the original resource inventory, and never deletes host source data. +Dead-owner recovery of source-bearing graph v3 remains outside this increment; +the separately qualified image-only recovery admission must refuse that version. + +The maintained macOS ignored control +`native::runtime::tests::live_source::approved_live_source_preserves_host_edits_and_cleanup_after_selected_source_moves` +requires a caller-created synthetic `live-fixture/project` and isolated sibling +`native-home`. Prepare its provider through the existing explicit development +`--project-share PROJECT --unfiltered-source` contract, then load a pinned Linux +ARM64 Bun image. The fixture verifies a run-bound private inode/mode manifest and +exclusively claims the invocation before graph effects; it does not enroll shares. +Set `HACK_NATIVE_SOURCE_TEST_FIXTURE`, `HACK_NATIVE_SOURCE_TEST_PROJECT`, +`HACK_LOCAL_TEST_ROOT`, `HACK_LOCAL_TEST_IMAGE` and a fresh 32-character hex +`HACK_NATIVE_SOURCE_TEST_RUN`. Source files are public synthetic data under private +ancestors. The control requires live HTTP reads after host edits and atomic +descendant replacement, an `EROFS` container-write refusal, source-withdrawal +inspection refusal and normal exact cleanup preserving host data. It publishes +no host ports. Compile the exact test before the caller's 300-second watchdog; +uncertain failures retain the graph/pool for inspection and never replay cleanup. +Its filesystem admission controls run without a provider. This does not qualify +whole frontend parity, dead-owner recovery or performance. + `provider::graph::native::selection` selects only the exact absolute native project root and reads bounded, stable regular `.hack/hack.project.json` and optional `.hack/hack.local.json` files. It forwards raw authored text and owner-supplied @@ -80,11 +132,13 @@ refuses until its primary-worktree verification is qualified; opted-out inherita preserves the owning compiler's checkout-local semantics. This is read-only input selection and private preparation, with no durable enrollment or runtime ownership. -`provider::graph::native::run` is an explicit library consumer for the bounded -image/process subset and the separate persistent-storage path. It retains the development guest mutation lease, requires an +`provider::graph::native::run` is an explicit library consumer for pinned images +with optional preapproved read-only live source; persistent-storage intent remains +a separate, inactive path. It retains the development guest mutation lease, requires an admitted Internet or explicitly restricted outbound pool, verifies existing immutable -images and shared graph/allocation capacity, and reserves a distinct v2 -`native-graph-runtime` journal in `run/native-graphs` before effects. Create/start +images and shared graph/allocation capacity, and reserves a distinct +`native-graph-runtime` journal in `run/native-graphs` before effects (v2 for image-only, +v3 for source-bearing graphs; the separate inactive storage contract uses v4). Create/start intent is durable and never replayed or adopted. Network create intent precedes the first network effect; its immutable ID, labels, bridge driver and outbound policy are verified before container work. Containers bind the recorded network ID and exact @@ -126,7 +180,8 @@ 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; +Native runtime receipts are v2 for image-only graphs and v3 for source-bearing +graphs; the authenticated control/ready wire remains 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 @@ -198,8 +253,8 @@ ordinary frontend selection yet and does not implement reactive health or hooks. The optional feature exposes this bounded consumer through a distinct public CLI: `graph native plan --source-file FILE --json`, then `graph native run --source-file FILE --expect-review SHA --json`. -`graph native inspect|cleanup --run-id RUN --json` use its v2 journal. These commands -are explicit image-only prerequisites, not ordinary project startup. Their project +`graph native inspect|cleanup --run-id RUN --json` use its versioned native journal. These commands +are explicit bounded prerequisites, not full ordinary project parity. Their project network does not publish ports or grant host-service access. Normal native `hack up` continues to require the full source/storage/routing/host and foreground-owner contract. diff --git a/packages/runtime-core/src/project/mod.rs b/packages/runtime-core/src/project/mod.rs index e311d5d92..fc9a01f03 100644 --- a/packages/runtime-core/src/project/mod.rs +++ b/packages/runtime-core/src/project/mod.rs @@ -23,6 +23,8 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; pub use enrollment::{EnrollmentReceipt, enroll, status, status_with_branch}; +#[cfg(feature = "native-config-plan")] +pub(crate) use source::resolve as resolve_source; pub use source::{SourceEntry, SourceSelection}; pub struct PlanOptions<'a> { diff --git a/packages/runtime-core/src/project/native.rs b/packages/runtime-core/src/project/native.rs index 6444df9f6..4701e67a7 100644 --- a/packages/runtime-core/src/project/native.rs +++ b/packages/runtime-core/src/project/native.rs @@ -7,7 +7,7 @@ use hack_config_compiler::{ local::LocalResolution, model::{ Access, Command, Dependency, EnvironmentValue, Mount, Plan, Readiness, ServiceCondition, - Source, Workload, WorktreePolicy, + Source, SourceMode, Workload, WorktreePolicy, }, process::{Entrypoint, Restart, ShutdownSignal}, }; @@ -98,6 +98,7 @@ pub struct WorkloadInputs { pub shutdown: Option, pub restart: Option, pub working_directory: Option, + pub source_mount: Option, pub environment: BTreeMap, pub readiness: Option, pub mounts: Vec, @@ -111,6 +112,14 @@ pub struct StorageMount { pub read_only: bool, } +/// Compiler-normalized source and destination; live sharing is admitted separately. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SourceMount { + pub source: String, + pub target: String, +} + /// Millisecond precision is retained; a future backend must qualify signal/timing delivery. pub struct Shutdown { pub signal: Option, @@ -128,7 +137,7 @@ pub struct ExecReadiness { fn refused() -> CandidateError { CandidateError::new( "native_graph_subset", - "Native graph adapter requires image-only workloads, exec readiness and only persistent worktree storage mounts; acquisition, source/file mounts, custom networks, routing, endpoints, host effects and automatic restart remain refused. Values omitted.", + "Native graph adapter requires images, exec readiness and either one read-only live project-source mount per workload or persistent worktree storage intent; mixed source/storage, acquisition, other mounts, custom networks, routing, endpoints, host effects and automatic restart remain unsupported; values omitted.", ) } @@ -197,10 +206,17 @@ fn workload(value: Workload, kind: WorkloadKind) -> Result { + source_mount = Some(SourceMount { source, target }); + } Mount::Storage { storage, target, @@ -214,15 +230,18 @@ fn workload(value: Workload, kind: WorkloadKind) -> Result { - Ok(StorageMount { + mounts.push(StorageMount { storage, target, read_only: matches!(access, Access::ReadOnly), - }) + }); } - _ => Err(refused()), - }) - .collect::, _>>()?; + _ => return Err(refused()), + } + } + if source_mount.is_some() && !mounts.is_empty() { + return Err(refused()); + } let readiness = value .readiness .map(|check| match check { @@ -262,6 +281,7 @@ fn workload(value: Workload, kind: WorkloadKind) -> Result, code: &str) { let error = match result { Ok(_) => panic!("expected refusal"), @@ -749,7 +776,7 @@ fn entrypoint_overrides_without_authored_command_refuse_until_image_cmd_is_quali fn unsupported_intent_is_never_dropped() { let empty = BTreeMap::new(); for field in [ - json!({"mounts":[{"source":".","target":"/app","access":"read-only"}]}), + json!({"mounts":[{"source":".","target":"/app","access":"read-write"}]}), json!({"pull_policy":"never"}), json!({"restart":{"kind":"on-failure","max_retries":2}}), json!({"readiness":{"kind":"http","port":8080,"path":"/","interval":"1s","timeout":"1s","retries":1}}), @@ -879,3 +906,23 @@ fn compiler_refuses_duplicate_keys_cycles_missing_readiness_and_malformed_input( "graph_budget", ); } + +#[test] +fn mixed_live_source_and_persistent_storage_refuses_before_private_copy() { + let mut project = basic(); + project["storage"] = json!({"database":{"kind":"persistent","scope":"worktree"}}); + project["services"]["web"]["mounts"] = + json!([{"source":"src","target":"/app","access":"read-only"}]); + for mounts in [ + json!([{"storage":"database","target":"/data","access":"read-write"}]), + json!([{"source":"other","target":"/other","access":"read-only"}]), + ] { + project["jobs"] = json!({"seed":{"image":"seed","mounts":mounts}}); + PRIVATE_COPIES.with(|copies| copies.set(0)); + refusal( + lower(&project, json!({"web":{},"seed":{}}), &BTreeMap::new()), + "native_graph_subset", + ); + assert_eq!(PRIVATE_COPIES.with(std::cell::Cell::get), 0); + } +} diff --git a/packages/runtime-core/src/provider/dependency_socket/quiescent_recovery_tests.rs b/packages/runtime-core/src/provider/dependency_socket/quiescent_recovery_tests.rs index 8edfbe567..3b7971d59 100644 --- a/packages/runtime-core/src/provider/dependency_socket/quiescent_recovery_tests.rs +++ b/packages/runtime-core/src/provider/dependency_socket/quiescent_recovery_tests.rs @@ -43,7 +43,16 @@ impl Fixture { drop(self.bind(slot)); } fn selection(&self) -> Selection { - current(self.scope(), &self.0).unwrap() + super::super::recovery::observation_diagnostic::clear(); + current(self.scope(), &self.0).unwrap_or_else(|error| { + panic!( + "Synthetic quiescent selection refused: code={}; closed_first_observation={}", + error.code, + super::super::recovery::observation_diagnostic::take() + .map(|facts| serde_json::to_string(&facts).unwrap()) + .unwrap_or_else(|| "null".into()) + ) + }) } fn hash(&self) -> String { digest(&self.selection()).unwrap() @@ -52,6 +61,52 @@ impl Fixture { recover_scope(&self.journal(), &self.scope(), &self.0, hash, || Ok(())) } } + +#[test] +fn first_socket_refusal_records_closed_live_listener_facts_without_removal() { + use super::super::recovery::observation_diagnostic as diagnostic; + let fixture = Fixture::new(); + let listener = fixture.bind(0); + diagnostic::clear(); + assert!(observed(&fixture.0, 0).is_err()); + let facts = diagnostic::take().unwrap(); + assert_eq!(facts["stage"], "socket-connect"); + assert!(facts["errno"].is_null()); + assert_eq!(facts["metadata"]["type"], "socket"); + assert_eq!(facts["metadata"]["mode"].as_u64().unwrap() & 0o7777, 0o600); + assert_eq!(facts["metadata"]["nlink"], 1); + assert!(facts["metadata"]["inode"].as_u64().unwrap() > 0); + let text = serde_json::to_string(&facts).unwrap(); + assert!(text.len() < 512); + assert!(!text.contains(fixture.0.to_str().unwrap())); + assert!(path(&fixture.0, 0).exists()); + drop(listener); + assert!(observed(&fixture.0, 0).unwrap().is_some()); +} + +#[test] +fn first_socket_refusal_precedes_later_facts_and_clear_starts_a_fresh_observation() { + use super::super::recovery::observation_diagnostic as diagnostic; + let fixture = Fixture::new(); + let listener = fixture.bind(0); + fs::set_permissions(path(&fixture.0, 0), fs::Permissions::from_mode(0o644)).unwrap(); + diagnostic::clear(); + assert!(observed(&fixture.0, 0).is_err()); + diagnostic::record(diagnostic::Stage::MetadataRead, None, Some(libc::EACCES)); + let first = diagnostic::take().unwrap(); + assert_eq!(first["stage"], "socket-metadata"); + assert_eq!(first["metadata"]["mode"].as_u64().unwrap() & 0o7777, 0o644); + assert!(first["errno"].is_null()); + diagnostic::clear(); + assert!(diagnostic::take().is_none()); + diagnostic::record(diagnostic::Stage::MetadataRead, None, Some(libc::EACCES)); + assert_eq!( + diagnostic::take().unwrap(), + json!({"stage":"metadata-read","errno":libc::EACCES,"metadata":null}) + ); + assert!(path(&fixture.0, 0).exists()); + drop(listener); +} impl Drop for Fixture { fn drop(&mut self) { fs::remove_dir_all(&self.0).unwrap(); diff --git a/packages/runtime-core/src/provider/dependency_socket/recovery.rs b/packages/runtime-core/src/provider/dependency_socket/recovery.rs index 3b0ff3df8..9844aed66 100644 --- a/packages/runtime-core/src/provider/dependency_socket/recovery.rs +++ b/packages/runtime-core/src/provider/dependency_socket/recovery.rs @@ -116,12 +116,68 @@ pub(super) fn path(directory: &Path, slot: u8) -> PathBuf { directory.join(format!("dependency-{slot:02}.sock")) } +// Test-only first-refusal facts survive fixture Drop in the captured test log. +// No paths, request data or production diagnostic behavior are added. +#[cfg(test)] +pub(super) mod observation_diagnostic { + use super::*; + use std::cell::RefCell; + + thread_local! { + static FIRST: RefCell> = const { RefCell::new(None) }; + } + + pub(in crate::provider::dependency_socket) fn clear() { + FIRST.with(|first| *first.borrow_mut() = None); + } + pub(in crate::provider::dependency_socket) fn take() -> Option { + FIRST.with(|first| first.borrow_mut().take()) + } + #[derive(Serialize)] + #[serde(rename_all = "kebab-case")] + pub(in crate::provider::dependency_socket) enum Stage { + MetadataRead, + SocketMetadata, + SocketConnect, + MetadataRecheckRead, + MetadataRecheck, + } + + pub(in crate::provider::dependency_socket) fn record( + stage: Stage, + metadata: Option<&fs::Metadata>, + errno: Option, + ) { + FIRST.with(|first| { + let mut first = first.borrow_mut(); + if first.is_none() { + *first = Some(json!({"stage":stage,"errno":errno, + "metadata":metadata.map(|metadata| json!({ + "type":if metadata.file_type().is_socket(){"socket"} + else if metadata.file_type().is_symlink(){"symlink"} + else if metadata.is_file(){"file"} + else if metadata.is_dir(){"directory"}else{"other"}, + "mode":metadata.mode(),"uid":metadata.uid(), + "nlink":metadata.nlink(),"inode":metadata.ino()}))})); + } + }); + } +} + pub(super) fn observed(directory: &Path, slot: u8) -> Result, CandidateError> { let target = path(directory, slot); let metadata = match fs::symlink_metadata(&target) { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(_) => return Err(refused()), + Err(_error) => { + #[cfg(test)] + observation_diagnostic::record( + observation_diagnostic::Stage::MetadataRead, + None, + _error.raw_os_error(), + ); + return Err(refused()); + } }; if !metadata.file_type().is_socket() || metadata.uid() != unsafe { libc::geteuid() } @@ -129,19 +185,47 @@ pub(super) fn observed(directory: &Path, slot: u8) -> Result, Can || metadata.nlink() != 1 || metadata.ino() == 0 { + #[cfg(test)] + observation_diagnostic::record( + observation_diagnostic::Stage::SocketMetadata, + Some(&metadata), + None, + ); return Err(refused()); } match UnixStream::connect(&target) { Err(error) if error.raw_os_error() == Some(libc::ECONNREFUSED) => {} - _ => return Err(refused()), + _result => { + #[cfg(test)] + observation_diagnostic::record( + observation_diagnostic::Stage::SocketConnect, + Some(&metadata), + _result.err().and_then(|error| error.raw_os_error()), + ); + return Err(refused()); + } } - let again = fs::symlink_metadata(&target).map_err(|_| refused())?; + let again = fs::symlink_metadata(&target).map_err(|_error| { + #[cfg(test)] + observation_diagnostic::record( + observation_diagnostic::Stage::MetadataRecheckRead, + Some(&metadata), + _error.raw_os_error(), + ); + refused() + })?; if again.dev() != metadata.dev() || again.ino() != metadata.ino() || again.mode() != metadata.mode() || again.uid() != metadata.uid() || !again.file_type().is_socket() { + #[cfg(test)] + observation_diagnostic::record( + observation_diagnostic::Stage::MetadataRecheck, + Some(&again), + None, + ); return Err(refused()); } Ok(Some(Socket { 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 8376a8034..a9d564155 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 @@ -517,6 +517,41 @@ fn partial_failed_missing_id_or_pending_state_never_creates_an_intent() { } } +#[test] +fn valid_source_bearing_ready_receipt_refuses_recovery_without_provider_or_intent() { + let fixture = Fixture::new(); + fixture.dead(); + let project = fixture.root.join("project"); + // An admitted project share requires a real project marker. The selector + // fixture otherwise contains only its authored input and is not shareable. + fs::write(project.join("package.json"), b"{}\n").unwrap(); + let metadata = fs::symlink_metadata(&project).unwrap(); + let share = crate::provider::ProjectShareIntent::approve(&project, true).unwrap(); + let mut encoded = serde_json::to_value(fixture.receipt()).unwrap(); + encoded["version"] = json!(3); + encoded["source"] = json!({ + "version":1, "policy":"host-mounted", "share":share, + "mounts":{"web":{"source":".","target":"/app"}}, + "anchors":{".":{"device":metadata.dev(),"inode":metadata.ino(), + "mode":metadata.mode(),"uid":metadata.uid(),"gid":metadata.gid(),"kind":"directory"}} + }); + let receipt: Receipt = serde_json::from_value(encoded).unwrap(); + // Establish a valid, complete v3 receipt before testing the specific + // recovery-version boundary; malformed source is not this oracle. + receipt.validate(RUN, OWNER).unwrap(); + assert_eq!(receipt.phase, Phase::ReadyObserved); + assert!( + receipt + .resources + .values() + .all(|resource| resource.id.is_some()) + ); + assert!(receipt.require_recovery_ready().is_err()); + state::write(&fixture.journal_root().join("state.json"), &receipt).unwrap(); + fixture.assert_refused_unchanged(); + assert!(!fixture.journal_root().join(FILE).exists()); +} + #[test] fn replaced_selected_owner_or_published_socket_and_lock_refuse_without_repair() { for name in ["owner.json", "control.sock", "operation.lock"] { 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 6f4222abd..11e60d3be 100644 --- a/packages/runtime-core/src/provider/graph/native/foreground/tests.rs +++ b/packages/runtime-core/src/provider/graph/native/foreground/tests.rs @@ -206,7 +206,6 @@ fn native_requests_and_replies_refuse_wrong_kind_version_scope_and_unknown_field ("/version", json!(1)), ("/review", json!("e".repeat(64))), ("/result/snapshot/observations", json!({})), - ("/result/snapshot/receipt/version", json!(1)), ] { let mut bad = encoded.clone(); *bad.pointer_mut(pointer).unwrap() = value; @@ -217,6 +216,9 @@ fn native_requests_and_replies_refuse_wrong_kind_version_scope_and_unknown_field .is_err() ); } + let mut bad = encoded.clone(); + bad["result"]["snapshot"]["receipt"]["version"] = json!(1); + assert!(serde_json::from_value::(bad).is_err()); let mut cleaned = Reply { version: 2, kind: ReplyKind::NativeGraphControlReply, diff --git a/packages/runtime-core/src/provider/graph/native/journal.rs b/packages/runtime-core/src/provider/graph/native/journal.rs index 28bb0807a..11d92de73 100644 --- a/packages/runtime-core/src/provider/graph/native/journal.rs +++ b/packages/runtime-core/src/provider/graph/native/journal.rs @@ -51,8 +51,9 @@ fn decode_failure_observation<'de, D: serde::Deserializer<'de>>( WireObservation::deserialize(reader).map(Into::into) } -/// Hash-only native provenance plus value-free resource ownership. No replay authority, -/// compiler request, argv, environment values or renewable timestamp is persisted. +/// Hash-only compiler provenance plus public source/resource ownership metadata. +/// No replay authority, source contents, compiler request, argv, environment values +/// or renewable timestamp is persisted. #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(try_from = "ReceiptWire")] pub struct Receipt { @@ -64,6 +65,8 @@ pub struct Receipt { pub(super) phase: Phase, pub(super) readiness: BTreeMap, pub(super) resources: BTreeMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) source: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub(super) data: BTreeMap, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] @@ -90,6 +93,8 @@ struct ReceiptWire { readiness: BTreeMap, resources: BTreeMap, #[serde(default, deserialize_with = "present_map")] + source: Option, + #[serde(default, deserialize_with = "present_map")] data: Option>, #[serde(default, deserialize_with = "present_map")] data_mounts: Option>>, @@ -108,11 +113,16 @@ fn present_map<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>( impl TryFrom for Receipt { type Error = &'static str; fn try_from(wire: ReceiptWire) -> Result { - if (wire.version == 2 - && (wire.data.is_some() || wire.data_mounts.is_some() || wire.data_tool.is_some())) - || (wire.version == 4 && (wire.data.is_none() || wire.data_mounts.is_none())) - { - return Err("Native receipt storage fields do not match its wire version."); + let storage_fields = + wire.data.is_some() || wire.data_mounts.is_some() || wire.data_tool.is_some(); + let valid = match wire.version { + 2 => wire.source.is_none() && !storage_fields, + 3 => wire.source.is_some() && !storage_fields, + 4 => wire.source.is_none() && wire.data.is_some() && wire.data_mounts.is_some(), + _ => false, + }; + if !valid { + return Err("Native receipt source/storage fields do not match its wire version."); } Ok(Self { version: wire.version, @@ -123,6 +133,7 @@ impl TryFrom for Receipt { phase: wire.phase, readiness: wire.readiness, resources: wire.resources, + source: wire.source, data: wire.data.unwrap_or_default(), data_mounts: wire.data_mounts.unwrap_or_default(), data_tool: wire.data_tool, @@ -136,6 +147,7 @@ impl Receipt { pub(super) fn check_binding(&self, expected: &Self) -> Result<(), CandidateError> { self.validate(expected.review.scope().run, &expected.owner)?; if self.review != expected.review + || self.source != expected.source || self.boot != expected.boot || self.readiness != expected.readiness || self.data != expected.data @@ -179,6 +191,8 @@ impl Receipt { #[cfg(target_os = "macos")] pub(super) fn require_recovery_ready(&self) -> Result<(), CandidateError> { self.validate(self.review.scope().run, &self.owner)?; + // Image-only v2 is the qualified dead-owner recovery contract. Source + // v3 parsing and ordinary cleanup grant no publisher-recovery authority. if self.version != 2 || self.phase != Phase::ReadyObserved || self.failure.is_some() @@ -196,8 +210,17 @@ impl Receipt { owner: &str, boot: &str, ) -> Result { + if config.source.is_some() && !config.storage.is_empty() { + return Err(refused()); + } let receipt = Self { - version: if config.storage.is_empty() { 2 } else { 4 }, + version: if config.source.is_some() { + 3 + } else if config.storage.is_empty() { + 2 + } else { + 4 + }, kind: InputKind::NativeGraphRuntime, owner: owner.into(), boot: boot.into(), @@ -210,6 +233,7 @@ impl Receipt { .map(|(name, service)| (name.clone(), service.ready)) .collect(), resources: config.resources.clone(), + source: config.source.clone(), data: config.data.clone(), data_mounts: config.data_mounts.clone(), data_tool: None, @@ -228,7 +252,8 @@ impl Receipt { pub(super) fn validate(&self, run: &str, owner: &str) -> Result<(), CandidateError> { let scope = self.review.scope(); self.review.validate(scope).map_err(|_| refused())?; - if ![2, 4].contains(&self.version) + if ![2, 3, 4].contains(&self.version) + || (self.version == 3) != self.source.is_some() || !hex(run, 32) || run != scope.run || !hex(owner, 32) @@ -243,6 +268,9 @@ impl Receipt { { return Err(refused()); } + if let Some(source) = &self.source { + source.validate(&self.readiness).map_err(|_| refused())?; + } self.validate_data()?; let network = self.resources.get("network:default").ok_or_else(refused)?; if network.kind != Kind::Network @@ -358,7 +386,7 @@ impl Receipt { } fn validate_data(&self) -> Result<(), CandidateError> { - if self.version == 2 { + if self.version != 4 { return if self.data.is_empty() && self.data_mounts.is_empty() && self.data_tool.is_none() diff --git a/packages/runtime-core/src/provider/graph/native/mod.rs b/packages/runtime-core/src/provider/graph/native/mod.rs index fe096ce7e..042d42533 100644 --- a/packages/runtime-core/src/provider/graph/native/mod.rs +++ b/packages/runtime-core/src/provider/graph/native/mod.rs @@ -1,4 +1,4 @@ -//! Native image/process and persistent-mount lowering; effects are separately admitted. +//! Native authored lowering; runtime ownership and effects are separately admitted. use super::*; use crate::{project::native::NativeInputs, provider::native_input}; #[cfg(target_os = "macos")] @@ -7,6 +7,7 @@ mod journal; pub mod persistent_data; mod runtime; pub mod selection; +mod source; pub use journal::{Phase, Receipt}; pub(super) use runtime::reservations; pub use runtime::{Snapshot, cleanup, inspect, run}; @@ -46,6 +47,7 @@ pub struct Configuration { review: native_input::Review, configs: BTreeMap, resources: BTreeMap, + source: Option, storage: BTreeSet, data_mounts: BTreeMap>, data: BTreeMap, @@ -68,7 +70,7 @@ impl Configuration { fn refused() -> CandidateError { error( "native_graph_admission", - "Native consumption requires its exact compiler review, immutable images, bounded process/readiness and separately enrolled persistent storage; values omitted.", + "Native consumption requires its exact compiler review, immutable images, bounded process/readiness and a separately admitted live-source or persistent-storage contract; values omitted.", ) } @@ -88,6 +90,13 @@ fn labels(owner: &str, review: &native_input::Review, resource: &Resource) -> Va pub fn configuration( prepared: &native_input::Prepared, owner: &str, +) -> Result { + configuration_with_source(prepared, owner, None) +} +fn configuration_with_source( + prepared: &native_input::Prepared, + owner: &str, + source: Option, ) -> Result { prepared.remaining()?; let inputs: &NativeInputs = prepared.inputs(); @@ -102,6 +111,31 @@ pub fn configuration( { return Err(refused()); } + let mounts: BTreeMap<_, _> = inputs + .workloads + .iter() + .filter_map(|(name, workload)| { + workload + .source_mount + .as_ref() + .map(|mount| (name.clone(), mount.clone())) + }) + .collect(); + if source.is_some() && !inputs.storage.is_empty() { + return Err(refused()); + } + match (&source, mounts.is_empty()) { + (None, true) => {} + (Some(source), false) if source.mounts == mounts => source.validate( + &inputs + .graph + .services + .iter() + .map(|(name, service)| (name.clone(), service.ready)) + .collect(), + )?, + _ => return Err(refused()), + } let mut configs = BTreeMap::new(); let mut resources = BTreeMap::new(); // Native workload networking is an implicit project contract: ordinary outbound @@ -139,6 +173,10 @@ pub fn configuration( phase: "reserved".into(), }; let mut config = config::container_base(&workload.image, labels(owner, review, &resource)); + if workload.source_mount.is_some() { + config["HostConfig"]["Mounts"] = + json!([source.as_ref().ok_or_else(refused)?.config(name)?]); + } let (primary, endpoints) = config::network_config( name, resource.networks.as_ref().ok_or_else(refused)?, @@ -197,6 +235,7 @@ pub fn configuration( review: review.clone(), configs, resources, + source, storage: inputs.storage.clone(), data_mounts: inputs .workloads diff --git a/packages/runtime-core/src/provider/graph/native/runtime.rs b/packages/runtime-core/src/provider/graph/native/runtime.rs index 44f010cb1..37f58c953 100644 --- a/packages/runtime-core/src/provider/graph/native/runtime.rs +++ b/packages/runtime-core/src/provider/graph/native/runtime.rs @@ -4,6 +4,12 @@ use crate::provider::{environment::PendingEnvironment, native_environment}; use std::{cell::Cell, path::Path, time::Instant}; trait Backend { + fn verify_source(&self, receipt: &Receipt, _active: bool) -> Result<(), CandidateError> { + if receipt.source.is_some() { + return Err(refused()); + } + Ok(()) + } fn request( &self, method: Method, @@ -48,6 +54,12 @@ struct GuardedBackend<'a, B> { guard: Option<&'a dyn Fn() -> Result<(), CandidateError>>, } impl Backend for GuardedBackend<'_, B> { + fn verify_source(&self, receipt: &Receipt, active: bool) -> Result<(), CandidateError> { + check_startup(self.guard)?; + let result = self.backend.verify_source(receipt, active); + check_startup(self.guard)?; + result + } fn verify_data( &self, receipt: &Receipt, @@ -108,6 +120,12 @@ struct OwnedBackend<'a> { leases: BTreeMap, } impl Backend for OwnedBackend<'_> { + fn verify_source(&self, receipt: &Receipt, active: bool) -> Result<(), CandidateError> { + if let Some(binding) = &receipt.source { + source::verify(&self.engine, binding, active)?; + } + Ok(()) + } fn verify_data( &self, receipt: &Receipt, @@ -229,6 +247,11 @@ fn ownership(receipt: &Receipt, resource: &Resource, value: &Value) -> Result<() { return Err(refused()); } + if resource.kind == Kind::Container + && let Some(binding) = &receipt.source + { + binding.verify_container(&resource.key, value)?; + } Ok(()) } @@ -481,13 +504,20 @@ impl Session<'_, B> { } impl Driver for Session<'_, B> { fn check_cancelled(&self) -> Result<(), CandidateError> { + check_startup(self.startup_guard)?; + self.selected.assert_fresh(self.candidate)?; + self.backend.verify_source(&self.receipt, true)?; + // Guest source checks can wait. They cannot renew the original authored + // selection/cancellation/deadline at the create or ready boundary. check_startup(self.startup_guard)?; self.selected.assert_fresh(self.candidate)?; self.backend .verify_data(&self.receipt, self.selected.remaining()?, &|| { check_startup(self.startup_guard)?; self.selected.assert_fresh(self.candidate) - }) + })?; + check_startup(self.startup_guard)?; + self.selected.assert_fresh(self.candidate) } fn record(&mut self, event: Event<'_>) -> Result<(), CandidateError> { match event { @@ -557,6 +587,7 @@ impl Driver for Session<'_, B> { .to_owned(); self.receipt.resources.get_mut(&key).ok_or_else(refused)?.id = Some(id.clone()); self.reserve(service, "created")?; + self.check_cancelled()?; let value = inspected(&self.backend, &self.receipt, &self.receipt.resources[&key])? .ok_or_else(refused)?; verify_config( @@ -572,6 +603,7 @@ impl Driver for Session<'_, B> { project_network(&self.backend, &self.receipt, None)?; self.backend .request(Method::POST, &format!("/v1.53/containers/{id}/start"), None)?; + self.check_cancelled()?; Ok(()) } fn observe(&mut self, service: &str) -> Result { @@ -587,6 +619,7 @@ impl Driver for Session<'_, B> { &value, )?; project_network(&self.backend, &self.receipt, Some(&value))?; + self.check_cancelled()?; observation(&value) } } @@ -644,7 +677,14 @@ pub(super) fn run_guarded( } check_startup(startup_guard)?; selected.assert_fresh(candidate)?; - let mut config = configuration(&input, engine.guest().incarnation())?; + let source = selected + .project_source + .as_ref() + .map(|source| source::prepare(&engine, source)) + .transpose()?; + check_startup(startup_guard)?; + selected.assert_fresh(candidate)?; + let mut config = configuration_with_source(&input, engine.guest().incarnation(), source)?; config.data = persistent_data::engine::select( candidate, &engine, @@ -797,6 +837,7 @@ pub struct Snapshot { pub observations: BTreeMap>, } fn snapshot(backend: &B, receipt: Receipt) -> Result { + backend.verify_source(&receipt, receipt.phase != Phase::Removed)?; let data_deadline = Instant::now() + Duration::from_secs(40); backend.verify_data(&receipt, data_deadline, &|| Ok(()))?; let mut observations = BTreeMap::new(); @@ -822,6 +863,7 @@ fn snapshot(backend: &B, receipt: Receipt) -> Result( check_startup(guard)?; let guarded = GuardedBackend { backend, guard }; let backend = &guarded; + backend.verify_source(receipt, false)?; backend.verify_data(receipt, Instant::now() + Duration::from_secs(40), &|| { check_startup(guard) })?; @@ -1008,6 +1051,7 @@ fn cleanup_using_guarded( } } check_startup(guard)?; + backend.verify_source(receipt, false)?; if removed { return Ok(()); } @@ -1028,8 +1072,10 @@ fn cleanup_using_guarded( .map(|(key, prepared)| (prepared.id.as_str(), receipt.resources[key].key.as_str())) .collect::>(); if !stopped { + backend.verify_source(receipt, false)?; backend.stop(&stops, &admitted)?; } + backend.verify_source(receipt, false)?; for (key, prepared) in &prepared { let resource = &receipt.resources[key]; let value = inspected(backend, receipt, resource)?.ok_or_else(refused)?; @@ -1053,10 +1099,12 @@ fn cleanup_using_guarded( } for key in prepared.keys() { check_startup(guard)?; + backend.verify_source(receipt, false)?; let resource = &receipt.resources[key]; let value = inspected(backend, receipt, resource)?.ok_or_else(refused)?; super::super::shutdown::terminal(resource, &value, false)?; check_startup(guard)?; + backend.verify_source(receipt, false)?; backend.request( Method::DELETE, &format!( @@ -1093,6 +1141,7 @@ fn cleanup_using_guarded( .phase = "remove-intent".into(); journal::save(root, receipt)?; check_startup(guard)?; + backend.verify_source(receipt, false)?; backend.request( Method::DELETE, &format!( @@ -1112,6 +1161,7 @@ fn cleanup_using_guarded( .ok_or_else(refused)? .phase = "removed".into(); receipt.phase = Phase::Removed; + backend.verify_source(receipt, false)?; backend.verify_data(receipt, Instant::now() + Duration::from_secs(40), &|| { check_startup(guard) })?; diff --git a/packages/runtime-core/src/provider/graph/native/runtime/tests.rs b/packages/runtime-core/src/provider/graph/native/runtime/tests.rs index 6ee3a15f8..f07ef5206 100644 --- a/packages/runtime-core/src/provider/graph/native/runtime/tests.rs +++ b/packages/runtime-core/src/provider/graph/native/runtime/tests.rs @@ -64,7 +64,12 @@ impl Fixture { } fn session(&self, prepared: selection::Prepared) -> (execution::Graph, Session<'_, Fake>) { let (selected, input) = prepared.into_parts(&self.candidate).unwrap(); - let mut config = configuration(&input, OWNER).unwrap(); + let source = selected.project_source.as_ref().map(|source| { + source + .bind(&crate::provider::ProjectShareIntent::approve(&self.project, true).unwrap()) + .unwrap() + }); + let mut config = configuration_with_source(&input, OWNER, source.clone()).unwrap(); for value in config.configs.values_mut() { value["StopTimeout"] = json!(10); } @@ -84,6 +89,7 @@ impl Fixture { let backend = Fake { root: root.clone(), run: RUN.into(), + source, state: RefCell::new(FakeState::default()), }; ( @@ -372,10 +378,12 @@ struct FakeState { cancel_after_stop: Option>>, cancel_after_delete: Option>>, cancel_on_delete_inspection: Option>>, + replace_source_after_create: Option, } struct Fake { root: PathBuf, run: String, + source: Option, state: RefCell, } impl Fake { @@ -384,6 +392,17 @@ impl Fake { } } impl Backend for Fake { + fn verify_source(&self, receipt: &Receipt, active: bool) -> Result<(), CandidateError> { + // This fake verifies the admitted binding and real host selection. The + // real backend separately checks the provider lease and virtiofs mapping. + if receipt.source != self.source { + return Err(refused()); + } + if active && let Some(source) = &receipt.source { + source.verify_host()?; + } + Ok(()) + } fn request( &self, method: Method, @@ -491,8 +510,12 @@ impl Backend for Fake { assert_eq!(config["HostConfig"]["NetworkMode"], network.name); let aliases = &config["NetworkingConfig"]["EndpointsConfig"][&network.name]["Aliases"]; assert_eq!(aliases, &json!([resource.key])); - let value = json!({"Id":id,"Name":format!("/{name}"),"Image":config["Image"],"Config":process,"HostConfig":config["HostConfig"],"NetworkSettings":{"Networks":{network.name.clone():{"NetworkID":"","Aliases":aliases}}},"State":{"Running":false,"Status":"created","Pid":0,"ExitCode":0,"OOMKilled":false,"Dead":false,"Paused":false,"Restarting":false}}); + let value = json!({"Id":id,"Name":format!("/{name}"),"Image":config["Image"],"Config":process,"HostConfig":config["HostConfig"],"Mounts":[],"NetworkSettings":{"Networks":{network.name.clone():{"NetworkID":"","Aliases":aliases}}},"State":{"Running":false,"Status":"created","Pid":0,"ExitCode":0,"OOMKilled":false,"Dead":false,"Paused":false,"Restarting":false}}); state.containers.insert(name.into(), value); + if let Some(path) = &state.replace_source_after_create { + fs::rename(path, path.with_extension("original")).unwrap(); + fs::DirBuilder::new().mode(0o700).create(path).unwrap(); + } return Ok(json!({"Id":id})); } if let Some(id) = path @@ -512,6 +535,7 @@ impl Backend for Fake { return Err(error("fake_start_uncertain", "once")); } let value = state.containers.get_mut(&resource.name).unwrap(); + value["Mounts"] = json!(value["HostConfig"]["Mounts"].as_array().map(|mounts| mounts.iter().map(|mount| json!({"Type":mount["Type"],"Source":mount["Source"],"Destination":mount["Target"],"RW":!mount["ReadOnly"].as_bool().unwrap(),"Propagation":mount["BindOptions"]["Propagation"]})).collect::>()).unwrap_or_default()); value["State"] = if resource.key == "z.seed" { json!({"Running":false,"Status":"exited","Pid":0,"ExitCode":0,"OOMKilled":false,"Dead":false,"Paused":false,"Restarting":false}) } else { @@ -611,6 +635,180 @@ impl Backend for Fake { } } +fn source_fixture() -> Fixture { + let mut project = basic(); + project["services"]["web"]["mounts"] = + json!([{"source":"src","target":"/app","access":"read-only"}]); + let fixture = Fixture::new(project); + fs::write(fixture.project.join("package.json"), "{}").unwrap(); + fs::DirBuilder::new() + .mode(0o700) + .create(fixture.project.join("src")) + .unwrap(); + fs::write(fixture.project.join("src/main.js"), "initial live source").unwrap(); + fixture +} + +#[test] +fn source_and_storage_receipt_wire_families_refuse_cross_fields() { + let fixture = source_fixture(); + let (_, session) = fixture.session(fixture.prepared(json!({"web":{}}), &BTreeMap::new())); + let source = serde_json::to_value(&session.receipt).unwrap(); + serde_json::from_value::(source.clone()) + .unwrap() + .validate(RUN, OWNER) + .unwrap(); + for fields in [ + json!({"data":{}}), + json!({"data_mounts":{}}), + json!({"data_tool":null}), + ] { + let mut wire = source.clone(); + wire.as_object_mut() + .unwrap() + .extend(fields.as_object().unwrap().clone()); + assert!(serde_json::from_value::(wire).is_err()); + } + for version in [2, 4, 5] { + let mut wire = source.clone(); + wire["version"] = json!(version); + assert!(serde_json::from_value::(wire).is_err()); + } + let mut wire = source; + wire["version"] = json!(4); + wire["data"] = json!({}); + wire["data_mounts"] = json!({}); + assert!(serde_json::from_value::(wire).is_err()); +} + +#[test] +fn live_source_controller_preserves_edits_and_cleans_only_original_owned_resources_after_move() { + let fixture = source_fixture(); + let (graph, mut session) = + fixture.session(fixture.prepared(json!({"web":{}}), &BTreeMap::new())); + execution::run(&graph, &mut session, Duration::from_secs(5)).unwrap(); + assert_eq!( + serde_json::to_value(&session.receipt).unwrap()["version"], + 3 + ); + fs::write(fixture.project.join("src/new"), "atomic edit").unwrap(); + fs::rename( + fixture.project.join("src/new"), + fixture.project.join("src/main.js"), + ) + .unwrap(); + snapshot(&session.backend, session.receipt.clone()).unwrap(); + let moved = fixture.project.join("moved"); + fs::rename(fixture.project.join("src"), &moved).unwrap(); + fs::write( + fixture.project.join("foreign-canary"), + "preserve foreign data", + ) + .unwrap(); + assert!(snapshot(&session.backend, session.receipt.clone()).is_err()); + cleanup_using(&session.backend, &mut session.receipt, &session.root).unwrap(); + assert_eq!(session.receipt.phase, Phase::Removed); + assert!(session.backend.state.borrow().containers.is_empty()); + assert!(session.backend.state.borrow().networks.is_empty()); + assert_eq!( + fs::read_to_string(moved.join("main.js")).unwrap(), + "atomic edit" + ); + assert_eq!( + fs::read_to_string(fixture.project.join("foreign-canary")).unwrap(), + "preserve foreign data" + ); + snapshot(&session.backend, session.receipt.clone()).unwrap(); +} + +#[test] +fn selected_source_replacement_before_effect_or_after_create_refuses_without_start_or_adoption() { + for after_create in [false, true] { + let fixture = source_fixture(); + let (graph, mut session) = + fixture.session(fixture.prepared(json!({"web":{}}), &BTreeMap::new())); + let path = fixture.project.join("src"); + if after_create { + session + .backend + .state + .borrow_mut() + .replace_source_after_create = Some(path); + } else { + fs::rename(&path, fixture.project.join("original")).unwrap(); + fs::DirBuilder::new().mode(0o700).create(&path).unwrap(); + } + assert!(execution::run(&graph, &mut session, Duration::from_secs(5)).is_err()); + assert!( + !session + .backend + .state + .borrow() + .effects + .iter() + .any(|effect| effect.starts_with("start:")) + ); + if after_create { + assert_eq!( + session.backend.state.borrow().effects, + ["create:network", "create:web"] + ); + cleanup_using(&session.backend, &mut session.receipt, &session.root).unwrap(); + assert_eq!(session.receipt.phase, Phase::Removed); + } else { + assert!(session.backend.state.borrow().effects.is_empty()); + } + assert!(execution::run(&graph, &mut session, Duration::from_secs(5)).is_err()); + } +} + +#[test] +fn retained_source_bind_or_approved_share_drift_refuses_cleanup_before_mutation() { + for changed_share in [false, true] { + let fixture = source_fixture(); + let (graph, mut session) = + fixture.session(fixture.prepared(json!({"web":{}}), &BTreeMap::new())); + execution::run(&graph, &mut session, Duration::from_secs(5)).unwrap(); + if changed_share { + session.backend.source.as_mut().unwrap().share.inode += 1; + } else { + let mut state = session.backend.state.borrow_mut(); + state.containers.values_mut().next().unwrap()["Mounts"][0]["RW"] = json!(true); + } + let effects = session.backend.state.borrow().effects.clone(); + assert!(cleanup_using(&session.backend, &mut session.receipt, &session.root).is_err()); + assert_eq!(session.backend.state.borrow().effects, effects); + } +} + +#[test] +fn source_cleanup_does_not_recapture_a_moved_or_deleted_host_project() { + for deleted in [false, true] { + let fixture = source_fixture(); + let (graph, mut session) = + fixture.session(fixture.prepared(json!({"web":{}}), &BTreeMap::new())); + execution::run(&graph, &mut session, Duration::from_secs(5)).unwrap(); + let moved = fixture.root.join("moved-project"); + if deleted { + fs::remove_dir_all(&fixture.project).unwrap(); + } else { + fs::rename(&fixture.project, &moved).unwrap(); + } + assert!(snapshot(&session.backend, session.receipt.clone()).is_err()); + cleanup_using(&session.backend, &mut session.receipt, &session.root).unwrap(); + assert_eq!(session.receipt.phase, Phase::Removed); + assert!(session.backend.state.borrow().containers.is_empty()); + assert!(session.backend.state.borrow().networks.is_empty()); + assert!(!fixture.project.exists()); + if !deleted { + assert_eq!( + fs::read_to_string(moved.join("src/main.js")).unwrap(), + "initial live source" + ); + } + } +} + #[test] fn real_compiler_job_dependency_and_readiness_drive_durable_native_effects_and_cleanup() { let mut project = basic(); @@ -1443,3 +1641,804 @@ fn old_boot_removed_history_releases_capacity_only_after_strict_owner_and_remova state::write(&session.root.join("state.pending"), &valid).unwrap(); assert!(check().is_err()); } + +// This owned synthetic fixture never approves a share or initializes a provider +// pool. The caller must use the explicit development project-share setup first. +#[cfg(target_os = "macos")] +mod live_source { + use super::*; + + use std::{ + fs::{File, OpenOptions}, + io::{Read, Write}, + os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}, + }; + + fn identity(metadata: &fs::Metadata) -> Value { + // SAFETY: geteuid has no caller preconditions. + assert_eq!( + metadata.uid(), + unsafe { libc::geteuid() }, + "synthetic fixture owner differs" + ); + json!({"device":metadata.dev(),"inode":metadata.ino(),"mode":metadata.mode(), + "uid":metadata.uid(),"gid":metadata.gid(), + "kind":if metadata.is_dir(){"directory"}else{"file"}}) + } + fn directory(path: &Path, mode: u32) -> Value { + crate::reject_aliased_state(path).unwrap(); + let metadata = path.symlink_metadata().unwrap(); + assert!(metadata.is_dir(), "synthetic fixture directory differs"); + assert_eq!( + metadata.mode() & 0o7777, + mode, + "synthetic fixture directory mode differs" + ); + identity(&metadata) + } + fn file(path: &Path, mode: u32, write: bool) -> (File, Value) { + crate::reject_aliased_state(path.parent().unwrap()).unwrap(); + let opened = OpenOptions::new() + .read(true) + .write(write) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC) + .open(path) + .unwrap(); + let metadata = opened.metadata().unwrap(); + assert!( + metadata.is_file() && metadata.nlink() == 1 && metadata.len() <= 128 * 1024, + "synthetic fixture file differs" + ); + assert_eq!( + metadata.mode() & 0o7777, + mode, + "synthetic fixture file mode differs" + ); + let anchor = identity(&metadata); + assert_eq!( + identity(&path.symlink_metadata().unwrap()), + anchor, + "synthetic fixture file incarnation differs" + ); + (opened, anchor) + } + fn bytes(path: &Path, mode: u32, anchor: &Value) -> Vec { + let (opened, observed) = file(path, mode, false); + assert_eq!(&observed, anchor, "synthetic fixture file changed"); + let mut bytes = Vec::new(); + opened.take(128 * 1024 + 1).read_to_end(&mut bytes).unwrap(); + assert!(bytes.len() <= 128 * 1024, "synthetic fixture input grew"); + assert_eq!( + file(path, mode, false).1, + observed, + "synthetic fixture input replaced" + ); + bytes + } + fn entries(path: &Path, expected: &[&str]) { + let mut found: Vec<_> = fs::read_dir(path) + .unwrap() + .map(|entry| { + entry + .unwrap() + .file_name() + .into_string() + .expect("synthetic fixture name differs") + }) + .collect(); + found.sort(); + let mut expected: Vec<_> = expected.iter().map(|name| (*name).to_string()).collect(); + expected.sort(); + assert!( + found == expected, + "synthetic fixture contains unexpected entries" + ); + } + struct Scope { + root: PathBuf, + project: PathBuf, + roots: BTreeMap, + src: Value, + files: BTreeMap, + marker: Value, + manifest: Value, + started: Option, + } + impl Scope { + fn admit( + root: &Path, + candidate: &Candidate, + project: &Path, + image: &str, + run: &str, + ) -> Self { + let root = root.to_path_buf(); + assert_eq!(root.file_name().unwrap(), "live-fixture"); + assert_eq!(root.canonicalize().unwrap(), root); + assert_eq!(project, root.join("project")); + assert_eq!(candidate.checkout, root.join("native-home")); + let roots = [&root, project, &project.join(".hack"), &candidate.checkout] + .into_iter() + .map(|path| (path.to_owned(), directory(path, 0o700))) + .collect::>(); + let src = directory(&project.join("src"), 0o755); + entries(&root, &["fixture.json", "native-home", "project"]); + entries(project, &[".hack", "package.json", "src"]); + entries(&project.join(".hack"), &["hack.project.json"]); + entries( + &project.join("src"), + &["health.txt", "message.txt", "server.js"], + ); + + let files = [ + "package.json", + ".hack/hack.project.json", + "src/server.js", + "src/health.txt", + "src/message.txt", + ] + .into_iter() + .map(|name| { + let mode = if name.starts_with("src/") { + 0o644 + } else { + 0o600 + }; + (name.to_owned(), file(&project.join(name), mode, false).1) + }) + .collect(); + let marker_path = root.join("fixture.json"); + let marker = file(&marker_path, 0o600, false).1; + let manifest = json!({"version":1,"run":run,"image":image, + "fixture":roots[&root],"project":roots[project],"home":roots[&candidate.checkout], + "hack":roots[&project.join(".hack")],"src":src,"files":files}); + let stored: Value = + serde_json::from_slice(&bytes(&marker_path, 0o600, &marker)).unwrap(); + assert!(stored == manifest, "synthetic fixture manifest differs"); + let mut scope = Self { + root, + project: project.into(), + roots, + src, + files, + marker, + manifest, + started: None, + }; + scope.verify(); + // Exclusive, nonrenewable invocation marker before native graph effects. + let claimed = scope.root.join("started.json"); + let mut opened = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(&claimed) + .unwrap(); + assert!( + opened.metadata().unwrap().is_file() && opened.metadata().unwrap().nlink() == 1 + ); + assert_eq!(opened.metadata().unwrap().mode() & 0o7777, 0o600); + opened + .write_all( + serde_json::to_string(&json!({"version":1,"run":run})) + .unwrap() + .as_bytes(), + ) + .unwrap(); + opened.sync_all().unwrap(); + let started = identity(&opened.metadata().unwrap()); + assert_eq!(started, identity(&claimed.symlink_metadata().unwrap())); + scope.started = Some(started); + let root_fd = OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(&scope.root) + .unwrap(); + assert_eq!( + identity(&root_fd.metadata().unwrap()), + scope.roots[&scope.root] + ); + root_fd.sync_all().unwrap(); + scope.verify(); + scope + } + fn verify_roots(&self) { + for (path, anchor) in &self.roots { + assert_eq!( + &directory(path, 0o700), + anchor, + "synthetic fixture directory replaced" + ); + } + let current: Value = serde_json::from_slice(&bytes( + &self.root.join("fixture.json"), + 0o600, + &self.marker, + )) + .unwrap(); + assert!( + current == self.manifest, + "synthetic fixture manifest changed" + ); + if let Some(anchor) = &self.started { + let current: Value = + serde_json::from_slice(&bytes(&self.root.join("started.json"), 0o600, anchor)) + .unwrap(); + assert!( + current == json!({"version":1,"run":self.manifest["run"]}), + "synthetic invocation marker changed" + ); + } + } + fn verify(&self) { + self.verify_roots(); + assert_eq!( + directory(&self.project.join("src"), 0o755), + self.src, + "synthetic source directory replaced" + ); + for (name, anchor) in &self.files { + let mode = if name.starts_with("src/") { + 0o644 + } else { + 0o600 + }; + assert_eq!( + &file(&self.project.join(name), mode, false).1, + anchor, + "synthetic fixture file replaced" + ); + } + self.expect("package.json", b"{}\n"); + self.expect("src/server.js", APP.as_bytes()); + self.expect("src/health.txt", b"live-source-ready\n"); + let authored: Value = serde_json::from_slice(&bytes( + &self.project.join(".hack/hack.project.json"), + 0o600, + &self.files[".hack/hack.project.json"], + )) + .unwrap(); + assert!( + authored == document(self.manifest["image"].as_str().unwrap()), + "synthetic authored document changed" + ); + } + fn expect(&self, name: &str, expected: &[u8]) { + let mode = if name.starts_with("src/") { + 0o644 + } else { + 0o600 + }; + assert!( + bytes(&self.project.join(name), mode, &self.files[name]) == expected, + "synthetic fixture input differs" + ); + } + fn edit(&self, expected: &[u8]) { + self.verify(); + let path = self.project.join("src/message.txt"); + let (mut opened, anchor) = file(&path, 0o644, true); + assert_eq!(anchor, self.files["src/message.txt"]); + opened.set_len(0).unwrap(); + opened.write_all(expected).unwrap(); + opened.sync_all().unwrap(); + self.verify(); + self.expect("src/message.txt", expected); + } + fn replace(&mut self, expected: &[u8]) { + self.verify(); + let source = self.project.join("src/message-next.txt"); + let target = self.project.join("src/message.txt"); + let mut opened = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o644) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(&source) + .unwrap(); + assert!( + opened.metadata().unwrap().is_file() && opened.metadata().unwrap().nlink() == 1 + ); + // Exact new owned descriptor only; do not path-chmod an existing input. + opened + .set_permissions(fs::Permissions::from_mode(0o644)) + .unwrap(); + opened.write_all(expected).unwrap(); + opened.sync_all().unwrap(); + let anchor = identity(&opened.metadata().unwrap()); + assert_eq!(file(&source, 0o644, false).1, anchor); + self.verify(); + fs::rename(&source, &target).unwrap(); + self.files.insert("src/message.txt".into(), anchor); + self.verify(); + self.expect("src/message.txt", expected); + } + fn withdraw(&self) { + self.verify(); + entries( + &self.project.join("src"), + &["health.txt", "message.txt", "server.js"], + ); + assert!(!self.project.join("src-withdrawn").try_exists().unwrap()); + fs::rename(self.project.join("src"), self.project.join("src-withdrawn")).unwrap(); + self.verify_roots(); + assert_eq!( + directory(&self.project.join("src-withdrawn"), 0o755), + self.src + ); + entries( + &self.project.join("src-withdrawn"), + &["health.txt", "message.txt", "server.js"], + ); + } + fn verify_withdrawn(&self, expected: &[u8]) { + self.verify_roots(); + assert_eq!( + directory(&self.project.join("src-withdrawn"), 0o755), + self.src + ); + assert!(!self.project.join("src").try_exists().unwrap()); + entries( + &self.project.join("src-withdrawn"), + &["health.txt", "message.txt", "server.js"], + ); + assert!( + bytes( + &self.project.join("src-withdrawn/message.txt"), + 0o644, + &self.files["src/message.txt"] + ) == expected, + "withdrawn synthetic source differs" + ); + } + } + + const INITIAL: &[u8] = b"source-initial\n"; + const EDITED: &[u8] = b"source-edited\n"; + const REPLACED: &[u8] = b"source-atomic-replacement\n"; + const APP: &str = "const routes = new Map([[\"/health.txt\", \"/app/health.txt\"], [\"/message.txt\", \"/app/message.txt\"]]);\nBun.serve({hostname: \"0.0.0.0\", port: 8080, fetch(request) { const file = routes.get(new URL(request.url).pathname); return file ? new Response(Bun.file(file), {headers: {\"cache-control\": \"no-store\"}}) : new Response(\"missing\", {status: 404}); }});\n"; + + fn document(image: &str) -> Value { + json!({"schema_version":1,"name":"native-source-fixture", + "source":{"mode":"host-mounted","root":"."}, + "services":{"web":{"image":image,"entrypoint":{"exec":[]}, + "command":{"exec":["/usr/local/bin/bun","/app/server.js"]},"init":true, + "restart":{"kind":"no"},"shutdown":{"signal":"SIGTERM","grace":"5s"}, + "mounts":[{"source":"src","target":"/app","access":"read-only"}], + "readiness":{"kind":"exec","command":{"exec":["/usr/local/bin/bun","-e", + "if (await (await fetch(\"http://127.0.0.1:8080/health.txt\", {signal: AbortSignal.timeout(1000)})).text() !== \"live-source-ready\\n\") process.exit(1)"]}, + "interval":"200ms","timeout":"2s","retries":20}}}}) + } + + fn remaining(deadline: Instant) -> Duration { + deadline + .checked_duration_since(Instant::now()) + .filter(|remaining| !remaining.is_zero()) + .expect("owned live-source fixture deadline expired") + } + + // Use the existing engine and source owners under the same mutation lease. + // This is a native backend proof, not a new native frontend exec capability. + fn exec( + candidate: &Candidate, + ready: &Receipt, + argv: &[&str], + deadline: Instant, + ) -> (i32, Vec, Vec) { + remaining(deadline); + let engine = Engine::connect(candidate).unwrap(); + let (receipt, _) = journal::load( + candidate, + ready.review.scope().run, + engine.guest().incarnation(), + engine.guest().boot_id(), + ) + .unwrap(); + receipt.check_binding(ready).unwrap(); + let backend = OwnedBackend { + engine, + launcher: None, + leases: BTreeMap::new(), + }; + let before = snapshot(&backend, receipt.clone()).unwrap(); + assert_eq!(before.receipt.phase, Phase::ReadyObserved); + assert_eq!( + before.observations["web"], + Some(Observation::Running { + health: execution::Health::Healthy, + }) + ); + let workload = &receipt.resources["container:web"]; + let observed = inspected(&backend, &receipt, workload).unwrap().unwrap(); + assert_eq!(observed["HostConfig"]["PublishAllPorts"], false); + let bindings = &observed["HostConfig"]["PortBindings"]; + assert!( + bindings.is_null() + || bindings + .as_object() + .is_some_and(|bindings| bindings.is_empty()), + "fixture publishes host ports" + ); + let ports = &observed["NetworkSettings"]["Ports"]; + assert!( + ports.is_null() + || ports + .as_object() + .is_some_and(|ports| ports.values().all(|bindings| bindings.is_null() + || bindings + .as_array() + .is_some_and(|bindings| bindings.is_empty()))), + "fixture has dynamic host ports" + ); + let id = workload.id.as_deref().unwrap(); + let result = backend + .engine + .service_exec( + id, + &argv.iter().map(|arg| (*arg).into()).collect::>(), + None, + remaining(deadline).min(Duration::from_secs(12)), + ) + .unwrap(); + let after = snapshot(&backend, receipt).unwrap(); + after.receipt.check_binding(ready).unwrap(); + assert_eq!(before.observations, after.observations); + assert!(!result.truncated); + remaining(deadline); + (result.exit_code, result.stdout, result.stderr) + } + + fn http(candidate: &Candidate, ready: &Receipt, expected: &[u8], deadline: Instant) { + let result = exec( + candidate, + ready, + &[ + "/usr/local/bin/bun", + "-e", + "process.stdout.write(await (await fetch(\"http://127.0.0.1:8080/message.txt\", {signal: AbortSignal.timeout(2000)})).text())", + ], + deadline, + ); + assert_eq!(result.0, 0); + assert!(result.2.is_empty()); + assert!(result.1 == expected, "synthetic HTTP data differs"); + } + + fn fixture() -> ( + crate::provider::graph::tests::Fixture, + PathBuf, + Candidate, + String, + ) { + let owner = crate::provider::graph::tests::Fixture::new(); + let root = owner.0.join("live-fixture"); + let project = root.join("project"); + let home = root.join("native-home"); + for path in [ + &root, + &project, + &project.join(".hack"), + &home, + &project.join("src"), + ] { + fs::DirBuilder::new().mode(0o700).create(path).unwrap(); + } + let src = OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(project.join("src")) + .unwrap(); + src.set_permissions(fs::Permissions::from_mode(0o755)) + .unwrap(); + let image = image(); + for (name, data, mode) in [ + ("package.json", b"{}\n".to_vec(), 0o600), + ( + ".hack/hack.project.json", + serde_json::to_vec(&document(&image)).unwrap(), + 0o600, + ), + ("src/server.js", APP.as_bytes().to_vec(), 0o644), + ("src/health.txt", b"live-source-ready\n".to_vec(), 0o644), + ("src/message.txt", INITIAL.to_vec(), 0o644), + ] { + let mut opened = OpenOptions::new() + .write(true) + .create_new(true) + .mode(mode) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(project.join(name)) + .unwrap(); + opened + .set_permissions(fs::Permissions::from_mode(mode)) + .unwrap(); + opened.write_all(&data).unwrap(); + } + let files = [ + "package.json", + ".hack/hack.project.json", + "src/server.js", + "src/health.txt", + "src/message.txt", + ] + .into_iter() + .map(|name| { + let mode = if name.starts_with("src/") { + 0o644 + } else { + 0o600 + }; + (name.to_owned(), file(&project.join(name), mode, false).1) + }) + .collect::>(); + let manifest = json!({"version":1,"run":RUN,"image":image, + "fixture":directory(&root,0o700),"project":directory(&project,0o700), + "home":directory(&home,0o700),"hack":directory(&project.join(".hack"),0o700), + "src":directory(&project.join("src"),0o755),"files":files}); + let mut marker = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(root.join("fixture.json")) + .unwrap(); + marker + .write_all(&serde_json::to_vec(&manifest).unwrap()) + .unwrap(); + ( + owner, + root, + Candidate::discover_installed(&home).unwrap(), + image, + ) + } + + #[test] + fn fixture_edits_preserve_descriptor_ownership_and_original_source_directory() { + let (_owner, root, candidate, image) = fixture(); + let mut scope = Scope::admit(&root, &candidate, &root.join("project"), &image, RUN); + scope.expect("src/message.txt", INITIAL); + scope.edit(EDITED); + scope.replace(REPLACED); + scope.withdraw(); + scope.verify_withdrawn(REPLACED); + assert!(root.join("started.json").is_file()); + } + #[test] + fn fixture_scope_refuses_source_aliases_without_changing_foreign_data() { + for hardlink in [false, true] { + let (_owner, root, candidate, image) = fixture(); + let foreign = root.parent().unwrap().join("foreign-message"); + let mut opened = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o644) + .custom_flags(libc::O_NOFOLLOW) + .open(&foreign) + .unwrap(); + opened + .set_permissions(fs::Permissions::from_mode(0o644)) + .unwrap(); + opened.write_all(INITIAL).unwrap(); + let original = identity(&opened.metadata().unwrap()); + let message = root.join("project/src/message.txt"); + fs::remove_file(&message).unwrap(); + if hardlink { + fs::hard_link(&foreign, &message).unwrap(); + } else { + symlink(&foreign, &message).unwrap(); + } + assert!( + std::panic::catch_unwind(|| Scope::admit( + &root, + &candidate, + &root.join("project"), + &image, + RUN + )) + .is_err() + ); + assert!(!root.join("started.json").exists()); + assert!(fs::read(&foreign).unwrap() == INITIAL); + assert_eq!(identity(&foreign.symlink_metadata().unwrap()), original); + } + } + #[test] + fn fixture_scope_refuses_rebound_project_and_existing_invocation_before_edit() { + let (_owner, root, candidate, image) = fixture(); + let scope = Scope::admit(&root, &candidate, &root.join("project"), &image, RUN); + assert!( + std::panic::catch_unwind(|| Scope::admit( + &root, + &candidate, + &root.join("project"), + &image, + RUN + )) + .is_err() + ); + let old = root.join("project-original"); + fs::rename(root.join("project"), &old).unwrap(); + fs::DirBuilder::new() + .mode(0o700) + .create(root.join("project")) + .unwrap(); + fs::DirBuilder::new() + .mode(0o700) + .create(root.join("project/src")) + .unwrap(); + let replacement = root.join("project/src/message.txt"); + fs::write(&replacement, b"unrelated replacement").unwrap(); + assert!(std::panic::catch_unwind(|| scope.edit(EDITED)).is_err()); + assert!(fs::read(&replacement).unwrap() == b"unrelated replacement"); + assert!(fs::read(old.join("src/message.txt")).unwrap() == INITIAL); + } + #[test] + fn fixture_edits_refuse_replaced_inode_and_preexisting_atomic_target() { + for atomic in [false, true] { + let (_owner, root, candidate, image) = fixture(); + let mut scope = Scope::admit(&root, &candidate, &root.join("project"), &image, RUN); + let message = root.join("project/src/message.txt"); + let target = if atomic { + root.join("project/src/message-next.txt") + } else { + fs::rename(&message, root.join("project/src/message-original.txt")).unwrap(); + message.clone() + }; + let mut opened = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o644) + .custom_flags(libc::O_NOFOLLOW) + .open(&target) + .unwrap(); + opened + .set_permissions(fs::Permissions::from_mode(0o644)) + .unwrap(); + opened.write_all(b"unrelated replacement").unwrap(); + assert!( + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + if atomic { + scope.replace(REPLACED); + } else { + scope.edit(EDITED); + } + })) + .is_err() + ); + assert!(fs::read(&target).unwrap() == b"unrelated replacement"); + assert!( + fs::read(if atomic { + message + } else { + root.join("project/src/message-original.txt") + }) + .unwrap() + == INITIAL + ); + } + } + + #[test] + fn fixture_withdrawal_refuses_an_added_descendant_before_renaming_source() { + let (_owner, root, candidate, image) = fixture(); + let scope = Scope::admit(&root, &candidate, &root.join("project"), &image, RUN); + let added = root.join("project/src/foreign-descendant"); + fs::write(&added, b"foreign retained").unwrap(); + assert!(std::panic::catch_unwind(|| scope.withdraw()).is_err()); + assert!(root.join("project/src").is_dir()); + assert!(!root.join("project/src-withdrawn").exists()); + assert!(fs::read(&added).unwrap() == b"foreign retained"); + assert!(fs::read(root.join("project/src/message.txt")).unwrap() == INITIAL); + } + + #[test] + #[ignore = "Caller-prepared exact synthetic project share, isolated development pool, pinned Linux ARM64 Bun image and 300s external watchdog required"] + fn approved_live_source_preserves_host_edits_and_cleanup_after_selected_source_moves() { + let deadline = Instant::now() + Duration::from_secs(180); + let candidate = Candidate::discover_installed(Path::new( + &std::env::var("HACK_LOCAL_TEST_ROOT").unwrap(), + )) + .unwrap(); + let project = PathBuf::from(std::env::var("HACK_NATIVE_SOURCE_TEST_PROJECT").unwrap()); + let image = std::env::var("HACK_LOCAL_TEST_IMAGE").unwrap(); + let run_id = std::env::var("HACK_NATIVE_SOURCE_TEST_RUN").unwrap(); + assert!(image_id(&image)); + assert!(super::super::super::hex(&run_id, 32)); + let fixture = PathBuf::from(std::env::var("HACK_NATIVE_SOURCE_TEST_FIXTURE").unwrap()); + let mut scope = Scope::admit(&fixture, &candidate, &project, &image, &run_id); + assert!( + !candidate + .state_root + .join("run/native-graphs") + .join(&run_id) + .exists() + ); + scope.expect("package.json", b"{}\n"); + scope.expect("src/server.js", APP.as_bytes()); + scope.expect("src/health.txt", b"live-source-ready\n"); + scope.expect("src/message.txt", INITIAL); + assert!(!project.join("src-withdrawn").exists()); + assert!(!project.join("src/guest-write").exists()); + let authored: Value = serde_json::from_slice(&bytes( + &project.join(".hack/hack.project.json"), + 0o600, + &scope.files[".hack/hack.project.json"], + )) + .unwrap(); + assert!( + authored == document(&image), + "synthetic authored document differs" + ); + { + let engine = Engine::connect(&candidate).unwrap(); + assert_eq!( + engine.guest().profile(), + crate::provider::Profile::Development + ); + let share = engine.guest().project_share().unwrap(); + assert_eq!(share.project, project); + assert!(share.unfiltered_source); + share.validate().unwrap(); + } + let metadata: EnvMetadata = serde_json::from_value(json!({"metadata_version":1, + "overlay":null,"overlay_exists":false,"workloads":{"web":{}},"inactive_scopes":[]})) + .unwrap(); + scope.verify(); + let prepared = selection::select( + &candidate, + selection::Options { + project: &project, + branch: Some("live-source-fixture"), + run: &run_id, + profiles: &[], + explicit_overlay: None, + metadata, + deadline, + }, + ) + .unwrap() + .prepare(&candidate, &BTreeMap::new()) + .unwrap(); + scope.verify(); + let ready = run(&candidate, prepared).unwrap(); + assert_eq!(serde_json::to_value(&ready).unwrap()["version"], 3); + assert_eq!(ready.phase, Phase::ReadyObserved); + assert_eq!(ready.source.as_ref().unwrap().share.project, project); + http(&candidate, &ready, INITIAL, deadline); + scope.edit(EDITED); + http(&candidate, &ready, EDITED, deadline); + scope.replace(REPLACED); + http(&candidate, &ready, REPLACED, deadline); + let denied = exec( + &candidate, + &ready, + &[ + "/usr/local/bin/bun", + "-e", + "try { await Bun.write(\"/app/guest-write\", \"synthetic-denied\"); process.exit(71); } catch (error) { if (!error || error.code !== \"EROFS\") process.exit(72); } if (await Bun.file(\"/app/guest-write\").exists()) process.exit(73); process.stdout.write(\"read-only\")", + ], + deadline, + ); + assert_eq!(denied.0, 0); + assert_eq!(denied.1, b"read-only"); + assert!(!project.join("src/guest-write").exists()); + remaining(deadline); + scope.withdraw(); + assert_eq!( + inspect(&candidate, &run_id).unwrap_err().code, + "native_graph_source" + ); + let removed = cleanup(&candidate, &run_id).unwrap(); + assert_eq!(removed.phase, Phase::Removed); + removed.check_binding(&ready).unwrap(); + let final_state = inspect(&candidate, &run_id).unwrap(); + assert_eq!(final_state.receipt.phase, Phase::Removed); + assert!(final_state.observations.values().all(Option::is_none)); + scope.verify_withdrawn(REPLACED); + scope.expect("package.json", b"{}\n"); + assert!(!project.join("src").exists()); + remaining(deadline); + } +} diff --git a/packages/runtime-core/src/provider/graph/native/selection.rs b/packages/runtime-core/src/provider/graph/native/selection.rs index b394063f1..f016384b8 100644 --- a/packages/runtime-core/src/provider/graph/native/selection.rs +++ b/packages/runtime-core/src/provider/graph/native/selection.rs @@ -283,6 +283,7 @@ pub struct Options<'a> { /// Opaque, ephemeral authored selection. No Debug/Serialize or raw request getter. pub struct Selected { + pub(super) project_source: Option, source: Option<(PathBuf, Document)>, candidate_root: PathBuf, root: PathBuf, @@ -316,6 +317,9 @@ impl Selected { /// Read-only recheck; no atomic multi-file snapshot or editor exclusion is claimed. pub fn assert_fresh(&self, candidate: &Candidate) -> Result<(), CandidateError> { self.remaining()?; + if let Some(source) = &self.project_source { + source.verify()?; + } if let Some((path, snapshot)) = &self.source { if document(path, true)?.0.as_ref() != Some(snapshot) { return Err(refused()); @@ -458,6 +462,7 @@ pub fn select(candidate: &Candidate, options: Options<'_>) -> Result CandidateError { + error( + "native_graph_source", + "Native host-mounted source requires its unchanged selected path and permissions, an already-approved exact live project share and read-only guest bind; values omitted.", + ) +} +fn relative(value: &str) -> bool { + value == "." + || (!value.is_empty() + && value.len() <= 4096 + && !value.chars().any(char::is_control) + && Path::new(value) + .components() + .all(|c| matches!(c, Component::Normal(_))) + && Path::new(value) + .components() + .map(|p| p.as_os_str().to_string_lossy()) + .collect::>() + .join("/") + == value) +} +fn absolute(value: &str) -> bool { + value.len() <= 4096 + && value.starts_with('/') + && !value.chars().any(char::is_control) + && Path::new(value) + .components() + .all(|c| matches!(c, Component::RootDir | Component::Normal(_))) + && format!( + "/{}", + Path::new(value) + .components() + .filter_map(|c| match c { + Component::Normal(name) => name.to_str(), + _ => None, + }) + .collect::>() + .join("/") + ) == value +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct Anchor { + pub device: u64, + pub inode: u64, + pub mode: u32, + pub uid: u32, + pub gid: u32, + pub kind: String, +} +impl Anchor { + fn read(metadata: &Metadata) -> Result { + // SAFETY: geteuid has no caller preconditions. + if metadata.uid() != unsafe { libc::geteuid() } + || metadata.mode() & 0o022 != 0 + || !(metadata.is_dir() || metadata.is_file()) + || (metadata.is_file() && (metadata.nlink() != 1 || metadata.mode() & 0o400 == 0)) + || (metadata.is_dir() && metadata.mode() & 0o500 != 0o500) + { + return Err(refused()); + } + Ok(Self { + device: metadata.dev(), + inode: metadata.ino(), + mode: metadata.mode(), + uid: metadata.uid(), + gid: metadata.gid(), + kind: if metadata.is_dir() { + "directory" + } else { + "file" + } + .into(), + }) + } + fn valid(&self) -> bool { + self.inode > 0 + && self.inode <= 9_007_199_254_740_991 + && self.device <= 9_007_199_254_740_991 + && self.mode <= 0xffff + && self.mode & 0o022 == 0 + && match self.kind.as_str() { + "directory" => { + self.mode & MODE_TYPE == MODE_DIRECTORY && self.mode & 0o500 == 0o500 + } + "file" => self.mode & MODE_TYPE == MODE_FILE && self.mode & 0o400 != 0, + _ => false, + } + } +} + +/// Content, mtimes and directory entry counts are deliberately absent. A selected +/// regular file may be edited in place; a selected directory permits descendant edits. +pub(super) struct Selection { + root: PathBuf, + mounts: BTreeMap, + anchors: BTreeMap, +} +fn selected_paths( + mounts: &BTreeMap, +) -> Result, CandidateError> { + let mut paths = BTreeSet::from([".".into()]); + if mounts.is_empty() || mounts.len() > MAX_SERVICES { + return Err(refused()); + } + for mount in mounts.values() { + if !relative(&mount.source) || !absolute(&mount.target) { + return Err(refused()); + } + if mount.source != "." { + for path in Path::new(&mount.source) + .ancestors() + .filter(|p| !p.as_os_str().is_empty()) + { + paths.insert(path.to_str().ok_or_else(refused)?.into()); + } + } + } + Ok(paths) +} +fn capture( + root: &Path, + mounts: &BTreeMap, +) -> Result, CandidateError> { + crate::reject_aliased_state(root).map_err(|_| refused())?; + let root_file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC | libc::O_DIRECTORY) + .open(root) + .map_err(|_| refused())?; + let root_anchor = Anchor::read(&root_file.metadata().map_err(|_| refused())?)?; + let mut anchors = BTreeMap::from([(".".into(), root_anchor)]); + for source in selected_paths(mounts)?.iter().filter(|p| p.as_str() != ".") { + crate::project::resolve_source(root, root, source, false).map_err(|_| refused())?; + let mut parent = root_file.try_clone().map_err(|_| refused())?; + let components: Vec<_> = Path::new(source).components().collect(); + for (index, component) in components.iter().enumerate() { + let Component::Normal(name) = component else { + return Err(refused()); + }; + let name = CString::new(name.as_encoded_bytes()).map_err(|_| refused())?; + let flags = libc::O_RDONLY + | libc::O_NOFOLLOW + | libc::O_NONBLOCK + | libc::O_CLOEXEC + | if index + 1 < components.len() { + libc::O_DIRECTORY + } else { + 0 + }; + // SAFETY: parent is an owned open directory; the name is a NUL-terminated + // single component. The successful descriptor is transferred exactly once. + let fd = unsafe { libc::openat(parent.as_raw_fd(), name.as_ptr(), flags) }; + if fd < 0 { + return Err(refused()); + } + // SAFETY: openat returned a fresh descriptor owned by this scope. + parent = unsafe { File::from_raw_fd(fd) }; + } + let anchor = Anchor::read(&parent.metadata().map_err(|_| refused())?)?; + let named = Anchor::read(&fs::symlink_metadata(root.join(source)).map_err(|_| refused())?)?; + if anchor != named { + return Err(refused()); + } + anchors.insert(source.clone(), anchor); + } + for (path, expected) in &anchors { + let path = if path == "." { + root.into() + } else { + root.join(path) + }; + // The shared state helper admits directories only. A selected regular + // file has a separately verified no-follow descriptor and named inode; + // apply the helper to its parent chain rather than rejecting the file. + let directories = if expected.kind == "file" { + path.parent().ok_or_else(refused)? + } else { + &path + }; + crate::reject_aliased_state(directories).map_err(|_| refused())?; + if *expected != Anchor::read(&fs::symlink_metadata(path).map_err(|_| refused())?)? { + return Err(refused()); + } + } + Ok(anchors) +} +impl Selection { + pub(super) fn new(root: &Path, inputs: &NativeInputs) -> Result, CandidateError> { + let mounts: BTreeMap<_, _> = inputs + .workloads + .iter() + .filter_map(|(name, workload)| { + workload + .source_mount + .as_ref() + .map(|mount| (name.clone(), mount.clone())) + }) + .collect(); + if mounts.is_empty() { + return Ok(None); + } + Ok(Some(Self { + root: root.into(), + anchors: capture(root, &mounts)?, + mounts, + })) + } + pub(super) fn verify(&self) -> Result<(), CandidateError> { + if capture(&self.root, &self.mounts)? != self.anchors { + return Err(refused()); + } + Ok(()) + } + pub(super) fn bind(&self, share: &ProjectShareIntent) -> Result { + share.validate_receipt().map_err(|_| refused())?; + if share.project != self.root + || share.device != self.anchors["."].device + || share.inode != self.anchors["."].inode + { + return Err(refused()); + } + self.verify()?; + Ok(Binding { + version: 1, + policy: "host-mounted".into(), + share: share.clone(), + mounts: self.mounts.clone(), + anchors: self.anchors.clone(), + }) + } +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct Binding { + pub version: u32, + pub policy: String, + pub share: ProjectShareIntent, + pub mounts: BTreeMap, + pub anchors: BTreeMap, +} +impl Binding { + pub(super) fn validate( + &self, + services: &BTreeMap, + ) -> Result<(), CandidateError> { + self.share.validate_receipt().map_err(|_| refused())?; + let paths = selected_paths(&self.mounts)?; + let root = self.anchors.get(".").ok_or_else(refused)?; + if self.version != 1 + || self.policy != "host-mounted" + || self.mounts.keys().any(|name| !services.contains_key(name)) + || self.anchors.keys().ne(paths.iter()) + || self + .anchors + .values() + .any(|a| !a.valid() || a.uid != root.uid) + || root.kind != "directory" + || root.device != self.share.device + || root.inode != self.share.inode + || paths.iter().any(|path| { + path != "." + && self + .mounts + .values() + .any(|m| m.source.starts_with(&format!("{path}/"))) + && self.anchors[path].kind != "directory" + }) + { + return Err(refused()); + } + Ok(()) + } + pub(super) fn path(&self, service: &str) -> Result { + let mount = self.mounts.get(service).ok_or_else(refused)?; + Ok(if mount.source == "." { + self.share.guest_path.clone() + } else { + format!("{}/{}", self.share.guest_path, mount.source) + }) + } + pub(super) fn config(&self, service: &str) -> Result { + let mount = self.mounts.get(service).ok_or_else(refused)?; + Ok( + json!({"Type":"bind","Source":self.path(service)?,"Target":mount.target,"ReadOnly":true,"BindOptions":{"Propagation":"rprivate"}}), + ) + } + pub(super) fn verify_host(&self) -> Result<(), CandidateError> { + if capture(&self.share.project, &self.mounts)? != self.anchors { + return Err(refused()); + } + Ok(()) + } + pub(super) fn verify_container( + &self, + service: &str, + value: &Value, + ) -> Result<(), CandidateError> { + let Some(mount) = self.mounts.get(service) else { + return Ok(()); + }; + let configs = value["HostConfig"]["Mounts"] + .as_array() + .ok_or_else(refused)?; + let configured: Vec<_> = configs + .iter() + .filter(|m| m["Target"].as_str() == Some(&mount.target)) + .collect(); + if configured.len() != 1 + || super::super::mismatch(&self.config(service)?, configured[0], "Mounts").is_some() + { + return Err(refused()); + } + let mounts = value["Mounts"].as_array().ok_or_else(refused)?; + // Docker can retain only the exact configured bind before its first start. + // A nonempty runtime mount must always match; exited/running containers do + // not inherit this created-only allowance. + if mounts.is_empty() + && value["State"]["Status"] == "created" + && value["State"]["Running"] == false + { + return Ok(()); + } + let selected: Vec<_> = mounts + .iter() + .filter(|m| m["Destination"].as_str() == Some(&mount.target)) + .collect(); + if selected.len() != 1 + || selected[0]["Type"] != "bind" + || selected[0]["Source"] != self.path(service)? + || selected[0]["RW"] != false + || selected[0]["Propagation"] != "rprivate" + { + return Err(refused()); + } + Ok(()) + } +} + +pub(super) fn prepare( + engine: &Engine<'_>, + selected: &Selection, +) -> Result { + selected.verify()?; + let share = engine.guest().project_share().ok_or_else(refused)?; + share.validate().map_err(|_| refused())?; + let binding = selected.bind(share)?; + verify(engine, &binding, true)?; + Ok(binding) +} + +/// Cleanup verifies the original approved pool share and read-only container bind, +/// without following/adopting a moved or deleted host source. Startup/status also +/// require the selected live host path and guest path to remain safe. +pub(super) fn verify( + engine: &Engine<'_>, + binding: &Binding, + active: bool, +) -> Result<(), CandidateError> { + if engine.guest().project_share() != Some(&binding.share) { + return Err(refused()); + } + if active { + binding.verify_host()?; + } + if active { + super::super::source::verify_shared_mount(engine, &binding.share)?; + } else { + super::super::source::verify_shared_mount_cleanup(engine, &binding.share)?; + } + if active { + for name in binding.mounts.keys() { + let path = binding.path(name)?; + let kind = &binding.anchors[&binding.mounts[name].source].kind; + engine.guest().execute("set -eu; test \"$(realpath -e -- \"$1\")\" = \"$1\"; if test \"$2\" = directory; then test -d \"$1\"; else test \"$2\" = file; test -f \"$1\"; fi", &[&path, kind], None)?; + } + binding.verify_host()?; + } + engine.guest().verify() +} + +#[cfg(test)] +mod tests; diff --git a/packages/runtime-core/src/provider/graph/native/source/tests.rs b/packages/runtime-core/src/provider/graph/native/source/tests.rs new file mode 100644 index 000000000..80672e8d3 --- /dev/null +++ b/packages/runtime-core/src/provider/graph/native/source/tests.rs @@ -0,0 +1,264 @@ +use super::*; +use std::{ + os::unix::fs::{DirBuilderExt, PermissionsExt, symlink}, + sync::atomic::{AtomicU64, Ordering}, +}; + +struct Fixture(PathBuf); +impl Fixture { + fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let root = std::env::temp_dir().canonicalize().unwrap().join(format!( + "native-source-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::DirBuilder::new().mode(0o700).create(&root).unwrap(); + fs::write(root.join("package.json"), "{}").unwrap(); + fs::DirBuilder::new() + .mode(0o700) + .create(root.join("src")) + .unwrap(); + fs::write(root.join("src/main.js"), "original source canary").unwrap(); + Self(root) + } + fn inputs(&self, source: &str) -> NativeInputs { + let project = json!({"schema_version":1,"name":"fixture","services":{"web":{"image":format!("sha256:{}", "a".repeat(64)),"mounts":[{"source":source,"target":"/app","access":"read-only"}]}}}); + let request = serde_json::to_vec(&json!({"request_version":1,"project":project.to_string(),"env_metadata":{"metadata_version":1,"overlay":null,"overlay_exists":false,"workloads":{"web":{}},"inactive_scopes":[]}})).unwrap(); + crate::project::native::review_inputs(&request, &[]).unwrap() + } + fn selected(&self, source: &str) -> Selection { + Selection::new(&self.0, &self.inputs(source)) + .unwrap() + .unwrap() + } + fn binding(&self, source: &str) -> Binding { + let selected = self.selected(source); + selected + .bind(&ProjectShareIntent::approve(&self.0, true).unwrap()) + .unwrap() + } +} +impl Drop for Fixture { + fn drop(&mut self) { + fs::remove_dir_all(&self.0).unwrap(); + } +} +fn services() -> BTreeMap { + BTreeMap::from([("web".into(), Condition::Started)]) +} +fn prepared(source: Option<&str>) -> native_input::Prepared { + let mut project = json!({"schema_version":1,"name":"fixture","services":{"web":{"image":format!("sha256:{}", "a".repeat(64))}}}); + if let Some(source) = source { + project["services"]["web"]["mounts"] = + json!([{"source":source,"target":"/app","access":"read-only"}]); + } + let request = serde_json::to_vec(&json!({"request_version":1,"project":project.to_string(),"env_metadata":{"metadata_version":1,"overlay":null,"overlay_exists":false,"workloads":{"web":{}},"inactive_scopes":[]}})).unwrap(); + let namespace = "a".repeat(64); + let run = "b".repeat(32); + let scope = native_input::Scope { + namespace: &namespace, + run: &run, + }; + let review = native_input::review(&request, &[], scope).unwrap(); + native_input::prepare(native_input::PrepareOptions { + compile: crate::project::native::CompileOptions { + request: &request, + profiles: &[], + managed_values: &BTreeMap::new(), + }, + scope, + expected_review: &review, + deadline: std::time::Instant::now() + Duration::from_secs(60), + }) + .unwrap() +} + +#[test] +fn source_lowering_requires_exact_binding_and_versions_do_not_widen_image_only_receipts() { + let fixture = Fixture::new(); + let input = prepared(Some("src")); + assert!(configuration(&input, &"c".repeat(32)).is_err()); + let binding = fixture.binding("src"); + let config = configuration_with_source(&input, &"c".repeat(32), Some(binding.clone())).unwrap(); + assert_eq!( + config.containers()["web"]["HostConfig"]["Mounts"], + json!([binding.config("web").unwrap()]) + ); + let receipt = Receipt::preparing( + &config, + &"c".repeat(32), + "12345678-abcd-abcd-abcd-123456789abc", + ) + .unwrap(); + let encoded = serde_json::to_value(&receipt).unwrap(); + assert_eq!(encoded["version"], 3); + for (field, value) in [("version", json!(2)), ("source", Value::Null)] { + let mut bad = encoded.clone(); + bad[field] = value; + if let Ok(decoded) = serde_json::from_value::(bad) { + assert!(decoded.validate(&"b".repeat(32), &"c".repeat(32)).is_err()); + } + } + let old = configuration(&prepared(None), &"c".repeat(32)).unwrap(); + let old = Receipt::preparing( + &old, + &"c".repeat(32), + "12345678-abcd-abcd-abcd-123456789abc", + ) + .unwrap(); + let mut old_bytes = serde_json::to_value(&old).unwrap(); + assert_eq!(old_bytes["version"], 2); + assert!(old_bytes.get("source").is_none()); + old_bytes["source"] = Value::Null; + assert!(serde_json::from_value::(old_bytes).is_err()); + let mut replaced = receipt.clone(); + replaced + .source + .as_mut() + .unwrap() + .anchors + .get_mut("src") + .unwrap() + .inode += 1; + assert!(replaced.check_binding(&receipt).is_err()); +} + +#[test] +fn live_directory_content_and_descendant_edits_are_not_snapshot_drift() { + let fixture = Fixture::new(); + let selected = fixture.selected("src"); + fs::write(fixture.0.join("src/main.js"), "edited live bytes").unwrap(); + fs::write(fixture.0.join("src/replacement"), "atomic editor").unwrap(); + fs::rename( + fixture.0.join("src/replacement"), + fixture.0.join("src/main.js"), + ) + .unwrap(); + fs::create_dir(fixture.0.join("src/new")).unwrap(); + fs::write(fixture.0.join("src/new/file"), "new content").unwrap(); + selected.verify().unwrap(); + fs::remove_dir_all(fixture.0.join("src/new")).unwrap(); + selected.verify().unwrap(); +} + +#[test] +fn live_regular_file_allows_in_place_bytes_but_refuses_replaced_inode_or_hardlink() { + let fixture = Fixture::new(); + let selected = fixture.selected("src/main.js"); + fs::write(fixture.0.join("src/main.js"), "a different length").unwrap(); + selected.verify().unwrap(); + fs::hard_link(fixture.0.join("src/main.js"), fixture.0.join("other")).unwrap(); + assert_eq!(selected.verify().unwrap_err().code, "native_graph_source"); + fs::remove_file(fixture.0.join("other")).unwrap(); + fs::write(fixture.0.join("replacement"), "new selected inode").unwrap(); + fs::rename(fixture.0.join("replacement"), fixture.0.join("src/main.js")).unwrap(); + assert_eq!(selected.verify().unwrap_err().code, "native_graph_source"); +} + +#[test] +fn selected_directory_alias_incarnation_and_permission_changes_refuse() { + for case in 0..3 { + let fixture = Fixture::new(); + let selected = fixture.selected("src"); + match case { + 0 => { + fs::rename(fixture.0.join("src"), fixture.0.join("old")).unwrap(); + symlink("old", fixture.0.join("src")).unwrap(); + } + 1 => { + fs::rename(fixture.0.join("src"), fixture.0.join("old")).unwrap(); + fs::DirBuilder::new() + .mode(0o700) + .create(fixture.0.join("src")) + .unwrap(); + } + _ => fs::set_permissions(fixture.0.join("src"), fs::Permissions::from_mode(0o755)) + .unwrap(), + } + assert_eq!(selected.verify().unwrap_err().code, "native_graph_source"); + } +} + +#[test] +fn source_binding_is_closed_to_exact_paths_policy_and_owned_permissions() { + let fixture = Fixture::new(); + let good = fixture.binding("src/main.js"); + good.validate(&services()).unwrap(); + for case in 0..6 { + let mut bad = good.clone(); + match case { + 0 => bad.policy = "immutable".into(), + 1 => bad.version = 2, + 2 => bad.share.inode += 1, + 3 => { + bad.anchors.remove("src"); + } + 4 => bad.anchors.get_mut("src").unwrap().mode |= 0o002, + _ => { + bad.anchors.insert("extra".into(), bad.anchors["."].clone()); + } + } + assert!(bad.validate(&services()).is_err()); + } + let mut encoded = serde_json::to_value(&good).unwrap(); + encoded["mounts"]["web"]["access"] = json!("read-write"); + assert!(serde_json::from_value::(encoded).is_err()); +} + +#[test] +fn exact_guest_bind_is_read_only_rprivate_and_cannot_follow_changed_selection() { + let fixture = Fixture::new(); + let binding = fixture.binding("src"); + let config = binding.config("web").unwrap(); + let value = json!({"HostConfig":{"Mounts":[config]},"Mounts":[{"Type":"bind","Source":binding.path("web").unwrap(),"Destination":"/app","RW":false,"Propagation":"rprivate"}]}); + binding.verify_container("web", &value).unwrap(); + for (pointer, replacement) in [ + ("/Mounts/0/RW", json!(true)), + ("/Mounts/0/Source", json!("/other")), + ("/Mounts/0/Propagation", json!("rshared")), + ("/HostConfig/Mounts/0/ReadOnly", json!(false)), + ] { + let mut bad = value.clone(); + *bad.pointer_mut(pointer).unwrap() = replacement; + assert!(binding.verify_container("web", &bad).is_err()); + } + fs::rename(fixture.0.join("src"), fixture.0.join("moved")).unwrap(); + assert!(binding.verify_host().is_err()); + // Original stored identity and exact RO mount remain usable for cleanup; + // no new host path or source publication is adopted. + binding.validate(&services()).unwrap(); + binding.verify_container("web", &value).unwrap(); +} + +#[test] +fn created_container_requires_the_configured_bind_and_later_states_require_runtime_mount() { + let fixture = Fixture::new(); + let binding = fixture.binding("src"); + let value = json!({"State":{"Status":"created","Running":false},"Mounts":[],"HostConfig":{"Mounts":[binding.config("web").unwrap()]}}); + binding.verify_container("web", &value).unwrap(); + for (pointer, replacement) in [ + ("/State/Status", json!("exited")), + ("/State/Running", json!(true)), + ("/HostConfig/Mounts/0/ReadOnly", json!(false)), + ("/HostConfig/Mounts/0/Source", json!("/foreign")), + ( + "/Mounts", + json!([{"Destination":"/app","Type":"bind","Source":"/foreign","RW":false,"Propagation":"rprivate"}]), + ), + ] { + let mut bad = value.clone(); + *bad.pointer_mut(pointer).unwrap() = replacement; + assert!(binding.verify_container("web", &bad).is_err()); + } +} + +#[test] +fn selected_credential_path_and_unsafe_directory_are_not_shared_implicitly() { + let fixture = Fixture::new(); + fs::create_dir(fixture.0.join(".aws")).unwrap(); + fs::write(fixture.0.join(".aws/key"), "synthetic credential canary").unwrap(); + assert!(Selection::new(&fixture.0, &fixture.inputs(".aws/key")).is_err()); + fs::set_permissions(fixture.0.join("src"), fs::Permissions::from_mode(0o777)).unwrap(); + assert!(Selection::new(&fixture.0, &fixture.inputs("src")).is_err()); +} diff --git a/packages/runtime-core/src/provider/graph/source.rs b/packages/runtime-core/src/provider/graph/source.rs index 8c09c49b5..70f4de7ab 100644 --- a/packages/runtime-core/src/provider/graph/source.rs +++ b/packages/runtime-core/src/provider/graph/source.rs @@ -86,11 +86,28 @@ fn shared_paths( Ok(selected) } +const VERIFY_SHARED_MOUNT: &str = "set -eu; test \"$(findmnt -n -o FSTYPE --mountpoint \"$1\")\" = virtiofs; case \",$(findmnt -n -o OPTIONS --mountpoint \"$1\"),\" in *,rw,*) ;; *) exit 1;; esac"; + pub(super) fn verify_shared_mount( engine: &Engine<'_>, share: &super::super::ProjectShareIntent, ) -> Result<(), CandidateError> { - engine.guest().execute("set -eu; test \"$(findmnt -n -o FSTYPE --mountpoint \"$1\")\" = virtiofs; case \",$(findmnt -n -o OPTIONS --mountpoint \"$1\"),\" in *,rw,*) ;; *) exit 1;; esac", &[&share.guest_path], None)?; + engine + .guest() + .execute(VERIFY_SHARED_MOUNT, &[&share.guest_path], None)?; + Ok(()) +} + +/// The same mount identity proof under the existing cleanup lease, without +/// authorizing allocation or requiring the startup operating-resource budget. +#[cfg(feature = "native-config-plan")] +pub(super) fn verify_shared_mount_cleanup( + engine: &Engine<'_>, + share: &super::super::ProjectShareIntent, +) -> Result<(), CandidateError> { + engine + .guest() + .execute_cleanup(VERIFY_SHARED_MOUNT, &[&share.guest_path])?; Ok(()) } diff --git a/packages/runtime-core/src/provider/https_recovery/ports.rs b/packages/runtime-core/src/provider/https_recovery/ports.rs index 646e833f2..220ba4775 100644 --- a/packages/runtime-core/src/provider/https_recovery/ports.rs +++ b/packages/runtime-core/src/provider/https_recovery/ports.rs @@ -1,5 +1,6 @@ //! MacOS wildcard and loopback claims held across HTTPS recovery effects. use super::{CandidateError, refused}; + pub(in crate::provider) fn port_absent( port: u16, ) -> Result, CandidateError> { @@ -11,16 +12,28 @@ pub(in crate::provider) fn port_absent( } fn bind_pair(port: u16, wildcard: bool) -> Result<[std::os::fd::OwnedFd; 2], CandidateError> { use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; + macro_rules! failure { + ($stage:ident, $family:expr) => {{ + #[cfg(all(test, target_os = "macos"))] + observation_diagnostic::record( + observation_diagnostic::Stage::$stage, + wildcard, + $family, + std::io::Error::last_os_error().raw_os_error(), + ); + refused() + }}; + } let make = |family| { // SAFETY: socket takes scalar constants; a successful new descriptor is uniquely owned below. let fd = unsafe { libc::socket(family, libc::SOCK_STREAM, 0) }; if fd < 0 { - return Err(refused()); + return Err(failure!(Socket, family)); } let fd = unsafe { OwnedFd::from_raw_fd(fd) }; // SAFETY: fd is owned; fcntl uses only scalar arguments and does not retain pointers. if unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_SETFD, libc::FD_CLOEXEC) } < 0 { - return Err(refused()); + return Err(failure!(Cloexec, family)); } Ok(fd) }; @@ -38,10 +51,10 @@ fn bind_pair(port: u16, wildcard: bool) -> Result<[std::os::fd::OwnedFd; 2], Can ) } != 0 { - return Err(refused()); + return Err(failure!(Ipv6Only, libc::AF_INET6)); } if !wildcard { - for fd in [&v4, &v6] { + for (fd, _family) in [(&v4, libc::AF_INET), (&v6, libc::AF_INET6)] { // SAFETY: only is a live integer; this permits our specific loopback guard beside the wildcard guard. // SO_REUSEPORT is never enabled, so another listener cannot share this exact address. if unsafe { @@ -54,7 +67,7 @@ fn bind_pair(port: u16, wildcard: bool) -> Result<[std::os::fd::OwnedFd; 2], Can ) } != 0 { - return Err(refused()); + return Err(failure!(ReuseAddress, _family)); } } } @@ -81,22 +94,76 @@ fn bind_pair(port: u16, wildcard: bool) -> Result<[std::os::fd::OwnedFd; 2], Can std::mem::size_of_val(&address4) as libc::socklen_t, ) } != 0 - || unsafe { - libc::bind( - v6.as_raw_fd(), - (&address6 as *const libc::sockaddr_in6).cast(), - std::mem::size_of_val(&address6) as libc::socklen_t, - ) - } != 0 { - return Err(refused()); + return Err(failure!(Bind, libc::AF_INET)); + } + if unsafe { + libc::bind( + v6.as_raw_fd(), + (&address6 as *const libc::sockaddr_in6).cast(), + std::mem::size_of_val(&address6) as libc::socklen_t, + ) + } != 0 + { + return Err(failure!(Bind, libc::AF_INET6)); } // SAFETY: both descriptors are owned bound TCP sockets; listen retains the exact address claim. - for fd in [&v4, &v6] { + for (fd, _family) in [(&v4, libc::AF_INET), (&v6, libc::AF_INET6)] { if unsafe { libc::listen(fd.as_raw_fd(), 1) } != 0 { - return Err(refused()); + return Err(failure!(Listen, _family)); } } // Retain these listeners across archival; never accept a connection. Ok([v4, v6]) } + +// Test-only first-refusal facts distinguish bind contention from a failed socket +// prerequisite. No port, address, descriptor or production diagnostic is exposed. +#[cfg(all(test, target_os = "macos"))] +pub(super) mod observation_diagnostic { + use std::cell::RefCell; + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub(in crate::provider::https_recovery) enum Stage { + Socket, + Cloexec, + Ipv6Only, + ReuseAddress, + Bind, + Listen, + } + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub(in crate::provider::https_recovery) struct Facts { + pub stage: Stage, + pub wildcard: bool, + pub family: i32, + pub errno: Option, + } + thread_local! { + static FIRST: RefCell> = const { RefCell::new(None) }; + } + pub(in crate::provider::https_recovery) fn clear() { + FIRST.with(|first| *first.borrow_mut() = None); + } + pub(in crate::provider::https_recovery) fn take() -> Option { + FIRST.with(|first| first.borrow_mut().take()) + } + pub(in crate::provider::https_recovery) fn record( + stage: Stage, + wildcard: bool, + family: i32, + errno: Option, + ) { + FIRST.with(|first| { + let mut first = first.borrow_mut(); + if first.is_none() { + *first = Some(Facts { + stage, + wildcard, + family, + errno, + }); + } + }); + } +} diff --git a/packages/runtime-core/src/provider/https_recovery/tests.rs b/packages/runtime-core/src/provider/https_recovery/tests.rs index 8ab9bde1e..fb4deee73 100644 --- a/packages/runtime-core/src/provider/https_recovery/tests.rs +++ b/packages/runtime-core/src/provider/https_recovery/tests.rs @@ -254,14 +254,69 @@ fn rejects_a_live_inherited_unix_listener_at_the_pinned_inode() { } #[test] fn wildcard_port_probe_refuses_ipv4_and_ipv6_listeners() { + use ports::observation_diagnostic as diagnostic; + let ipv4 = std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).unwrap(); + diagnostic::clear(); assert!(port_absent(ipv4.local_addr().unwrap().port()).is_err()); drop(ipv4); let ipv6 = std::net::TcpListener::bind((std::net::Ipv6Addr::LOCALHOST, 0)).unwrap(); let port = ipv6.local_addr().unwrap().port(); + diagnostic::clear(); assert!(port_absent(port).is_err()); drop(ipv6); - port_absent(port).unwrap(); + diagnostic::clear(); + port_absent(port).unwrap_or_else(|error| { + panic!("{error:?}; port_probe={:?}", diagnostic::take()); + }); +} + +#[test] +fn port_probe_diagnostic_reports_the_owned_ipv4_listener() { + use ports::observation_diagnostic as diagnostic; + + let listener = std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).unwrap(); + diagnostic::clear(); + assert!(port_absent(listener.local_addr().unwrap().port()).is_err()); + assert_eq!( + diagnostic::take(), + Some(diagnostic::Facts { + stage: diagnostic::Stage::Bind, + wildcard: true, + family: libc::AF_INET, + errno: Some(libc::EADDRINUSE), + }) + ); +} + +#[test] +fn port_probe_diagnostic_keeps_only_the_first_refusal() { + use ports::observation_diagnostic as diagnostic; + + diagnostic::clear(); + assert!(diagnostic::take().is_none()); + diagnostic::record( + diagnostic::Stage::Socket, + true, + libc::AF_INET, + Some(libc::EMFILE), + ); + diagnostic::record( + diagnostic::Stage::Bind, + false, + libc::AF_INET6, + Some(libc::EADDRINUSE), + ); + assert_eq!( + diagnostic::take(), + Some(diagnostic::Facts { + stage: diagnostic::Stage::Socket, + wildcard: true, + family: libc::AF_INET, + errno: Some(libc::EMFILE), + }) + ); + assert!(diagnostic::take().is_none()); } #[test] fn symlinked_configuration_and_wrong_selectors_refuse_before_mutation() { diff --git a/src/backends/native-authored-graph-protocol.ts b/src/backends/native-authored-graph-protocol.ts index 7a8fae493..542482b06 100644 --- a/src/backends/native-authored-graph-protocol.ts +++ b/src/backends/native-authored-graph-protocol.ts @@ -64,8 +64,30 @@ type Terminal = { readonly oom_killed: boolean; readonly stop_requested: boolean; }; -export type NativeAuthoredReceipt = { - readonly version: 2 | 4; +type NativeSourceAnchor = { + readonly device: number; + readonly inode: number; + readonly mode: number; + readonly uid: number; + readonly gid: number; + readonly kind: "file" | "directory"; +}; +type NativeSourceBinding = { + readonly version: 1; + readonly policy: "host-mounted"; + readonly share: { + readonly project: string; + readonly guest_path: string; + readonly device: number; + readonly inode: number; + readonly unfiltered_source: true; + }; + readonly mounts: Readonly< + Record + >; + readonly anchors: Readonly>; +}; +type NativeAuthoredInventory = { readonly kind: "native-graph-runtime"; readonly owner: string; readonly boot: string; @@ -84,6 +106,12 @@ export type NativeAuthoredReceipt = { }; readonly terminal?: Readonly>; }; +export type NativeAuthoredReceipt = NativeAuthoredInventory & + ( + | { readonly version: 2; readonly source?: never } + | { readonly version: 3; readonly source: NativeSourceBinding } + | { readonly version: 4; readonly source?: never } + ); function refused(): never { throw new Error( "Native authored graph response is invalid or changed; values omitted." @@ -108,6 +136,174 @@ function hash(value: unknown): value is string { function utf8Order(left: string, right: string): number { return Buffer.compare(Buffer.from(left), Buffer.from(right)); } +function unsigned(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} +function relativeSource(value: unknown): value is string { + return ( + typeof value === "string" && + value.isWellFormed() && + Buffer.byteLength(value) <= 4096 && + !CONTROL.test(value) && + (value === "." || + (value.length > 0 && + value + .split("/") + .every((part) => part.length > 0 && part !== "." && part !== ".."))) + ); +} +function absoluteSource(value: unknown): value is string { + return ( + typeof value === "string" && + value.isWellFormed() && + value.startsWith("/") && + Buffer.byteLength(value) <= 4096 && + !CONTROL.test(value) && + (value === "/" || + value + .slice(1) + .split("/") + .every((part) => part.length > 0 && part !== "." && part !== "..")) + ); +} +function sourceAnchor(value: unknown): NativeSourceAnchor { + if ( + !( + fields(value, ["device", "inode", "mode", "uid", "gid", "kind"]) && + unsigned(value.device) && + unsigned(value.inode) + ) || + value.inode === 0 || + !unsigned(value.mode) || + value.mode > 0xff_ff || + !unsigned(value.uid) || + value.uid > 0xff_ff_ff_ff || + !unsigned(value.gid) || + value.gid > 0xff_ff_ff_ff || + (value.mode & 0o022) !== 0 || + !( + (value.kind === "file" && + (value.mode & 0o17_0000) === 0o10_0000 && + (value.mode & 0o400) !== 0) || + (value.kind === "directory" && + (value.mode & 0o17_0000) === 0o04_0000 && + (value.mode & 0o500) === 0o500) + ) + ) { + return refused(); + } + return { + device: value.device, + inode: value.inode, + mode: value.mode, + uid: value.uid, + gid: value.gid, + kind: value.kind, + }; +} +function sourceBinding( + value: unknown, + services: readonly string[] +): NativeSourceBinding { + if ( + !fields(value, ["version", "policy", "share", "mounts", "anchors"]) || + value.version !== 1 || + value.policy !== "host-mounted" || + !fields(value.share, [ + "project", + "guest_path", + "device", + "inode", + "unfiltered_source", + ]) || + !absoluteSource(value.share.project) || + value.share.project.includes(":") || + !unsigned(value.share.device) || + !unsigned(value.share.inode) || + value.share.inode === 0 || + value.share.unfiltered_source !== true || + value.share.guest_path !== + `/mnt/hack-projects/${createHash("sha256").update(value.share.project).digest("hex")}` || + !isRecord(value.mounts) || + !isRecord(value.anchors) + ) { + return refused(); + } + const declared = Object.keys(value.mounts).sort(utf8Order); + const declaredMounts = value.mounts; + const declaredAnchors = value.anchors; + if ( + declared.length === 0 || + declared.length > services.length || + declared.some((name) => !services.includes(name)) + ) { + return refused(); + } + const required = new Set(["."]); + const mounts = Object.fromEntries( + declared.map((name) => { + const mount = declaredMounts[name]; + if ( + !( + fields(mount, ["source", "target"]) && + relativeSource(mount.source) && + absoluteSource(mount.target) + ) + ) { + return refused(); + } + if (mount.source !== ".") { + const parts = mount.source.split("/"); + for (let length = 1; length <= parts.length; length += 1) { + required.add(parts.slice(0, length).join("/")); + } + } + return [name, { source: mount.source, target: mount.target }]; + }) + ); + const anchors = Object.fromEntries( + Object.keys(declaredAnchors) + .sort(utf8Order) + .map((name) => { + if (!required.has(name)) { + return refused(); + } + return [name, sourceAnchor(declaredAnchors[name])]; + }) + ); + const root = anchors["."]; + if ( + Object.keys(anchors).length !== required.size || + root === undefined || + root.kind !== "directory" || + root.device !== value.share.device || + root.inode !== value.share.inode || + Object.values(anchors).some((anchor) => anchor.uid !== root.uid) || + [...required].some( + (path) => + path !== "." && + Object.values(mounts).some((mount) => + mount.source.startsWith(`${path}/`) + ) && + anchors[path]?.kind !== "directory" + ) + ) { + return refused(); + } + return { + version: 1, + policy: "host-mounted", + share: { + project: value.share.project, + guest_path: value.share.guest_path, + device: value.share.device, + inode: value.share.inode, + unfiltered_source: true, + }, + mounts, + anchors, + }; +} function profiles(value: unknown): value is readonly string[] { return ( Array.isArray(value) && @@ -312,7 +508,7 @@ function resource( }; } -/** Native v2 remains disjoint from legacy Compose receipts and their plan IDs. */ +/** Closed image-only v2, live-source v3 and persistent-intent v4 remain distinct. */ export function parseNativeAuthoredReceipt( value: unknown ): NativeAuthoredReceipt { @@ -329,9 +525,12 @@ export function parseNativeAuthoredReceipt( "readiness", "resources", ], - ["failure", "terminal", "data", "data_mounts", "data_tool"] + ["failure", "terminal", "source", "data", "data_mounts", "data_tool"] ) || - (value.version !== 2 && value.version !== 4) || + (value.version !== 2 && value.version !== 3 && value.version !== 4) || + (value.version === 3 + ? !Object.hasOwn(value, "source") + : Object.hasOwn(value, "source")) || value.kind !== "native-graph-runtime" || typeof value.owner !== "string" || !HEX32.test(value.owner) || @@ -407,7 +606,7 @@ export function parseNativeAuthoredReceipt( const hasData = Object.hasOwn(value, "data") || Object.hasOwn(value, "data_mounts"); if ( - (value.version === 2 && (hasData || Object.hasOwn(value, "data_tool"))) || + (value.version !== 4 && (hasData || Object.hasOwn(value, "data_tool"))) || (value.version === 4 && !hasData) ) { return refused(); @@ -430,8 +629,7 @@ export function parseNativeAuthoredReceipt( }) : undefined; const tool = parseReceiptTool({ value, resources }); - return { - version: value.version, + const common: NativeAuthoredInventory = { kind: "native-graph-runtime", owner: value.owner, boot: value.boot, @@ -444,6 +642,14 @@ export function parseNativeAuthoredReceipt( ...(failure ? { failure } : {}), ...(terminal ? { terminal } : {}), }; + if (value.version === 3) { + return { + version: 3, + ...common, + source: sourceBinding(value.source, names), + }; + } + return { version: value.version, ...common }; } function parseReceiptTool(opts: { readonly value: Record; @@ -533,6 +739,7 @@ export function nativeAuthoredReceiptBinding( receipt: NativeAuthoredReceipt ): string { return JSON.stringify({ + ...(receipt.version === 3 ? { version: 3, source: receipt.source } : {}), ...(receipt.version === 4 ? { version: 4, diff --git a/tests/native-authored-graph-protocol.test.ts b/tests/native-authored-graph-protocol.test.ts index f529f6518..47b9ba66d 100644 --- a/tests/native-authored-graph-protocol.test.ts +++ b/tests/native-authored-graph-protocol.test.ts @@ -64,6 +64,161 @@ function receipt() { }, }; } +function sourceReceipt() { + const project = "/private/project"; + const anchor = { + device: 1, + inode: 2, + mode: 0o04_0700, + uid: 502, + gid: 20, + kind: "directory", + }; + return { + ...receipt(), + version: 3, + source: { + version: 1, + policy: "host-mounted", + share: { + project, + guest_path: `/mnt/hack-projects/${createHash("sha256").update(project).digest("hex")}`, + device: 1, + inode: 2, + unfiltered_source: true, + }, + mounts: { "a.peer": { source: "src/main.js", target: "/app/main.js" } }, + anchors: { + ".": anchor, + src: { ...anchor, inode: 3 }, + "src/main.js": { ...anchor, inode: 4, mode: 0o10_0600, kind: "file" }, + }, + }, + }; +} + +test("source-bearing native receipts bind host-mounted intent while retaining image-only v2", () => { + const old = parseNativeAuthoredReceipt(receipt()); + expect(old.version).toBe(2); + expect(Object.hasOwn(old, "source")).toBe(false); + const raw = sourceReceipt(); + const admitted = parseNativeAuthoredReceipt(raw); + expect(admitted.version).toBe(3); + expect( + parseNativeAuthoredReady( + { + version: 2, + kind: "native-graph-foreground-ready", + run, + review: raw.review.review_id, + receipt: raw, + }, + parseNativeAuthoredReview(raw.review) + ) + ).toEqual(admitted); + const changed = sourceReceipt(); + changed.source.anchors["src/main.js"].inode += 1; + expect( + nativeAuthoredReceiptBinding(parseNativeAuthoredReceipt(changed)) + ).not.toBe(nativeAuthoredReceiptBinding(admitted)); + expect(() => + parseNativeAuthoredSnapshot({ + value: { + receipt: changed, + observations: { "a.peer": { state: "running", health: "healthy" } }, + }, + expectedReview: admitted.review, + admitted, + }) + ).toThrow("invalid or changed"); + expect(nativeAuthoredReceiptBinding(old)).toBe( + JSON.stringify({ + owner: old.owner, + boot: old.boot, + review: old.review, + readiness: old.readiness, + resources: Object.fromEntries( + Object.entries(old.resources).map(([key, item]) => [ + key, + { + kind: item.kind, + key: item.key, + name: item.name, + id: item.id, + image: item.image, + networks: item.networks, + outbound: item.outbound, + }, + ]) + ), + }) + ); +}); + +test("source receipt refuses version, policy, selection, permission and private-field drift", () => { + const cases = [ + (value: ReturnType) => { + value.version = 2; + }, + (value: ReturnType) => { + value.source.policy = "immutable"; + }, + (value: ReturnType) => { + value.source.share.inode += 1; + }, + (value: ReturnType) => { + value.source.share.unfiltered_source = false; + }, + (value: ReturnType) => { + value.source.share.guest_path = "/foreign"; + }, + (value: ReturnType) => { + value.source.mounts["a.peer"].source = "../other"; + }, + (value: ReturnType) => { + value.source.mounts["a.peer"].target = "/app/../foreign"; + }, + (value: ReturnType) => { + value.source.anchors.src.kind = "file"; + }, + (value: ReturnType) => { + value.source.anchors.src.mode |= 0o002; + }, + (value: ReturnType) => { + value.source.anchors.src.uid += 1; + }, + (value: ReturnType) => { + value.source.anchors.src.inode = Number.MAX_SAFE_INTEGER + 1; + }, + (value: ReturnType) => { + Object.assign(value.source.mounts["a.peer"], { access: "read-write" }); + }, + (value: ReturnType) => { + Object.assign(value.source.anchors.src, { + content: "synthetic-private-canary", + }); + }, + (value: ReturnType) => { + Object.assign(value.source.anchors, { extra: value.source.anchors.src }); + }, + ]; + for (const mutate of cases) { + const value = sourceReceipt(); + mutate(value); + expect(() => parseNativeAuthoredReceipt(value)).toThrow( + "invalid or changed" + ); + } + expect(() => + parseNativeAuthoredReceipt({ ...receipt(), source: null }) + ).toThrow("invalid or changed"); + expect(() => + parseNativeAuthoredReceipt({ ...sourceReceipt(), source: null }) + ).toThrow("invalid or changed"); + expect(() => + parseNativeAuthoredReceipt({ ...receipt(), version: 3 }) + ).toThrow("invalid or changed"); +}); function persistentReceipt() { const base = receipt(); @@ -799,3 +954,31 @@ test("native profile names require scalar strings including valid surrogate pair expect(() => parseNativeAuthoredReview(review([name]))).toThrow("invalid"); } }); + +test("graph source and persistent families refuse every cross-family field", () => { + const source = sourceReceipt(); + const persistent = persistentReceipt(); + for (const fields of [ + { data: {} }, + { data_mounts: {} }, + { data_tool: null }, + { data: null }, + { data_mounts: null }, + ]) { + expect(() => parseNativeAuthoredReceipt({ ...source, ...fields })).toThrow( + "invalid or changed" + ); + } + for (const value of [ + { ...persistent, source: source.source }, + { ...persistent, source: null }, + { ...source, ...persistent, source: source.source }, + { ...source, version: 4 }, + ]) { + expect(() => parseNativeAuthoredReceipt(value)).toThrow( + "invalid or changed" + ); + } + expect(parseNativeAuthoredReceipt(source).version).toBe(3); + expect(parseNativeAuthoredReceipt(persistent).version).toBe(4); +}); diff --git a/tests/native-authored-project-process.test.ts b/tests/native-authored-project-process.test.ts index 65f983612..dec950239 100644 --- a/tests/native-authored-project-process.test.ts +++ b/tests/native-authored-project-process.test.ts @@ -121,6 +121,7 @@ async function fixture( const args = process.argv.slice(2); appendFileSync("calls", JSON.stringify(args) + "\\n"); if (args.includes("control")) { + await Bun.sleep(${opts.controlDelayMs ?? 0}); await Bun.write("authenticated-status-started", "status"); if (${opts.controlKeeper ?? false}) { const keeper = Bun.spawn([process.execPath, "-e", 'await Bun.sleep(2000); await Bun.write("keeper-complete", "exited");'], { @@ -130,7 +131,6 @@ async function fixture( await Bun.write("keeper-pid", String(keeper.pid)); console.error("synthetic-private-keeper-detail"); } - await Bun.sleep(${opts.controlDelayMs ?? 0}); if (${opts.controlFailure ?? false}) { console.error(JSON.stringify({code:"graph_owner_recovery",message:"synthetic-private-control-detail"})); process.exit(2); diff --git a/tests/native-authored-recovery-protocol.test.ts b/tests/native-authored-recovery-protocol.test.ts index 8bc208514..0b43564eb 100644 --- a/tests/native-authored-recovery-protocol.test.ts +++ b/tests/native-authored-recovery-protocol.test.ts @@ -76,6 +76,35 @@ function sessionSelection() { host_boot_uuid: "12345678-abcd-abcd-abcd-123456789abc", }; } +function sourceReceipt() { + const project = "/private/native-source-fixture"; + return { + ...receipt(), + version: 3, + source: { + version: 1, + policy: "host-mounted", + share: { + project, + guest_path: `/mnt/hack-projects/${createHash("sha256").update(project).digest("hex")}`, + device: 1, + inode: 2, + unfiltered_source: true, + }, + mounts: { web: { source: ".", target: "/app" } }, + anchors: { + ".": { + device: 1, + inode: 2, + mode: 0o04_0700, + uid: 502, + gid: 20, + kind: "directory", + }, + }, + }, + }; +} function result() { const removed = receipt(); removed.phase = "removed"; @@ -107,6 +136,26 @@ test("native recovery copies original selectors and binds exact Removed to durab .phase ).toBe("removed"); }); +test("valid source-bearing v3 parsing grants no dead-owner recovery selection", () => { + const source = sourceReceipt(); + const sourceAdmitted = parseNativeAuthoredReceipt(source); + const imageAdmitted = parseNativeAuthoredReceipt(receipt()); + expect(sourceAdmitted.version).toBe(3); + for (const selector of [selection(), sessionSelection()]) { + for (const [selected, admitted] of [ + [source, sourceAdmitted], + [source, imageAdmitted], + [receipt(), sourceAdmitted], + ] as const) { + const value = { ...selector, receipt: selected }; + const before = structuredClone(value); + expect(() => + parseNativeAuthoredRecoverySelection({ value, admitted }) + ).toThrow("Native recovery response is invalid"); + expect(value).toEqual(before); + } + } +}); test("session selection is closed version two and preserves original qualifier without migration", () => { const admitted = parseNativeAuthoredReceipt(receipt()); const expected = parseNativeAuthoredRecoverySelection({