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}