sandbox: a manifest is pinned only when its image AND every feature are digest-addressed #270
4 changed files with 76 additions and 12 deletions
|
|
@ -386,7 +386,7 @@ Long-horizon work outlives the sandbox that started it. A host that suspends and
|
||||||
|
|
||||||
## 14. The environment manifest
|
## 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
|
## 15. Warm pools
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,12 +29,22 @@ use crate::jcs;
|
||||||
/// One tool feature (the devcontainer `features` table, normalized).
|
/// One tool feature (the devcontainer `features` table, normalized).
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct Feature {
|
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,
|
pub id: String,
|
||||||
/// The feature's options, JCS-canonicalized JSON text ("" when empty).
|
/// The feature's options, JCS-canonicalized JSON text ("" when empty).
|
||||||
pub options: String,
|
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.
|
/// One mount, normalized.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct Mount {
|
pub struct Mount {
|
||||||
|
|
@ -190,9 +200,22 @@ impl EnvironmentManifest {
|
||||||
Ok(EnvironmentManifest { image, image_digest, features, env, mounts, remote_user })
|
Ok(EnvironmentManifest { image, image_digest, features, env, mounts, remote_user })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the environment is fully pinned (digest-addressed base image).
|
/// Whether the toolchain is fully pinned: the base image AND every
|
||||||
/// Only a pinned manifest can honestly claim "same address ⇒ same bytes".
|
/// 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 {
|
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()
|
self.image_digest.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -311,7 +334,9 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn ingest_canonicalize_address() {
|
fn ingest_canonicalize_address() {
|
||||||
let m = EnvironmentManifest::from_devcontainer(SAMPLE).unwrap();
|
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.env, vec![("A".into(), "1".into()), ("B".into(), "2".into())]);
|
||||||
assert_eq!(m.features.len(), 2);
|
assert_eq!(m.features.len(), 2);
|
||||||
assert_eq!(m.features[0].id, "ghcr.io/devcontainers/features/node:1");
|
assert_eq!(m.features[0].id, "ghcr.io/devcontainers/features/node:1");
|
||||||
|
|
@ -351,9 +376,33 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn unpinned_image_is_marked() {
|
fn unpinned_image_is_marked() {
|
||||||
let m = EnvironmentManifest::from_devcontainer(r#"{"image": "ubuntu:24.04"}"#).unwrap();
|
let m = EnvironmentManifest::from_devcontainer(r#"{"image": "ubuntu:24.04"}"#).unwrap();
|
||||||
|
assert!(!m.image_pinned());
|
||||||
assert!(!m.is_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]
|
#[test]
|
||||||
fn sign_verify_and_tamper() {
|
fn sign_verify_and_tamper() {
|
||||||
let m = EnvironmentManifest::from_devcontainer(SAMPLE).unwrap();
|
let m = EnvironmentManifest::from_devcontainer(SAMPLE).unwrap();
|
||||||
|
|
|
||||||
|
|
@ -286,7 +286,15 @@ fn c7() -> Result<(), String> {
|
||||||
"name": "cosmetic", "forwardPorts": [3000]
|
"name": "cosmetic", "forwardPorts": [3000]
|
||||||
}"#;
|
}"#;
|
||||||
let m = EnvironmentManifest::from_devcontainer(doc).map_err(|e| format!("{e:?}"))?;
|
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:?}"))?;
|
let id = m.content_id().map_err(|e| format!("{e:?}"))?;
|
||||||
// Determinism: key order / whitespace must not change the address.
|
// Determinism: key order / whitespace must not change the address.
|
||||||
let shuffled = doc.replace("\"B\": \"2\", \"A\": \"1\"", "\"A\":\"1\",\"B\":\"2\"");
|
let shuffled = doc.replace("\"B\": \"2\", \"A\": \"1\"", "\"A\":\"1\",\"B\":\"2\"");
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,8 @@ industry. This standard ingests its **declarative subset**:
|
||||||
|
|
||||||
| accepted | meaning |
|
| accepted | meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `image` | base image reference; a `@sha256:` digest marks the manifest **pinned** |
|
| `image` | base image reference; a `@sha256:` digest pins the image |
|
||||||
| `features` | tool features, normalized to (id, JCS-canonical options), sorted |
|
| `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 |
|
| `containerEnv` | environment variables, sorted |
|
||||||
| `mounts` | string or object form, normalized to (source, target, kind), sorted by target |
|
| `mounts` | string or object form, normalized to (source, target, kind), sorted by target |
|
||||||
| `remoteUser` | the user the unit runs as |
|
| `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
|
the canonical bytes, mirroring the attestation convention (§7.3). Key order
|
||||||
and whitespace in the source document do not affect the address.
|
and whitespace in the source document do not affect the address.
|
||||||
|
|
||||||
A manifest without an image digest is **unpinned**: its address identifies a
|
A manifest is **pinned** only when its image and every feature are addressed
|
||||||
declaration, not bytes. Hosts SHOULD treat unpinned manifests the way the
|
by digest. Otherwise it is **unpinned**: its address identifies a
|
||||||
energy receipt treats estimates — usable, but labelled, and never the basis
|
declaration, not bytes — a tag such as `rust:1` resolves to whatever its
|
||||||
for a "same environment" claim across hosts.
|
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
|
## Use in durable runs
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue