1use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
18use std::time::Duration;
19
20use reqwest::{Client, Url};
21
22use crate::backend::container_image::{parse_bearer_challenge, parse_image_ref};
23use crate::backend::rpc_types::ContainerImageAccess;
24
25const REQUEST_TIMEOUT: Duration = Duration::from_secs(6);
26const DNS_TIMEOUT: Duration = Duration::from_secs(3);
27const PROBE_TIMEOUT: Duration = Duration::from_secs(15);
28const MAX_TOKEN_BYTES: usize = 64 * 1024;
30
31const MANIFEST_ACCEPT: &str = "application/vnd.oci.image.index.v1+json, \
32 application/vnd.oci.image.manifest.v1+json, \
33 application/vnd.docker.distribution.manifest.list.v2+json, \
34 application/vnd.docker.distribution.manifest.v2+json";
35
36#[derive(Clone, Copy)]
39struct Policy {
40 strict_registry: bool,
42 strict_realm: bool,
44}
45
46const PRODUCTION: Policy = Policy { strict_registry: true, strict_realm: true };
47
48pub async fn probe_anonymous(image: &str) -> ContainerImageAccess {
53 probe_with(image, PRODUCTION, None).await
54}
55
56async fn probe_with(image: &str, policy: Policy, registry_base: Option<&str>) -> ContainerImageAccess {
57 match tokio::time::timeout(PROBE_TIMEOUT, probe_inner(image, policy, registry_base)).await {
58 Ok(access) => access,
59 Err(_) => ContainerImageAccess::Unknown,
60 }
61}
62
63async fn probe_inner(image: &str, policy: Policy, registry_base: Option<&str>) -> ContainerImageAccess {
64 use ContainerImageAccess::{Denied, NotFound, Public, Unknown};
65
66 let Some(image_ref) = parse_image_ref(image) else { return Unknown };
67 let base = match registry_base {
68 Some(b) => b.to_string(),
69 None => format!("https://{}/", image_ref.registry),
70 };
71 let Ok(mut manifest_url) = Url::parse(&base) else { return Unknown };
72 if manifest_url.host().is_none() || !manifest_url.username().is_empty() || manifest_url.password().is_some() {
73 return Unknown;
74 }
75 {
76 let Ok(mut segments) = manifest_url.path_segments_mut() else { return Unknown };
77 segments.clear().push("v2");
78 for part in image_ref.repo.split('/') {
79 segments.push(part);
80 }
81 segments.push("manifests").push(&image_ref.reference);
82 }
83
84 let Some(client) = guarded_client(&manifest_url, policy.strict_registry).await else { return Unknown };
85 let first = match client.get(manifest_url.clone()).header("Accept", MANIFEST_ACCEPT).send().await {
86 Ok(r) => r,
87 Err(_) => return Unknown,
88 };
89 match first.status().as_u16() {
90 200 => return Public,
91 404 => return NotFound,
92 401 => {}
93 403 => return Denied,
94 _ => return Unknown,
96 }
97
98 let challenge = first
99 .headers()
100 .get("www-authenticate")
101 .and_then(|v| v.to_str().ok())
102 .and_then(parse_bearer_challenge);
103 let Some(challenge) = challenge else { return Denied };
104
105 let Ok(mut token_url) = validate_realm(&challenge.realm, policy.strict_realm) else { return Unknown };
106 {
107 let mut query = token_url.query_pairs_mut();
108 if let Some(service) = &challenge.service {
109 query.append_pair("service", service);
110 }
111 let scope = challenge.scope.clone().unwrap_or_else(|| format!("repository:{}:pull", image_ref.repo));
112 query.append_pair("scope", &scope);
113 }
114 let Some(token_client) = guarded_client(&token_url, policy.strict_realm).await else { return Unknown };
115 let token = match token_client.get(token_url).send().await {
116 Ok(r) if r.status().is_success() => read_capped(r, MAX_TOKEN_BYTES).await.and_then(|body| {
117 let v: serde_json::Value = serde_json::from_slice(&body).ok()?;
118 v.get("token")
119 .or_else(|| v.get("access_token"))
120 .and_then(|t| t.as_str())
121 .map(str::to_string)
122 }),
123 Ok(r) if matches!(r.status().as_u16(), 401 | 403) => return Denied,
124 _ => None,
125 };
126 let Some(token) = token else { return Unknown };
127
128 match client
129 .get(manifest_url)
130 .header("Accept", MANIFEST_ACCEPT)
131 .bearer_auth(token)
132 .send()
133 .await
134 {
135 Ok(r) => match r.status().as_u16() {
136 200 => Public,
137 401 | 403 => Denied,
138 404 => NotFound,
139 _ => Unknown,
140 },
141 Err(_) => Unknown,
142 }
143}
144
145async fn read_capped(mut resp: reqwest::Response, max: usize) -> Option<Vec<u8>> {
147 if resp.content_length().is_some_and(|len| len > max as u64) {
148 return None;
149 }
150 let mut body = Vec::new();
151 while let Some(chunk) = resp.chunk().await.ok()? {
152 if body.len() + chunk.len() > max {
153 return None;
154 }
155 body.extend_from_slice(&chunk);
156 }
157 Some(body)
158}
159
160async fn guarded_client(url: &Url, strict: bool) -> Option<Client> {
165 let mut builder = Client::builder()
166 .timeout(REQUEST_TIMEOUT)
167 .redirect(reqwest::redirect::Policy::none());
168 if strict {
169 if url.scheme() != "https" {
170 return None;
171 }
172 let port = url.port_or_known_default().unwrap_or(443);
173 match url.host()? {
174 url::Host::Ipv4(ip) => {
175 if ip_is_blocked(IpAddr::V4(ip)) {
176 return None;
177 }
178 }
179 url::Host::Ipv6(ip) => {
180 if ip_is_blocked(IpAddr::V6(ip)) {
181 return None;
182 }
183 }
184 url::Host::Domain(name) => {
185 if hostname_is_blocked(name) {
186 return None;
187 }
188 let resolved: Vec<SocketAddr> = tokio::time::timeout(DNS_TIMEOUT, tokio::net::lookup_host((name, port)))
189 .await
190 .ok()?
191 .ok()?
192 .collect();
193 if !all_public(&resolved) {
194 return None;
195 }
196 builder = builder.resolve_to_addrs(name, &resolved);
197 }
198 }
199 }
200 builder.build().ok()
201}
202
203fn validate_realm(realm: &str, strict: bool) -> Result<Url, &'static str> {
207 let url = Url::parse(realm).map_err(|_| "not a URL")?;
208 if !strict {
209 return Ok(url);
210 }
211 if url.scheme() != "https" {
212 return Err("not https");
213 }
214 if !url.username().is_empty() || url.password().is_some() {
215 return Err("has credentials");
216 }
217 if url.port().is_some_and(|p| p != 443) {
218 return Err("unusual port");
219 }
220 match url.host().ok_or("no host")? {
221 url::Host::Ipv4(ip) if ip_is_blocked(IpAddr::V4(ip)) => Err("private address"),
222 url::Host::Ipv6(ip) if ip_is_blocked(IpAddr::V6(ip)) => Err("private address"),
223 url::Host::Domain(name) if hostname_is_blocked(name) => Err("local name"),
224 _ => Ok(url),
225 }
226}
227
228fn all_public(addrs: &[SocketAddr]) -> bool {
230 !addrs.is_empty() && addrs.iter().all(|a| !ip_is_blocked(a.ip()))
231}
232
233fn hostname_is_blocked(name: &str) -> bool {
235 let name = name.trim_end_matches('.').to_ascii_lowercase();
236 if name.is_empty() || !name.contains('.') {
237 return true;
238 }
239 [".localhost", ".local", ".internal", ".localdomain", ".home.arpa", ".lan"]
240 .iter()
241 .any(|suffix| name.ends_with(suffix))
242}
243
244fn embedded_v4(high: u16, low: u16) -> Ipv4Addr {
245 Ipv4Addr::new((high >> 8) as u8, high as u8, (low >> 8) as u8, low as u8)
246}
247
248pub(crate) fn ip_is_blocked(ip: IpAddr) -> bool {
253 match ip {
254 IpAddr::V4(v4) => ipv4_is_blocked(v4),
255 IpAddr::V6(v6) => ipv6_is_blocked(v6),
256 }
257}
258
259fn ipv4_is_blocked(ip: Ipv4Addr) -> bool {
260 let o = ip.octets();
261 ip.is_loopback()
262 || ip.is_private()
263 || ip.is_link_local()
264 || ip.is_unspecified()
265 || ip.is_broadcast()
266 || ip.is_multicast()
267 || ip.is_documentation()
268 || o[0] == 0
269 || (o[0] == 100 && (o[1] & 0xC0) == 64)
270 || (o[0] == 192 && o[1] == 0 && o[2] == 0)
271 || (o[0] == 198 && (o[1] & 0xFE) == 18)
272 || o[0] >= 240
273}
274
275fn ipv6_is_blocked(ip: Ipv6Addr) -> bool {
276 if let Some(v4) = ip.to_ipv4_mapped() {
277 return ipv4_is_blocked(v4);
278 }
279 let s = ip.segments();
280 let first_six_zero = s[..6].iter().all(|&x| x == 0);
281 if ip.is_loopback() || ip.is_unspecified() || ip.is_multicast() {
282 return true;
283 }
284 if first_six_zero {
286 return true;
287 }
288 if s[0] == 0x64 && s[1] == 0xff9b && s[2..6].iter().all(|&x| x == 0) {
289 return ipv4_is_blocked(embedded_v4(s[6], s[7]));
290 }
291 if s[0] == 0x2002 {
292 return ipv4_is_blocked(embedded_v4(s[1], s[2]));
293 }
294 (s[0] & 0xfe00) == 0xfc00 || (s[0] & 0xffc0) == 0xfe80 || (s[0] & 0xffc0) == 0xfec0 || (s[0] == 0x2001 && s[1] == 0) || (s[0] == 0x2001 && s[1] == 0x0db8) || (s[0] == 0x0100 && s[1] == 0 && s[2] == 0 && s[3] == 0) }
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305 use std::sync::atomic::{AtomicUsize, Ordering};
306 use std::sync::Arc;
307 use tokio::io::{AsyncReadExt, AsyncWriteExt};
308
309 fn ip(s: &str) -> IpAddr {
310 s.parse().unwrap()
311 }
312
313 #[test]
314 fn private_local_and_reserved_v4_addresses_are_blocked() {
315 for a in [
316 "127.0.0.1", "127.255.255.254",
317 "169.254.169.254", "169.254.0.1", "100.64.0.1", "100.127.255.255", "0.0.0.0", "0.1.2.3",
318 "255.255.255.255", "224.0.0.1", "192.0.0.8", "192.0.2.1", "198.18.0.1", "198.19.255.255",
319 "240.0.0.1",
320 ] {
321 assert!(ip_is_blocked(ip(a)), "{a} must be blocked");
322 }
323 }
324
325 #[test]
326 fn the_rfc1918_private_ranges_are_blocked() {
327 for (a, b, c, d) in [(10, 0, 0, 1), (10, 255, 255, 255), (172, 16, 0, 1), (172, 31, 255, 255), (192, 168, 0, 1), (192, 168, 255, 255)] {
329 assert!(ip_is_blocked(IpAddr::V4(Ipv4Addr::new(a, b, c, d))), "{a}.{b}.{c}.{d}");
330 }
331 }
332
333 #[test]
334 fn public_v4_addresses_pass() {
335 for a in ["140.82.112.34", "8.8.8.8", "52.1.2.3", "100.63.255.255", "100.128.0.1", "172.15.0.1", "172.32.0.1", "198.17.0.1", "198.20.0.1"] {
336 assert!(!ip_is_blocked(ip(a)), "{a} must pass");
337 }
338 }
339
340 #[test]
341 fn private_local_and_embedding_v6_addresses_are_blocked() {
342 for a in [
343 "::1", "::", "fe80::1", "febf::1", "fc00::1", "fd12:3456::1", "ff02::1", "fec0::1", "2001:db8::1", "2001:0:4136:e378::1",
344 "::ffff:127.0.0.1", "::ffff:a01:203", "::ffff:169.254.169.254", "::127.0.0.1", "::a00:1",
345 "64:ff9b::7f00:1", "64:ff9b::a9fe:a9fe", "2002:7f00:1::1", "2002:a9fe:a9fe::1", "100::1",
346 ] {
347 assert!(ip_is_blocked(ip(a)), "{a} must be blocked");
348 }
349 }
350
351 #[test]
352 fn public_v6_addresses_pass() {
353 for a in ["2606:4700:4700::1111", "2a00:1450:4001:81b::200e", "::ffff:8.8.8.8", "64:ff9b::808:808", "2002:808:808::1"] {
354 assert!(!ip_is_blocked(ip(a)), "{a} must pass");
355 }
356 }
357
358 #[test]
359 fn local_names_are_blocked_and_registry_names_pass() {
360 for n in [
361 "localhost", "LOCALHOST", "localhost.", "foo.localhost", "printer.local", "metadata.google.internal",
362 "host.internal", "box.localdomain", "router.home.arpa", "nas.lan", "intranet", "", ".",
363 ] {
364 assert!(hostname_is_blocked(n), "{n:?} must be blocked");
365 }
366 for n in ["ghcr.io", "auth.docker.io", "quay.io", "gcr.io", "registry-1.docker.io", "us-docker.pkg.dev", "public.ecr.aws"] {
367 assert!(!hostname_is_blocked(n), "{n} must pass");
368 }
369 }
370
371 #[test]
372 fn every_resolved_address_must_be_public() {
373 let sa = |s: &str| SocketAddr::new(ip(s), 443);
374 assert!(all_public(&[sa("140.82.112.34"), sa("2606:4700::1")]));
375 assert!(!all_public(&[sa("140.82.112.34"), SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5)), 443)]), "one private answer is enough to refuse");
376 assert!(!all_public(&[sa("::1")]));
377 assert!(!all_public(&[]), "no answer is no answer");
378 }
379
380 #[test]
381 fn the_real_registries_token_endpoints_are_accepted() {
382 for realm in [
383 "https://ghcr.io/token",
384 "https://auth.docker.io/token",
385 "https://quay.io/v2/auth",
386 "https://gcr.io/v2/token",
387 "https://us-docker.pkg.dev/v2/token",
388 "https://ghcr.io:443/token",
389 ] {
390 assert!(validate_realm(realm, true).is_ok(), "{realm} must be accepted");
391 }
392 }
393
394 #[test]
395 fn a_token_endpoint_must_be_https_public_and_plain() {
396 let cases = [
397 ("http://ghcr.io/token", "not https"),
398 ("ftp://ghcr.io/token", "not https"),
399 ("https://user:pw@ghcr.io/token", "has credentials"),
400 ("https://ghcr.io:8443/token", "unusual port"),
401 ("https://127.0.0.1/token", "private address"),
402 ("https://[::1]/token", "private address"),
403 ("https://[::ffff:127.0.0.1]/token", "private address"),
404 ("https://[fd00::1]/token", "private address"),
405 ("https://169.254.169.254/latest/meta-data", "private address"),
406 ("https://0xa000007/token", "private address"),
407 ("https://100.64.1.1/token", "private address"),
408 ("https://2130706433/token", "private address"),
409 ("https://0x7f.1/token", "private address"),
410 ("https://localhost/token", "local name"),
411 ("https://metadata.google.internal/token", "local name"),
412 ("https://registry.local/token", "local name"),
413 ("https://intranet/token", "local name"),
414 ("not a url", "not a URL"),
415 ("", "not a URL"),
416 ];
417 for (realm, why) in cases {
418 assert_eq!(validate_realm(realm, true).err(), Some(why), "{realm}");
419 }
420 }
421
422 #[tokio::test]
423 async fn a_strict_client_refuses_private_and_local_hosts_without_connecting() {
424 for u in [
425 "https://127.0.0.1/",
426 "https://[::1]/",
427 "https://169.254.169.254/",
428 "https://localhost/",
429 "https://metadata.google.internal/",
430 "http://ghcr.io/",
431 ] {
432 assert!(guarded_client(&Url::parse(u).unwrap(), true).await.is_none(), "{u}");
433 }
434 assert!(guarded_client(&Url::parse("http://127.0.0.1/").unwrap(), false).await.is_some());
435 }
436
437 #[tokio::test]
438 async fn an_image_on_a_private_registry_host_is_not_probed() {
439 use ContainerImageAccess::Unknown;
440 for image in ["127.0.0.1/o/r:1", "169.254.169.254/latest/meta-data:x", "0xa.0.0.5/o/r:1", "localhost.localdomain/o/r:1", "registry.internal/o/r:1"] {
441 assert_eq!(probe_anonymous(image).await, Unknown, "{image}");
442 }
443 }
444
445 async fn serve<F>(respond: F) -> (SocketAddr, Arc<AtomicUsize>)
448 where
449 F: Fn(&str) -> String + Send + Sync + 'static,
450 {
451 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
452 let addr = listener.local_addr().unwrap();
453 let hits = Arc::new(AtomicUsize::new(0));
454 let counter = hits.clone();
455 let respond = Arc::new(respond);
456 tokio::spawn(async move {
457 loop {
458 let Ok((mut sock, _)) = listener.accept().await else { return };
459 let counter = counter.clone();
460 let respond = respond.clone();
461 tokio::spawn(async move {
462 let mut buf = vec![0u8; 8192];
463 let mut head = String::new();
464 while !head.contains("\r\n\r\n") {
465 match sock.read(&mut buf).await {
466 Ok(0) | Err(_) => return,
467 Ok(n) => head.push_str(&String::from_utf8_lossy(&buf[..n])),
468 }
469 }
470 counter.fetch_add(1, Ordering::SeqCst);
471 let _ = sock.write_all(respond(&head).as_bytes()).await;
472 let _ = sock.shutdown().await;
473 });
474 }
475 });
476 (addr, hits)
477 }
478
479 fn http(status: &str, headers: &[&str], body: &str) -> String {
480 let mut out = format!("HTTP/1.1 {status}\r\ncontent-length: {}\r\nconnection: close\r\n", body.len());
481 for h in headers {
482 out.push_str(h);
483 out.push_str("\r\n");
484 }
485 out.push_str("\r\n");
486 out.push_str(body);
487 out
488 }
489
490 const FAKE_REGISTRY: Policy = Policy { strict_registry: false, strict_realm: true };
491 const FAKE_BOTH: Policy = Policy { strict_registry: false, strict_realm: false };
492
493 #[tokio::test]
494 async fn a_token_endpoint_on_loopback_is_never_requested() {
495 let (victim, victim_hits) = serve(|_| http("200 OK", &[], r#"{"token":"x"}"#)).await;
496 let realm = format!("http://{victim}/token");
497 let challenge = format!("www-authenticate: Bearer realm=\"{realm}\",service=\"s\"");
498 let (registry, registry_hits) = serve(move |_| http("401 Unauthorized", &[&challenge], "")).await;
499
500 let got = probe_with("example.com/o/r:1", FAKE_REGISTRY, Some(&format!("http://{registry}/"))).await;
501 assert_eq!(got, ContainerImageAccess::Unknown, "a refused token endpoint never blocks the sandbox");
502 assert_eq!(registry_hits.load(Ordering::SeqCst), 1);
503 assert_eq!(victim_hits.load(Ordering::SeqCst), 0, "no request reached the token endpoint");
504 }
505
506 #[tokio::test]
507 async fn a_redirect_to_a_private_address_is_not_followed() {
508 let (victim, victim_hits) = serve(|_| http("200 OK", &[], "secret")).await;
509 let location = format!("location: http://{victim}/internal");
510 let (registry, registry_hits) = serve(move |_| http("302 Found", &[&location], "")).await;
511
512 let got = probe_with("example.com/o/r:1", FAKE_REGISTRY, Some(&format!("http://{registry}/"))).await;
513 assert_eq!(got, ContainerImageAccess::Unknown);
514 assert_eq!(registry_hits.load(Ordering::SeqCst), 1);
515 assert_eq!(victim_hits.load(Ordering::SeqCst), 0, "the redirect target was never requested");
516 }
517
518 #[tokio::test]
519 async fn a_redirecting_token_endpoint_is_not_followed_either() {
520 let (victim, victim_hits) = serve(|_| http("200 OK", &[], r#"{"token":"x"}"#)).await;
521 let location = format!("location: http://{victim}/token");
522 let (idp, _) = serve(move |_| http("307 Temporary Redirect", &[&location], "")).await;
523 let challenge = format!("www-authenticate: Bearer realm=\"http://{idp}/token\"");
524 let (registry, _) = serve(move |_| http("401 Unauthorized", &[&challenge], "")).await;
525
526 let got = probe_with("example.com/o/r:1", FAKE_BOTH, Some(&format!("http://{registry}/"))).await;
527 assert_eq!(got, ContainerImageAccess::Unknown);
528 assert_eq!(victim_hits.load(Ordering::SeqCst), 0);
529 }
530
531 #[tokio::test]
532 async fn the_token_flow_still_reaches_a_verdict() {
533 let (addr, _) = serve_self_realm().await;
534 let base = format!("http://{addr}/");
535 assert_eq!(probe_with("example.com/o/ok:1", FAKE_BOTH, Some(&base)).await, ContainerImageAccess::Public);
536 assert_eq!(probe_with("example.com/denied/x:1", FAKE_BOTH, Some(&base)).await, ContainerImageAccess::Denied);
537 assert_eq!(probe_with("example.com/missing/x:1", FAKE_BOTH, Some(&base)).await, ContainerImageAccess::NotFound);
538 }
539
540 async fn serve_self_realm() -> (SocketAddr, Arc<AtomicUsize>) {
542 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
543 let addr = listener.local_addr().unwrap();
544 let hits = Arc::new(AtomicUsize::new(0));
545 tokio::spawn(async move {
546 loop {
547 let Ok((mut sock, _)) = listener.accept().await else { return };
548 tokio::spawn(async move {
549 let mut buf = vec![0u8; 8192];
550 let mut head = String::new();
551 while !head.contains("\r\n\r\n") {
552 match sock.read(&mut buf).await {
553 Ok(0) | Err(_) => return,
554 Ok(n) => head.push_str(&String::from_utf8_lossy(&buf[..n])),
555 }
556 }
557 let first = head.lines().next().unwrap_or("").to_string();
558 let reply = if first.contains("/token") {
559 http("200 OK", &[], r#"{"token":"t0k"}"#)
560 } else if head.to_ascii_lowercase().contains("authorization: bearer t0k") {
561 if first.contains("/denied/") {
562 http("403 Forbidden", &[], "")
563 } else if first.contains("/missing/") {
564 http("404 Not Found", &[], "")
565 } else {
566 http("200 OK", &[], "{}")
567 }
568 } else {
569 let challenge = format!("www-authenticate: Bearer realm=\"http://{addr}/token\",service=\"svc\"");
570 http("401 Unauthorized", &[&challenge], "")
571 };
572 let _ = sock.write_all(reply.as_bytes()).await;
573 let _ = sock.shutdown().await;
574 });
575 }
576 });
577 (addr, hits)
578 }
579
580 #[tokio::test]
581 async fn an_oversized_token_response_is_not_read() {
582 let big = "x".repeat(MAX_TOKEN_BYTES + 1024);
583 let body = format!(r#"{{"token":"{big}"}}"#);
584 let (idp, _) = serve(move |_| http("200 OK", &[], &body)).await;
585 let challenge = format!("www-authenticate: Bearer realm=\"http://{idp}/token\"");
586 let (registry, _) = serve(move |_| http("401 Unauthorized", &[&challenge], "")).await;
587
588 let got = probe_with("example.com/o/r:1", FAKE_BOTH, Some(&format!("http://{registry}/"))).await;
589 assert_eq!(got, ContainerImageAccess::Unknown);
590 }
591
592 #[tokio::test]
595 #[ignore]
596 async fn probe_real_registries() {
597 let public = probe_anonymous("docker.io/library/alpine:latest").await;
598 let ghcr_public = probe_anonymous("ghcr.io/astral-sh/uv:latest").await;
599 let quay_public = probe_anonymous("quay.io/prometheus/prometheus:latest").await;
600 let gcr_public = probe_anonymous("gcr.io/distroless/static:latest").await;
601 let private = probe_anonymous("ghcr.io/agentmuxai/agent-claude:latest").await;
602 let missing = probe_anonymous("docker.io/library/agentmux-no-such-image-xyz:latest").await;
603 println!("alpine={public:?} uv={ghcr_public:?} prometheus={quay_public:?} distroless={gcr_public:?} agent-claude={private:?} missing={missing:?}");
604 assert_eq!(public, ContainerImageAccess::Public);
605 assert_eq!(ghcr_public, ContainerImageAccess::Public);
606 assert_eq!(quay_public, ContainerImageAccess::Public);
607 assert_eq!(gcr_public, ContainerImageAccess::Public);
608 assert_eq!(private, ContainerImageAccess::Denied);
609 assert!(matches!(missing, ContainerImageAccess::Denied | ContainerImageAccess::NotFound));
610 }
611}