Skip to main content

rto_exec/
image_ref.rs

1//! Whether an image reference is pinned by a digest — the one place that decides.
2//!
3//! Ungated, like [`crate::guidance`] and [`crate::sandbox_store`], and for a
4//! reason of the same shape: **the rule is about a string somebody wrote in a
5//! config file, not about which backends were compiled in.** `roteiro config`
6//! has to be able to *report* an unpinned reference in a build with no sandbox
7//! at all — it is the command an operator runs precisely because a key is not
8//! doing what they expected — and a second copy of the check living in the
9//! `roteiro` crate for that purpose is how one of the two ends up laxer than the
10//! other. There is one function; [`crate::boxlite::pinned_digest`] wraps it into
11//! the backend's error type and adds nothing.
12//!
13//! # Why a tag is refused at all
14//!
15//! Not reproducibility. ADR-0020 retires that argument for builders, because a
16//! build's answer depends on a toolchain no digest pins. The reason is that the
17//! image **is** the boundary: it is where somebody else's code executes, and a
18//! tag is a mutable pointer to it. Whoever controls the tag can replace what
19//! runs, with no version change and no notice, and the run would go on reporting
20//! success. You may choose your own boundary; you may not choose one that can be
21//! swapped under you.
22//!
23//! # …and why "a tag is refused" is not the whole of what this module says
24//!
25//! That argument is correct and it covers **one** of the four ways a reference
26//! fails the check. The other three belong to people who have already pinned:
27//! a `sha512` digest, an `@` naming no algorithm, a `@sha256:` whose value is
28//! the abbreviated form a registry UI displayed. Telling any of them "that is a
29//! tag" describes something they did not write, and handing them the
30//! mutable-pointer argument answers a question they did not ask. Each defect
31//! therefore carries its own sentence and its own guidance — see [`PinDefect`],
32//! which records what that cost before it was fixed.
33//!
34//! @rto:0014
35//! @rto:0020
36
37use crate::guidance::{Guidance, Line};
38
39/// Why a **mutable pointer** will not do, and how to get the digest instead.
40///
41/// A [`Guidance`] rather than a wrapped literal: this text is multi-line and
42/// ends in something to paste, and written the other way it leaked its own
43/// source indentation into the middle of a sentence (see [`crate::guidance`]).
44///
45/// The example is deliberately keyless — `<key> = "…"` rather than
46/// `image = "…"` — because two different keys reach this message
47/// (`[lint] image` and a `[security.images]` entry) and the refusal already
48/// names which one it is talking about in its first sentence.
49///
50/// **This block belongs to [`PinDefect::Tag`] and [`PinDefect::ImplicitLatest`]
51/// and to nothing else.** It argues that a tag is mutable and shows how to
52/// obtain a digest — advice that is correct for someone who has never typed a
53/// digest and *wrong* for someone whose digest is merely malformed, who is being
54/// answered a question they did not ask. That is the defect this module was
55/// reviewed for; see [`PinDefect`].
56pub const PIN_IT: Guidance = Guidance::new(&[
57    Line::Note(&[
58        "An image is where somebody else's code executes, and a tag is a mutable",
59        "pointer to it — whoever controls the tag can replace what runs, with no",
60        "version change and no notice.",
61    ]),
62    Line::Note(&["Pin it by digest instead:"]),
63    Line::Command("<key> = \"docker.io/you/image@sha256:<64 hex>\""),
64    Line::Note(&[
65        "`docker buildx imagetools inspect <reference>` prints it. Use the **index**",
66        "digest — the one printed for the tag itself — so one reference resolves on",
67        "both amd64 and arm64 rather than two that can drift apart.",
68    ]),
69]);
70
71/// For a reference already pinned, by an algorithm this does not read.
72///
73/// **It does not argue that the reference is mutable, because it is not.** A
74/// `sha512` digest is content-addressed and immutable; the only thing wrong with
75/// it is that Roteiro reads one algorithm. Repeating [`PIN_IT`]'s mutability
76/// argument here would tell an operator their immutable pin is a moving target.
77pub const SHA256_IS_THE_PIN: Guidance = Guidance::new(&[
78    Line::Note(&[
79        "The reference is digest-addressed and is not a moving target — Roteiro",
80        "simply pins by **sha256**, which is what an OCI registry serves as a",
81        "manifest digest.",
82    ]),
83    Line::Note(&["Use the sha256 digest of the same image:"]),
84    Line::Command("<key> = \"docker.io/you/image@sha256:<64 hex>\""),
85    Line::Note(&[
86        "`docker buildx imagetools inspect <reference>` prints it. Use the **index**",
87        "digest — the one printed for the tag itself — so one reference resolves on",
88        "both amd64 and arm64 rather than two that can drift apart.",
89    ]),
90]);
91
92/// For a reference that says `@sha256:` and then gets the value wrong.
93///
94/// **The reader here has already pinned, correctly, and mistyped.** So this says
95/// nothing about tags, offers no argument for pinning, and does not show the
96/// shape of the key — they have the shape right. It names the one thing that
97/// tends to be true: the value is the short form a registry UI or `docker
98/// images` displays, which is a *prefix* of the digest rather than the digest.
99pub const CHECK_THE_WHOLE_DIGEST: Guidance = Guidance::new(&[
100    Line::Note(&[
101        "A sha256 digest is exactly 64 hexadecimal characters. The commonest cause",
102        "of a short one is the abbreviated form a registry UI or `docker images`",
103        "shows, which is a prefix of the digest and not the digest.",
104    ]),
105    Line::Note(&["The whole one is printed by:"]),
106    Line::Command("docker buildx imagetools inspect <reference>"),
107    Line::Note(&[
108        "Take the **index** digest — the one printed for the tag itself — so one",
109        "reference resolves on both amd64 and arm64 rather than two that can drift",
110        "apart.",
111    ]),
112]);
113
114/// What is actually wrong with a reference that is not a digest pin.
115///
116/// # Why this exists at all
117///
118/// [`NotPinned`] used to carry no such thing, and its message said *"which is a
119/// **tag** rather than a digest"* for **every** way of failing the check. Three
120/// of the four were not tags. `image@sha256:deadbeef` is a reference whose
121/// author has already pinned and has pasted the abbreviated digest a registry UI
122/// showed them — and they were told they had typed a tag, and then handed
123/// [`PIN_IT`], which explains why tags are dangerous and how to obtain a digest.
124/// Both halves answer a question they did not ask; neither answers the one they
125/// did. Someone in that position looks at a config line visibly containing a
126/// digest, reads "that is a tag", and concludes the tool is broken.
127///
128/// The failure was not carelessness in one string. It was that the *reason* was
129/// thrown away at the point it was known: [`pinned_digest`] distinguishes these
130/// cases precisely — the comment at the length check has always said so — and
131/// then routed all of them through one constructor. So the reason is now **data
132/// the refusal carries** rather than a fact the parser knew and discarded, which
133/// is what stops a future case being added to the check and silently inheriting
134/// somebody else's sentence.
135///
136/// # The set is closed, and deliberately not `#[non_exhaustive]`
137///
138/// These are not a taxonomy someone chose; they are the branches of
139/// [`pinned_digest`], which are exhaustive by construction — a reference either
140/// has no `@`, or has one that does not introduce `sha256:`, or has one that
141/// does and is followed by something other than 64 hex characters. Marking this
142/// `#[non_exhaustive]` would imply a fifth is anticipated when the parse says
143/// there cannot be one, and would be **weaker documentation than the silence**
144/// — the reasoning ADR-0001 records for `derived | authored | inferred` (#448),
145/// applied where it holds for the same reason.
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub enum PinDefect {
148    /// `repo:1.2.3` — a written tag, and therefore a mutable pointer.
149    Tag,
150    /// `repo` — neither tag nor digest, which an OCI resolver reads as
151    /// `:latest`.
152    ///
153    /// Its own variant rather than folded into [`Self::Tag`] for this module's
154    /// whole reason: it *is* a mutable pointer and gets the same guidance, but
155    /// telling someone who wrote no tag that they wrote one is describing
156    /// something they did not do. The remedy is shared; the diagnosis is not.
157    ImplicitLatest,
158    /// An `@` that does not introduce a sha256 digest — `repo@sha512:…`, or
159    /// something that names no algorithm at all.
160    ///
161    /// Carries the text after the `@` rather than a pre-parsed algorithm, so the
162    /// message can quote what was written when there is no algorithm to name.
163    NotSha256 {
164        /// Everything after the last `@`, as written.
165        after_at: String,
166    },
167    /// `@sha256:` followed by something that is not a sha256 digest — truncated,
168    /// over-long, empty, or not hexadecimal.
169    MalformedDigest {
170        /// What followed `@sha256:`, as written.
171        given: String,
172    },
173}
174
175impl PinDefect {
176    /// The guidance that fits **this** defect.
177    ///
178    /// The method is the point of the type. Three blocks rather than one because
179    /// the three readers are in different situations: one has never pinned, one
180    /// has pinned by the wrong algorithm, one has pinned correctly and mistyped
181    /// the value. A single accurate-but-vague block would be not-false for all
182    /// three and useful to none, and would cost [`PinDefect::Tag`] the specific,
183    /// correct argument that is the reason the whole rule exists.
184    #[must_use]
185    pub fn guidance(&self) -> Guidance {
186        match self {
187            Self::Tag | Self::ImplicitLatest => PIN_IT,
188            Self::NotSha256 { .. } => SHA256_IS_THE_PIN,
189            Self::MalformedDigest { .. } => CHECK_THE_WHOLE_DIGEST,
190        }
191    }
192}
193
194impl std::fmt::Display for PinDefect {
195    /// The clause after *"…which"*, so each defect states what is true of the
196    /// reference that was actually received.
197    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198        match self {
199            Self::Tag => f.write_str("is a tag rather than a digest"),
200            Self::ImplicitLatest => f.write_str(
201                "names neither a tag nor a digest, so a registry resolves it as `:latest` — \
202                 a tag by another name",
203            ),
204            Self::NotSha256 { after_at } => match after_at.split_once(':') {
205                // No trailing "…, which is what Roteiro reads": the outer message
206                // already opens with "which", and two of them in one sentence is
207                // a message people stop reading halfway through.
208                Some((algorithm, _)) => {
209                    write!(f, "is pinned by {algorithm}, and Roteiro pins by sha256")
210                }
211                None => write!(
212                    f,
213                    "has an `@` followed by {after_at:?}, which names no digest algorithm at all"
214                ),
215            },
216            Self::MalformedDigest { given } if given.is_empty() => {
217                f.write_str("says `@sha256:` and then stops, so it names no digest")
218            }
219            Self::MalformedDigest { given } => {
220                match given.chars().find(|c| !c.is_ascii_hexdigit()) {
221                    // The character rather than its position: an operator scans a
222                    // 64-character string for a symbol far faster than they count
223                    // to an ordinal.
224                    Some(bad) => write!(
225                        f,
226                        "says `@sha256:{given}`, and {bad:?} is not a hexadecimal digit"
227                    ),
228                    None => write!(
229                        f,
230                        "says `@sha256:{given}` — {} hex characters, where a sha256 digest is \
231                         exactly 64",
232                        given.len()
233                    ),
234                }
235            }
236        }
237    }
238}
239
240/// An image reference that is not pinned by a sha256 digest, and why not.
241///
242/// Carries `what` because *which setting to go and change* is half of any
243/// message a reader can act on, and [`PinDefect`] because **what is wrong with
244/// the reference** is the other half. It used to carry only the first and assert
245/// the second, which is the defect [`PinDefect`] documents.
246///
247/// Its audience is deliberately stated wide: anyone who wrote a reference this
248/// module will not accept, **including people who have already pinned**. The
249/// previous doc comment said this was *"a message met by people who have only
250/// ever typed a tag"* — a type narrowing its own audience and then being used
251/// for a wider one, which is how the wrong sentence stayed comfortable for three
252/// of its four callers.
253#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
254#[error("the image for {what} is {reference:?}, which {defect}.{}", defect.guidance())]
255pub struct NotPinned {
256    /// What wanted the image, so the reader knows which setting to change.
257    pub what: String,
258    /// The reference as it was written.
259    pub reference: String,
260    /// What is wrong with it, which selects both the sentence and the guidance.
261    pub defect: PinDefect,
262}
263
264/// The digest `reference` is pinned to, or a refusal naming what to fix.
265///
266/// Holds a [`SANDBOX_IMAGES`](crate::boxlite::SANDBOX_IMAGES) entry, a
267/// user-supplied builder image and a `[security.images]` entry to the same
268/// standard — the difference between them is *who chose*, never *how strong the
269/// pin is*.
270///
271/// Checked structurally rather than by looking for an `@`: a reference may carry
272/// a registry port (`host:5000/repo`) and a tag, so "contains a colon" and
273/// "names a digest" are different questions and only one of them is this one.
274///
275/// Each rejection carries the [`PinDefect`] that produced it. That is not
276/// bookkeeping: this function is the only place that *knows* which of the four
277/// things went wrong, and discarding it here is what previously left three of
278/// four refusals describing a reference nobody had written.
279///
280/// # Errors
281/// Returns [`NotPinned`] with [`PinDefect::Tag`] or [`PinDefect::ImplicitLatest`]
282/// when `reference` names no digest at all, [`PinDefect::NotSha256`] when its
283/// `@` introduces something other than `sha256:`, and
284/// [`PinDefect::MalformedDigest`] when `@sha256:` is followed by anything but 64
285/// hexadecimal characters.
286pub fn pinned_digest<'a>(what: &str, reference: &'a str) -> Result<&'a str, NotPinned> {
287    let refuse = |defect| NotPinned {
288        what: what.to_owned(),
289        reference: reference.to_owned(),
290        defect,
291    };
292
293    let Some((_, digest)) = reference.rsplit_once('@') else {
294        // Only the **last** path segment can carry a tag: `registry:5000/repo`
295        // has a colon and no tag, which is the same distinction that makes the
296        // digest check structural rather than a search for a colon.
297        let tagged = reference
298            .rsplit('/')
299            .next()
300            .is_some_and(|last| last.contains(':'));
301        return Err(refuse(if tagged {
302            PinDefect::Tag
303        } else {
304            PinDefect::ImplicitLatest
305        }));
306    };
307
308    let Some(hex) = digest.strip_prefix("sha256:") else {
309        return Err(refuse(PinDefect::NotSha256 {
310            after_at: digest.to_owned(),
311        }));
312    };
313
314    // Length *and* alphabet: `@sha256:` followed by anything at all would
315    // otherwise satisfy a prefix check while naming nothing.
316    if hex.len() == 64 && hex.bytes().all(|b| b.is_ascii_hexdigit()) {
317        Ok(digest)
318    } else {
319        Err(refuse(PinDefect::MalformedDigest {
320            given: hex.to_owned(),
321        }))
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::{PinDefect, pinned_digest};
328
329    /// A reference is pinned by a sha256 digest or it is refused, and this is
330    /// the one place that decides it — for a `SANDBOX_IMAGES` entry, a
331    /// user-supplied builder image and a `[security.images]` entry alike.
332    ///
333    /// The rejections matter more than the acceptance. A prefix check would let
334    /// `@sha256:` followed by anything through, and a "contains a colon" check
335    /// would reject a registry port — so both the alphabet and the length are
336    /// checked, and a port is not confused for a tag.
337    #[test]
338    fn a_reference_is_pinned_by_a_sha256_digest_or_it_is_refused() {
339        let hex = "a".repeat(64);
340        for pinned in [
341            format!("docker.io/library/rust@sha256:{hex}"),
342            // A registry with a port, which contains a colon and is not a tag.
343            format!("registry.internal:5000/team/rust-clippy@sha256:{hex}"),
344            // A tag *and* a digest: the digest is what resolves, so this is
345            // pinned. Refusing it would reject what `docker pull` prints.
346            format!("docker.io/library/rust:1.97.1@sha256:{hex}"),
347            // Upper-case hex is hex.
348            format!("docker.io/library/rust@sha256:{}", "A".repeat(64)),
349        ] {
350            assert!(pinned_digest("test", &pinned).is_ok(), "{pinned}");
351        }
352
353        for unpinned in [
354            "docker.io/library/rust",
355            "docker.io/library/rust:1.97.1",
356            "registry.internal:5000/team/rust-clippy:latest",
357            "x@sha256:",
358            "x@sha256:deadbeef",
359            "x@sha512:aaaa",
360        ] {
361            assert!(
362                pinned_digest("test", unpinned).is_err(),
363                "{unpinned} must be refused"
364            );
365        }
366    }
367
368    /// **Every refusal says something true about the reference it received.**
369    ///
370    /// This is the regression test for the review finding on #541: one message
371    /// — *"which is a tag rather than a digest"* — was rendered for all four
372    /// ways of failing the check, three of which are not tags. So each case is
373    /// pinned to its own sentence **and** excluded from the others: a table of
374    /// `contains` assertions alone would have passed happily while every row
375    /// rendered the same string.
376    #[test]
377    fn each_defect_says_what_is_actually_wrong_and_not_what_is_wrong_with_another() {
378        let hex = "a".repeat(64);
379        // (reference, the phrase this case must say, the phrases it must not)
380        let cases: &[(&str, &str, &[&str])] = &[
381            (
382                "registry.example/you/tool:1.2.3",
383                "is a tag rather than a digest",
384                &[
385                    "names neither",
386                    "pinned by",
387                    "hex characters",
388                    "no digest algorithm",
389                ],
390            ),
391            (
392                "registry.example:5000/you/tool:latest",
393                "is a tag rather than a digest",
394                &["names neither", "pinned by", "hex characters"],
395            ),
396            (
397                "registry.example/you/tool",
398                "names neither a tag nor a digest",
399                // Not "is a tag": nobody wrote one.
400                &["is a tag rather than", "pinned by", "hex characters"],
401            ),
402            (
403                // A registry port and no tag: the colon is not a tag, and the
404                // last segment is what decides.
405                "registry.example:5000/you/tool",
406                "names neither a tag nor a digest",
407                &["is a tag rather than", "pinned by", "hex characters"],
408            ),
409            (
410                &format!("registry.example/you/tool@sha512:{hex}"),
411                "is pinned by sha512, and Roteiro pins by sha256",
412                &["is a tag rather than", "names neither", "hex characters"],
413            ),
414            (
415                "registry.example/you/tool@nonsense",
416                "names no digest algorithm at all",
417                &["is a tag rather than", "names neither", "hex characters"],
418            ),
419            (
420                "registry.example/you/tool@sha256:",
421                "stops, so it names no digest",
422                &["is a tag rather than", "names neither", "pinned by"],
423            ),
424            (
425                // The one the review named: the abbreviated digest a registry UI
426                // shows. The author has already pinned.
427                "registry.example/you/tool@sha256:deadbeef",
428                "8 hex characters, where a sha256 digest is exactly 64",
429                &["is a tag rather than", "names neither", "pinned by"],
430            ),
431            (
432                &format!("registry.example/you/tool@sha256:{}", "a".repeat(65)),
433                "65 hex characters, where a sha256 digest is exactly 64",
434                &["is a tag rather than", "names neither"],
435            ),
436            (
437                &format!("registry.example/you/tool@sha256:{}z", "a".repeat(63)),
438                "and 'z' is not a hexadecimal digit",
439                &[
440                    "is a tag rather than",
441                    "names neither",
442                    "hex characters where",
443                ],
444            ),
445        ];
446
447        for (reference, must_say, must_not_say) in cases {
448            let message = pinned_digest("`[security.images] tool`", reference)
449                .expect_err(&format!("{reference} must be refused"))
450                .to_string();
451            assert!(
452                message.contains(must_say),
453                "{reference} should say {must_say:?}:\n{message}"
454            );
455            for wrong in *must_not_say {
456                assert!(
457                    !message.contains(wrong),
458                    "{reference} must not say {wrong:?} — that describes a different mistake:\n{message}"
459                );
460            }
461            // Whatever the defect, the two halves a reader acts on are present.
462            assert!(message.contains("`[security.images] tool`"), "{message}");
463            assert!(message.contains(*reference), "{message}");
464            assert!(message.contains("imagetools inspect"), "{message}");
465        }
466    }
467
468    /// The guidance follows the defect, and the mutability argument is attached
469    /// to **only** the references that are actually mutable.
470    ///
471    /// The wrong-guidance half is the more damaging half of the original defect
472    /// and would survive a fix that only reworded the first sentence: someone
473    /// whose digest is eight characters was being told why tags are dangerous
474    /// and how to obtain a digest, neither of which is their problem. A
475    /// `sha512` pin was being called a moving target when it is immutable.
476    #[test]
477    fn the_guidance_matches_the_defect_rather_than_the_first_case_written() {
478        let hex = "a".repeat(64);
479        let render = |reference: &str| {
480            pinned_digest("`[lint] image`", reference)
481                .expect_err("refused")
482                .to_string()
483        };
484
485        for mutable in ["repo/tool:1.2", "repo/tool"] {
486            let message = render(mutable);
487            assert!(message.contains("mutable"), "{mutable}: {message}");
488            assert!(message.contains("Pin it by digest instead"), "{message}");
489        }
490
491        let other_algorithm = render(&format!("repo/tool@sha512:{hex}"));
492        assert!(
493            !other_algorithm.contains("mutable"),
494            "a sha512 digest is immutable; calling it a moving target is false:\n{other_algorithm}"
495        );
496        assert!(
497            other_algorithm.contains("is not a moving target"),
498            "{other_algorithm}"
499        );
500        assert!(other_algorithm.contains("sha256"), "{other_algorithm}");
501
502        let malformed = render("repo/tool@sha256:deadbeef");
503        assert!(
504            !malformed.contains("mutable"),
505            "this reader has already pinned:\n{malformed}"
506        );
507        assert!(
508            !malformed.contains("Pin it by digest instead"),
509            "they did pin; the value is what is wrong:\n{malformed}"
510        );
511        assert!(
512            malformed.contains("abbreviated form"),
513            "the message names the thing that is usually true:\n{malformed}"
514        );
515
516        // Three distinct guidance blocks, so no two defects are being served one
517        // block that happens to fit neither.
518        let blocks = [
519            PinDefect::Tag.guidance().to_string(),
520            PinDefect::NotSha256 {
521                after_at: "sha512:x".to_owned(),
522            }
523            .guidance()
524            .to_string(),
525            PinDefect::MalformedDigest {
526                given: "deadbeef".to_owned(),
527            }
528            .guidance()
529            .to_string(),
530        ];
531        for (i, a) in blocks.iter().enumerate() {
532            for b in blocks.iter().skip(i + 1) {
533                assert_ne!(a, b, "two defects share one block of guidance");
534            }
535        }
536        // The two mutable-pointer defects deliberately *do* share theirs: the
537        // diagnosis differs, the remedy does not.
538        assert_eq!(
539            PinDefect::Tag.guidance().to_string(),
540            PinDefect::ImplicitLatest.guidance().to_string()
541        );
542    }
543
544    /// The refusal names the key it was asked about, so one message can serve
545    /// every surface without any reader being told to edit another's file.
546    #[test]
547    fn the_refusal_names_whichever_key_carried_the_reference() {
548        for what in ["`[lint] image`", "`[security.images] osv-scanner`"] {
549            for reference in ["example.com/i:latest", "example.com/i@sha256:beef"] {
550                let err = pinned_digest(what, reference).expect_err("refused");
551                assert!(err.to_string().contains(what), "{err}");
552            }
553        }
554    }
555}