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