use crate::guidance::{Guidance, Line};
pub const PIN_IT: Guidance = Guidance::new(&[
Line::Note(&[
"An image is where somebody else's code executes, and a tag is a mutable",
"pointer to it — whoever controls the tag can replace what runs, with no",
"version change and no notice.",
]),
Line::Note(&["Pin it by digest instead:"]),
Line::Command("<key> = \"docker.io/you/image@sha256:<64 hex>\""),
Line::Note(&[
"`docker buildx imagetools inspect <reference>` prints it. Use the **index**",
"digest — the one printed for the tag itself — so one reference resolves on",
"both amd64 and arm64 rather than two that can drift apart.",
]),
]);
pub const SHA256_IS_THE_PIN: Guidance = Guidance::new(&[
Line::Note(&[
"The reference is digest-addressed and is not a moving target — Roteiro",
"simply pins by **sha256**, which is what an OCI registry serves as a",
"manifest digest.",
]),
Line::Note(&["Use the sha256 digest of the same image:"]),
Line::Command("<key> = \"docker.io/you/image@sha256:<64 hex>\""),
Line::Note(&[
"`docker buildx imagetools inspect <reference>` prints it. Use the **index**",
"digest — the one printed for the tag itself — so one reference resolves on",
"both amd64 and arm64 rather than two that can drift apart.",
]),
]);
pub const CHECK_THE_WHOLE_DIGEST: Guidance = Guidance::new(&[
Line::Note(&[
"A sha256 digest is exactly 64 hexadecimal characters. The commonest cause",
"of a short one is the abbreviated form a registry UI or `docker images`",
"shows, which is a prefix of the digest and not the digest.",
]),
Line::Note(&["The whole one is printed by:"]),
Line::Command("docker buildx imagetools inspect <reference>"),
Line::Note(&[
"Take the **index** digest — the one printed for the tag itself — so one",
"reference resolves on both amd64 and arm64 rather than two that can drift",
"apart.",
]),
]);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PinDefect {
Tag,
ImplicitLatest,
NotSha256 {
after_at: String,
},
MalformedDigest {
given: String,
},
}
impl PinDefect {
#[must_use]
pub fn guidance(&self) -> Guidance {
match self {
Self::Tag | Self::ImplicitLatest => PIN_IT,
Self::NotSha256 { .. } => SHA256_IS_THE_PIN,
Self::MalformedDigest { .. } => CHECK_THE_WHOLE_DIGEST,
}
}
}
impl std::fmt::Display for PinDefect {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Tag => f.write_str("is a tag rather than a digest"),
Self::ImplicitLatest => f.write_str(
"names neither a tag nor a digest, so a registry resolves it as `:latest` — \
a tag by another name",
),
Self::NotSha256 { after_at } => match after_at.split_once(':') {
Some((algorithm, _)) => {
write!(f, "is pinned by {algorithm}, and Roteiro pins by sha256")
}
None => write!(
f,
"has an `@` followed by {after_at:?}, which names no digest algorithm at all"
),
},
Self::MalformedDigest { given } if given.is_empty() => {
f.write_str("says `@sha256:` and then stops, so it names no digest")
}
Self::MalformedDigest { given } => {
match given.chars().find(|c| !c.is_ascii_hexdigit()) {
Some(bad) => write!(
f,
"says `@sha256:{given}`, and {bad:?} is not a hexadecimal digit"
),
None => write!(
f,
"says `@sha256:{given}` — {} hex characters, where a sha256 digest is \
exactly 64",
given.len()
),
}
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("the image for {what} is {reference:?}, which {defect}.{}", defect.guidance())]
pub struct NotPinned {
pub what: String,
pub reference: String,
pub defect: PinDefect,
}
pub fn pinned_digest<'a>(what: &str, reference: &'a str) -> Result<&'a str, NotPinned> {
let refuse = |defect| NotPinned {
what: what.to_owned(),
reference: reference.to_owned(),
defect,
};
let Some((_, digest)) = reference.rsplit_once('@') else {
let tagged = reference
.rsplit('/')
.next()
.is_some_and(|last| last.contains(':'));
return Err(refuse(if tagged {
PinDefect::Tag
} else {
PinDefect::ImplicitLatest
}));
};
let Some(hex) = digest.strip_prefix("sha256:") else {
return Err(refuse(PinDefect::NotSha256 {
after_at: digest.to_owned(),
}));
};
if hex.len() == 64 && hex.bytes().all(|b| b.is_ascii_hexdigit()) {
Ok(digest)
} else {
Err(refuse(PinDefect::MalformedDigest {
given: hex.to_owned(),
}))
}
}
#[cfg(test)]
mod tests {
use super::{PinDefect, pinned_digest};
#[test]
fn a_reference_is_pinned_by_a_sha256_digest_or_it_is_refused() {
let hex = "a".repeat(64);
for pinned in [
format!("docker.io/library/rust@sha256:{hex}"),
format!("registry.internal:5000/team/rust-clippy@sha256:{hex}"),
format!("docker.io/library/rust:1.97.1@sha256:{hex}"),
format!("docker.io/library/rust@sha256:{}", "A".repeat(64)),
] {
assert!(pinned_digest("test", &pinned).is_ok(), "{pinned}");
}
for unpinned in [
"docker.io/library/rust",
"docker.io/library/rust:1.97.1",
"registry.internal:5000/team/rust-clippy:latest",
"x@sha256:",
"x@sha256:deadbeef",
"x@sha512:aaaa",
] {
assert!(
pinned_digest("test", unpinned).is_err(),
"{unpinned} must be refused"
);
}
}
#[test]
fn each_defect_says_what_is_actually_wrong_and_not_what_is_wrong_with_another() {
let hex = "a".repeat(64);
let cases: &[(&str, &str, &[&str])] = &[
(
"registry.example/you/tool:1.2.3",
"is a tag rather than a digest",
&[
"names neither",
"pinned by",
"hex characters",
"no digest algorithm",
],
),
(
"registry.example:5000/you/tool:latest",
"is a tag rather than a digest",
&["names neither", "pinned by", "hex characters"],
),
(
"registry.example/you/tool",
"names neither a tag nor a digest",
&["is a tag rather than", "pinned by", "hex characters"],
),
(
"registry.example:5000/you/tool",
"names neither a tag nor a digest",
&["is a tag rather than", "pinned by", "hex characters"],
),
(
&format!("registry.example/you/tool@sha512:{hex}"),
"is pinned by sha512, and Roteiro pins by sha256",
&["is a tag rather than", "names neither", "hex characters"],
),
(
"registry.example/you/tool@nonsense",
"names no digest algorithm at all",
&["is a tag rather than", "names neither", "hex characters"],
),
(
"registry.example/you/tool@sha256:",
"stops, so it names no digest",
&["is a tag rather than", "names neither", "pinned by"],
),
(
"registry.example/you/tool@sha256:deadbeef",
"8 hex characters, where a sha256 digest is exactly 64",
&["is a tag rather than", "names neither", "pinned by"],
),
(
&format!("registry.example/you/tool@sha256:{}", "a".repeat(65)),
"65 hex characters, where a sha256 digest is exactly 64",
&["is a tag rather than", "names neither"],
),
(
&format!("registry.example/you/tool@sha256:{}z", "a".repeat(63)),
"and 'z' is not a hexadecimal digit",
&[
"is a tag rather than",
"names neither",
"hex characters where",
],
),
];
for (reference, must_say, must_not_say) in cases {
let message = pinned_digest("`[security.images] tool`", reference)
.expect_err(&format!("{reference} must be refused"))
.to_string();
assert!(
message.contains(must_say),
"{reference} should say {must_say:?}:\n{message}"
);
for wrong in *must_not_say {
assert!(
!message.contains(wrong),
"{reference} must not say {wrong:?} — that describes a different mistake:\n{message}"
);
}
assert!(message.contains("`[security.images] tool`"), "{message}");
assert!(message.contains(*reference), "{message}");
assert!(message.contains("imagetools inspect"), "{message}");
}
}
#[test]
fn the_guidance_matches_the_defect_rather_than_the_first_case_written() {
let hex = "a".repeat(64);
let render = |reference: &str| {
pinned_digest("`[lint] image`", reference)
.expect_err("refused")
.to_string()
};
for mutable in ["repo/tool:1.2", "repo/tool"] {
let message = render(mutable);
assert!(message.contains("mutable"), "{mutable}: {message}");
assert!(message.contains("Pin it by digest instead"), "{message}");
}
let other_algorithm = render(&format!("repo/tool@sha512:{hex}"));
assert!(
!other_algorithm.contains("mutable"),
"a sha512 digest is immutable; calling it a moving target is false:\n{other_algorithm}"
);
assert!(
other_algorithm.contains("is not a moving target"),
"{other_algorithm}"
);
assert!(other_algorithm.contains("sha256"), "{other_algorithm}");
let malformed = render("repo/tool@sha256:deadbeef");
assert!(
!malformed.contains("mutable"),
"this reader has already pinned:\n{malformed}"
);
assert!(
!malformed.contains("Pin it by digest instead"),
"they did pin; the value is what is wrong:\n{malformed}"
);
assert!(
malformed.contains("abbreviated form"),
"the message names the thing that is usually true:\n{malformed}"
);
let blocks = [
PinDefect::Tag.guidance().to_string(),
PinDefect::NotSha256 {
after_at: "sha512:x".to_owned(),
}
.guidance()
.to_string(),
PinDefect::MalformedDigest {
given: "deadbeef".to_owned(),
}
.guidance()
.to_string(),
];
for (i, a) in blocks.iter().enumerate() {
for b in blocks.iter().skip(i + 1) {
assert_ne!(a, b, "two defects share one block of guidance");
}
}
assert_eq!(
PinDefect::Tag.guidance().to_string(),
PinDefect::ImplicitLatest.guidance().to_string()
);
}
#[test]
fn the_refusal_names_whichever_key_carried_the_reference() {
for what in ["`[lint] image`", "`[security.images] osv-scanner`"] {
for reference in ["example.com/i:latest", "example.com/i@sha256:beef"] {
let err = pinned_digest(what, reference).expect_err("refused");
assert!(err.to_string().contains(what), "{err}");
}
}
}
}