Skip to main content

netget/client/oci_registry/
api.rs

1//! Selected pull and registry-token semantics, without a content store.
2use 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            // A registry MAY omit Link even when further tags exist. A full page
717            // therefore needs another explicit cursor request to establish completion.
718            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}