1use crate::server::oci_registry::{actions::sha256_digest, is_valid_repository_name};
3use anyhow::{bail, ensure, Context, Result};
4use hyper::{header, HeaderMap};
5use serde_json::{json, Value};
6pub const MAX_BODY: usize = 4 * 1024 * 1024;
7pub const MAX_MANIFEST: usize = 1024 * 1024;
8pub const MAX_DEPTH: usize = 32;
9pub const MAX_NODES: usize = 65_536;
10pub const MAX_RETAINED: usize = 8 * 1024 * 1024;
11pub const MAX_TEXT: usize = 65_536;
12pub const MAX_TOKEN: usize = 16_384;
13pub const MAX_ITEMS: usize = 1000;
14pub const ACCEPT: &str = "application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json";
15pub fn within_budget(v: &Value) -> bool {
16 crate::utils::json_budget::within_budget(v, MAX_RETAINED, MAX_NODES, MAX_DEPTH)
17}
18pub fn repository(s: &str) -> Result<()> {
19 ensure!(
20 is_valid_repository_name(s),
21 "OCI repository grammar refusal"
22 );
23 Ok(())
24}
25pub fn digest(s: &str) -> Result<()> {
26 ensure!(
27 s.starts_with("sha256:")
28 && s.len() == 71
29 && s[7..]
30 .bytes()
31 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)),
32 "OCI digest requires lowercase sha256 hex"
33 );
34 Ok(())
35}
36pub fn tag(s: &str) -> Result<()> {
37 ensure!(
38 !s.is_empty() && s.len() <= 128 && s.as_bytes()[0].is_ascii_alphanumeric()
39 || s.starts_with('_'),
40 "OCI tag refusal"
41 );
42 ensure!(
43 s.len() <= 128
44 && s.bytes()
45 .all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b)),
46 "OCI tag grammar refusal"
47 );
48 Ok(())
49}
50fn text<'a>(v: &'a Value, k: &str, cap: usize) -> Result<&'a str> {
51 v.get(k)
52 .and_then(Value::as_str)
53 .filter(|s| !s.is_empty() && s.len() <= cap)
54 .context("OCI bounded text field required")
55}
56pub fn token(s: &str) -> Result<header::HeaderValue> {
57 ensure!(
58 !s.is_empty()
59 && s.len() <= MAX_TOKEN
60 && s.bytes()
61 .all(|b| b.is_ascii_alphanumeric() || b"-._~+/=".contains(&b)),
62 "OCI bounded Bearer token refusal"
63 );
64 let mut h: header::HeaderValue = format!("Bearer {s}").parse()?;
65 h.set_sensitive(true);
66 Ok(h)
67}
68#[derive(Clone)]
69pub struct Request {
70 pub operation: &'static str,
71 pub method: &'static str,
72 pub path: String,
73 pub repository: Option<String>,
74 pub reference: Option<String>,
75 pub expected_size: Option<u64>,
76 pub n: Option<u64>,
77 pub last: Option<String>,
78}
79pub enum Action {
80 Request(Request),
81 Authenticate {
82 username: Option<String>,
83 password: Option<String>,
84 },
85 SetToken(String),
86 ClearToken,
87 Disconnect,
88}
89pub fn action(v: &Value) -> Result<Action> {
90 ensure!(
91 within_budget(v),
92 "OCI action depth/node/retained-content limit"
93 );
94 let kind = text(v, "type", 64)?;
95 let mut fields = vec!["type"];
96 let result = match kind {
97 "disconnect" => Action::Disconnect,
98 "oci_clear_token" => Action::ClearToken,
99 "oci_set_token" => {
100 fields.push("token");
101 let s = text(v, "token", MAX_TOKEN)?;
102 token(s)?;
103 Action::SetToken(s.into())
104 }
105 "oci_authenticate" => {
106 fields.extend(["username", "password"]);
107 let username = v
108 .get("username")
109 .map(|_| text(v, "username", 256))
110 .transpose()?;
111 let password = v
112 .get("password")
113 .map(|_| text(v, "password", MAX_TOKEN))
114 .transpose()?;
115 ensure!(
116 username.is_some() == password.is_some(),
117 "OCI token basic authentication requires both username and password"
118 );
119 if let Some(s) = username {
120 ensure!(
121 !s.contains(':') && !crate::utils::sanitize::has_controls(&s),
122 "OCI basic principal refusal"
123 );
124 }
125 Action::Authenticate {
126 username: username.map(str::to_owned),
127 password: password.map(str::to_owned),
128 }
129 }
130 "oci_request" => {
131 fields.push("operation");
132 let operation = text(v, "operation", 32)?;
133 let operation: &'static str = match operation {
134 "probe" => "probe",
135 "catalog" => "catalog",
136 "tags" => "tags",
137 "manifest" => "manifest",
138 "manifest_head" => "manifest_head",
139 "blob" => "blob",
140 "blob_head" => "blob_head",
141 _ => bail!("unsupported selected OCI operation"),
142 };
143 let repo = if matches!(operation, "probe" | "catalog") {
144 None
145 } else {
146 fields.push("repository");
147 let s = text(v, "repository", 255)?;
148 repository(s)?;
149 Some(s.to_owned())
150 };
151 let reference = if matches!(
152 operation,
153 "manifest" | "manifest_head" | "blob" | "blob_head"
154 ) {
155 fields.push("reference");
156 let s = text(v, "reference", 128)?;
157 if s.contains(':') || operation.starts_with("blob") {
158 digest(s)?
159 } else {
160 tag(s)?
161 }
162 Some(s.to_owned())
163 } else {
164 None
165 };
166 let mut path = match operation {
167 "probe" => "/v2/".into(),
168 "catalog" => "/v2/_catalog".into(),
169 "tags" => format!("/v2/{}/tags/list", repo.as_ref().unwrap()),
170 "manifest" | "manifest_head" => format!(
171 "/v2/{}/manifests/{}",
172 repo.as_ref().unwrap(),
173 reference.as_ref().unwrap()
174 ),
175 _ => format!(
176 "/v2/{}/blobs/{}",
177 repo.as_ref().unwrap(),
178 reference.as_ref().unwrap()
179 ),
180 };
181 let n = if matches!(operation, "catalog" | "tags") {
182 fields.extend(["n", "last"]);
183 let n = match v.get("n") {
184 None => 100,
185 Some(v) => v
186 .as_u64()
187 .filter(|n| (1..=MAX_ITEMS as u64).contains(n))
188 .context("OCI page count must be1..1000")?,
189 };
190 path.push_str(&format!("?n={n}"));
191 if v.get("last").is_some() {
192 let last = text(v, "last", 255)?;
193 if operation == "catalog" {
194 repository(last)?
195 } else {
196 tag(last)?
197 }
198 path.push_str("&last=");
199 path.push_str(
200 &url::form_urlencoded::byte_serialize(last.as_bytes()).collect::<String>(),
201 );
202 }
203 Some(n)
204 } else {
205 None
206 };
207 let expected_size = if operation == "blob" {
208 fields.push("expected_size");
209 v.get("expected_size")
210 .map(|v| {
211 v.as_u64()
212 .filter(|n| *n <= MAX_BODY as u64)
213 .context("OCI expected blob size bound")
214 })
215 .transpose()?
216 } else {
217 None
218 };
219 Action::Request(Request {
220 operation,
221 method: if operation.ends_with("_head") {
222 "HEAD"
223 } else {
224 "GET"
225 },
226 path,
227 repository: repo,
228 reference,
229 expected_size,
230 n,
231 last: v.get("last").and_then(Value::as_str).map(str::to_owned),
232 })
233 }
234 _ => bail!("unknown selected OCI action"),
235 };
236 ensure!(
237 v.as_object()
238 .context("OCI action must be object")?
239 .keys()
240 .all(|k| fields.contains(&k.as_str())),
241 "unsupported selected OCI field"
242 );
243 Ok(result)
244}
245pub fn origin(s: &str) -> Result<url::Url> {
246 ensure!(s.len() <= 4096, "OCI endpoint bound");
247 let u = url::Url::parse(s).map_err(|_| anyhow::anyhow!("invalid OCI origin"))?;
248 ensure!(
249 matches!(u.scheme(), "http" | "https")
250 && u.host().is_some()
251 && u.username().is_empty()
252 && u.password().is_none()
253 && matches!(u.path(), "" | "/")
254 && u.query().is_none()
255 && u.fragment().is_none(),
256 "OCI endpoint requires HTTP(S) origin without credentials/path/query"
257 );
258 #[cfg(target_arch = "wasm32")]
259 ensure!(u.scheme() == "http", "browser OCI supports HTTP only");
260 Ok(u)
261}
262pub fn secure_credentials(u: &url::Url) -> bool {
263 u.scheme() == "https"
264 || match u.host() {
265 Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
266 Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
267 _ => false,
268 }
269}
270fn one<'a>(h: &'a HeaderMap, key: &str) -> Result<Option<&'a str>> {
271 let mut values = h.get_all(key).iter();
272 let value = values.next();
273 ensure!(values.next().is_none(), "duplicate OCI response header");
274 value
275 .map(|v| v.to_str().context("OCI response header requires text"))
276 .transpose()
277}
278pub fn headers(h: &HeaderMap) -> Result<()> {
279 ensure!(
280 h.len() <= 64
281 && h.iter()
282 .map(|(k, v)| k.as_str().len() + v.len())
283 .sum::<usize>()
284 <= 32768,
285 "OCI response header limit"
286 );
287 ensure!(
288 h.values().all(|v| v.len() <= 8192),
289 "OCI response header value limit"
290 );
291 ensure!(
292 one(h, "content-encoding")?.is_none_or(|s| s.eq_ignore_ascii_case("identity")),
293 "OCI encoded body refused"
294 );
295 Ok(())
296}
297fn media(h: &HeaderMap) -> Result<&str> {
298 Ok(one(h, "content-type")?
299 .context("OCI content type required")?
300 .split(';')
301 .next()
302 .unwrap()
303 .trim())
304}
305fn json_media(h: &HeaderMap) -> Result<()> {
306 ensure!(
307 media(h)?.eq_ignore_ascii_case("application/json"),
308 "OCI JSON content type required"
309 );
310 Ok(())
311}
312fn registry_json_media(h: &HeaderMap) -> Result<()> {
313 if let Some(mt) = one(h, "content-type")? {
314 let mt = mt.split(';').next().unwrap().trim();
315 ensure!(
316 mt.eq_ignore_ascii_case("application/json") || mt.eq_ignore_ascii_case("text/plain"),
317 "OCI registry JSON media refusal"
318 );
319 }
320 Ok(())
321}
322#[derive(Clone)]
323pub struct Challenge {
324 pub realm: url::Url,
325 pub service: Option<String>,
326 pub scopes: Vec<String>,
327}
328impl Challenge {
329 pub fn shown(&self) -> Value {
330 json!({"scheme":"Bearer","realm":self.realm.as_str(),"service":self.service,"scopes":self.scopes})
331 }
332 pub fn token_url(&self) -> url::Url {
333 let mut u = self.realm.clone();
334 {
335 let mut q = u.query_pairs_mut();
336 if let Some(s) = &self.service {
337 q.append_pair("service", s);
338 }
339 for s in &self.scopes {
340 q.append_pair("scope", s);
341 }
342 q.append_pair("client_id", "netget");
343 }
344 u
345 }
346}
347pub fn challenge(h: &HeaderMap, r: &Request) -> Result<Challenge> {
348 let s = one(h, "www-authenticate")?.context("OCI401 requires Bearer challenge")?;
349 ensure!(s.len() <= 4096, "OCI challenge bound");
350 let (scheme, attrs) = s.split_once(' ').context("OCI malformed challenge")?;
351 ensure!(
352 scheme.eq_ignore_ascii_case("Bearer"),
353 "OCI only Bearer challenge is selected"
354 );
355 let mut attrs = attrs.trim().as_bytes();
356 let mut values = std::collections::BTreeMap::new();
357 while !attrs.is_empty() {
358 let n = attrs
359 .iter()
360 .position(|b| *b == b'=')
361 .context("OCI challenge attribute requires equals")?;
362 let name = std::str::from_utf8(&attrs[..n])?
363 .trim()
364 .to_ascii_lowercase();
365 ensure!(
366 !name.is_empty()
367 && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
368 && !values.contains_key(&name),
369 "OCI challenge attribute refusal"
370 );
371 attrs = &attrs[n + 1..];
372 ensure!(
373 attrs.first() == Some(&b'"'),
374 "OCI challenge attributes must be quoted"
375 );
376 attrs = &attrs[1..];
377 let mut out = Vec::new();
378 let mut closed = false;
379 while let Some((&b, rest)) = attrs.split_first() {
380 attrs = rest;
381 if b == b'"' {
382 closed = true;
383 break;
384 }
385 if b == b'\\' {
386 let (&b, rest) = attrs
387 .split_first()
388 .context("OCI challenge escape refusal")?;
389 ensure!(b == b'"' || b == b'\\', "OCI challenge escape refusal");
390 out.push(b);
391 attrs = rest;
392 } else {
393 ensure!(!b.is_ascii_control(), "OCI challenge control refusal");
394 out.push(b);
395 }
396 }
397 ensure!(closed, "OCI unterminated challenge");
398 values.insert(name, String::from_utf8(out)?);
399 while attrs.first().is_some_and(u8::is_ascii_whitespace) {
400 attrs = &attrs[1..];
401 }
402 if attrs.is_empty() {
403 break;
404 }
405 ensure!(attrs[0] == b',', "OCI challenge separator refusal");
406 attrs = &attrs[1..];
407 while attrs.first().is_some_and(u8::is_ascii_whitespace) {
408 attrs = &attrs[1..];
409 }
410 ensure!(
411 !attrs.is_empty() && values.len() < 16,
412 "OCI challenge attribute count"
413 );
414 }
415 let realm = url::Url::parse(
416 values
417 .get("realm")
418 .context("OCI challenge realm required")?,
419 )
420 .map_err(|_| anyhow::anyhow!("invalid OCI token realm"))?;
421 ensure!(
422 matches!(realm.scheme(), "http" | "https")
423 && realm.host().is_some()
424 && realm.username().is_empty()
425 && realm.password().is_none()
426 && realm.fragment().is_none()
427 && !realm.query_pairs().any(|(k, _)| matches!(
428 k.as_ref(),
429 "scope" | "service" | "account" | "client_id" | "offline_token"
430 )),
431 "OCI token realm refusal"
432 );
433 let scopes = values
434 .get("scope")
435 .map(|s| {
436 s.split_ascii_whitespace()
437 .map(str::to_owned)
438 .collect::<Vec<_>>()
439 })
440 .unwrap_or_default();
441 ensure!(scopes.len() <= 16, "OCI scope count");
442 for scope in &scopes {
443 let expected = if let Some(repo) = &r.repository {
444 format!("repository:{repo}:pull")
445 } else if r.operation == "catalog" {
446 "registry:catalog:*".into()
447 } else {
448 bail!("OCI probe cannot request repository permissions")
449 };
450 ensure!(
451 *scope == expected,
452 "OCI challenge must request only the selected pull scope"
453 );
454 }
455 Ok(Challenge {
456 realm,
457 service: values.remove("service"),
458 scopes,
459 })
460}
461pub fn trusted(c: &Challenge, registry: &url::Url, token_origin: Option<&url::Url>) -> Result<()> {
462 ensure!(
463 c.realm.origin() == registry.origin()
464 || token_origin.is_some_and(|o| c.realm.origin() == o.origin()),
465 "OCI token realm origin is not explicitly trusted"
466 );
467 ensure!(
468 secure_credentials(&c.realm),
469 "OCI credentials require verified HTTPS or numeric loopback HTTP"
470 );
471 #[cfg(target_arch = "wasm32")]
472 ensure!(
473 c.realm.scheme() == "http",
474 "browser OCI token transport supports HTTP only"
475 );
476 Ok(())
477}
478fn header_digest(h: &HeaderMap) -> Result<Option<&str>> {
479 let d = one(h, "docker-content-digest")?;
480 if let Some(d) = d {
481 digest(d)?;
482 }
483 Ok(d)
484}
485fn content_size(h: &HeaderMap) -> Result<Option<u64>> {
486 one(h, "content-length")?
487 .map(|s| {
488 s.parse::<u64>()
489 .context("OCI content length requires integer")
490 })
491 .transpose()
492}
493fn descriptor(v: &Value) -> Result<()> {
494 ensure!(
495 v.get("data").is_none(),
496 "OCI embedded descriptor bytes are outside the selected scope"
497 );
498 ensure!(v.is_object(), "OCI descriptor must be object");
499 digest(text(v, "digest", 71)?)?;
500 text(v, "mediaType", 256)?;
501 ensure!(
502 v.get("size").and_then(Value::as_u64).is_some(),
503 "OCI descriptor size required"
504 );
505 if let Some(p) = v.get("platform") {
506 ensure!(p.is_object(), "OCI platform must be object");
507 text(p, "architecture", 256)?;
508 text(p, "os", 256)?;
509 }
510 if let Some(a) = v.get("annotations") {
511 ensure!(
512 a.as_object()
513 .is_some_and(|a| a.values().all(Value::is_string)),
514 "OCI annotations must be string map"
515 );
516 }
517 if let Some(u) = v.get("urls") {
518 ensure!(
519 u.as_array().is_some_and(|u| u.iter().all(Value::is_string)),
520 "OCI descriptor URLs must be strings"
521 );
522 }
523 Ok(())
524}
525fn manifest(v: &Value, mt: &str) -> Result<()> {
526 ensure!(
527 v.is_object() && v.get("schemaVersion").and_then(Value::as_u64) == Some(2),
528 "OCI schemaVersion2 manifest required"
529 );
530 let index = match mt {
531 "application/vnd.oci.image.manifest.v1+json"
532 | "application/vnd.docker.distribution.manifest.v2+json" => false,
533 "application/vnd.oci.image.index.v1+json"
534 | "application/vnd.docker.distribution.manifest.list.v2+json" => true,
535 _ => bail!("unsupported selected OCI manifest media type"),
536 };
537 if let Some(t) = v.get("mediaType") {
538 ensure!(
539 t.as_str() == Some(mt),
540 "OCI document/header media type mismatch"
541 );
542 }
543 if index {
544 let a = v
545 .get("manifests")
546 .and_then(Value::as_array)
547 .filter(|a| a.len() <= MAX_ITEMS)
548 .context("OCI bounded index descriptors required")?;
549 for d in a {
550 descriptor(d)?;
551 }
552 } else {
553 descriptor(v.get("config").context("OCI config descriptor required")?)?;
554 let a = v
555 .get("layers")
556 .and_then(Value::as_array)
557 .filter(|a| a.len() <= MAX_ITEMS)
558 .context("OCI bounded layers required")?;
559 for d in a {
560 descriptor(d)?;
561 }
562 }
563 if let Some(s) = v.get("subject") {
564 descriptor(s)?;
565 }
566 if let Some(a) = v.get("annotations") {
567 ensure!(
568 a.as_object()
569 .is_some_and(|a| a.values().all(Value::is_string)),
570 "OCI annotations must be string map"
571 );
572 }
573 if let Some(s) = v.get("artifactType") {
574 ensure!(
575 s.as_str().is_some_and(|s| !s.is_empty() && s.len() <= 256),
576 "OCI artifact type refusal"
577 );
578 }
579 Ok(())
580}
581pub fn continuation(h: &HeaderMap, r: &Request) -> Result<Option<String>> {
582 let Some(s) = one(h, "link")? else {
583 return Ok(None);
584 };
585 ensure!(s.len() <= 4096, "OCI pagination link bound");
586 let (target, params) = s.split_once('>').context("OCI pagination link malformed")?;
587 let target = target
588 .strip_prefix('<')
589 .context("OCI pagination angle bracket required")?;
590 ensure!(
591 target.starts_with('/') && !target.starts_with("//"),
592 "OCI pagination requires relative route"
593 );
594 ensure!(
595 params.trim() == "; rel=\"next\"" || params.trim() == "; rel=next",
596 "OCI single next pagination link required"
597 );
598 let base = url::Url::parse("http://netget.invalid")?;
599 let next = base.join(target).context("OCI pagination URL refusal")?;
600 let expected = r.path.split('?').next().unwrap();
601 ensure!(
602 next.origin() == base.origin()
603 && next.path() == expected
604 && next.username().is_empty()
605 && next.password().is_none()
606 && next.fragment().is_none(),
607 "OCI pagination must stay on selected relative route"
608 );
609 let mut n = None;
610 let mut last = None;
611 for (k, v) in next.query_pairs() {
612 match k.as_ref() {
613 "n" => {
614 ensure!(n.is_none(), "OCI duplicate pagination parameter");
615 n = Some(v.parse::<u64>()?);
616 }
617 "last" => {
618 ensure!(last.is_none(), "OCI duplicate pagination parameter");
619 last = Some(v.into_owned());
620 }
621 _ => bail!("OCI pagination parameter refusal"),
622 }
623 }
624 ensure!(
625 (n.is_none() || n == r.n) && last.as_ref().is_some_and(|s| !s.is_empty()),
626 "OCI pagination count/cursor refusal"
627 );
628 let last = last.unwrap();
629 if r.operation == "catalog" {
630 repository(&last)?
631 } else {
632 tag(&last)?
633 }
634 Ok(Some(last))
635}
636pub enum Outcome {
637 Result(Value),
638 Challenge(Challenge, Value),
639 Failure(Value),
640}
641pub fn response(r: &Request, status: u16, h: &HeaderMap, body: &[u8]) -> Result<Outcome> {
642 headers(h)?;
643 ensure!(body.len() <= MAX_BODY, "OCI response body bound");
644 if status == 401 {
645 let c = challenge(h, r)?;
646 return Ok(Outcome::Challenge(
647 c,
648 json!({"operation":r.operation,"status":status,"errors":errors(h,body)?}),
649 ));
650 }
651 if status != 200 {
652 ensure!(
653 (400..=599).contains(&status),
654 "OCI redirects/nonstandard success refused"
655 );
656 return Ok(Outcome::Failure(
657 json!({"operation":r.operation,"status":status,"errors":errors(h,body)?,"retry_after":one(h,"retry-after")?}),
658 ));
659 }
660 if let Some(s) = one(h, "docker-distribution-api-version")? {
661 ensure!(s == "registry/2.0", "OCI API version header refusal");
662 }
663 let data = match r.operation {
664 "probe" => json!({"api_available":true}),
665 "catalog" | "tags" => {
666 registry_json_media(h)?;
667 let v = json_body(body)?;
668 let key = if r.operation == "catalog" {
669 "repositories"
670 } else {
671 "tags"
672 };
673 if r.operation == "tags" {
674 ensure!(
675 v.get("name").and_then(Value::as_str) == r.repository.as_deref(),
676 "OCI tags repository mismatch"
677 );
678 }
679 let items = match v.get(key) {
680 Some(Value::Array(a)) => a.as_slice(),
681 Some(Value::Null) if r.operation == "tags" => &[],
682 _ => bail!("OCI response list required"),
683 };
684 ensure!(
685 items.len() <= r.n.unwrap() as usize,
686 "OCI response page count refusal"
687 );
688 let mut previous = r.last.as_deref();
689 let mut seen = std::collections::HashSet::new();
690 for x in items {
691 let s = x.as_str().context("OCI list entry requires string")?;
692 if key == "tags" {
693 tag(s)?
694 } else {
695 repository(s)?
696 }
697 ensure!(seen.insert(s), "OCI duplicate list entry");
698 if let Some(p) = previous {
699 let ordered = if key == "tags" {
700 p.to_ascii_lowercase() <= s.to_ascii_lowercase()
701 } else {
702 p < s
703 };
704 ensure!(ordered && p != s, "OCI list ordering/cursor refusal");
705 }
706 previous = Some(s);
707 }
708 let mut next = continuation(h, r)?;
709 let mut continuation_source = if next.is_some() { "link" } else { "none" };
710 if let Some(last) = &next {
711 ensure!(
712 items.last().and_then(Value::as_str) == Some(last.as_str()),
713 "OCI pagination cursor must match last returned entry"
714 );
715 }
716 if next.is_none() && items.len() == r.n.unwrap() as usize {
719 next = items.last().and_then(Value::as_str).map(str::to_owned);
720 continuation_source = "count_fallback";
721 }
722 json!({key:items,"repository":r.repository,"next_last":next,"pagination_complete":next.is_none(),"continuation_source":continuation_source})
723 }
724 "manifest" => {
725 ensure!(body.len() <= MAX_MANIFEST, "OCI manifest byte cap");
726 let mt = media(h)?;
727 let d = sha256_digest(body);
728 if let Some(expected) = r.reference.as_deref().filter(|s| s.contains(':')) {
729 ensure!(d == expected, "OCI manifest digest mismatch");
730 }
731 if let Some(expected) = header_digest(h)? {
732 ensure!(d == expected, "OCI manifest header digest mismatch");
733 }
734 let v = json_body(body)?;
735 manifest(&v, mt)?;
736 json!({"manifest":v,"digest":d,"digest_verified":true,"size":body.len(),"media_type":mt})
737 }
738 "blob" => {
739 let d = sha256_digest(body);
740 ensure!(
741 r.reference.as_deref() == Some(d.as_str()),
742 "OCI blob digest mismatch"
743 );
744 if let Some(expected) = header_digest(h)? {
745 ensure!(d == expected, "OCI blob header digest mismatch");
746 }
747 if let Some(n) = r.expected_size {
748 ensure!(n == body.len() as u64, "OCI blob descriptor size mismatch");
749 }
750 let text = std::str::from_utf8(body)
751 .ok()
752 .filter(|s| s.len() <= MAX_TEXT);
753 json!({"digest":d,"digest_verified":true,"size":body.len(),"media_type":one(h,"content-type")?,"text":text,"content_omitted":text.is_none()})
754 }
755 "manifest_head" | "blob_head" => {
756 ensure!(body.is_empty(), "OCI HEAD must have no body");
757 let d = header_digest(h)?;
758 if let (Some(expected), Some(actual)) =
759 (r.reference.as_deref().filter(|s| s.contains(':')), d)
760 {
761 ensure!(expected == actual, "OCI HEAD digest mismatch");
762 }
763 json!({"digest":d,"digest_verified":false,"size":content_size(h)?,"media_type":one(h,"content-type")?,"exists":true})
764 }
765 _ => bail!("unsupported OCI response operation"),
766 };
767 Ok(Outcome::Result(
768 json!({"operation":r.operation,"status":status,"repository":r.repository,"reference":r.reference,"data":data}),
769 ))
770}
771fn errors(h: &HeaderMap, body: &[u8]) -> Result<Value> {
772 if body.is_empty() {
773 return Ok(Value::Null);
774 }
775 registry_json_media(h)?;
776 let v = json_body(body)?;
777 let errors = v
778 .get("errors")
779 .and_then(Value::as_array)
780 .filter(|a| !a.is_empty() && a.len() <= 64)
781 .context("OCI bounded error envelope required")?;
782 for e in errors {
783 let code = text(e, "code", 128)?;
784 ensure!(
785 code.bytes().all(|b| b.is_ascii_uppercase() || b == b'_'),
786 "OCI error code refusal"
787 );
788 ensure!(
789 e.get("message").and_then(Value::as_str).is_some(),
790 "OCI error message required"
791 );
792 }
793 Ok(Value::Array(errors.clone()))
794}
795pub struct IssuedToken {
796 pub secret: String,
797 pub ttl: std::time::Duration,
798 pub metadata: Value,
799}
800pub fn issued_token(status: u16, h: &HeaderMap, body: &[u8]) -> Result<IssuedToken> {
801 headers(h)?;
802 ensure!(status == 200, "OCI token service refused authentication");
803 json_media(h)?;
804 let v = json_body(body)?;
805 let a = v
806 .get("token")
807 .map(|_| text(&v, "token", MAX_TOKEN))
808 .transpose()?;
809 let b = v
810 .get("access_token")
811 .map(|_| text(&v, "access_token", MAX_TOKEN))
812 .transpose()?;
813 ensure!(
814 a.is_none() || b.is_none() || a == b,
815 "OCI token aliases disagree"
816 );
817 let s = a.or(b).context("OCI token service omitted token")?;
818 token(s)?;
819 let seconds = match v.get("expires_in") {
820 None => 60,
821 Some(v) => v
822 .as_u64()
823 .filter(|n| (1..=86400).contains(n))
824 .context("OCI token expiry bound")?,
825 };
826 let remaining = if let Some(t) = v.get("issued_at") {
827 let t = t.as_str().context("OCI token issue time requires text")?;
828 let t = chrono::DateTime::parse_from_rfc3339(t)
829 .context("OCI token issue time requires RFC3339")?;
830 let deadline = t.with_timezone(&chrono::Utc) + chrono::Duration::seconds(seconds as i64);
831 let now = chrono::Utc::now();
832 ensure!(
833 t <= now + chrono::Duration::seconds(30),
834 "OCI token issue time is in the future"
835 );
836 let ms = (deadline - now).num_milliseconds();
837 ensure!(ms > 0, "OCI issued token already expired");
838 std::time::Duration::from_millis(ms as u64).min(std::time::Duration::from_secs(seconds))
839 } else {
840 std::time::Duration::from_secs(seconds)
841 };
842 Ok(IssuedToken {
843 secret: s.into(),
844 ttl: remaining,
845 metadata: json!({"token_received":true,"registry_authorization_verified":false,"expires_in":v.get("expires_in"),"effective_ttl_ms":remaining.as_millis() as u64,"issued_at":v.get("issued_at")}),
846 })
847}
848pub fn redact(v: &mut Value, secrets: &[String]) {
849 redact_inner(v, secrets, 0)
850}
851fn redact_inner(v: &mut Value, secrets: &[String], depth: usize) {
852 if depth > MAX_DEPTH + 8 {
853 let old = std::mem::replace(v, Value::Null);
854 crate::utils::json_budget::drop_iteratively(old);
855 return;
856 }
857 match v {
858 Value::String(s) => {
859 if secrets.iter().filter(|s| !s.is_empty()).any(|secret| {
860 let e = serde_json::to_string(secret).unwrap();
861 let debug = format!("{secret:?}");
862 s.contains(secret)
863 || s.contains(&e[1..e.len() - 1])
864 || s.contains(&debug[1..debug.len() - 1])
865 }) {
866 *s = crate::utils::redact::REDACTED.into();
867 }
868 }
869 Value::Array(a) => {
870 for v in a {
871 redact_inner(v, secrets, depth + 1)
872 }
873 }
874 Value::Object(m) => {
875 let old = std::mem::take(m);
876 for (k, mut v) in old {
877 let mut key = Value::String(k);
878 redact_inner(&mut key, secrets, depth + 1);
879 redact_inner(&mut v, secrets, depth + 1);
880 m.insert(key.as_str().unwrap().into(), v);
881 }
882 }
883 _ => {}
884 }
885}
886
887struct Seed<'a> {
888 depth: usize,
889 nodes: &'a mut usize,
890}
891impl<'de> serde::de::DeserializeSeed<'de> for Seed<'_> {
892 type Value = Value;
893 fn deserialize<D: serde::Deserializer<'de>>(
894 self,
895 d: D,
896 ) -> std::result::Result<Value, D::Error> {
897 use serde::de::Error;
898 *self.nodes += 1;
899 if self.depth > MAX_DEPTH || *self.nodes > MAX_NODES {
900 return Err(D::Error::custom("OCI JSON nesting/node limit"));
901 }
902 d.deserialize_any(self)
903 }
904}
905impl<'de> serde::de::Visitor<'de> for Seed<'_> {
906 type Value = Value;
907 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
908 f.write_str("bounded OCI JSON")
909 }
910 fn visit_bool<E: serde::de::Error>(self, v: bool) -> std::result::Result<Value, E> {
911 Ok(Value::Bool(v))
912 }
913 fn visit_i64<E: serde::de::Error>(self, v: i64) -> std::result::Result<Value, E> {
914 Ok(v.into())
915 }
916 fn visit_u64<E: serde::de::Error>(self, v: u64) -> std::result::Result<Value, E> {
917 Ok(v.into())
918 }
919 fn visit_f64<E: serde::de::Error>(self, v: f64) -> std::result::Result<Value, E> {
920 serde_json::Number::from_f64(v)
921 .map(Value::Number)
922 .ok_or_else(|| E::custom("invalid JSON number"))
923 }
924 fn visit_unit<E: serde::de::Error>(self) -> std::result::Result<Value, E> {
925 Ok(Value::Null)
926 }
927 fn visit_str<E: serde::de::Error>(self, v: &str) -> std::result::Result<Value, E> {
928 if v.len() > MAX_TEXT {
929 return Err(E::custom("OCI text limit"));
930 }
931 Ok(Value::String(v.into()))
932 }
933 fn visit_string<E: serde::de::Error>(self, v: String) -> std::result::Result<Value, E> {
934 if v.len() > MAX_TEXT {
935 return Err(E::custom("OCI text limit"));
936 }
937 Ok(Value::String(v))
938 }
939 fn visit_seq<A: serde::de::SeqAccess<'de>>(
940 self,
941 mut a: A,
942 ) -> std::result::Result<Value, A::Error> {
943 use serde::de::Error;
944 let mut values = Vec::new();
945 while let Some(v) = a.next_element_seed(Seed {
946 depth: self.depth + 1,
947 nodes: self.nodes,
948 })? {
949 if values.len() >= MAX_ITEMS {
950 return Err(A::Error::custom("OCI array limit"));
951 }
952 values.push(v);
953 }
954 Ok(Value::Array(values))
955 }
956 fn visit_map<A: serde::de::MapAccess<'de>>(
957 self,
958 mut a: A,
959 ) -> std::result::Result<Value, A::Error> {
960 use serde::de::Error;
961 let mut values = serde_json::Map::new();
962 while let Some(key) = a.next_key::<String>()? {
963 if key.len() > 256 || values.len() >= 256 {
964 return Err(A::Error::custom("OCI object field/name limit"));
965 }
966 if values.contains_key(&key) {
967 return Err(A::Error::custom("duplicate OCI JSON field"));
968 }
969 let value = a.next_value_seed(Seed {
970 depth: self.depth + 1,
971 nodes: self.nodes,
972 })?;
973 values.insert(key, value);
974 }
975 Ok(Value::Object(values))
976 }
977}
978pub fn json_body(body: &[u8]) -> Result<Value> {
979 ensure!(body.len() <= MAX_BODY, "OCI body limit");
980 use serde::de::DeserializeSeed;
981 let mut decoder = serde_json::Deserializer::from_slice(body);
982 let value = Seed {
983 depth: 0,
984 nodes: &mut 0,
985 }
986 .deserialize(&mut decoder)
987 .context("invalid OCI JSON")?;
988 decoder.end().context("trailing OCI JSON content")?;
989 Ok(value)
990}
991#[derive(Clone, Copy)]
992enum Schema {
993 Envelope,
994 Manifest,
995 Descriptor,
996 Platform,
997 Error,
998 Generic,
999}
1000pub fn redact_payload(v: &mut Value, secrets: &[String]) {
1001 redact_schema(v, secrets, 0, Schema::Envelope);
1002}
1003fn redact_schema(v: &mut Value, secrets: &[String], depth: usize, schema: Schema) {
1004 if depth > MAX_DEPTH + 8 {
1005 let old = std::mem::replace(v, Value::Null);
1006 crate::utils::json_budget::drop_iteratively(old);
1007 return;
1008 }
1009 match v {
1010 Value::Array(a) => {
1011 for v in a {
1012 redact_schema(v, secrets, depth + 1, schema)
1013 }
1014 }
1015 Value::Object(m) => {
1016 let old = std::mem::take(m);
1017 for (k, mut v) in old {
1018 let fixed = match schema {
1019 Schema::Envelope => matches!(
1020 k.as_str(),
1021 "operation"
1022 | "status"
1023 | "repository"
1024 | "reference"
1025 | "data"
1026 | "manifest"
1027 | "digest"
1028 | "digest_verified"
1029 | "size"
1030 | "media_type"
1031 | "text"
1032 | "content_omitted"
1033 | "exists"
1034 | "repositories"
1035 | "tags"
1036 | "next_last"
1037 | "pagination_complete"
1038 | "continuation_source"
1039 | "api_available"
1040 | "errors"
1041 | "retry_after"
1042 | "challenge"
1043 | "scheme"
1044 | "realm"
1045 | "service"
1046 | "scopes"
1047 | "origin"
1048 | "token_present"
1049 | "authentication_verified"
1050 | "category"
1051 | "error"
1052 | "token_received"
1053 | "registry_authorization_verified"
1054 | "expires_in"
1055 | "effective_ttl_ms"
1056 | "issued_at"
1057 ),
1058 Schema::Manifest => matches!(
1059 k.as_str(),
1060 "schemaVersion"
1061 | "mediaType"
1062 | "config"
1063 | "layers"
1064 | "manifests"
1065 | "subject"
1066 | "artifactType"
1067 | "annotations"
1068 ),
1069 Schema::Descriptor => matches!(
1070 k.as_str(),
1071 "mediaType"
1072 | "digest"
1073 | "size"
1074 | "urls"
1075 | "annotations"
1076 | "platform"
1077 | "artifactType"
1078 | "data"
1079 ),
1080 Schema::Platform => matches!(
1081 k.as_str(),
1082 "architecture"
1083 | "os"
1084 | "variant"
1085 | "os.version"
1086 | "os.features"
1087 | "features"
1088 ),
1089 Schema::Error => matches!(k.as_str(), "code" | "message" | "detail"),
1090 Schema::Generic => false,
1091 };
1092 let next = match (schema, k.as_str()) {
1093 (Schema::Envelope, "data" | "challenge") => Schema::Envelope,
1094 (Schema::Envelope, "manifest") => Schema::Manifest,
1095 (Schema::Envelope, "errors") => Schema::Error,
1096 (Schema::Manifest, "config" | "layers" | "manifests" | "subject") => {
1097 Schema::Descriptor
1098 }
1099 (Schema::Descriptor, "platform") => Schema::Platform,
1100 _ => Schema::Generic,
1101 };
1102 let key = if fixed {
1103 k
1104 } else {
1105 let mut key = Value::String(k);
1106 redact(&mut key, secrets);
1107 key.as_str().unwrap().into()
1108 };
1109 redact_schema(&mut v, secrets, depth + 1, next);
1110 m.insert(key, v);
1111 }
1112 }
1113 _ => redact(v, secrets),
1114 }
1115}