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
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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\"");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue