diff --git a/sandbox/SPEC.md b/sandbox/SPEC.md index 4f9e4025d..a0cd42afc 100644 --- a/sandbox/SPEC.md +++ b/sandbox/SPEC.md @@ -386,7 +386,7 @@ Long-horizon work outlives the sandbox that started it. A host that suspends and ## 14. The environment manifest -A durable run is only the same run if it resumes into the same toolchain — checkable by hash, not asserted by trust. The **environment manifest** is one canonical, content-addressed object standing for the declared environment: base image (pinned by digest where honest claims require it), tool features, environment variables and mounts. Declarations are ingested from the open `devcontainer.json` format — its **declarative subset only**: fields that execute commands or widen the sandbox (`build`, the lifecycle `*Command` hooks, `privileged`, …) are refused, and unknown fields are refused rather than silently dropped. Networking fields are excluded from identity: the network surface belongs to the grant (§5). The manifest is JCS-canonicalized, BLAKE3-addressed, Ed25519-signed (§7.3 convention); `SnapshotRef.manifest` (§13) records it at suspension and resumption **MUST** be refused on a drifted address. Conformance class **C7**. See [`spec/environment-manifest.md`](spec/environment-manifest.md); the reference `sandbox-attest::manifest` implements it. +A durable run is only the same run if it resumes into the same toolchain — checkable by hash, not asserted by trust. The **environment manifest** is one canonical, content-addressed object standing for the declared environment: base image and tool features (each pinned by digest where honest claims require it: a manifest is pinned only when both are), environment variables and mounts. Declarations are ingested from the open `devcontainer.json` format — its **declarative subset only**: fields that execute commands or widen the sandbox (`build`, the lifecycle `*Command` hooks, `privileged`, …) are refused, and unknown fields are refused rather than silently dropped. Networking fields are excluded from identity: the network surface belongs to the grant (§5). The manifest is JCS-canonicalized, BLAKE3-addressed, Ed25519-signed (§7.3 convention); `SnapshotRef.manifest` (§13) records it at suspension and resumption **MUST** be refused on a drifted address. Conformance class **C7**. See [`spec/environment-manifest.md`](spec/environment-manifest.md); the reference `sandbox-attest::manifest` implements it. ## 15. Warm pools diff --git a/sandbox/sandbox-rs/crates/sandbox-attest/src/manifest.rs b/sandbox/sandbox-rs/crates/sandbox-attest/src/manifest.rs index ae2f4898b..e18a08d5e 100644 --- a/sandbox/sandbox-rs/crates/sandbox-attest/src/manifest.rs +++ b/sandbox/sandbox-rs/crates/sandbox-attest/src/manifest.rs @@ -29,12 +29,22 @@ use crate::jcs; /// One tool feature (the devcontainer `features` table, normalized). #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct Feature { - /// The feature reference as declared (e.g. `ghcr.io/devcontainers/features/rust:1`). + /// The feature reference as declared. A reference with a digest + /// (`ghcr.io/devcontainers/features/rust@sha256:…`) names fixed bytes; a + /// tag (`ghcr.io/devcontainers/features/rust:1`) floats, and resolves to + /// whatever its publisher last pushed under it. pub id: String, /// The feature's options, JCS-canonicalized JSON text ("" when empty). pub options: String, } +impl Feature { + /// Whether the reference is addressed by digest, and so names fixed bytes. + pub fn is_pinned(&self) -> bool { + self.id.contains("@sha256:") + } +} + /// One mount, normalized. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct Mount { @@ -190,9 +200,22 @@ impl EnvironmentManifest { Ok(EnvironmentManifest { image, image_digest, features, env, mounts, remote_user }) } - /// Whether the environment is fully pinned (digest-addressed base image). - /// Only a pinned manifest can honestly claim "same address ⇒ same bytes". + /// Whether the toolchain is fully pinned: the base image AND every + /// feature are addressed by digest. Only then does "same address ⇒ same + /// bytes" hold. A pinned image with a feature declared by tag (`rust:1`) + /// is not pinned: the tag floats, so two hosts can resolve one address to + /// different toolchains. + /// + /// Mounted content is data, not toolchain, and is never part of the + /// address: a pinned manifest fixes what the unit runs with, not what its + /// mounts hold. pub fn is_pinned(&self) -> bool { + self.image_pinned() && self.features.iter().all(Feature::is_pinned) + } + + /// Whether the base image alone is addressed by digest. Necessary for + /// [`EnvironmentManifest::is_pinned`], not sufficient. + pub fn image_pinned(&self) -> bool { self.image_digest.is_some() } @@ -311,7 +334,9 @@ mod tests { #[test] fn ingest_canonicalize_address() { let m = EnvironmentManifest::from_devcontainer(SAMPLE).unwrap(); - assert!(m.is_pinned()); + // The image is digest-addressed, but `rust:1` and `node:1` float. + assert!(m.image_pinned()); + assert!(!m.is_pinned()); assert_eq!(m.env, vec![("A".into(), "1".into()), ("B".into(), "2".into())]); assert_eq!(m.features.len(), 2); assert_eq!(m.features[0].id, "ghcr.io/devcontainers/features/node:1"); @@ -351,9 +376,33 @@ mod tests { #[test] fn unpinned_image_is_marked() { let m = EnvironmentManifest::from_devcontainer(r#"{"image": "ubuntu:24.04"}"#).unwrap(); + assert!(!m.image_pinned()); assert!(!m.is_pinned()); } + /// Pinned means the whole toolchain: a digest image with digest features + /// is pinned; one feature by tag beside them is not; and a mount, whose + /// content is never addressed, does not change either answer. + #[test] + fn pinned_means_every_feature_is_digest_addressed() { + let doc = |rust: &str| { + format!( + r#"{{"image": "ghcr.io/acme/builder@sha256:0011aabb", + "features": {{"{rust}": {{}}, + "ghcr.io/devcontainers/features/node@sha256:ccdd": {{}}}}, + "mounts": ["source=cache,target=/cache,type=volume"]}}"# + ) + }; + let pinned = EnvironmentManifest::from_devcontainer(&doc("ghcr.io/devcontainers/features/rust@sha256:eeff")).unwrap(); + assert!(pinned.features.iter().all(Feature::is_pinned)); + assert!(pinned.is_pinned()); + let floating = EnvironmentManifest::from_devcontainer(&doc("ghcr.io/devcontainers/features/rust:1")).unwrap(); + assert!(floating.image_pinned()); + assert!(!floating.is_pinned(), "a feature by tag leaves the toolchain unpinned"); + let no_features = EnvironmentManifest::from_devcontainer(r#"{"image": "ghcr.io/acme/builder@sha256:0011aabb"}"#).unwrap(); + assert!(no_features.is_pinned()); + } + #[test] fn sign_verify_and_tamper() { let m = EnvironmentManifest::from_devcontainer(SAMPLE).unwrap(); diff --git a/sandbox/sandbox-rs/crates/sandbox-conformance/src/lib.rs b/sandbox/sandbox-rs/crates/sandbox-conformance/src/lib.rs index e32d84af4..a44ef5b26 100644 --- a/sandbox/sandbox-rs/crates/sandbox-conformance/src/lib.rs +++ b/sandbox/sandbox-rs/crates/sandbox-conformance/src/lib.rs @@ -286,7 +286,15 @@ fn c7() -> Result<(), String> { "name": "cosmetic", "forwardPorts": [3000] }"#; let m = EnvironmentManifest::from_devcontainer(doc).map_err(|e| format!("{e:?}"))?; - want(m.is_pinned(), "digest-addressed image is pinned")?; + want(m.image_pinned(), "digest-addressed image is pinned")?; + // The image is pinned, but `rust:1` floats: the toolchain is not. + want(!m.is_pinned(), "a feature declared by tag leaves the manifest unpinned")?; + let pinned_doc = doc.replace( + "ghcr.io/devcontainers/features/rust:1", + "ghcr.io/devcontainers/features/rust@sha256:eeff", + ); + let pinned = EnvironmentManifest::from_devcontainer(&pinned_doc).map_err(|e| format!("{e:?}"))?; + want(pinned.is_pinned(), "digest image and digest features are pinned")?; let id = m.content_id().map_err(|e| format!("{e:?}"))?; // Determinism: key order / whitespace must not change the address. let shuffled = doc.replace("\"B\": \"2\", \"A\": \"1\"", "\"A\":\"1\",\"B\":\"2\""); diff --git a/sandbox/spec/environment-manifest.md b/sandbox/spec/environment-manifest.md index 6fd2ac8e4..754a7c750 100644 --- a/sandbox/spec/environment-manifest.md +++ b/sandbox/spec/environment-manifest.md @@ -18,8 +18,8 @@ industry. This standard ingests its **declarative subset**: | accepted | meaning | |---|---| -| `image` | base image reference; a `@sha256:` digest marks the manifest **pinned** | -| `features` | tool features, normalized to (id, JCS-canonical options), sorted | +| `image` | base image reference; a `@sha256:` digest pins the image | +| `features` | tool features, normalized to (id, JCS-canonical options), sorted; a reference with a `@sha256:` digest is pinned, one by tag (`:1`) floats | | `containerEnv` | environment variables, sorted | | `mounts` | string or object form, normalized to (source, target, kind), sorted by target | | `remoteUser` | the user the unit runs as | @@ -45,10 +45,17 @@ its BLAKE3 address is the environment's identity; it is signed Ed25519 over the canonical bytes, mirroring the attestation convention (§7.3). Key order and whitespace in the source document do not affect the address. -A manifest without an image digest is **unpinned**: its address identifies a -declaration, not bytes. Hosts SHOULD treat unpinned manifests the way the -energy receipt treats estimates — usable, but labelled, and never the basis -for a "same environment" claim across hosts. +A manifest is **pinned** only when its image and every feature are addressed +by digest. Otherwise it is **unpinned**: its address identifies a +declaration, not bytes — a tag such as `rust:1` resolves to whatever its +publisher last pushed, so a pinned image beside a feature by tag does not fix +the toolchain. Hosts SHOULD treat unpinned manifests the way the energy +receipt treats estimates — usable, but labelled, and never the basis for a +"same environment" claim across hosts. + +Mounted content is data, not toolchain, and is never part of the address. A +pinned manifest fixes what a unit runs with; it says nothing about what its +mounts hold. ## Use in durable runs