Hedronite · Dev Lesson · Polyglot-Dev/Rust · Sat 2026-10-03

Match guards on image references a Binary Authorization census

The digest marker wins before any tag parse.

Lesson Class: Dev (T3 · Rust, lagged to Patterns ch19)
Edge: match guards · port-colon is not a tag
Checked: cargo test --offline · 3 passed · rustc 1.99.0
Paired Ops: GKE require-attestation policy
Paired Cert: enforced block versus dry-run
Guard order
@sha256: must match before the tag arm.
Last slash
The tag colon is in the leaf, after the final slash.
kube-rs
Api::all lists Pods. It does not return attestation status.
A guard is not a comment. Swap it and digest_beats_a_tag goes red.

<!-- hal:authoritative:yaml -->

The digest marker wins before any tag parse. The colon after the last slash is the only tag separator.

§I. Frame

Duha shipped Patterns yesterday (Topics #22, TRPL ch19). Unsafe is Topics #23 and is out of this lesson. The Dhuhr Dev job is still Rust that touches Kubernetes, lagged to what Duha has already taught.

Ops today is GKE Binary Authorization: the project policy can require an attestor, and the cluster flag PROJECT_SINGLETON_POLICY_ENFORCE turns that policy on. The Pod API does not return "attested" or "denied". It returns container image strings. Classifying those strings is the Rust work.

09-30 already listed Pods with kube-rs to see Fargate labels. This lesson keeps Api::all as the live path and spends the new code on match.

§II. What the string actually contains

An image reference has three optional pieces: registry plus name, a tag, and a digest.

The digest, when present, is @sha256: plus a hex string. A tag may sit in front of it: gcr.io/payments/api:1.2@sha256:dead. Admission will resolve a digest either way. The spec only proves a digest if @sha256: is written down.

The tag separator is the colon in the last path segment, the part after the final /. localhost:5000/payments/api has a colon and still has no tag. That colon is the registry port. Treating the first colon as the tag marks every private registry as tagged.

No tag, and the tag latest, both mean the name can move. Binary Authorization attestors sign digests. A moving name is the case the census should flag, even though this function cannot see the signature.

§III. The match

ImageShape is a four-variant enum: DigestPinned, MovingTag, ImplicitLatest, Missing. Patterns already taught you to match an enum. The new piece is a match guard, an if condition after the pattern (TRPL 19.3).

fn classify(image: Option<&str>) -> ImageShape {
    match image {
        None | Some("") => ImageShape::Missing,
        Some(raw) if raw.contains("@sha256:") => ImageShape::DigestPinned,
        Some(raw) => match tag_after_last_slash(name_before_digest(raw)) {
            None | Some("latest") => ImageShape::ImplicitLatest,
            Some(_) => ImageShape::MovingTag,
        },
    }
}

None | Some("") is one pattern with two alternatives. The guard on the next arm runs only after Some(raw) matches. Guard order matters: the digest arm must sit above the tag arm, or repo:1.2@sha256:dead falls through to MovingTag.

name_before_digest splits on the first @. tag_after_last_slash splits the last /, then one colon inside that leaf. Both are ordinary match on Option from split_once, which is the same tool as ch6, used here as a refutable pattern.

Some(_) in the inner match means a tag other than latest. The _ ignores the tag text. You do not need the characters to know the name can move.

None | Some("latest") is the "multiple patterns" form from the same ch19.3 page: two patterns, one arm. That is enough. An @ binding would capture the digest text, and this census never prints the hex, so there is no binding.

Swap the guard and the tag arm and the tests fail in a specific way. gcr.io/payments/api:1.2@sha256:dead contains a colon-tag 1.2 before the digest. The tag arm would classify it MovingTag and digest_beats_a_tag would go red. The guard is not a comment. It is the only reason the digest wins.

The guard pattern is refutable: it does not match every Option<&str>. That is why it sits in match and not in a plain let. The arms together are exhaustive. None, empty, digest, tag-or-latest, and any other tag each have one arm. Rust will not compile a hole.

§IV. Where kube-rs fits

The live list is the Ops script, not this test crate. This crate has zero crates.io dependencies so cargo test --offline is honest. The script that does link kube (checked on the lab Mac: kube 2.0.1, exit 0) walks Pods like this:

let client = Client::try_default().await?;
let pods: Api<Pod> = Api::all(client);
let list = pods.list(&ListParams::default()).await?;
for pod in &list.items {
    let spec = match &pod.spec {
        Some(spec) => spec,
        None => continue,
    };
    for container in &spec.containers {
        let shape = classify(container.image.as_deref());
        // DigestPinned is shape-ok. The other three shapes print ATTENTION.
    }
}

container.image is Option<String> in k8s-openapi. as_deref() turns it into Option<&str> so classify does not take ownership. Api::all lists every namespace the credentials can see. A namespace selector would hide the break-glass namespace you most want to see.

Do not read securityContext for this policy. PSA restricted mode cares about privilege, hostPath, and runAs. Binary Authorization does not look at those fields. Matching them here would answer the 09-12 question again.

§V. What the tests locked

File on disk next to this lesson: image_shape.rs. Three tests, the lab Mac, rustc 1.99.0:

running 3 tests
test tests::digest_beats_a_tag ... ok
test tests::latest_and_bare_names ... ok
test tests::port_colon_is_not_a_tag ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

digest_beats_a_tag pins gcr.io/payments/api:1.2@sha256:dead to DigestPinned. port_colon_is_not_a_tag pins localhost:5000/payments/api to ImplicitLatest and localhost:5000/payments/api:1 to MovingTag. The third covers bare names, :latest, a normal tag, None, and "".

Real cargo run --offline --quiet stdout:

None -> Missing
Some("gcr.io/payments/api@sha256:abc") -> DigestPinned
Some("gcr.io/payments/api:1.4.2") -> MovingTag
Some("gcr.io/payments/api:latest") -> ImplicitLatest
Some("gcr.io/payments/api") -> ImplicitLatest
Some("localhost:5000/payments/api") -> ImplicitLatest
Some("localhost:5000/payments/api:1") -> MovingTag
Some("gcr.io/payments/api:1.2@sha256:dead") -> DigestPinned

§VI. Close

The teaching edge is one guard: @sha256: short-circuits tag parsing. The port-colon rule is the bug that guard does not catch by itself, so the tag parse starts after the last slash. kube-rs supplies the Pod list. It does not supply attestation status.

Paired Ops owns the GKE policy and the cargo-script entrypoint. Paired Cert owns enforced block versus dry-run.

Related