diff --git a/packages/runtime-core/src/project/compose/mod.rs b/packages/runtime-core/src/project/compose/mod.rs index ca8d52f57..be2935aa0 100644 --- a/packages/runtime-core/src/project/compose/mod.rs +++ b/packages/runtime-core/src/project/compose/mod.rs @@ -3,6 +3,7 @@ use serde_json::{Map, Value}; mod dependency_cache; mod fields; mod graph; +mod ownership_labels; pub(super) mod routing; mod service; use fields::*; diff --git a/packages/runtime-core/src/project/compose/ownership_labels.rs b/packages/runtime-core/src/project/compose/ownership_labels.rs new file mode 100644 index 000000000..198c72317 --- /dev/null +++ b/packages/runtime-core/src/project/compose/ownership_labels.rs @@ -0,0 +1,63 @@ +//! Label namespaces that Hack writes as resource ownership evidence. + +/// Container, network and volume label namespaces that Hack writes to prove +/// which resources it owns. This mirrors `HACK_OWNERSHIP_LABEL_NAMESPACES` in +/// `src/lib/hack-ownership-labels.ts`, the source of truth; +/// `tests/hack-ownership-labels.test.ts` fails when the two lists differ. +/// +/// A project must not declare a label in these namespaces, because the native +/// runtime and native-config Compose trust them as ownership evidence. The +/// TypeScript classifier rejects such labels first; the candidate's own label +/// review rejects them again so a bypassed classifier cannot pass one through. +pub(super) const HACK_OWNERSHIP_LABEL_NAMESPACES: [&str; 4] = [ + "io.hack-local.", + "io.hack.native-config.", + "io.hack.native-file.", + "io.hack.storage-witness.", +]; + +/// Whether `key` is inside a Hack ownership namespace or is a namespace root +/// without its trailing dot (for example `io.hack-local`). +pub(super) fn is_hack_ownership_label(key: &str) -> bool { + HACK_OWNERSHIP_LABEL_NAMESPACES + .iter() + .any(|namespace| key.starts_with(namespace) || namespace.strip_suffix('.') == Some(key)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ownership_namespaces_and_roots_are_hack_ownership_labels() { + for key in [ + "io.hack-local.owner", + "io.hack-local", + "io.hack.native-config.owner", + "io.hack.native-config", + "io.hack.native-file.token", + "io.hack.native-file", + "io.hack.storage-witness.carrier", + "io.hack.storage-witness", + ] { + assert!(is_hack_ownership_label(key), "{key}"); + } + } + + #[test] + fn near_miss_keys_are_not_hack_ownership_labels() { + for key in [ + "io.hack", + "io.hack.", + "io.hacker.owner", + "io.hack-localx.owner", + "io.hack.native-configx", + "io.hack.storage", + "io.hack.custom", + "example.io.hack-local.owner", + "IO.HACK-LOCAL.OWNER", + ] { + assert!(!is_hack_ownership_label(key), "{key}"); + } + } +} diff --git a/packages/runtime-core/src/project/compose/service.rs b/packages/runtime-core/src/project/compose/service.rs index 875c562a6..5cc51846a 100644 --- a/packages/runtime-core/src/project/compose/service.rs +++ b/packages/runtime-core/src/project/compose/service.rs @@ -805,11 +805,14 @@ pub(super) fn compile( } if routing_invalid || labels.keys().any(|key| { - !(dependency_cache.is_some() && super::dependency_cache::recognized(key)) - && !(routing.is_some() && super::routing::recognized(key)) - && ["caddy", "traefik", "hack", "com.docker.compose"] - .iter() - .any(|prefix| key == prefix || key.starts_with(&format!("{prefix}."))) + // Hack's ownership namespaces are never exempt: no routing or + // dependency cache declaration may carry ownership evidence. + super::ownership_labels::is_hack_ownership_label(key) + || (!(dependency_cache.is_some() && super::dependency_cache::recognized(key)) + && !(routing.is_some() && super::routing::recognized(key)) + && ["caddy", "traefik", "hack", "com.docker.compose"] + .iter() + .any(|prefix| key == prefix || key.starts_with(&format!("{prefix}.")))) }) { diagnostics.push(Diagnostic::error( diff --git a/packages/runtime-core/tests/project_contract.rs b/packages/runtime-core/tests/project_contract.rs index 00199a38b..440cf3217 100644 --- a/packages/runtime-core/tests/project_contract.rs +++ b/packages/runtime-core/tests/project_contract.rs @@ -523,6 +523,61 @@ fn unsupported_privileged_external_and_unknown_fields_block_enrollment() { assert!(fixture.codes().contains(&"external_network".into())); } +/// The candidate's own label review rejects Hack's ownership namespaces (the +/// list `HACK_OWNERSHIP_LABEL_NAMESPACES` declares in TypeScript), so a +/// project cannot carry ownership evidence even when the TypeScript classifier +/// is bypassed. Routing and dependency cache declarations never exempt them. +#[test] +fn hack_ownership_label_namespaces_block_enrollment() { + let owner_label = |fixture: &Fixture| { + fixture + .codes() + .iter() + .any(|code| code == "external_route_or_owner_label") + }; + for key in [ + "io.hack-local.owner", + "io.hack.native-config.owner", + "io.hack.native-file.token", + "io.hack.storage-witness.carrier", + ] { + for labels in [ + format!(" labels: {{'{key}': owner-canary}}\n"), + format!(" labels: ['{key}=owner-canary']\n"), + format!( + " labels:\n caddy: web.hack\n caddy.reverse_proxy: '{{{{upstreams 3000}}}}'\n caddy.tls: internal\n '{key}': owner-canary\n" + ), + ] { + let fixture = Fixture::new(&format!("{BASIC}{labels}")); + let plan = fixture.plan(); + assert!(owner_label(&fixture), "{key}: {labels}"); + assert!(!plan.plan.enrollment_compatible, "{key}"); + assert!( + !serde_json::to_string(&plan) + .unwrap() + .contains("owner-canary") + ); + assert_eq!( + project::enroll(&fixture.candidate, fixture.options(), &plan.plan_id) + .unwrap_err() + .code, + "incompatible_compose" + ); + assert!(!fixture.candidate.state_root.exists()); + } + } + // Near misses outside every ownership namespace stay reviewable. Closer + // misses (a longer first segment, or another segment under the same + // reverse-DNS root) are pinned by the `ownership_labels` unit tests; they + // stay out of this file because the TypeScript parity scan reads every + // such key here as a label Hack writes. + let fixture = Fixture::new(&format!( + "{BASIC} labels: {{example.io.hack-local.owner: a, io-hack-local.owner: b, io_hack.native-config.owner: c}}\n" + )); + assert!(!owner_label(&fixture)); + assert!(fixture.plan().plan.enrollment_compatible); +} + #[test] fn explicit_external_network_replacement_is_owned_internal_and_review_bound() { let original = diff --git a/src/lib/hack-ownership-labels.ts b/src/lib/hack-ownership-labels.ts index 2cbc1d146..2bfd5e94a 100644 --- a/src/lib/hack-ownership-labels.ts +++ b/src/lib/hack-ownership-labels.ts @@ -18,7 +18,11 @@ * (`owner`, `generation`, `helper`, `carrier`). * * A project must not declare a label in these namespaces, because their - * consumers trust them as ownership evidence. Add a namespace here before + * consumers trust them as ownership evidence. The TypeScript Compose + * classifier rejects them, and runtime-core's own label review rejects them + * again from a Rust mirror of this list + * (`packages/runtime-core/src/project/compose/ownership_labels.rs`) that the + * same test keeps identical. Add a namespace here, and to the mirror, before * writing a label in it. */ export const HACK_OWNERSHIP_LABEL_NAMESPACES = [ diff --git a/tests/hack-ownership-labels.test.ts b/tests/hack-ownership-labels.test.ts index dea0628c5..47901d64c 100644 --- a/tests/hack-ownership-labels.test.ts +++ b/tests/hack-ownership-labels.test.ts @@ -5,9 +5,10 @@ import { HACK_OWNERSHIP_LABEL_NAMESPACES } from "../src/lib/hack-ownership-label /** * Pins `HACK_OWNERSHIP_LABEL_NAMESPACES` to the label keys Hack actually - * writes and verifies. The scan reads the TypeScript CLI and the Rust - * runtime-core sources (read-only); the two files that only restate the list - * are left out, so the list cannot satisfy itself. + * writes and verifies, and to runtime-core's Rust mirror of the list. The scan + * reads the TypeScript CLI and the Rust runtime-core sources (read-only); the + * files that only restate the list are left out, so the list cannot satisfy + * itself. */ const REPO = resolve(import.meta.dir, ".."); @@ -17,9 +18,12 @@ const PATTERNS = [ "packages/*/src/**/*.rs", "packages/*/tests/**/*.rs", ]; +const RUST_MIRROR = + "packages/runtime-core/src/project/compose/ownership_labels.rs"; const RESTATEMENTS = new Set([ "src/lib/hack-ownership-labels.ts", "src/lib/native-compose-backend-features.ts", + RUST_MIRROR, ]); const KEY = /\bio\.hack[A-Za-z0-9._-]*/g; @@ -62,3 +66,27 @@ test("every declared ownership namespace is written or verified somewhere", asyn ); expect(unused).toEqual([]); }); + +/** The string literals of the Rust `HACK_OWNERSHIP_LABEL_NAMESPACES` array. */ +async function rustMirrorNamespaces(): Promise { + const text = await readFile(join(REPO, RUST_MIRROR), "utf8"); + const body = + /const HACK_OWNERSHIP_LABEL_NAMESPACES: \[&str; (\d+)\] = \[([^\]]*)\];/.exec( + text + ); + expect(body).not.toBeNull(); + // Line comments are not entries, even when they quote a namespace. + const entries = (body?.[2] ?? "").replace(/\/\/[^\n]*/g, ""); + const literals = [...entries.matchAll(/"([^"]*)"/g)].map( + (match) => match[1] ?? "" + ); + // The declared array length must agree with the literals that were read. + expect(literals.length).toBe(Number(body?.[1])); + return literals; +} + +test("runtime-core's label review rejects exactly the declared ownership namespaces", async () => { + expect(await rustMirrorNamespaces()).toEqual([ + ...HACK_OWNERSHIP_LABEL_NAMESPACES, + ]); +});