1use std::io::Read;
18use std::process::{Command, Stdio};
19use std::time::{Duration, Instant};
20
21use crate::error::{Error, Result};
22use crate::plan::ImageSource;
23
24pub const CHECK_TIMEOUT: Duration = Duration::from_secs(15);
26pub const DEPLOY_TIMEOUT: Duration = Duration::from_secs(60);
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum Probe {
32 Found(Option<String>),
34 NotFound(String),
37 Denied(String),
40 Unknown(String),
43}
44
45impl Probe {
46 pub fn digest(&self) -> Option<&str> {
48 match self {
49 Probe::Found(d) => d.as_deref(),
50 _ => None,
51 }
52 }
53}
54
55pub fn remote(image: &str) -> Option<(String, String)> {
61 let src = ImageSource::parse(image).ok()?;
62 if !src.is_oci() {
63 return None;
64 }
65 let host = src.server.as_deref()?.strip_prefix("https://")?;
66 let name = match host {
67 "docker.io" => "Docker Hub".to_string(),
68 "ghcr.io" => "GitHub Container Registry".to_string(),
69 h => h.to_string(),
70 };
71 Some((format!("docker://{host}/{}", src.alias), name))
72}
73
74pub fn probe(image: &str, timeout: Duration) -> Probe {
77 let Some((r, _)) = remote(image) else {
78 return Probe::Found(None);
79 };
80 let child = Command::new("skopeo")
81 .args(["inspect", "--no-tags", "--format", "{{.Digest}}", &r])
82 .stdin(Stdio::null())
83 .stdout(Stdio::piped())
84 .stderr(Stdio::piped())
85 .spawn();
86 let mut child = match child {
87 Ok(c) => c,
88 Err(e) => return Probe::Unknown(format!("cannot run skopeo: {e}")),
89 };
90 let mut out = child.stdout.take();
92 let mut err = child.stderr.take();
93 let o = std::thread::spawn(move || {
94 let mut s = String::new();
95 if let Some(p) = out.as_mut() {
96 let _ = p.read_to_string(&mut s);
97 }
98 s
99 });
100 let e = std::thread::spawn(move || {
101 let mut s = String::new();
102 if let Some(p) = err.as_mut() {
103 let _ = p.read_to_string(&mut s);
104 }
105 s
106 });
107 let started = Instant::now();
108 let ok = loop {
109 match child.try_wait() {
110 Ok(Some(s)) => break s.success(),
111 Ok(None) if started.elapsed() > timeout => {
112 let _ = child.kill();
113 let _ = child.wait();
114 return Probe::Unknown(format!("no answer within {}s", timeout.as_secs()));
115 }
116 Ok(None) => std::thread::sleep(Duration::from_millis(50)),
117 Err(e) => return Probe::Unknown(format!("skopeo: {e}")),
118 }
119 };
120 let stdout = o.join().unwrap_or_default();
121 let stderr = e.join().unwrap_or_default();
122 if ok {
123 let d = stdout.trim();
124 let digest = (d.starts_with("sha256:") && d.len() == 71).then(|| d.to_string());
125 return Probe::Found(digest);
126 }
127 classify(&stderr)
128}
129
130pub fn classify(stderr: &str) -> Probe {
132 let why = reason(stderr);
133 let l = stderr.to_ascii_lowercase();
134 if [
135 "manifest unknown",
136 "name unknown",
137 "no image found in manifest list",
138 "not found: manifest",
139 ]
140 .iter()
141 .any(|k| l.contains(k))
142 {
143 Probe::NotFound(why)
144 } else if [
145 "denied",
146 "unauthorized",
147 "authentication required",
148 "401",
149 "403 forbidden",
150 ]
151 .iter()
152 .any(|k| l.contains(k))
153 {
154 Probe::Denied(why)
155 } else {
156 Probe::Unknown(why)
157 }
158}
159
160fn reason(stderr: &str) -> String {
164 let line = stderr
165 .lines()
166 .rev()
167 .find(|l| !l.trim().is_empty())
168 .unwrap_or("");
169 let msg = match line.split_once("msg=\"") {
170 Some((_, m)) => quoted(m),
171 None => line.trim().to_string(),
172 };
173 let tail = msg.rsplit(": ").next().unwrap_or(&msg).trim();
174 if tail.is_empty() {
175 "no reason given".to_string()
176 } else {
177 tail.to_string()
178 }
179}
180
181fn quoted(m: &str) -> String {
184 let mut out = String::new();
185 let mut chars = m.chars();
186 while let Some(c) = chars.next() {
187 match c {
188 '\\' => out.extend(chars.next()),
189 '"' => break,
190 c => out.push(c),
191 }
192 }
193 out
194}
195
196pub fn suggestion(image: &str) -> Option<String> {
200 let rest = image.strip_prefix("docker:")?;
201 if rest.contains('/') || rest.contains('@') {
202 return None;
203 }
204 let (owner, name) = rest.split_once(':')?;
205 if owner.is_empty() || name.is_empty() || name.contains(':') {
206 return None;
207 }
208 Some(format!("docker:{owner}/{name}"))
209}
210
211pub fn check(image: &str, probe: &dyn Fn(&str) -> Probe) -> Result<Option<String>> {
215 let Some((_, registry)) = remote(image) else {
216 return Ok(None);
217 };
218 let found = |p: &Probe| matches!(p, Probe::Found(_));
219 let better = || suggestion(image).filter(|s| found(&probe(s)));
220 match probe(image) {
221 Probe::Found(_) => Ok(None),
222 Probe::NotFound(why) => Err(Error::invalid(not_found(image, ®istry, &why, better()))),
223 Probe::Denied(why) => Ok(Some(match better() {
224 Some(s) => format!(
225 "{registry} refused to show image {image} without credentials ({why}): it is private or does not exist. Did you mean {s}?"
226 ),
227 None => format!(
228 "{registry} refused to show image {image} without credentials ({why}): if it is private, the host needs credentials to pull it; if it does not exist, the deploy will fail"
229 ),
230 })),
231 Probe::Unknown(why) => Ok(Some(format!(
232 "could not check image {image} on {registry} ({why}); saved unchecked"
233 ))),
234 }
235}
236
237pub fn not_found(image: &str, registry: &str, why: &str, better: Option<String>) -> String {
239 let mut m = format!("image {image} not found on {registry} ({why})");
240 match better {
241 Some(s) => m.push_str(&format!(": did you mean {s}?")),
242 None => m.push_str(
243 ": check the name and tag (Docker Hub images are docker:NAME[:TAG] or docker:OWNER/NAME[:TAG], e.g. docker:nginx:1.27 or docker:traefik/whoami)",
244 ),
245 }
246 m
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 const UNKNOWN: &str = r#"time="2026-10-05T04:38:08Z" level=fatal msg="Error parsing image name \"docker://docker.io/library/traefik:whoami\": reading manifest whoami in docker.io/library/traefik: manifest unknown""#;
254 const DENIED: &str = r#"time="x" level=fatal msg="Error parsing image name \"docker://docker.io/library/ngnixx:latest\": reading manifest latest in docker.io/library/ngnixx: requested access to the resource is denied""#;
255 const OFFLINE: &str = r#"time="x" level=fatal msg="Error parsing image name \"docker://nosuch.invalid/a/b:1\": pinging container registry nosuch.invalid: Get \"https://nosuch.invalid/v2/\": dial tcp: lookup nosuch.invalid on 127.0.0.53:53: no such host""#;
256 const GHCR: &str = r#"time="x" level=fatal msg="Error parsing image name \"docker://ghcr.io/o/a:v1\": Requesting bearer token: received unexpected HTTP status: 403 Forbidden""#;
257 const PLATFORM: &str = r#"time="x" level=fatal msg="Error parsing image name \"docker://docker.io/o/a:1\": choosing image instance: no image found in manifest list for architecture \"amd64\", variant \"\", OS \"linux\"""#;
258
259 #[test]
260 fn skopeo_errors_are_sorted_by_what_they_mean() {
261 assert_eq!(
262 classify(UNKNOWN),
263 Probe::NotFound("manifest unknown".into())
264 );
265 assert!(matches!(classify(PLATFORM), Probe::NotFound(w) if w.ends_with("OS \"linux\"")));
266 assert_eq!(
267 classify(DENIED),
268 Probe::Denied("requested access to the resource is denied".into())
269 );
270 assert!(matches!(classify(GHCR), Probe::Denied(_)));
271 assert!(matches!(classify(OFFLINE), Probe::Unknown(w) if w == "no such host"));
272 assert!(matches!(classify(""), Probe::Unknown(_)));
273 }
274
275 #[test]
276 fn only_remote_oci_images_are_asked_about() {
277 assert_eq!(
278 remote("docker:traefik:whoami").unwrap(),
279 (
280 "docker://docker.io/library/traefik:whoami".to_string(),
281 "Docker Hub".to_string()
282 )
283 );
284 assert_eq!(
285 remote("docker:traefik/whoami").unwrap().0,
286 "docker://docker.io/traefik/whoami:latest"
287 );
288 assert_eq!(
289 remote("ghcr:org/app:v1").unwrap(),
290 (
291 "docker://ghcr.io/org/app:v1".to_string(),
292 "GitHub Container Registry".to_string()
293 )
294 );
295 assert_eq!(
296 remote("oci:reg.example.com:5000/a/b:1").unwrap().1,
297 "reg.example.com:5000"
298 );
299 for local in [
300 "dev-base",
301 "images:debian/12",
302 "registry:web:v1",
303 "nonsense:x",
304 ] {
305 assert!(remote(local).is_none(), "{local}");
306 }
307 }
308
309 #[test]
310 fn a_colon_for_a_slash_is_suggested_back() {
311 assert_eq!(
312 suggestion("docker:traefik:whoami").as_deref(),
313 Some("docker:traefik/whoami")
314 );
315 for none in [
316 "docker:traefik/whoami",
317 "docker:nginx",
318 "docker:a:b:c",
319 "docker:a@sha256:x",
320 "ghcr:a:b",
321 ] {
322 assert!(suggestion(none).is_none(), "{none}");
323 }
324 }
325
326 fn registry(found: &'static [&'static str], answer: Probe) -> impl Fn(&str) -> Probe {
327 move |i: &str| {
328 if found.contains(&i) {
329 Probe::Found(Some(format!("sha256:{}", "a".repeat(64))))
330 } else {
331 answer.clone()
332 }
333 }
334 }
335
336 #[test]
337 fn a_missing_image_is_refused_with_what_was_meant() {
338 let p = registry(
339 &["docker:traefik/whoami"],
340 Probe::NotFound("manifest unknown".into()),
341 );
342 let e = check("docker:traefik:whoami", &p).unwrap_err().to_string();
343 assert!(
344 e.contains("image docker:traefik:whoami not found on Docker Hub (manifest unknown): did you mean docker:traefik/whoami?"),
345 "{e}"
346 );
347 let e = check("docker:nginx:nosuchtag", &p).unwrap_err().to_string();
348 assert!(
349 e.contains("not found on Docker Hub") && e.contains("docker:nginx:1.27"),
350 "{e}"
351 );
352 assert_eq!(check("docker:traefik/whoami", &p).unwrap(), None);
353 }
354
355 #[test]
356 fn what_cannot_be_checked_warns_and_never_blocks() {
357 let p = registry(&[], Probe::Unknown("no such host".into()));
358 let w = check("oci:nosuch.invalid/a/b:1", &p).unwrap().unwrap();
359 assert!(
360 w.contains("could not check") && w.contains("no such host"),
361 "{w}"
362 );
363 let p = registry(&[], Probe::Denied("denied".into()));
364 let w = check("ghcr:o/private:v1", &p).unwrap().unwrap();
365 assert!(w.contains("without credentials"), "{w}");
366 let p = registry(&["docker:traefik/whoami"], Probe::Denied("denied".into()));
367 let w = check("docker:traefik:whoami", &p).unwrap().unwrap();
368 assert!(w.contains("Did you mean docker:traefik/whoami?"), "{w}");
369 let never = |_: &str| -> Probe { panic!("asked") };
371 assert_eq!(check("dev-base", &never).unwrap(), None);
372 assert_eq!(check("registry:web:v1", &never).unwrap(), None);
373 }
374}