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}