Skip to main content

varve_core/
registry.rs

1//! The public-registry source (REQ-REGISTRY-001, REQ-REGISTRY-002, DD-003).
2//!
3//! Pull over the OCI distribution API: challenge → token → manifest → blobs.
4//! On a registry a layer is one OCI artifact manifest whose `layers[]` are
5//! the DSSE envelope, the manifest payload, and the tool blobs, each
6//! annotated; the tag is the layer name. Tags are mutable and therefore
7//! DISCOVERY ONLY — everything this source returns passes the same pipeline
8//! (signature against the trust root, payload digest, per-blob digests,
9//! anti-rollback) as every other source. The registry decides availability;
10//! it has no voice in acceptance.
11//!
12//! Pull-only by design: publishing happens in CI with standard tooling
13//! (`oras push`), which never joins the client trust path.
14//!
15//! # Speaking the spec, not one registry's dialect (REQ-REGISTRY-002)
16//!
17//! The client makes the request first and lets the registry say how to
18//! authenticate: a 401 carries `WWW-Authenticate: Bearer realm=…,service=…`
19//! and the token is fetched from THAT realm. Nothing about the token endpoint
20//! is guessed, because every registry puts it somewhere else — and a guessed
21//! endpoint fails on public third-party registries too, not only private ones.
22//!
23//! ## Credential precedence (REQ-REGISTRY-002 clause 2)
24//!
25//! First match wins; a source that names a credential helper rather than a
26//! credential is remembered only so the error can say so:
27//!
28//! 1. `$VARVE_REGISTRY_AUTH` — `username:password`, applied to the registry
29//!    named in the `oci://` reference.
30//! 2. `$DOCKER_CONFIG/config.json`
31//! 3. `~/.docker/config.json`
32//! 4. `$XDG_RUNTIME_DIR/containers/auth.json` (podman)
33//!
34//! Each file is read for its `auths` object only: the `auth` field is
35//! base64 `username:password`, or `username`/`password` may appear directly.
36//! varve does NOT execute `credsStore` / `credHelpers` credential helpers.
37//! Sourcing a secret by exec'ing a PATH-resolved binary is the exact class of
38//! trust REQ-SHADOW-001 exists because PATH does not deserve. Cloud registries
39//! (ECR, GCP Artifact Registry, ACR) are therefore reached by handing varve the
40//! credential — `VARVE_REGISTRY_AUTH="AWS:$(aws ecr get-login-password …)"` —
41//! which is friction, and is the accepted price of running no external binary.
42//!
43//! The credential is sent as HTTP Basic to the TOKEN endpoint only, never to
44//! the registry API, and never across a redirect (see `agent_config`).
45
46use std::cell::{OnceCell, RefCell};
47use std::path::PathBuf;
48
49use crate::source::{LayerRef, LayerSource, SourceError};
50
51/// artifactType of a layer artifact manifest on a registry.
52pub const LAYER_ARTIFACT_TYPE: &str = "application/vnd.pulseengine.varve.layer.v1+json";
53/// Annotation marking the envelope entry in `layers[]`.
54pub const ANN_ROLE: &str = "eu.pulseengine.varve.role";
55pub const ROLE_ENVELOPE: &str = "envelope";
56pub const ROLE_PAYLOAD: &str = "payload";
57/// The baseline line-status DSSE envelope carried beside a layer on the
58/// registry (REQ-STATUS-DIST-001), so `varve status` works after an
59/// `oci://` install with no local layout.
60pub const ROLE_LINE_STATUS: &str = "line-status";
61/// The realm's signed line-index envelope (REQ-INDEXAUTH-001), carried under
62/// its own per-line tag rather than beside a layer: the index has to be
63/// obtainable when the layer a consumer wants is precisely the one being
64/// withheld, so it must not be reachable only THROUGH a layer.
65pub const ROLE_LINE_INDEX: &str = "line-index";
66/// A carried attestation's signed STATEMENT (REQ-ATTEST-002). Many per layer,
67/// unlike line-status which is one per line — so these are found by scanning
68/// all layers, not by taking the first match.
69pub const ROLE_ATTESTATION_STATEMENT: &str = "attestation-statement";
70/// The attested bytes travelling verbatim beside a statement. Linked back to
71/// its statement by the `eu.pulseengine.varve.attests` annotation, so a layer
72/// carrying several attestations cannot mix up which evidence belongs to which
73/// claim.
74pub const ROLE_ATTESTATION_BYTES: &str = "attestation-bytes";
75
76/// Environment variable carrying `username:password` for the registry named
77/// in the reference. The one credential route that runs no external binary.
78pub const CREDENTIAL_ENV: &str = "VARVE_REGISTRY_AUTH";
79
80/// Manifest media types varve will accept (REQ-REGISTRY-002 clause 4).
81/// Offering only the OCI type makes every registry that serves the Docker
82/// schema-2 type unreachable, which is most of the older estate.
83pub const MANIFEST_ACCEPT: &str = "application/vnd.oci.image.manifest.v1+json, \
84     application/vnd.docker.distribution.manifest.v2+json, \
85     application/vnd.oci.image.index.v1+json, \
86     application/vnd.docker.distribution.manifest.list.v2+json";
87
88/// Tags requested per `/tags/list` page.
89const TAGS_PAGE_SIZE: u32 = 100;
90/// Hard bound on pages followed. A registry that keeps handing out
91/// `Link: rel="next"` forever must stop the client, not spin it — and the
92/// stop is an ERROR, never a short list, because a short list is precisely
93/// the failure mode this bound exists to make impossible (varve#70).
94const MAX_TAG_PAGES: usize = 64;
95
96/// Tool binaries are tens of MB; ureq's 10 MiB default would reject them
97/// (caught on the first real GHCR pull). 8 GiB is a sanity bound, not a
98/// promise — digests still decide.
99const MAX_BODY_BYTES: u64 = 8 * 1024 * 1024 * 1024;
100/// A token response is JSON with one field. Nothing legitimate is large.
101const MAX_TOKEN_BYTES: u64 = 1024 * 1024;
102
103/// The digest of the first layer in an OCI artifact manifest carrying the
104/// given `eu.pulseengine.varve.role` annotation, if present. Pure — the
105/// unit-testable heart of registry blob discovery.
106/// The tags of a repository that name layers of one line — what a registry is
107/// willing to SERVE for that line, which is what omission is measured against
108/// (REQ-INDEXAUTH-001 clause 3). Pure, so the filter is unit-testable: the
109/// mutation gate runs `--lib`, and an over-eager filter here would report a
110/// served layer as hidden and accuse an honest registry of tampering.
111///
112/// A tag that is not a canonical `YYYY.MM.P` cannot be a layer this line
113/// contains — a signed index names layers by that grammar and nothing else —
114/// so junk tags, other lines' layers, and the `line-index-*` tag carrying the
115/// index itself are all excluded rather than reported as extra layers.
116fn layers_of_line(tags: Vec<String>, line: &str) -> Vec<String> {
117    tags.into_iter()
118        .filter(|tag| {
119            tag.parse::<crate::layer::LayerId>()
120                .is_ok_and(|id| id.line().to_string() == line)
121        })
122        .collect()
123}
124
125fn layer_digest_for_role(manifest: &serde_json::Value, role: &str) -> Option<String> {
126    manifest["layers"]
127        .as_array()?
128        .iter()
129        .find(|l| l["annotations"][ANN_ROLE] == role)
130        .and_then(|l| l["digest"].as_str())
131        .map(str::to_string)
132}
133
134// ───────────────────────────── base64 ─────────────────────────────
135// Hand-rolled rather than pulled in as a dependency: it is forty lines, it
136// is on the credential path, and a dependency there is a dependency that can
137// read secrets. Both directions are unit-tested and round-tripped.
138
139const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
140
141fn base64_encode(input: &[u8]) -> String {
142    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
143    for chunk in input.chunks(3) {
144        let b0 = chunk[0] as u32;
145        let b1 = *chunk.get(1).unwrap_or(&0) as u32;
146        let b2 = *chunk.get(2).unwrap_or(&0) as u32;
147        let n = (b0 << 16) | (b1 << 8) | b2;
148        out.push(B64[(n >> 18) as usize & 63] as char);
149        out.push(B64[(n >> 12) as usize & 63] as char);
150        out.push(if chunk.len() > 1 {
151            B64[(n >> 6) as usize & 63] as char
152        } else {
153            '='
154        });
155        out.push(if chunk.len() > 2 {
156            B64[n as usize & 63] as char
157        } else {
158            '='
159        });
160    }
161    out
162}
163
164/// Decode standard base64, tolerating absent padding and embedded newlines —
165/// both occur in hand-edited docker configs. Any other stray byte is a
166/// refusal, not a silent skip.
167fn base64_decode(input: &str) -> Option<Vec<u8>> {
168    let mut acc: u32 = 0;
169    let mut bits: u32 = 0;
170    let mut out = Vec::with_capacity(input.len() / 4 * 3);
171    for c in input.bytes() {
172        let v = match c {
173            b'A'..=b'Z' => c - b'A',
174            b'a'..=b'z' => c - b'a' + 26,
175            b'0'..=b'9' => c - b'0' + 52,
176            b'+' => 62,
177            b'/' => 63,
178            b'=' | b'\n' | b'\r' | b' ' | b'\t' => continue,
179            _ => return None,
180        } as u32;
181        acc = ((acc << 6) | v) & 0x3_FFFF;
182        bits += 6;
183        if bits >= 8 {
184            bits -= 8;
185            out.push((acc >> bits) as u8);
186        }
187    }
188    Some(out)
189}
190
191// ─────────────────────────── credentials ───────────────────────────
192
193/// A username/password pair for one registry. Never `Debug`-printed in the
194/// clear: `RegistrySource` derives `Debug`, and a derived `Debug` on a
195/// credential is how secrets reach panic messages and logs.
196#[derive(Clone, PartialEq, Eq)]
197struct Credential {
198    username: String,
199    password: String,
200    /// Human-readable provenance for error messages — a path or an env var
201    /// NAME. Never the value.
202    origin: String,
203}
204
205impl std::fmt::Debug for Credential {
206    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
207        f.debug_struct("Credential")
208            .field("origin", &self.origin)
209            .field("username", &"<redacted>")
210            .field("password", &"<redacted>")
211            .finish()
212    }
213}
214
215impl Credential {
216    fn basic_header(&self) -> String {
217        format!(
218            "Basic {}",
219            base64_encode(format!("{}:{}", self.username, self.password).as_bytes())
220        )
221    }
222}
223
224/// What a credential source had to say. The non-`Found` variants exist so a
225/// 401 can explain WHICH kind of nothing varve had (REQ-REGISTRY-002
226/// clause 5) instead of collapsing every case into a confusing downstream
227/// error.
228#[derive(Debug, Clone, PartialEq, Eq)]
229enum CredentialLookup {
230    Found(Credential),
231    /// The config delegates this registry to a credential helper. varve does
232    /// not execute it; the name travels only into the advice text.
233    HelperOnly {
234        helper: String,
235        origin: String,
236    },
237    /// A credential was configured but cannot be read as one.
238    Malformed {
239        origin: String,
240    },
241    Absent,
242}
243
244/// Fold the configured sources in precedence order: the first usable
245/// credential wins; failing that, the first source that had something to say
246/// is kept so the error can say it.
247fn first_usable(lookups: Vec<CredentialLookup>) -> CredentialLookup {
248    let mut explanation = CredentialLookup::Absent;
249    for lookup in lookups {
250        match lookup {
251            CredentialLookup::Found(_) => return lookup,
252            CredentialLookup::Absent => {}
253            other => {
254                if matches!(explanation, CredentialLookup::Absent) {
255                    explanation = other;
256                }
257            }
258        }
259    }
260    explanation
261}
262
263/// `username:password` out of the environment variable's value.
264fn credential_from_env_value(value: &str) -> CredentialLookup {
265    let origin = format!("${CREDENTIAL_ENV}");
266    // `$(aws ecr get-login-password)` and friends routinely arrive with a
267    // trailing newline. Only line endings are trimmed — trimming spaces
268    // would corrupt a password that legitimately ends in one.
269    let value = value.trim_end_matches(['\n', '\r']);
270    if value.is_empty() {
271        return CredentialLookup::Absent;
272    }
273    match value.split_once(':') {
274        Some((username, password)) if !username.is_empty() => CredentialLookup::Found(Credential {
275            username: username.to_string(),
276            password: password.to_string(),
277            origin,
278        }),
279        _ => CredentialLookup::Malformed { origin },
280    }
281}
282
283/// Decode a docker config `auth` field (base64 `username:password`).
284fn decode_basic_auth(encoded: &str) -> Option<(String, String)> {
285    let decoded = base64_decode(encoded.trim())?;
286    let text = String::from_utf8(decoded).ok()?;
287    let (username, password) = text.split_once(':')?;
288    if username.is_empty() {
289        return None;
290    }
291    Some((username.to_string(), password.to_string()))
292}
293
294/// Do a docker-config `auths` key and a registry host name refer to the same
295/// registry? Keys are written with and without a scheme and with and without
296/// a trailing path, and Docker Hub is spelled three different ways.
297fn registry_key_matches(key: &str, registry: &str) -> bool {
298    fn host(s: &str) -> String {
299        let s = s
300            .strip_prefix("https://")
301            .or_else(|| s.strip_prefix("http://"))
302            .unwrap_or(s);
303        s.split('/').next().unwrap_or(s).to_ascii_lowercase()
304    }
305    const HUB: [&str; 3] = ["docker.io", "index.docker.io", "registry-1.docker.io"];
306    let (key, registry) = (host(key), host(registry));
307    key == registry || (HUB.contains(&key.as_str()) && HUB.contains(&registry.as_str()))
308}
309
310/// Read one docker/podman config for this registry. Pure over the parsed
311/// JSON so the whole matrix — `auth`, plaintext `username`/`password`,
312/// `credHelpers`, `credsStore`, absence — is unit-testable.
313fn credential_from_docker_config(
314    config: &serde_json::Value,
315    registry: &str,
316    origin: &str,
317) -> CredentialLookup {
318    if let Some(auths) = config["auths"].as_object()
319        && let Some((_, entry)) = auths
320            .iter()
321            .find(|(k, _)| registry_key_matches(k, registry))
322    {
323        if let Some(auth) = entry["auth"].as_str().filter(|a| !a.is_empty()) {
324            return match decode_basic_auth(auth) {
325                Some((username, password)) => CredentialLookup::Found(Credential {
326                    username,
327                    password,
328                    origin: origin.to_string(),
329                }),
330                None => CredentialLookup::Malformed {
331                    origin: origin.to_string(),
332                },
333            };
334        }
335        if let (Some(username), Some(password)) =
336            (entry["username"].as_str(), entry["password"].as_str())
337            && !username.is_empty()
338        {
339            return CredentialLookup::Found(Credential {
340                username: username.to_string(),
341                password: password.to_string(),
342                origin: origin.to_string(),
343            });
344        }
345    }
346    // No credential here — but the config may still explain where the user
347    // thinks it is. varve will not run the helper; it will name it.
348    if let Some(helpers) = config["credHelpers"].as_object()
349        && let Some((_, helper)) = helpers
350            .iter()
351            .find(|(k, _)| registry_key_matches(k, registry))
352        && let Some(helper) = helper.as_str().filter(|h| !h.is_empty())
353    {
354        return CredentialLookup::HelperOnly {
355            helper: helper.to_string(),
356            origin: origin.to_string(),
357        };
358    }
359    if let Some(store) = config["credsStore"].as_str().filter(|s| !s.is_empty()) {
360        return CredentialLookup::HelperOnly {
361            helper: store.to_string(),
362            origin: origin.to_string(),
363        };
364    }
365    CredentialLookup::Absent
366}
367
368/// The config files consulted, in precedence order.
369fn credential_config_paths() -> Vec<PathBuf> {
370    let dir = |var: &str, tail: &str| -> Option<PathBuf> {
371        let value = std::env::var(var).ok()?;
372        if value.is_empty() {
373            return None;
374        }
375        Some(PathBuf::from(value).join(tail))
376    };
377    [
378        dir("DOCKER_CONFIG", "config.json"),
379        dir("HOME", ".docker/config.json"),
380        dir("XDG_RUNTIME_DIR", "containers/auth.json"),
381    ]
382    .into_iter()
383    .flatten()
384    .collect()
385}
386
387/// Read each path that exists and parses, in order. An unreadable or
388/// unparseable config contributes nothing rather than failing the pull —
389/// a broken docker config must not stop an anonymous install.
390fn lookups_from_paths(paths: &[PathBuf], registry: &str) -> Vec<CredentialLookup> {
391    paths
392        .iter()
393        .filter_map(|path| {
394            let text = std::fs::read_to_string(path).ok()?;
395            let json = serde_json::from_str::<serde_json::Value>(&text).ok()?;
396            Some(credential_from_docker_config(
397                &json,
398                registry,
399                &path.display().to_string(),
400            ))
401        })
402        .collect()
403}
404
405fn resolve_credential(registry: &str) -> CredentialLookup {
406    let mut lookups = Vec::new();
407    if let Ok(value) = std::env::var(CREDENTIAL_ENV) {
408        lookups.push(credential_from_env_value(&value));
409    }
410    lookups.extend(lookups_from_paths(&credential_config_paths(), registry));
411    first_usable(lookups)
412}
413
414/// What to tell the user when a registry refuses. Distinguishes "varve had
415/// no credential" from "varve had one and it was refused" and names the fix
416/// (REQ-REGISTRY-002 clause 5). Never contains the secret — only its origin.
417fn credential_advice(lookup: &CredentialLookup, registry: &str, repository: &str) -> String {
418    match lookup {
419        CredentialLookup::Found(credential) => format!(
420            "varve sent the credential from {} and the registry rejected it. Check that the \
421             username is right and that it may pull {repository}.",
422            credential.origin
423        ),
424        CredentialLookup::HelperOnly { helper, origin } => format!(
425            "varve offered no credential: {origin} delegates {registry} to the credential helper \
426             '{helper}', and varve does not execute credential helpers — sourcing a secret by \
427             running a PATH-resolved binary is exactly the trust varve refuses (REQ-SHADOW-001). \
428             Supply it directly instead: {CREDENTIAL_ENV}='<username>:<password>' (for ECR: \
429             {CREDENTIAL_ENV}=\"AWS:$(aws ecr get-login-password --region <region>)\")."
430        ),
431        CredentialLookup::Malformed { origin } => format!(
432            "varve offered no credential: {origin} is set but is not a `username:password` pair. \
433             (varve does not log the value.)"
434        ),
435        CredentialLookup::Absent => format!(
436            "varve offered no credential: set {CREDENTIAL_ENV}='<username>:<password>', or \
437             `docker login {registry}` so the credential lands in the `auths` section of \
438             ~/.docker/config.json — varve reads `auths`, and does not run credential helpers."
439        ),
440    }
441}
442
443// ──────────────────── WWW-Authenticate / token realm ────────────────────
444
445/// The parts of a `WWW-Authenticate: Bearer …` challenge varve uses.
446#[derive(Debug, Clone, Default, PartialEq, Eq)]
447struct BearerChallenge {
448    realm: Option<String>,
449    service: Option<String>,
450    scope: Option<String>,
451}
452
453/// Parse a Bearer challenge. Quoted values are honoured verbatim, which
454/// matters: a scope is `repository:name:pull,push` and splitting the header
455/// on commas would truncate it.
456fn parse_bearer_challenge(header: &str) -> Option<BearerChallenge> {
457    let header = header.trim();
458    let (scheme, params) = match header.split_once(char::is_whitespace) {
459        Some((scheme, params)) => (scheme, params),
460        None => (header, ""),
461    };
462    if !scheme.eq_ignore_ascii_case("Bearer") {
463        return None;
464    }
465    let chars: Vec<char> = params.chars().collect();
466    let mut challenge = BearerChallenge::default();
467    let mut i = 0;
468    while i < chars.len() {
469        while i < chars.len() && (chars[i] == ',' || chars[i].is_whitespace()) {
470            i += 1;
471        }
472        let key_start = i;
473        while i < chars.len() && chars[i] != '=' && chars[i] != ',' {
474            i += 1;
475        }
476        if i >= chars.len() || chars[i] != '=' {
477            break;
478        }
479        let key = chars[key_start..i]
480            .iter()
481            .collect::<String>()
482            .trim()
483            .to_ascii_lowercase();
484        i += 1;
485        let value = if chars.get(i) == Some(&'"') {
486            i += 1;
487            let mut value = String::new();
488            while i < chars.len() {
489                if chars[i] == '\\' && i + 1 < chars.len() {
490                    value.push(chars[i + 1]);
491                    i += 2;
492                    continue;
493                }
494                if chars[i] == '"' {
495                    i += 1;
496                    break;
497                }
498                value.push(chars[i]);
499                i += 1;
500            }
501            value
502        } else {
503            let value_start = i;
504            while i < chars.len() && chars[i] != ',' {
505                i += 1;
506            }
507            chars[value_start..i]
508                .iter()
509                .collect::<String>()
510                .trim()
511                .to_string()
512        };
513        match key.as_str() {
514            "realm" => challenge.realm = Some(value),
515            "service" => challenge.service = Some(value),
516            "scope" => challenge.scope = Some(value),
517            _ => {}
518        }
519    }
520    Some(challenge)
521}
522
523fn percent_encode(value: &str) -> String {
524    let mut out = String::with_capacity(value.len());
525    for b in value.bytes() {
526        match b {
527            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
528                out.push(b as char)
529            }
530            _ => out.push_str(&format!("%{b:02X}")),
531        }
532    }
533    out
534}
535
536/// The token URL the CHALLENGE names — never a guessed path. `default_scope`
537/// covers registries that challenge without naming one.
538fn token_url(challenge: &BearerChallenge, default_scope: &str) -> Option<String> {
539    let realm = challenge.realm.as_deref()?.trim();
540    if realm.is_empty() {
541        return None;
542    }
543    let mut query = Vec::new();
544    if let Some(service) = challenge.service.as_deref().filter(|s| !s.is_empty()) {
545        query.push(format!("service={}", percent_encode(service)));
546    }
547    let scope = challenge
548        .scope
549        .as_deref()
550        .filter(|s| !s.is_empty())
551        .unwrap_or(default_scope);
552    query.push(format!("scope={}", percent_encode(scope)));
553    // Realms carry their own query string in the wild (GitLab's does).
554    let separator = if realm.contains('?') { '&' } else { '?' };
555    Some(format!("{realm}{separator}{}", query.join("&")))
556}
557
558/// May varve send a credential to this realm? The realm host CANNOT be
559/// constrained — a different host is normal and correct (auth.docker.io for
560/// registry-1.docker.io, gitlab.com/jwt/auth for registry.gitlab.com). The
561/// SCHEME can: an https registry must not be able to talk varve into posting
562/// a Basic credential over cleartext.
563fn realm_is_acceptable(realm: &str, reference_scheme: &str) -> bool {
564    if reference_scheme == "https" {
565        realm.starts_with("https://")
566    } else {
567        realm.starts_with("http://") || realm.starts_with("https://")
568    }
569}
570
571fn token_from_body(body: &str) -> Option<String> {
572    let json: serde_json::Value = serde_json::from_str(body).ok()?;
573    // `token` is the distribution spec's field; `access_token` is the OAuth2
574    // spelling several registries answer with instead.
575    json["token"]
576        .as_str()
577        .or_else(|| json["access_token"].as_str())
578        .filter(|t| !t.is_empty())
579        .map(str::to_string)
580}
581
582// ───────────────────────── tags/list pagination ─────────────────────────
583
584/// The scheme+authority of a URL, for same-origin comparison.
585fn origin_of(url: &str) -> Option<String> {
586    let scheme_end = url.find("://")?;
587    let after = &url[scheme_end + 3..];
588    let authority_end = after.find('/').unwrap_or(after.len());
589    Some(url[..scheme_end + 3 + authority_end].to_ascii_lowercase())
590}
591
592/// Resolve a `Link` target against the page it came from, refusing anything
593/// that leaves the origin. A registry that could point `rel="next"` at
594/// another host could harvest the bearer token varve is carrying.
595fn resolve_next_url(base: &str, target: &str) -> Option<String> {
596    let origin = origin_of(base)?;
597    let absolute = if target.contains("://") {
598        target.to_string()
599    } else if let Some(path) = target.strip_prefix('/') {
600        format!("{origin}/{path}")
601    } else {
602        let path_base = base.split(['?', '#']).next().unwrap_or(base);
603        let cut = path_base.rfind('/')?;
604        format!("{}/{target}", &path_base[..cut])
605    };
606    (origin_of(&absolute)? == origin).then_some(absolute)
607}
608
609/// The `rel="next"` target of a `Link` header, if any. Commas inside the
610/// angle brackets belong to the URL, not to the header's list syntax.
611fn parse_link_next(link: &str, current: &str) -> Option<String> {
612    let mut segments = Vec::new();
613    let mut current_segment = String::new();
614    let mut depth = 0i32;
615    for c in link.chars() {
616        match c {
617            '<' => {
618                depth += 1;
619                current_segment.push(c);
620            }
621            '>' => {
622                depth -= 1;
623                current_segment.push(c);
624            }
625            ',' if depth == 0 => segments.push(std::mem::take(&mut current_segment)),
626            _ => current_segment.push(c),
627        }
628    }
629    segments.push(current_segment);
630    for segment in segments {
631        let segment = segment.trim();
632        let Some(open) = segment.find('<') else {
633            continue;
634        };
635        let Some(close) = segment[open..].find('>').map(|i| open + i) else {
636            continue;
637        };
638        let is_next = segment[close + 1..].split(';').any(|param| {
639            param
640                .split_once('=')
641                .is_some_and(|(k, v)| k.trim().eq_ignore_ascii_case("rel") && rel_is_next(v))
642        });
643        if is_next {
644            return resolve_next_url(current, segment[open + 1..close].trim());
645        }
646    }
647    None
648}
649
650/// `rel="next"`, `rel=next`, and `rel="prev next"` all mean next.
651fn rel_is_next(value: &str) -> bool {
652    value
653        .trim()
654        .trim_matches('"')
655        .split_whitespace()
656        .any(|r| r.eq_ignore_ascii_case("next"))
657}
658
659/// The first `/tags/list` page URL. `?n=` is what makes a registry paginate
660/// at all — without it many serve one implementation-defined page and the
661/// client never learns there was more.
662fn tags_first_page_url(base: &str) -> String {
663    format!("{base}/tags/list?n={TAGS_PAGE_SIZE}")
664}
665
666/// The tags on one `/tags/list` page. `tags: null` is spec-legal and means
667/// an empty page; anything that is not JSON is a transport failure, not an
668/// empty repository.
669fn tags_from_page(bytes: &[u8]) -> Result<Vec<String>, SourceError> {
670    let json: serde_json::Value = serde_json::from_slice(bytes)
671        .map_err(|e| SourceError::Transport(format!("tags/list: {e}")))?;
672    Ok(json["tags"]
673        .as_array()
674        .map(|tags| {
675            tags.iter()
676                .filter_map(|t| t.as_str().map(str::to_string))
677                .collect()
678        })
679        .unwrap_or_default())
680}
681
682// ───────────────────────────── the source ─────────────────────────────
683
684/// An `oci://` reference: registry host + repository.
685#[derive(Debug, Clone, PartialEq, Eq)]
686pub struct RegistryRef {
687    pub registry: String,
688    pub repository: String,
689    /// http for the test double, https everywhere real. Never configurable
690    /// from a pin — parsed from the explicit `--from` reference only.
691    pub scheme: String,
692}
693
694impl RegistryRef {
695    /// Parse `oci://ghcr.io/org/repo` (https) or `oci+http://host:port/repo`
696    /// (test double / air-gapped mirror on a trusted network — acceptance is
697    /// unaffected either way; transport privacy is not what the trust model
698    /// rests on).
699    pub fn parse(reference: &str) -> Result<Self, SourceError> {
700        let (scheme, rest) = if let Some(rest) = reference.strip_prefix("oci://") {
701            ("https", rest)
702        } else if let Some(rest) = reference.strip_prefix("oci+http://") {
703            ("http", rest)
704        } else {
705            return Err(SourceError::Transport(format!(
706                "'{reference}' is not an oci:// reference"
707            )));
708        };
709        let (registry, repository) = rest.split_once('/').ok_or_else(|| {
710            SourceError::Transport(format!("'{reference}' has no repository path"))
711        })?;
712        if registry.is_empty() || repository.is_empty() {
713            return Err(SourceError::Transport(format!(
714                "'{reference}' has an empty registry or repository"
715            )));
716        }
717        Ok(RegistryRef {
718            registry: registry.to_string(),
719            repository: repository.trim_end_matches('/').to_string(),
720            scheme: scheme.to_string(),
721        })
722    }
723}
724
725/// The HTTP client configuration varve pulls with.
726///
727/// `redirect_auth_headers(Never)` is the load-bearing setting
728/// (REQ-REGISTRY-002 clause 6): blob fetches redirect to CDNs, and a client
729/// that carries `Authorization` across that redirect hands the registry
730/// credential to a third party. `Never` is stricter than the requirement's
731/// "not to a DIFFERENT host" — ureq offers only `Never` and `SameHost`, and
732/// distribution-spec redirect targets carry their own credentials in the URL,
733/// so the strict setting costs nothing. It is also ureq's current default;
734/// stating it explicitly means a change of that default cannot silently
735/// change varve's behaviour, and the unit test below fails if it does.
736///
737/// `http_status_as_error(false)` is required for clause 1: ureq's default
738/// turns a 401 into an `Err` with the response — and therefore the
739/// `WWW-Authenticate` challenge — discarded.
740fn agent_config() -> ureq::config::Config {
741    ureq::Agent::config_builder()
742        .redirect_auth_headers(ureq::config::RedirectAuthHeaders::Never)
743        .http_status_as_error(false)
744        .build()
745}
746
747/// One HTTP response, reduced to what the client reasons about.
748struct Fetched {
749    status: u16,
750    bytes: Vec<u8>,
751    link: Option<String>,
752    challenge: Option<String>,
753}
754
755/// Pull-only OCI distribution client implementing `LayerSource`.
756pub struct RegistrySource {
757    reference: RegistryRef,
758    agent: ureq::Agent,
759    /// The bearer token the realm issued. A short-lived credential is still a
760    /// credential — never derived into `Debug`.
761    token: RefCell<Option<String>>,
762    /// Resolved lazily: an anonymous pull from a public registry must not
763    /// read the user's docker config at all.
764    credential: OnceCell<CredentialLookup>,
765}
766
767impl std::fmt::Debug for RegistrySource {
768    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
769        f.debug_struct("RegistrySource")
770            .field("reference", &self.reference)
771            .field(
772                "token",
773                &self
774                    .token
775                    .borrow()
776                    .as_ref()
777                    .map(|_| "<redacted bearer token>"),
778            )
779            .field("credential", &self.credential)
780            .finish()
781    }
782}
783
784impl RegistrySource {
785    pub fn new(reference: RegistryRef) -> Self {
786        RegistrySource {
787            reference,
788            agent: ureq::Agent::new_with_config(agent_config()),
789            token: RefCell::new(None),
790            credential: OnceCell::new(),
791        }
792    }
793
794    pub fn parse(reference: &str) -> Result<Self, SourceError> {
795        Ok(Self::new(RegistryRef::parse(reference)?))
796    }
797
798    /// Use this credential instead of consulting the environment. Sent as
799    /// HTTP Basic to the token realm only.
800    pub fn with_credential(self, username: &str, password: &str) -> Self {
801        let _ = self.credential.set(CredentialLookup::Found(Credential {
802            username: username.to_string(),
803            password: password.to_string(),
804            origin: "the credential supplied to RegistrySource::with_credential".to_string(),
805        }));
806        self
807    }
808
809    fn credential(&self) -> &CredentialLookup {
810        self.credential
811            .get_or_init(|| resolve_credential(&self.reference.registry))
812    }
813
814    fn base(&self) -> String {
815        format!(
816            "{}://{}/v2/{}",
817            self.reference.scheme, self.reference.registry, self.reference.repository
818        )
819    }
820
821    fn send(&self, url: &str, accept: &str, token: Option<&str>) -> Result<Fetched, SourceError> {
822        let mut request = self.agent.get(url).header("Accept", accept);
823        if let Some(token) = token {
824            request = request.header("Authorization", &format!("Bearer {token}"));
825        }
826        let mut response = request
827            .call()
828            .map_err(|e| SourceError::Transport(e.to_string()))?;
829        let status = response.status().as_u16();
830        let header = |name: &str| {
831            response
832                .headers()
833                .get(name)
834                .and_then(|v| v.to_str().ok())
835                .map(str::to_string)
836        };
837        let link = header("link");
838        let challenge = header("www-authenticate");
839        let bytes = response
840            .body_mut()
841            .with_config()
842            .limit(MAX_BODY_BYTES)
843            .read_to_vec()
844            .map_err(|e| SourceError::Transport(e.to_string()))?;
845        Ok(Fetched {
846            status,
847            bytes,
848            link,
849            challenge,
850        })
851    }
852
853    /// Ask the realm the CHALLENGE names for a token, sending the resolved
854    /// credential as Basic if there is one.
855    fn obtain_token(&self, challenge: &BearerChallenge) -> Result<String, SourceError> {
856        let default_scope = format!("repository:{}:pull", self.reference.repository);
857        let url = token_url(challenge, &default_scope).ok_or_else(|| {
858            SourceError::Transport(format!(
859                "{} demanded authentication but its WWW-Authenticate challenge names no realm, \
860                 so varve has no token endpoint to ask",
861                self.reference.registry
862            ))
863        })?;
864        if !realm_is_acceptable(&url, &self.reference.scheme) {
865            return Err(SourceError::Transport(format!(
866                "{} is an https registry but points its token realm at {url}; varve will not \
867                 send a credential over cleartext",
868                self.reference.registry
869            )));
870        }
871        let mut request = self.agent.get(&url).header("Accept", "application/json");
872        if let CredentialLookup::Found(credential) = self.credential() {
873            request = request.header("Authorization", &credential.basic_header());
874        }
875        let mut response = request
876            .call()
877            .map_err(|e| SourceError::Transport(format!("token request to {url} failed: {e}")))?;
878        let status = response.status().as_u16();
879        if status == 401 || status == 403 {
880            return Err(self.auth_error(&format!("the token endpoint {url}"), status));
881        }
882        if !(200..300).contains(&status) {
883            return Err(SourceError::Transport(format!(
884                "token endpoint {url} returned HTTP {status}"
885            )));
886        }
887        let body = response
888            .body_mut()
889            .with_config()
890            .limit(MAX_TOKEN_BYTES)
891            .read_to_string()
892            .map_err(|e| SourceError::Transport(format!("token response: {e}")))?;
893        token_from_body(&body).ok_or_else(|| {
894            SourceError::Transport(format!(
895                "token endpoint {url} answered HTTP {status} with no `token` field"
896            ))
897        })
898    }
899
900    /// A refusal, said out loud with the fix (clause 5) and never with the
901    /// secret.
902    fn auth_error(&self, what: &str, status: u16) -> SourceError {
903        SourceError::Transport(format!(
904            "{} refused access to {} at {what} (HTTP {status}). {}",
905            self.reference.registry,
906            self.reference.repository,
907            credential_advice(
908                self.credential(),
909                &self.reference.registry,
910                &self.reference.repository
911            )
912        ))
913    }
914
915    /// One GET, authenticating on demand: make the request, and if the
916    /// registry answers 401, take the token endpoint from ITS challenge
917    /// (REQ-REGISTRY-002 clause 1) rather than guessing a path.
918    fn fetch(&self, url: &str, accept: &str) -> Result<Fetched, SourceError> {
919        let cached = self.token.borrow().clone();
920        let first = self.send(url, accept, cached.as_deref())?;
921        if first.status != 401 {
922            return Ok(first);
923        }
924        let challenge = first
925            .challenge
926            .as_deref()
927            .and_then(parse_bearer_challenge)
928            .ok_or_else(|| {
929                SourceError::Transport(format!(
930                    "{} answered HTTP 401 for {url} with no Bearer challenge varve could parse \
931                     ({}), so there is no token endpoint to ask. {}",
932                    self.reference.registry,
933                    match &first.challenge {
934                        Some(header) => format!("WWW-Authenticate: {header}"),
935                        None => "no WWW-Authenticate header".to_string(),
936                    },
937                    credential_advice(
938                        self.credential(),
939                        &self.reference.registry,
940                        &self.reference.repository
941                    )
942                ))
943            })?;
944        let token = self.obtain_token(&challenge)?;
945        *self.token.borrow_mut() = Some(token.clone());
946        let second = self.send(url, accept, Some(&token))?;
947        if second.status == 401 {
948            return Err(self.auth_error(url, second.status));
949        }
950        Ok(second)
951    }
952
953    /// `fetch` plus status interpretation: 404 is honest absence, every
954    /// other non-2xx is transport trouble said out loud.
955    fn get_checked(&self, url: &str, accept: &str) -> Result<Fetched, SourceError> {
956        let fetched = self.fetch(url, accept)?;
957        match fetched.status {
958            200..=299 => Ok(fetched),
959            404 => Err(SourceError::NotFound(url.to_string())),
960            status => Err(SourceError::Transport(format!(
961                "{url} returned HTTP {status}"
962            ))),
963        }
964    }
965
966    fn get(&self, url: &str, accept: &str) -> Result<Vec<u8>, SourceError> {
967        Ok(self.get_checked(url, accept)?.bytes)
968    }
969
970    /// Fetch the OCI artifact manifest for a tag. Untrusted discovery: the
971    /// pipeline re-verifies whatever blobs this points at.
972    fn artifact_manifest_for_tag(&self, tag: &str) -> Result<serde_json::Value, SourceError> {
973        let manifest_bytes =
974            self.get(&format!("{}/manifests/{tag}", self.base()), MANIFEST_ACCEPT)?;
975        serde_json::from_slice(&manifest_bytes)
976            .map_err(|e| SourceError::Transport(format!("artifact manifest: {e}")))
977    }
978
979    /// The envelope blob a tag's artifact manifest references.
980    fn envelope_for_tag(&self, tag: &str) -> Result<Vec<u8>, SourceError> {
981        let manifest = self.artifact_manifest_for_tag(tag)?;
982        let envelope_digest = layer_digest_for_role(&manifest, ROLE_ENVELOPE).ok_or_else(|| {
983            SourceError::NotFound(format!("tag {tag} carries no varve envelope layer"))
984        })?;
985        self.fetch_blob(&envelope_digest)
986    }
987
988    /// The baseline line-status blob a tag's artifact manifest references,
989    /// if any (REQ-STATUS-DIST-001). Absence is `Ok(None)`, not an error.
990    fn line_status_for_tag(&self, tag: &str) -> Result<Option<Vec<u8>>, SourceError> {
991        let manifest = self.artifact_manifest_for_tag(tag)?;
992        match layer_digest_for_role(&manifest, ROLE_LINE_STATUS) {
993            Some(digest) => self.fetch_blob(&digest).map(Some),
994            None => Ok(None),
995        }
996    }
997
998    /// The realm's signed line-index envelope for a line, if this registry
999    /// carries one (REQ-INDEXAUTH-001 clause 1). Absence — no such tag, or a
1000    /// tag carrying no index layer — is `Ok(None)`, never an error: whether
1001    /// absence is tolerable is the REALM's call (clause 5), settled in
1002    /// `lineindex::check`, and a registry must not get to decide it by
1003    /// answering 404. Opaque, untrusted bytes: the caller verifies them
1004    /// against the realm's root, and the registry is the party they constrain.
1005    /// The line-status document published under its OWN tag
1006    /// (REQ-POSTDEPOSIT-001 clause 1), if this registry carries one.
1007    ///
1008    /// Same absence contract as `line_index_for_tag`: no such tag, or a tag
1009    /// carrying no line-status layer, is `Ok(None)`. Opaque untrusted bytes —
1010    /// the caller verifies them against the realm root before letting the
1011    /// counter inside decide anything.
1012    fn line_status_tag_document(&self, tag: &str) -> Result<Option<Vec<u8>>, SourceError> {
1013        let manifest = match self.artifact_manifest_for_tag(tag) {
1014            Ok(manifest) => manifest,
1015            Err(SourceError::NotFound(_)) => return Ok(None),
1016            Err(e) => return Err(e),
1017        };
1018        match layer_digest_for_role(&manifest, ROLE_LINE_STATUS) {
1019            Some(digest) => self.fetch_blob(&digest).map(Some),
1020            None => Ok(None),
1021        }
1022    }
1023
1024    fn line_index_for_tag(&self, tag: &str) -> Result<Option<Vec<u8>>, SourceError> {
1025        let manifest = match self.artifact_manifest_for_tag(tag) {
1026            Ok(manifest) => manifest,
1027            Err(SourceError::NotFound(_)) => return Ok(None),
1028            Err(e) => return Err(e),
1029        };
1030        match layer_digest_for_role(&manifest, ROLE_LINE_INDEX) {
1031            Some(digest) => self.fetch_blob(&digest).map(Some),
1032            None => Ok(None),
1033        }
1034    }
1035
1036    /// Every attestation a tag's artifact manifest references
1037    /// (REQ-ATTEST-002). A statement whose bytes are absent from the manifest
1038    /// is an ERROR, not a skipped entry: evidence that did not travel is the
1039    /// thing this requirement exists to detect, and dropping it here would
1040    /// reproduce the mirror-boundary bug inside the code meant to catch it.
1041    fn attestations_for_tag(
1042        &self,
1043        tag: &str,
1044    ) -> Result<Vec<crate::attestcarry::CarriedAttestation>, SourceError> {
1045        let manifest = self.artifact_manifest_for_tag(tag)?;
1046        let Some(layers) = manifest["layers"].as_array() else {
1047            return Ok(Vec::new());
1048        };
1049        let mut out = Vec::new();
1050        for l in layers
1051            .iter()
1052            .filter(|l| l["annotations"][ANN_ROLE] == ROLE_ATTESTATION_STATEMENT)
1053        {
1054            let Some(st_digest) = l["digest"].as_str() else {
1055                continue;
1056            };
1057            let bytes_digest = layers
1058                .iter()
1059                .find(|b| {
1060                    b["annotations"][ANN_ROLE] == ROLE_ATTESTATION_BYTES
1061                        && b["annotations"][crate::attestcarry::ANN_STATEMENT] == *st_digest
1062                })
1063                .and_then(|b| b["digest"].as_str())
1064                .ok_or_else(|| {
1065                    SourceError::NotFound(format!(
1066                        "tag {tag} carries attestation statement {st_digest} but the manifest \
1067                         references no bytes for it — the claim travelled and the evidence \
1068                         did not"
1069                    ))
1070                })?;
1071            out.push(crate::attestcarry::CarriedAttestation {
1072                statement_digest: st_digest.to_string(),
1073                statement: self.fetch_blob(st_digest)?,
1074                bytes: self.fetch_blob(bytes_digest)?,
1075            });
1076        }
1077        // Deterministic, matching the layout and store readers — a report that
1078        // reshuffles between transports is a diff generator for anyone
1079        // recording verify output as evidence.
1080        out.sort_by(|a, b| a.statement_digest.cmp(&b.statement_digest));
1081        Ok(out)
1082    }
1083
1084    /// Every tag in the repository, following `Link: rel="next"` to the end
1085    /// (REQ-REGISTRY-002 clause 3, varve#70).
1086    ///
1087    /// Three digest-pin paths enumerate tags. A truncated list does not
1088    /// merely lose tags — it makes a digest pin, a status baseline and the
1089    /// carried attestations all come back EMPTY, which is indistinguishable
1090    /// from legitimate absence. So exhaustion is the only acceptable outcome
1091    /// besides an error: running out of pages raises, it never returns short.
1092    fn tags(&self) -> Result<Vec<String>, SourceError> {
1093        let mut url = tags_first_page_url(&self.base());
1094        let mut out = Vec::new();
1095        for _ in 0..MAX_TAG_PAGES {
1096            let page = self.get_checked(&url, "application/json")?;
1097            out.extend(tags_from_page(&page.bytes)?);
1098            let next = page
1099                .link
1100                .as_deref()
1101                .and_then(|link| parse_link_next(link, &url));
1102            let Some(next) = next else {
1103                return Ok(out);
1104            };
1105            if next == url {
1106                return Err(SourceError::Transport(format!(
1107                    "{url} answered with a Link rel=\"next\" pointing at the page it came from; \
1108                     refusing to loop"
1109                )));
1110            }
1111            url = next;
1112        }
1113        Err(SourceError::Transport(format!(
1114            "{}/tags/list was still handing out `Link: rel=\"next\"` after {MAX_TAG_PAGES} pages. \
1115             varve stops rather than looping, and refuses to answer from a partial tag list — a \
1116             short list would silently turn a digest pin into 'not found'.",
1117            self.base()
1118        )))
1119    }
1120}
1121
1122impl LayerSource for RegistrySource {
1123    fn fetch_manifest(&self, layer: &LayerRef) -> Result<Vec<u8>, SourceError> {
1124        match layer {
1125            LayerRef::Name(id) => self.envelope_for_tag(&id.to_string()),
1126            LayerRef::Digest(digest) => {
1127                // A pin's digest names the PAYLOAD, not any registry object;
1128                // tags are enumerated and each candidate's payload digest
1129                // compared. Discovery only — verification decides.
1130                for tag in self.tags()? {
1131                    if let Ok(envelope) = self.envelope_for_tag(&tag)
1132                        && let Ok(text) = std::str::from_utf8(&envelope)
1133                        && let Ok(env) = wsc::dsse::DsseEnvelope::from_json(text)
1134                        && let Ok(payload) = env.payload_bytes()
1135                        && &crate::store::manifest_digest(&payload) == digest
1136                    {
1137                        return Ok(envelope);
1138                    }
1139                }
1140                Err(SourceError::NotFound(digest.clone()))
1141            }
1142        }
1143    }
1144
1145    fn fetch_line_status(&self, layer: &LayerRef) -> Result<Option<Vec<u8>>, SourceError> {
1146        // Resolve the tag whose artifact manifest carries the baseline.
1147        // A named pin maps straight to its tag; a digest pin is located by
1148        // the same tag scan fetch_manifest uses.
1149        match layer {
1150            LayerRef::Name(id) => self.line_status_for_tag(&id.to_string()),
1151            LayerRef::Digest(digest) => {
1152                for tag in self.tags()? {
1153                    if let Ok(envelope) = self.envelope_for_tag(&tag)
1154                        && let Ok(text) = std::str::from_utf8(&envelope)
1155                        && let Ok(env) = wsc::dsse::DsseEnvelope::from_json(text)
1156                        && let Ok(payload) = env.payload_bytes()
1157                        && &crate::store::manifest_digest(&payload) == digest
1158                    {
1159                        return self.line_status_for_tag(&tag);
1160                    }
1161                }
1162                Ok(None)
1163            }
1164        }
1165    }
1166
1167    fn fetch_published_line_status(&self, line: &str) -> Result<Option<Vec<u8>>, SourceError> {
1168        // Reuses `line_index_for_tag`'s shape deliberately: a MISSING tag is
1169        // `Ok(None)`, not an error. Most lines have never been corrected, and
1170        // a registry must not be able to turn "nothing to say" into a failed
1171        // install by answering 404 — nor into a suppressed yank by answering
1172        // one for a line that does have a correction. Absence is reported;
1173        // what it MEANS is the caller's call.
1174        self.line_status_tag_document(&crate::linestatus::status_tag(line))
1175    }
1176
1177    fn fetch_line_index(&self, line: &str) -> Result<Option<Vec<u8>>, SourceError> {
1178        self.line_index_for_tag(&crate::lineindex::index_tag(line))
1179    }
1180
1181    fn served_layers(&self, line: &str) -> Result<Option<Vec<String>>, SourceError> {
1182        // A registry CAN enumerate, so it answers `Some(..)` — including
1183        // `Some(vec![])` when it serves nothing of this line, which against a
1184        // signed index means it is hiding everything. `None` here would mean
1185        // "cannot enumerate" and would switch clause 3 off for every registry.
1186        //
1187        // `tags()` raises rather than returning a short list, and that is
1188        // load-bearing here: a truncated page would look like a registry that
1189        // legitimately serves fewer layers, so omission detection would report
1190        // a hidden layer that is not hidden — or, worse, a hostile registry
1191        // could truncate its way to any listing it liked.
1192        Ok(Some(layers_of_line(self.tags()?, line)))
1193    }
1194
1195    fn fetch_attestations(
1196        &self,
1197        layer: &LayerRef,
1198    ) -> Result<Vec<crate::attestcarry::CarriedAttestation>, SourceError> {
1199        // Same tag resolution as the baseline: a named pin maps to its tag, a
1200        // digest pin is located by the tag scan. Untrusted bytes throughout —
1201        // the caller re-verifies every statement against the trust root, and
1202        // the registry is precisely the party this evidence constrains.
1203        match layer {
1204            LayerRef::Name(id) => self.attestations_for_tag(&id.to_string()),
1205            LayerRef::Digest(digest) => {
1206                for tag in self.tags()? {
1207                    if let Ok(envelope) = self.envelope_for_tag(&tag)
1208                        && let Ok(text) = std::str::from_utf8(&envelope)
1209                        && let Ok(env) = wsc::dsse::DsseEnvelope::from_json(text)
1210                        && let Ok(payload) = env.payload_bytes()
1211                        && &crate::store::manifest_digest(&payload) == digest
1212                    {
1213                        return self.attestations_for_tag(&tag);
1214                    }
1215                }
1216                Ok(Vec::new())
1217            }
1218        }
1219    }
1220
1221    fn fetch_blob(&self, digest: &str) -> Result<Vec<u8>, SourceError> {
1222        let bytes = self.get(
1223            &format!("{}/blobs/{digest}", self.base()),
1224            "application/octet-stream",
1225        )?;
1226        // Transport-level integrity: a registry answering a digest request
1227        // with other bytes is broken or hostile either way. The pipeline
1228        // re-checks against the SIGNED digests; this check just fails fast.
1229        if crate::store::manifest_digest(&bytes) != digest {
1230            return Err(SourceError::Transport(format!(
1231                "registry returned wrong bytes for {digest}"
1232            )));
1233        }
1234        Ok(bytes)
1235    }
1236}
1237
1238#[cfg(test)]
1239mod tests {
1240    use super::*;
1241
1242    const SECRET: &str = "s3cr3t-do-not-log";
1243
1244    // rivet: verifies REQ-REGISTRY-001
1245    #[test]
1246    fn oci_references_parse_and_bad_ones_are_refused() {
1247        let r = RegistryRef::parse("oci://ghcr.io/pulseengine/layers").unwrap();
1248        assert_eq!(r.registry, "ghcr.io");
1249        assert_eq!(r.repository, "pulseengine/layers");
1250        assert_eq!(r.scheme, "https");
1251        let t = RegistryRef::parse("oci+http://127.0.0.1:5000/test/repo").unwrap();
1252        assert_eq!(t.scheme, "http");
1253        assert_eq!(t.registry, "127.0.0.1:5000");
1254        for bad in [
1255            "https://ghcr.io/x",
1256            "oci://",
1257            "oci://hostonly",
1258            "oci://host/",
1259        ] {
1260            assert!(RegistryRef::parse(bad).is_err(), "{bad} must not parse");
1261        }
1262    }
1263
1264    // rivet: verifies REQ-STATUS-DIST-001
1265    #[test]
1266    fn a_role_annotated_layer_digest_is_found_and_absence_is_none() {
1267        let manifest = serde_json::json!({
1268            "layers": [
1269                {"digest": "sha256:aaa", "annotations": {ANN_ROLE: ROLE_ENVELOPE}},
1270                {"digest": "sha256:bbb", "annotations": {ANN_ROLE: ROLE_PAYLOAD}},
1271                {"digest": "sha256:ccc", "annotations": {ANN_ROLE: ROLE_LINE_STATUS}},
1272            ]
1273        });
1274        assert_eq!(
1275            layer_digest_for_role(&manifest, ROLE_LINE_STATUS),
1276            Some("sha256:ccc".to_string()),
1277            "the baseline line-status layer must be found by its role"
1278        );
1279        assert_eq!(
1280            layer_digest_for_role(&manifest, ROLE_ENVELOPE),
1281            Some("sha256:aaa".to_string())
1282        );
1283        // A manifest with no line-status layer yields None, not an error.
1284        let bare = serde_json::json!({
1285            "layers": [{"digest": "sha256:aaa", "annotations": {ANN_ROLE: ROLE_ENVELOPE}}]
1286        });
1287        assert_eq!(layer_digest_for_role(&bare, ROLE_LINE_STATUS), None);
1288        // Each signed document has its OWN role. Sharing one would let the
1289        // line-status blob be handed over where the index was asked for; the
1290        // payload-type check would then reject it, but only after the source
1291        // had chosen which document the consumer got (REQ-INDEXAUTH-001).
1292        assert_ne!(ROLE_LINE_INDEX, ROLE_LINE_STATUS);
1293        assert_eq!(layer_digest_for_role(&manifest, ROLE_LINE_INDEX), None);
1294        let indexed = serde_json::json!({
1295            "layers": [
1296                {"digest": "sha256:ccc", "annotations": {ANN_ROLE: ROLE_LINE_STATUS}},
1297                {"digest": "sha256:ddd", "annotations": {ANN_ROLE: ROLE_LINE_INDEX}},
1298            ]
1299        });
1300        assert_eq!(
1301            layer_digest_for_role(&indexed, ROLE_LINE_INDEX),
1302            Some("sha256:ddd".to_string())
1303        );
1304    }
1305
1306    // rivet: verifies REQ-INDEXAUTH-001
1307    #[test]
1308    fn a_registrys_listing_for_a_line_is_that_lines_layers_and_nothing_else() {
1309        // What `served_layers` answers with, and therefore what omission is
1310        // measured against (clause 3). Both directions matter and neither is
1311        // obvious: including a tag that is not a layer of this line would
1312        // let an index name it and be satisfied by junk, while EXCLUDING a
1313        // layer that is served would accuse an honest registry of hiding it.
1314        let tags = vec![
1315            "2026.08.0".to_string(),
1316            "2026.08.10".to_string(),
1317            "2026.09.0".to_string(),          // another line
1318            "line-index-2026.08".to_string(), // the index's own tag
1319            "latest".to_string(),             // a floating tag some pipeline pushed
1320            "2026.08.01".to_string(),         // non-canonical: leading zero
1321            "2026.08".to_string(),            // a line, not a layer
1322        ];
1323        assert_eq!(
1324            layers_of_line(tags.clone(), "2026.08"),
1325            vec!["2026.08.0".to_string(), "2026.08.10".to_string()],
1326        );
1327        assert_eq!(
1328            layers_of_line(tags, "2026.09"),
1329            vec!["2026.09.0".to_string()]
1330        );
1331        // A repository with no layer of this line enumerates EMPTY, which
1332        // against a signed index means it is hiding everything. That is a
1333        // different statement from "cannot enumerate", and the distinction is
1334        // the difference between catching a hostile registry and refusing
1335        // every air-gapped install.
1336        assert!(layers_of_line(vec!["latest".to_string()], "2026.08").is_empty());
1337    }
1338
1339    // ─────────────── clause 1: the challenge, not a guess ───────────────
1340
1341    // rivet: verifies REQ-REGISTRY-002
1342    #[test]
1343    fn a_bearer_challenge_yields_realm_service_and_scope() {
1344        let c = parse_bearer_challenge(
1345            r#"Bearer realm="https://auth.example.test/token",service="registry.example.test",scope="repository:org/repo:pull""#,
1346        )
1347        .expect("a Bearer challenge must parse");
1348        assert_eq!(c.realm.as_deref(), Some("https://auth.example.test/token"));
1349        assert_eq!(c.service.as_deref(), Some("registry.example.test"));
1350        assert_eq!(c.scope.as_deref(), Some("repository:org/repo:pull"));
1351
1352        // A scope contains commas. Splitting the header on commas — the
1353        // obvious wrong implementation — truncates it to "repository:x:pull".
1354        let c =
1355            parse_bearer_challenge(r#"Bearer realm="https://a/t",scope="repository:x:pull,push""#)
1356                .unwrap();
1357        assert_eq!(
1358            c.scope.as_deref(),
1359            Some("repository:x:pull,push"),
1360            "a quoted scope must survive its own commas"
1361        );
1362
1363        // Unquoted values, odd spacing, and a lowercase scheme are all legal.
1364        let c = parse_bearer_challenge("bearer realm=https://a/t, service=reg").unwrap();
1365        assert_eq!(c.realm.as_deref(), Some("https://a/t"));
1366        assert_eq!(c.service.as_deref(), Some("reg"));
1367
1368        // A Basic challenge is not a Bearer challenge.
1369        assert_eq!(parse_bearer_challenge(r#"Basic realm="x""#), None);
1370        // A bare scheme parses to a challenge with no realm, which the caller
1371        // reports as "no token endpoint to ask" rather than guessing one.
1372        assert_eq!(
1373            parse_bearer_challenge("Bearer"),
1374            Some(BearerChallenge::default())
1375        );
1376    }
1377
1378    // rivet: verifies REQ-REGISTRY-002
1379    #[test]
1380    fn the_token_url_comes_from_the_realm_the_registry_named() {
1381        let c = parse_bearer_challenge(
1382            r#"Bearer realm="https://auth.example.test/v1/token",service="reg.example.test""#,
1383        )
1384        .unwrap();
1385        let url = token_url(&c, "repository:fallback:pull").unwrap();
1386        assert!(
1387            url.starts_with("https://auth.example.test/v1/token?"),
1388            "the realm decides the endpoint, not a hardcoded /token: {url}"
1389        );
1390        assert!(url.contains("service=reg.example.test"), "{url}");
1391        assert!(
1392            url.contains("scope=repository%3Afallback%3Apull"),
1393            "an absent scope falls back to a pull scope for the repository: {url}"
1394        );
1395
1396        // A realm that already carries a query string gets '&', not a second '?'.
1397        let c = parse_bearer_challenge(r#"Bearer realm="https://gl.test/jwt/auth?x=1""#).unwrap();
1398        let url = token_url(&c, "repository:r:pull").unwrap();
1399        assert!(url.starts_with("https://gl.test/jwt/auth?x=1&"), "{url}");
1400        assert_eq!(url.matches('?').count(), 1, "{url}");
1401
1402        // No realm, no endpoint — and no guess.
1403        assert_eq!(token_url(&BearerChallenge::default(), "s"), None);
1404        assert_eq!(
1405            token_url(
1406                &BearerChallenge {
1407                    realm: Some("  ".into()),
1408                    ..Default::default()
1409                },
1410                "s"
1411            ),
1412            None
1413        );
1414    }
1415
1416    // rivet: verifies REQ-REGISTRY-002
1417    #[test]
1418    fn an_https_registry_may_not_redirect_its_token_realm_to_cleartext() {
1419        assert!(realm_is_acceptable(
1420            "https://auth.example.test/token",
1421            "https"
1422        ));
1423        assert!(
1424            !realm_is_acceptable("http://auth.example.test/token", "https"),
1425            "an https registry must not talk varve into posting Basic over http"
1426        );
1427        // The test double and air-gapped mirrors are reached over http.
1428        assert!(realm_is_acceptable("http://127.0.0.1:5000/token", "http"));
1429        assert!(realm_is_acceptable("https://127.0.0.1:5000/token", "http"));
1430        assert!(!realm_is_acceptable("ftp://x/token", "http"));
1431    }
1432
1433    // rivet: verifies REQ-REGISTRY-002
1434    #[test]
1435    fn a_token_response_is_read_from_either_spelling() {
1436        assert_eq!(
1437            token_from_body(r#"{"token":"abc"}"#).as_deref(),
1438            Some("abc")
1439        );
1440        assert_eq!(
1441            token_from_body(r#"{"access_token":"xyz"}"#).as_deref(),
1442            Some("xyz"),
1443            "the OAuth2 spelling several registries answer with"
1444        );
1445        assert_eq!(token_from_body(r#"{"token":""}"#), None);
1446        assert_eq!(token_from_body(r#"{"nope":1}"#), None);
1447        assert_eq!(token_from_body("not json"), None);
1448    }
1449
1450    // ─────────────── clause 2: credentials without exec ───────────────
1451
1452    // rivet: verifies REQ-REGISTRY-002
1453    #[test]
1454    fn base64_round_trips_and_decodes_a_docker_auth_field() {
1455        for input in [
1456            "".as_bytes(),
1457            b"a",
1458            b"ab",
1459            b"abc",
1460            b"user:pass",
1461            b"\x00\xff\xfe\x01",
1462        ] {
1463            assert_eq!(
1464                base64_decode(&base64_encode(input)).as_deref(),
1465                Some(input),
1466                "round trip"
1467            );
1468        }
1469        assert_eq!(base64_encode(b"user:pass"), "dXNlcjpwYXNz");
1470        assert_eq!(
1471            decode_basic_auth("dXNlcjpwYXNz"),
1472            Some(("user".to_string(), "pass".to_string()))
1473        );
1474        // Padding-free and newline-wrapped configs still decode.
1475        assert_eq!(
1476            decode_basic_auth("dXNlcjpwYXNz\n"),
1477            Some(("user".to_string(), "pass".to_string()))
1478        );
1479        // A password may contain colons; only the first splits.
1480        assert_eq!(
1481            decode_basic_auth(&base64_encode(b"user:a:b")),
1482            Some(("user".to_string(), "a:b".to_string()))
1483        );
1484        assert_eq!(base64_decode("not base64!"), None);
1485        assert_eq!(decode_basic_auth(&base64_encode(b"nocolon")), None);
1486        assert_eq!(decode_basic_auth(&base64_encode(b":onlypass")), None);
1487    }
1488
1489    // rivet: verifies REQ-REGISTRY-002
1490    #[test]
1491    fn a_docker_config_auths_entry_becomes_a_credential() {
1492        let config = serde_json::json!({
1493            "auths": {
1494                "ghcr.io": { "auth": base64_encode(format!("alice:{SECRET}").as_bytes()) }
1495            }
1496        });
1497        match credential_from_docker_config(&config, "ghcr.io", "/cfg") {
1498            CredentialLookup::Found(c) => {
1499                assert_eq!(c.username, "alice");
1500                assert_eq!(c.password, SECRET);
1501                assert_eq!(c.origin, "/cfg");
1502            }
1503            other => panic!("expected a credential, got {other:?}"),
1504        }
1505
1506        // Keys are written with a scheme and a path in the wild.
1507        let config = serde_json::json!({
1508            "auths": { "https://index.docker.io/v1/": { "auth": base64_encode(b"bob:pw") } }
1509        });
1510        assert!(matches!(
1511            credential_from_docker_config(&config, "registry-1.docker.io", "/cfg"),
1512            CredentialLookup::Found(_)
1513        ));
1514
1515        // Plaintext username/password entries (podman writes these).
1516        let config = serde_json::json!({
1517            "auths": { "reg.test": { "username": "carol", "password": SECRET } }
1518        });
1519        match credential_from_docker_config(&config, "reg.test", "/cfg") {
1520            CredentialLookup::Found(c) => assert_eq!(c.username, "carol"),
1521            other => panic!("expected a credential, got {other:?}"),
1522        }
1523
1524        // A different registry's entry is not this registry's credential.
1525        assert_eq!(
1526            credential_from_docker_config(&config, "other.test", "/cfg"),
1527            CredentialLookup::Absent
1528        );
1529        // An unreadable auth blob is malformed, not silently absent.
1530        let config = serde_json::json!({ "auths": { "reg.test": { "auth": "%%%" } } });
1531        assert!(matches!(
1532            credential_from_docker_config(&config, "reg.test", "/cfg"),
1533            CredentialLookup::Malformed { .. }
1534        ));
1535    }
1536
1537    // rivet: verifies REQ-REGISTRY-002
1538    #[test]
1539    fn a_credential_helper_is_named_and_never_run() {
1540        let config = serde_json::json!({ "credsStore": "osxkeychain" });
1541        assert_eq!(
1542            credential_from_docker_config(&config, "ghcr.io", "~/.docker/config.json"),
1543            CredentialLookup::HelperOnly {
1544                helper: "osxkeychain".to_string(),
1545                origin: "~/.docker/config.json".to_string()
1546            },
1547            "a credsStore-only config must be reported, not executed"
1548        );
1549        let config = serde_json::json!({ "credHelpers": { "ghcr.io": "ghcr-login" } });
1550        assert_eq!(
1551            credential_from_docker_config(&config, "ghcr.io", "/cfg"),
1552            CredentialLookup::HelperOnly {
1553                helper: "ghcr-login".to_string(),
1554                origin: "/cfg".to_string()
1555            }
1556        );
1557        // A helper for ANOTHER registry says nothing about this one.
1558        let config = serde_json::json!({ "credHelpers": { "other.test": "h" } });
1559        assert_eq!(
1560            credential_from_docker_config(&config, "ghcr.io", "/cfg"),
1561            CredentialLookup::Absent
1562        );
1563        // A real credential beats the store setting sitting next to it.
1564        let config = serde_json::json!({
1565            "credsStore": "osxkeychain",
1566            "auths": { "ghcr.io": { "auth": base64_encode(b"alice:pw") } }
1567        });
1568        assert!(matches!(
1569            credential_from_docker_config(&config, "ghcr.io", "/cfg"),
1570            CredentialLookup::Found(_)
1571        ));
1572    }
1573
1574    // rivet: verifies REQ-REGISTRY-002
1575    #[test]
1576    fn the_environment_variable_is_a_username_colon_password_pair() {
1577        match credential_from_env_value(&format!("alice:{SECRET}")) {
1578            CredentialLookup::Found(c) => {
1579                assert_eq!(c.username, "alice");
1580                assert_eq!(c.password, SECRET);
1581                assert_eq!(c.origin, "$VARVE_REGISTRY_AUTH");
1582            }
1583            other => panic!("expected a credential, got {other:?}"),
1584        }
1585        // `$(aws ecr get-login-password)` brings a newline along.
1586        match credential_from_env_value("AWS:token-value\n") {
1587            CredentialLookup::Found(c) => assert_eq!(c.password, "token-value"),
1588            other => panic!("expected a credential, got {other:?}"),
1589        }
1590        assert_eq!(credential_from_env_value(""), CredentialLookup::Absent);
1591        assert!(matches!(
1592            credential_from_env_value("no-colon-here"),
1593            CredentialLookup::Malformed { .. }
1594        ));
1595        assert!(matches!(
1596            credential_from_env_value(":only-password"),
1597            CredentialLookup::Malformed { .. }
1598        ));
1599    }
1600
1601    // rivet: verifies REQ-REGISTRY-002
1602    #[test]
1603    fn precedence_prefers_a_real_credential_and_otherwise_keeps_the_explanation() {
1604        let found = CredentialLookup::Found(Credential {
1605            username: "a".into(),
1606            password: "b".into(),
1607            origin: "second".into(),
1608        });
1609        let helper = CredentialLookup::HelperOnly {
1610            helper: "h".into(),
1611            origin: "first".into(),
1612        };
1613        // A helper-only earlier source must not shadow a usable later one.
1614        assert_eq!(
1615            first_usable(vec![helper.clone(), found.clone()]),
1616            found,
1617            "a usable credential wins wherever it is found"
1618        );
1619        // Two usable ones: the earlier source wins.
1620        let first_found = CredentialLookup::Found(Credential {
1621            username: "z".into(),
1622            password: "b".into(),
1623            origin: "first".into(),
1624        });
1625        assert_eq!(
1626            first_usable(vec![first_found.clone(), found.clone()]),
1627            first_found
1628        );
1629        // Nothing usable: the first source that had something to say.
1630        assert_eq!(
1631            first_usable(vec![CredentialLookup::Absent, helper.clone()]),
1632            helper
1633        );
1634        assert_eq!(first_usable(vec![]), CredentialLookup::Absent);
1635    }
1636
1637    // rivet: verifies REQ-REGISTRY-002
1638    #[test]
1639    fn config_files_are_read_in_order_and_a_broken_one_is_skipped() {
1640        let tmp = tempfile::tempdir().unwrap();
1641        let broken = tmp.path().join("broken.json");
1642        std::fs::write(&broken, "{ not json").unwrap();
1643        let good = tmp.path().join("good.json");
1644        std::fs::write(
1645            &good,
1646            serde_json::to_vec(&serde_json::json!({
1647                "auths": { "reg.test": { "auth": base64_encode(format!("dave:{SECRET}").as_bytes()) } }
1648            }))
1649            .unwrap(),
1650        )
1651        .unwrap();
1652        let missing = tmp.path().join("absent.json");
1653
1654        let lookups = lookups_from_paths(&[missing, broken, good], "reg.test");
1655        assert_eq!(
1656            lookups.len(),
1657            1,
1658            "a missing and an unparseable config contribute nothing, they do not fail the pull"
1659        );
1660        match first_usable(lookups) {
1661            CredentialLookup::Found(c) => assert_eq!(c.username, "dave"),
1662            other => panic!("expected the good config's credential, got {other:?}"),
1663        }
1664    }
1665
1666    // rivet: verifies REQ-REGISTRY-002
1667    #[test]
1668    fn a_credential_never_reaches_a_debug_line_or_an_error_message() {
1669        let credential = Credential {
1670            username: "alice".into(),
1671            password: SECRET.into(),
1672            origin: "/home/u/.docker/config.json".into(),
1673        };
1674        let debug = format!("{credential:?}");
1675        assert!(
1676            !debug.contains(SECRET),
1677            "Debug leaked the password: {debug}"
1678        );
1679        assert!(
1680            !debug.contains("alice"),
1681            "Debug leaked the username: {debug}"
1682        );
1683        assert!(debug.contains("/home/u/.docker/config.json"), "{debug}");
1684
1685        let lookup = CredentialLookup::Found(credential.clone());
1686        let debug = format!("{lookup:?}");
1687        assert!(!debug.contains(SECRET), "{debug}");
1688
1689        let advice = credential_advice(&lookup, "ghcr.io", "org/repo");
1690        assert!(!advice.contains(SECRET), "advice leaked the password");
1691        assert!(
1692            advice.contains("/home/u/.docker/config.json"),
1693            "the advice must name where the rejected credential came from: {advice}"
1694        );
1695
1696        // The Basic header is the one place the secret legitimately appears —
1697        // and it is built, never printed.
1698        assert_eq!(
1699            credential.basic_header(),
1700            format!(
1701                "Basic {}",
1702                base64_encode(format!("alice:{SECRET}").as_bytes())
1703            )
1704        );
1705
1706        // A source Debug-printed whole must not carry it either — not the
1707        // configured credential, and not the bearer token the realm issued,
1708        // which is short-lived but is still a credential.
1709        let source = RegistrySource::parse("oci://ghcr.io/org/repo")
1710            .unwrap()
1711            .with_credential("alice", SECRET);
1712        *source.token.borrow_mut() = Some("issued-bearer-token".to_string());
1713        let debug = format!("{source:?}");
1714        assert!(
1715            !debug.contains("issued-bearer-token"),
1716            "RegistrySource Debug leaked the bearer token: {debug}"
1717        );
1718        assert!(
1719            debug.contains("ghcr.io"),
1720            "the reference is not a secret and must stay legible: {debug}"
1721        );
1722        assert!(
1723            !debug.contains(SECRET),
1724            "RegistrySource Debug leaked the password: {debug}"
1725        );
1726    }
1727
1728    // ─────────────── clause 5: say which kind of nothing ───────────────
1729
1730    // rivet: verifies REQ-REGISTRY-002
1731    #[test]
1732    fn a_refusal_distinguishes_no_credential_from_a_rejected_one() {
1733        let rejected = credential_advice(
1734            &CredentialLookup::Found(Credential {
1735                username: "alice".into(),
1736                password: SECRET.into(),
1737                origin: "$VARVE_REGISTRY_AUTH".into(),
1738            }),
1739            "ghcr.io",
1740            "org/repo",
1741        );
1742        assert!(
1743            rejected.contains("rejected it"),
1744            "a rejected credential must be named as rejected: {rejected}"
1745        );
1746        assert!(!rejected.contains("offered no credential"), "{rejected}");
1747
1748        for lookup in [
1749            CredentialLookup::Absent,
1750            CredentialLookup::Malformed {
1751                origin: "$VARVE_REGISTRY_AUTH".into(),
1752            },
1753            CredentialLookup::HelperOnly {
1754                helper: "osxkeychain".into(),
1755                origin: "~/.docker/config.json".into(),
1756            },
1757        ] {
1758            let advice = credential_advice(&lookup, "ghcr.io", "org/repo");
1759            assert!(
1760                advice.contains("offered no credential"),
1761                "{lookup:?} must be reported as having offered nothing: {advice}"
1762            );
1763            assert!(
1764                advice.contains(CREDENTIAL_ENV),
1765                "every no-credential message must name the fix: {advice}"
1766            );
1767        }
1768
1769        // The helper case must say WHY varve did not run the helper, and name
1770        // the alternative — otherwise the user reads it as a varve bug.
1771        let advice = credential_advice(
1772            &CredentialLookup::HelperOnly {
1773                helper: "osxkeychain".into(),
1774                origin: "~/.docker/config.json".into(),
1775            },
1776            "ghcr.io",
1777            "org/repo",
1778        );
1779        assert!(advice.contains("osxkeychain"), "{advice}");
1780        assert!(
1781            advice.contains("does not execute credential helpers"),
1782            "{advice}"
1783        );
1784        assert!(advice.contains("REQ-SHADOW-001"), "{advice}");
1785    }
1786
1787    // ─────────────── clause 3: pagination ───────────────
1788
1789    // rivet: verifies REQ-REGISTRY-002
1790    #[test]
1791    fn a_link_header_names_the_next_page_and_only_within_the_origin() {
1792        let current = "https://reg.test/v2/org/repo/tags/list?n=100";
1793        assert_eq!(
1794            parse_link_next(
1795                r#"</v2/org/repo/tags/list?n=100&last=2026.08.9>; rel="next""#,
1796                current
1797            )
1798            .as_deref(),
1799            Some("https://reg.test/v2/org/repo/tags/list?n=100&last=2026.08.9")
1800        );
1801        // Unquoted rel, extra params, and a multi-link header.
1802        assert_eq!(
1803            parse_link_next(
1804                r#"</v2/a?x=1>; rel=prev, </v2/b?x=2>; type="text"; rel="next""#,
1805                current
1806            )
1807            .as_deref(),
1808            Some("https://reg.test/v2/b?x=2")
1809        );
1810        // An absolute same-origin link is fine.
1811        assert_eq!(
1812            parse_link_next(r#"<https://reg.test/v2/next>; rel="next""#, current).as_deref(),
1813            Some("https://reg.test/v2/next")
1814        );
1815        // A cross-origin next would hand the bearer token to another host.
1816        assert_eq!(
1817            parse_link_next(r#"<https://evil.test/v2/next>; rel="next""#, current),
1818            None,
1819            "a rel=next pointing off-origin must not be followed"
1820        );
1821        // No next link, and a rel that is not next.
1822        assert_eq!(parse_link_next(r#"</v2/a>; rel="prev""#, current), None);
1823        assert_eq!(parse_link_next("", current), None);
1824        // rel="prev next" is a next link.
1825        assert!(parse_link_next(r#"</v2/a>; rel="prev next""#, current).is_some());
1826    }
1827
1828    // rivet: verifies REQ-REGISTRY-002
1829    #[test]
1830    fn a_tags_page_is_parsed_and_a_broken_one_is_not_an_empty_repository() {
1831        assert_eq!(
1832            tags_from_page(br#"{"name":"r","tags":["a","b"]}"#).unwrap(),
1833            vec!["a".to_string(), "b".to_string()]
1834        );
1835        // `tags: null` is spec-legal for an empty page.
1836        assert_eq!(
1837            tags_from_page(br#"{"name":"r","tags":null}"#).unwrap(),
1838            Vec::<String>::new()
1839        );
1840        // Garbage is a transport failure. Returning an empty list here would
1841        // read downstream as "this repository has no such layer".
1842        assert!(tags_from_page(b"<html>502</html>").is_err());
1843    }
1844
1845    // rivet: verifies REQ-REGISTRY-002
1846    #[test]
1847    fn the_first_tags_page_asks_the_registry_to_paginate() {
1848        let url = tags_first_page_url("https://reg.test/v2/org/repo");
1849        assert_eq!(
1850            url,
1851            format!("https://reg.test/v2/org/repo/tags/list?n={TAGS_PAGE_SIZE}")
1852        );
1853        assert!(
1854            url.contains("?n="),
1855            "without ?n= a registry may answer one implementation-defined page and \
1856             the client never learns there was more: {url}"
1857        );
1858        // The page bound is what stops a registry that never says 'no more'.
1859        // Its effect is proven end-to-end by the registry_double test
1860        // `an_endless_tag_list_stops_with_an_error_rather_than_looping_or_truncating`.
1861        assert_eq!(MAX_TAG_PAGES, 64);
1862    }
1863
1864    // ─────────────── clause 4: both manifest media types ───────────────
1865
1866    // rivet: verifies REQ-REGISTRY-002
1867    #[test]
1868    fn the_manifest_accept_header_offers_the_docker_type_as_well_as_the_oci_one() {
1869        assert!(
1870            MANIFEST_ACCEPT.contains("application/vnd.oci.image.manifest.v1+json"),
1871            "{MANIFEST_ACCEPT}"
1872        );
1873        assert!(
1874            MANIFEST_ACCEPT.contains("application/vnd.docker.distribution.manifest.v2+json"),
1875            "a registry serving only the Docker type is unreachable without this: \
1876             {MANIFEST_ACCEPT}"
1877        );
1878    }
1879
1880    // ─────────────── clause 6: no Authorization across a redirect ───────────────
1881
1882    // rivet: verifies REQ-REGISTRY-002
1883    #[test]
1884    fn the_agent_never_carries_authorization_across_a_redirect() {
1885        let config = agent_config();
1886        assert_eq!(
1887            config.redirect_auth_headers(),
1888            ureq::config::RedirectAuthHeaders::Never,
1889            "blob fetches redirect to CDNs; the credential must not go with them"
1890        );
1891        assert!(
1892            !config.http_status_as_error(),
1893            "a 401 must arrive as a response so its WWW-Authenticate challenge can be read"
1894        );
1895    }
1896
1897    #[test]
1898    fn percent_encoding_escapes_what_a_scope_contains() {
1899        assert_eq!(
1900            percent_encode("repository:org/repo:pull"),
1901            "repository%3Aorg%2Frepo%3Apull"
1902        );
1903        assert_eq!(percent_encode("a-b_c.d~e"), "a-b_c.d~e");
1904        assert_eq!(percent_encode("a b"), "a%20b");
1905    }
1906}