Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/runtime-core/src/project/compose/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down
63 changes: 63 additions & 0 deletions packages/runtime-core/src/project/compose/ownership_labels.rs
Original file line number Diff line number Diff line change
@@ -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}");
}
}
}
13 changes: 8 additions & 5 deletions packages/runtime-core/src/project/compose/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
55 changes: 55 additions & 0 deletions packages/runtime-core/tests/project_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
6 changes: 5 additions & 1 deletion src/lib/hack-ownership-labels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
34 changes: 31 additions & 3 deletions tests/hack-ownership-labels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, "..");
Expand All @@ -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;

Expand Down Expand Up @@ -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<readonly string[]> {
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,
]);
});
Loading