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    fn line_index_for_tag(&self, tag: &str) -> Result<Option<Vec<u8>>, SourceError> {
1006        let manifest = match self.artifact_manifest_for_tag(tag) {
1007            Ok(manifest) => manifest,
1008            Err(SourceError::NotFound(_)) => return Ok(None),
1009            Err(e) => return Err(e),
1010        };
1011        match layer_digest_for_role(&manifest, ROLE_LINE_INDEX) {
1012            Some(digest) => self.fetch_blob(&digest).map(Some),
1013            None => Ok(None),
1014        }
1015    }
1016
1017    /// Every attestation a tag's artifact manifest references
1018    /// (REQ-ATTEST-002). A statement whose bytes are absent from the manifest
1019    /// is an ERROR, not a skipped entry: evidence that did not travel is the
1020    /// thing this requirement exists to detect, and dropping it here would
1021    /// reproduce the mirror-boundary bug inside the code meant to catch it.
1022    fn attestations_for_tag(
1023        &self,
1024        tag: &str,
1025    ) -> Result<Vec<crate::attestcarry::CarriedAttestation>, SourceError> {
1026        let manifest = self.artifact_manifest_for_tag(tag)?;
1027        let Some(layers) = manifest["layers"].as_array() else {
1028            return Ok(Vec::new());
1029        };
1030        let mut out = Vec::new();
1031        for l in layers
1032            .iter()
1033            .filter(|l| l["annotations"][ANN_ROLE] == ROLE_ATTESTATION_STATEMENT)
1034        {
1035            let Some(st_digest) = l["digest"].as_str() else {
1036                continue;
1037            };
1038            let bytes_digest = layers
1039                .iter()
1040                .find(|b| {
1041                    b["annotations"][ANN_ROLE] == ROLE_ATTESTATION_BYTES
1042                        && b["annotations"][crate::attestcarry::ANN_STATEMENT] == *st_digest
1043                })
1044                .and_then(|b| b["digest"].as_str())
1045                .ok_or_else(|| {
1046                    SourceError::NotFound(format!(
1047                        "tag {tag} carries attestation statement {st_digest} but the manifest \
1048                         references no bytes for it — the claim travelled and the evidence \
1049                         did not"
1050                    ))
1051                })?;
1052            out.push(crate::attestcarry::CarriedAttestation {
1053                statement_digest: st_digest.to_string(),
1054                statement: self.fetch_blob(st_digest)?,
1055                bytes: self.fetch_blob(bytes_digest)?,
1056            });
1057        }
1058        // Deterministic, matching the layout and store readers — a report that
1059        // reshuffles between transports is a diff generator for anyone
1060        // recording verify output as evidence.
1061        out.sort_by(|a, b| a.statement_digest.cmp(&b.statement_digest));
1062        Ok(out)
1063    }
1064
1065    /// Every tag in the repository, following `Link: rel="next"` to the end
1066    /// (REQ-REGISTRY-002 clause 3, varve#70).
1067    ///
1068    /// Three digest-pin paths enumerate tags. A truncated list does not
1069    /// merely lose tags — it makes a digest pin, a status baseline and the
1070    /// carried attestations all come back EMPTY, which is indistinguishable
1071    /// from legitimate absence. So exhaustion is the only acceptable outcome
1072    /// besides an error: running out of pages raises, it never returns short.
1073    fn tags(&self) -> Result<Vec<String>, SourceError> {
1074        let mut url = tags_first_page_url(&self.base());
1075        let mut out = Vec::new();
1076        for _ in 0..MAX_TAG_PAGES {
1077            let page = self.get_checked(&url, "application/json")?;
1078            out.extend(tags_from_page(&page.bytes)?);
1079            let next = page
1080                .link
1081                .as_deref()
1082                .and_then(|link| parse_link_next(link, &url));
1083            let Some(next) = next else {
1084                return Ok(out);
1085            };
1086            if next == url {
1087                return Err(SourceError::Transport(format!(
1088                    "{url} answered with a Link rel=\"next\" pointing at the page it came from; \
1089                     refusing to loop"
1090                )));
1091            }
1092            url = next;
1093        }
1094        Err(SourceError::Transport(format!(
1095            "{}/tags/list was still handing out `Link: rel=\"next\"` after {MAX_TAG_PAGES} pages. \
1096             varve stops rather than looping, and refuses to answer from a partial tag list — a \
1097             short list would silently turn a digest pin into 'not found'.",
1098            self.base()
1099        )))
1100    }
1101}
1102
1103impl LayerSource for RegistrySource {
1104    fn fetch_manifest(&self, layer: &LayerRef) -> Result<Vec<u8>, SourceError> {
1105        match layer {
1106            LayerRef::Name(id) => self.envelope_for_tag(&id.to_string()),
1107            LayerRef::Digest(digest) => {
1108                // A pin's digest names the PAYLOAD, not any registry object;
1109                // tags are enumerated and each candidate's payload digest
1110                // compared. Discovery only — verification decides.
1111                for tag in self.tags()? {
1112                    if let Ok(envelope) = self.envelope_for_tag(&tag)
1113                        && let Ok(text) = std::str::from_utf8(&envelope)
1114                        && let Ok(env) = wsc::dsse::DsseEnvelope::from_json(text)
1115                        && let Ok(payload) = env.payload_bytes()
1116                        && &crate::store::manifest_digest(&payload) == digest
1117                    {
1118                        return Ok(envelope);
1119                    }
1120                }
1121                Err(SourceError::NotFound(digest.clone()))
1122            }
1123        }
1124    }
1125
1126    fn fetch_line_status(&self, layer: &LayerRef) -> Result<Option<Vec<u8>>, SourceError> {
1127        // Resolve the tag whose artifact manifest carries the baseline.
1128        // A named pin maps straight to its tag; a digest pin is located by
1129        // the same tag scan fetch_manifest uses.
1130        match layer {
1131            LayerRef::Name(id) => self.line_status_for_tag(&id.to_string()),
1132            LayerRef::Digest(digest) => {
1133                for tag in self.tags()? {
1134                    if let Ok(envelope) = self.envelope_for_tag(&tag)
1135                        && let Ok(text) = std::str::from_utf8(&envelope)
1136                        && let Ok(env) = wsc::dsse::DsseEnvelope::from_json(text)
1137                        && let Ok(payload) = env.payload_bytes()
1138                        && &crate::store::manifest_digest(&payload) == digest
1139                    {
1140                        return self.line_status_for_tag(&tag);
1141                    }
1142                }
1143                Ok(None)
1144            }
1145        }
1146    }
1147
1148    fn fetch_line_index(&self, line: &str) -> Result<Option<Vec<u8>>, SourceError> {
1149        self.line_index_for_tag(&crate::lineindex::index_tag(line))
1150    }
1151
1152    fn served_layers(&self, line: &str) -> Result<Option<Vec<String>>, SourceError> {
1153        // A registry CAN enumerate, so it answers `Some(..)` — including
1154        // `Some(vec![])` when it serves nothing of this line, which against a
1155        // signed index means it is hiding everything. `None` here would mean
1156        // "cannot enumerate" and would switch clause 3 off for every registry.
1157        //
1158        // `tags()` raises rather than returning a short list, and that is
1159        // load-bearing here: a truncated page would look like a registry that
1160        // legitimately serves fewer layers, so omission detection would report
1161        // a hidden layer that is not hidden — or, worse, a hostile registry
1162        // could truncate its way to any listing it liked.
1163        Ok(Some(layers_of_line(self.tags()?, line)))
1164    }
1165
1166    fn fetch_attestations(
1167        &self,
1168        layer: &LayerRef,
1169    ) -> Result<Vec<crate::attestcarry::CarriedAttestation>, SourceError> {
1170        // Same tag resolution as the baseline: a named pin maps to its tag, a
1171        // digest pin is located by the tag scan. Untrusted bytes throughout —
1172        // the caller re-verifies every statement against the trust root, and
1173        // the registry is precisely the party this evidence constrains.
1174        match layer {
1175            LayerRef::Name(id) => self.attestations_for_tag(&id.to_string()),
1176            LayerRef::Digest(digest) => {
1177                for tag in self.tags()? {
1178                    if let Ok(envelope) = self.envelope_for_tag(&tag)
1179                        && let Ok(text) = std::str::from_utf8(&envelope)
1180                        && let Ok(env) = wsc::dsse::DsseEnvelope::from_json(text)
1181                        && let Ok(payload) = env.payload_bytes()
1182                        && &crate::store::manifest_digest(&payload) == digest
1183                    {
1184                        return self.attestations_for_tag(&tag);
1185                    }
1186                }
1187                Ok(Vec::new())
1188            }
1189        }
1190    }
1191
1192    fn fetch_blob(&self, digest: &str) -> Result<Vec<u8>, SourceError> {
1193        let bytes = self.get(
1194            &format!("{}/blobs/{digest}", self.base()),
1195            "application/octet-stream",
1196        )?;
1197        // Transport-level integrity: a registry answering a digest request
1198        // with other bytes is broken or hostile either way. The pipeline
1199        // re-checks against the SIGNED digests; this check just fails fast.
1200        if crate::store::manifest_digest(&bytes) != digest {
1201            return Err(SourceError::Transport(format!(
1202                "registry returned wrong bytes for {digest}"
1203            )));
1204        }
1205        Ok(bytes)
1206    }
1207}
1208
1209#[cfg(test)]
1210mod tests {
1211    use super::*;
1212
1213    const SECRET: &str = "s3cr3t-do-not-log";
1214
1215    // rivet: verifies REQ-REGISTRY-001
1216    #[test]
1217    fn oci_references_parse_and_bad_ones_are_refused() {
1218        let r = RegistryRef::parse("oci://ghcr.io/pulseengine/layers").unwrap();
1219        assert_eq!(r.registry, "ghcr.io");
1220        assert_eq!(r.repository, "pulseengine/layers");
1221        assert_eq!(r.scheme, "https");
1222        let t = RegistryRef::parse("oci+http://127.0.0.1:5000/test/repo").unwrap();
1223        assert_eq!(t.scheme, "http");
1224        assert_eq!(t.registry, "127.0.0.1:5000");
1225        for bad in [
1226            "https://ghcr.io/x",
1227            "oci://",
1228            "oci://hostonly",
1229            "oci://host/",
1230        ] {
1231            assert!(RegistryRef::parse(bad).is_err(), "{bad} must not parse");
1232        }
1233    }
1234
1235    // rivet: verifies REQ-STATUS-DIST-001
1236    #[test]
1237    fn a_role_annotated_layer_digest_is_found_and_absence_is_none() {
1238        let manifest = serde_json::json!({
1239            "layers": [
1240                {"digest": "sha256:aaa", "annotations": {ANN_ROLE: ROLE_ENVELOPE}},
1241                {"digest": "sha256:bbb", "annotations": {ANN_ROLE: ROLE_PAYLOAD}},
1242                {"digest": "sha256:ccc", "annotations": {ANN_ROLE: ROLE_LINE_STATUS}},
1243            ]
1244        });
1245        assert_eq!(
1246            layer_digest_for_role(&manifest, ROLE_LINE_STATUS),
1247            Some("sha256:ccc".to_string()),
1248            "the baseline line-status layer must be found by its role"
1249        );
1250        assert_eq!(
1251            layer_digest_for_role(&manifest, ROLE_ENVELOPE),
1252            Some("sha256:aaa".to_string())
1253        );
1254        // A manifest with no line-status layer yields None, not an error.
1255        let bare = serde_json::json!({
1256            "layers": [{"digest": "sha256:aaa", "annotations": {ANN_ROLE: ROLE_ENVELOPE}}]
1257        });
1258        assert_eq!(layer_digest_for_role(&bare, ROLE_LINE_STATUS), None);
1259        // Each signed document has its OWN role. Sharing one would let the
1260        // line-status blob be handed over where the index was asked for; the
1261        // payload-type check would then reject it, but only after the source
1262        // had chosen which document the consumer got (REQ-INDEXAUTH-001).
1263        assert_ne!(ROLE_LINE_INDEX, ROLE_LINE_STATUS);
1264        assert_eq!(layer_digest_for_role(&manifest, ROLE_LINE_INDEX), None);
1265        let indexed = serde_json::json!({
1266            "layers": [
1267                {"digest": "sha256:ccc", "annotations": {ANN_ROLE: ROLE_LINE_STATUS}},
1268                {"digest": "sha256:ddd", "annotations": {ANN_ROLE: ROLE_LINE_INDEX}},
1269            ]
1270        });
1271        assert_eq!(
1272            layer_digest_for_role(&indexed, ROLE_LINE_INDEX),
1273            Some("sha256:ddd".to_string())
1274        );
1275    }
1276
1277    // rivet: verifies REQ-INDEXAUTH-001
1278    #[test]
1279    fn a_registrys_listing_for_a_line_is_that_lines_layers_and_nothing_else() {
1280        // What `served_layers` answers with, and therefore what omission is
1281        // measured against (clause 3). Both directions matter and neither is
1282        // obvious: including a tag that is not a layer of this line would
1283        // let an index name it and be satisfied by junk, while EXCLUDING a
1284        // layer that is served would accuse an honest registry of hiding it.
1285        let tags = vec![
1286            "2026.08.0".to_string(),
1287            "2026.08.10".to_string(),
1288            "2026.09.0".to_string(),          // another line
1289            "line-index-2026.08".to_string(), // the index's own tag
1290            "latest".to_string(),             // a floating tag some pipeline pushed
1291            "2026.08.01".to_string(),         // non-canonical: leading zero
1292            "2026.08".to_string(),            // a line, not a layer
1293        ];
1294        assert_eq!(
1295            layers_of_line(tags.clone(), "2026.08"),
1296            vec!["2026.08.0".to_string(), "2026.08.10".to_string()],
1297        );
1298        assert_eq!(
1299            layers_of_line(tags, "2026.09"),
1300            vec!["2026.09.0".to_string()]
1301        );
1302        // A repository with no layer of this line enumerates EMPTY, which
1303        // against a signed index means it is hiding everything. That is a
1304        // different statement from "cannot enumerate", and the distinction is
1305        // the difference between catching a hostile registry and refusing
1306        // every air-gapped install.
1307        assert!(layers_of_line(vec!["latest".to_string()], "2026.08").is_empty());
1308    }
1309
1310    // ─────────────── clause 1: the challenge, not a guess ───────────────
1311
1312    // rivet: verifies REQ-REGISTRY-002
1313    #[test]
1314    fn a_bearer_challenge_yields_realm_service_and_scope() {
1315        let c = parse_bearer_challenge(
1316            r#"Bearer realm="https://auth.example.test/token",service="registry.example.test",scope="repository:org/repo:pull""#,
1317        )
1318        .expect("a Bearer challenge must parse");
1319        assert_eq!(c.realm.as_deref(), Some("https://auth.example.test/token"));
1320        assert_eq!(c.service.as_deref(), Some("registry.example.test"));
1321        assert_eq!(c.scope.as_deref(), Some("repository:org/repo:pull"));
1322
1323        // A scope contains commas. Splitting the header on commas — the
1324        // obvious wrong implementation — truncates it to "repository:x:pull".
1325        let c =
1326            parse_bearer_challenge(r#"Bearer realm="https://a/t",scope="repository:x:pull,push""#)
1327                .unwrap();
1328        assert_eq!(
1329            c.scope.as_deref(),
1330            Some("repository:x:pull,push"),
1331            "a quoted scope must survive its own commas"
1332        );
1333
1334        // Unquoted values, odd spacing, and a lowercase scheme are all legal.
1335        let c = parse_bearer_challenge("bearer realm=https://a/t, service=reg").unwrap();
1336        assert_eq!(c.realm.as_deref(), Some("https://a/t"));
1337        assert_eq!(c.service.as_deref(), Some("reg"));
1338
1339        // A Basic challenge is not a Bearer challenge.
1340        assert_eq!(parse_bearer_challenge(r#"Basic realm="x""#), None);
1341        // A bare scheme parses to a challenge with no realm, which the caller
1342        // reports as "no token endpoint to ask" rather than guessing one.
1343        assert_eq!(
1344            parse_bearer_challenge("Bearer"),
1345            Some(BearerChallenge::default())
1346        );
1347    }
1348
1349    // rivet: verifies REQ-REGISTRY-002
1350    #[test]
1351    fn the_token_url_comes_from_the_realm_the_registry_named() {
1352        let c = parse_bearer_challenge(
1353            r#"Bearer realm="https://auth.example.test/v1/token",service="reg.example.test""#,
1354        )
1355        .unwrap();
1356        let url = token_url(&c, "repository:fallback:pull").unwrap();
1357        assert!(
1358            url.starts_with("https://auth.example.test/v1/token?"),
1359            "the realm decides the endpoint, not a hardcoded /token: {url}"
1360        );
1361        assert!(url.contains("service=reg.example.test"), "{url}");
1362        assert!(
1363            url.contains("scope=repository%3Afallback%3Apull"),
1364            "an absent scope falls back to a pull scope for the repository: {url}"
1365        );
1366
1367        // A realm that already carries a query string gets '&', not a second '?'.
1368        let c = parse_bearer_challenge(r#"Bearer realm="https://gl.test/jwt/auth?x=1""#).unwrap();
1369        let url = token_url(&c, "repository:r:pull").unwrap();
1370        assert!(url.starts_with("https://gl.test/jwt/auth?x=1&"), "{url}");
1371        assert_eq!(url.matches('?').count(), 1, "{url}");
1372
1373        // No realm, no endpoint — and no guess.
1374        assert_eq!(token_url(&BearerChallenge::default(), "s"), None);
1375        assert_eq!(
1376            token_url(
1377                &BearerChallenge {
1378                    realm: Some("  ".into()),
1379                    ..Default::default()
1380                },
1381                "s"
1382            ),
1383            None
1384        );
1385    }
1386
1387    // rivet: verifies REQ-REGISTRY-002
1388    #[test]
1389    fn an_https_registry_may_not_redirect_its_token_realm_to_cleartext() {
1390        assert!(realm_is_acceptable(
1391            "https://auth.example.test/token",
1392            "https"
1393        ));
1394        assert!(
1395            !realm_is_acceptable("http://auth.example.test/token", "https"),
1396            "an https registry must not talk varve into posting Basic over http"
1397        );
1398        // The test double and air-gapped mirrors are reached over http.
1399        assert!(realm_is_acceptable("http://127.0.0.1:5000/token", "http"));
1400        assert!(realm_is_acceptable("https://127.0.0.1:5000/token", "http"));
1401        assert!(!realm_is_acceptable("ftp://x/token", "http"));
1402    }
1403
1404    // rivet: verifies REQ-REGISTRY-002
1405    #[test]
1406    fn a_token_response_is_read_from_either_spelling() {
1407        assert_eq!(
1408            token_from_body(r#"{"token":"abc"}"#).as_deref(),
1409            Some("abc")
1410        );
1411        assert_eq!(
1412            token_from_body(r#"{"access_token":"xyz"}"#).as_deref(),
1413            Some("xyz"),
1414            "the OAuth2 spelling several registries answer with"
1415        );
1416        assert_eq!(token_from_body(r#"{"token":""}"#), None);
1417        assert_eq!(token_from_body(r#"{"nope":1}"#), None);
1418        assert_eq!(token_from_body("not json"), None);
1419    }
1420
1421    // ─────────────── clause 2: credentials without exec ───────────────
1422
1423    // rivet: verifies REQ-REGISTRY-002
1424    #[test]
1425    fn base64_round_trips_and_decodes_a_docker_auth_field() {
1426        for input in [
1427            "".as_bytes(),
1428            b"a",
1429            b"ab",
1430            b"abc",
1431            b"user:pass",
1432            b"\x00\xff\xfe\x01",
1433        ] {
1434            assert_eq!(
1435                base64_decode(&base64_encode(input)).as_deref(),
1436                Some(input),
1437                "round trip"
1438            );
1439        }
1440        assert_eq!(base64_encode(b"user:pass"), "dXNlcjpwYXNz");
1441        assert_eq!(
1442            decode_basic_auth("dXNlcjpwYXNz"),
1443            Some(("user".to_string(), "pass".to_string()))
1444        );
1445        // Padding-free and newline-wrapped configs still decode.
1446        assert_eq!(
1447            decode_basic_auth("dXNlcjpwYXNz\n"),
1448            Some(("user".to_string(), "pass".to_string()))
1449        );
1450        // A password may contain colons; only the first splits.
1451        assert_eq!(
1452            decode_basic_auth(&base64_encode(b"user:a:b")),
1453            Some(("user".to_string(), "a:b".to_string()))
1454        );
1455        assert_eq!(base64_decode("not base64!"), None);
1456        assert_eq!(decode_basic_auth(&base64_encode(b"nocolon")), None);
1457        assert_eq!(decode_basic_auth(&base64_encode(b":onlypass")), None);
1458    }
1459
1460    // rivet: verifies REQ-REGISTRY-002
1461    #[test]
1462    fn a_docker_config_auths_entry_becomes_a_credential() {
1463        let config = serde_json::json!({
1464            "auths": {
1465                "ghcr.io": { "auth": base64_encode(format!("alice:{SECRET}").as_bytes()) }
1466            }
1467        });
1468        match credential_from_docker_config(&config, "ghcr.io", "/cfg") {
1469            CredentialLookup::Found(c) => {
1470                assert_eq!(c.username, "alice");
1471                assert_eq!(c.password, SECRET);
1472                assert_eq!(c.origin, "/cfg");
1473            }
1474            other => panic!("expected a credential, got {other:?}"),
1475        }
1476
1477        // Keys are written with a scheme and a path in the wild.
1478        let config = serde_json::json!({
1479            "auths": { "https://index.docker.io/v1/": { "auth": base64_encode(b"bob:pw") } }
1480        });
1481        assert!(matches!(
1482            credential_from_docker_config(&config, "registry-1.docker.io", "/cfg"),
1483            CredentialLookup::Found(_)
1484        ));
1485
1486        // Plaintext username/password entries (podman writes these).
1487        let config = serde_json::json!({
1488            "auths": { "reg.test": { "username": "carol", "password": SECRET } }
1489        });
1490        match credential_from_docker_config(&config, "reg.test", "/cfg") {
1491            CredentialLookup::Found(c) => assert_eq!(c.username, "carol"),
1492            other => panic!("expected a credential, got {other:?}"),
1493        }
1494
1495        // A different registry's entry is not this registry's credential.
1496        assert_eq!(
1497            credential_from_docker_config(&config, "other.test", "/cfg"),
1498            CredentialLookup::Absent
1499        );
1500        // An unreadable auth blob is malformed, not silently absent.
1501        let config = serde_json::json!({ "auths": { "reg.test": { "auth": "%%%" } } });
1502        assert!(matches!(
1503            credential_from_docker_config(&config, "reg.test", "/cfg"),
1504            CredentialLookup::Malformed { .. }
1505        ));
1506    }
1507
1508    // rivet: verifies REQ-REGISTRY-002
1509    #[test]
1510    fn a_credential_helper_is_named_and_never_run() {
1511        let config = serde_json::json!({ "credsStore": "osxkeychain" });
1512        assert_eq!(
1513            credential_from_docker_config(&config, "ghcr.io", "~/.docker/config.json"),
1514            CredentialLookup::HelperOnly {
1515                helper: "osxkeychain".to_string(),
1516                origin: "~/.docker/config.json".to_string()
1517            },
1518            "a credsStore-only config must be reported, not executed"
1519        );
1520        let config = serde_json::json!({ "credHelpers": { "ghcr.io": "ghcr-login" } });
1521        assert_eq!(
1522            credential_from_docker_config(&config, "ghcr.io", "/cfg"),
1523            CredentialLookup::HelperOnly {
1524                helper: "ghcr-login".to_string(),
1525                origin: "/cfg".to_string()
1526            }
1527        );
1528        // A helper for ANOTHER registry says nothing about this one.
1529        let config = serde_json::json!({ "credHelpers": { "other.test": "h" } });
1530        assert_eq!(
1531            credential_from_docker_config(&config, "ghcr.io", "/cfg"),
1532            CredentialLookup::Absent
1533        );
1534        // A real credential beats the store setting sitting next to it.
1535        let config = serde_json::json!({
1536            "credsStore": "osxkeychain",
1537            "auths": { "ghcr.io": { "auth": base64_encode(b"alice:pw") } }
1538        });
1539        assert!(matches!(
1540            credential_from_docker_config(&config, "ghcr.io", "/cfg"),
1541            CredentialLookup::Found(_)
1542        ));
1543    }
1544
1545    // rivet: verifies REQ-REGISTRY-002
1546    #[test]
1547    fn the_environment_variable_is_a_username_colon_password_pair() {
1548        match credential_from_env_value(&format!("alice:{SECRET}")) {
1549            CredentialLookup::Found(c) => {
1550                assert_eq!(c.username, "alice");
1551                assert_eq!(c.password, SECRET);
1552                assert_eq!(c.origin, "$VARVE_REGISTRY_AUTH");
1553            }
1554            other => panic!("expected a credential, got {other:?}"),
1555        }
1556        // `$(aws ecr get-login-password)` brings a newline along.
1557        match credential_from_env_value("AWS:token-value\n") {
1558            CredentialLookup::Found(c) => assert_eq!(c.password, "token-value"),
1559            other => panic!("expected a credential, got {other:?}"),
1560        }
1561        assert_eq!(credential_from_env_value(""), CredentialLookup::Absent);
1562        assert!(matches!(
1563            credential_from_env_value("no-colon-here"),
1564            CredentialLookup::Malformed { .. }
1565        ));
1566        assert!(matches!(
1567            credential_from_env_value(":only-password"),
1568            CredentialLookup::Malformed { .. }
1569        ));
1570    }
1571
1572    // rivet: verifies REQ-REGISTRY-002
1573    #[test]
1574    fn precedence_prefers_a_real_credential_and_otherwise_keeps_the_explanation() {
1575        let found = CredentialLookup::Found(Credential {
1576            username: "a".into(),
1577            password: "b".into(),
1578            origin: "second".into(),
1579        });
1580        let helper = CredentialLookup::HelperOnly {
1581            helper: "h".into(),
1582            origin: "first".into(),
1583        };
1584        // A helper-only earlier source must not shadow a usable later one.
1585        assert_eq!(
1586            first_usable(vec![helper.clone(), found.clone()]),
1587            found,
1588            "a usable credential wins wherever it is found"
1589        );
1590        // Two usable ones: the earlier source wins.
1591        let first_found = CredentialLookup::Found(Credential {
1592            username: "z".into(),
1593            password: "b".into(),
1594            origin: "first".into(),
1595        });
1596        assert_eq!(
1597            first_usable(vec![first_found.clone(), found.clone()]),
1598            first_found
1599        );
1600        // Nothing usable: the first source that had something to say.
1601        assert_eq!(
1602            first_usable(vec![CredentialLookup::Absent, helper.clone()]),
1603            helper
1604        );
1605        assert_eq!(first_usable(vec![]), CredentialLookup::Absent);
1606    }
1607
1608    // rivet: verifies REQ-REGISTRY-002
1609    #[test]
1610    fn config_files_are_read_in_order_and_a_broken_one_is_skipped() {
1611        let tmp = tempfile::tempdir().unwrap();
1612        let broken = tmp.path().join("broken.json");
1613        std::fs::write(&broken, "{ not json").unwrap();
1614        let good = tmp.path().join("good.json");
1615        std::fs::write(
1616            &good,
1617            serde_json::to_vec(&serde_json::json!({
1618                "auths": { "reg.test": { "auth": base64_encode(format!("dave:{SECRET}").as_bytes()) } }
1619            }))
1620            .unwrap(),
1621        )
1622        .unwrap();
1623        let missing = tmp.path().join("absent.json");
1624
1625        let lookups = lookups_from_paths(&[missing, broken, good], "reg.test");
1626        assert_eq!(
1627            lookups.len(),
1628            1,
1629            "a missing and an unparseable config contribute nothing, they do not fail the pull"
1630        );
1631        match first_usable(lookups) {
1632            CredentialLookup::Found(c) => assert_eq!(c.username, "dave"),
1633            other => panic!("expected the good config's credential, got {other:?}"),
1634        }
1635    }
1636
1637    // rivet: verifies REQ-REGISTRY-002
1638    #[test]
1639    fn a_credential_never_reaches_a_debug_line_or_an_error_message() {
1640        let credential = Credential {
1641            username: "alice".into(),
1642            password: SECRET.into(),
1643            origin: "/home/u/.docker/config.json".into(),
1644        };
1645        let debug = format!("{credential:?}");
1646        assert!(
1647            !debug.contains(SECRET),
1648            "Debug leaked the password: {debug}"
1649        );
1650        assert!(
1651            !debug.contains("alice"),
1652            "Debug leaked the username: {debug}"
1653        );
1654        assert!(debug.contains("/home/u/.docker/config.json"), "{debug}");
1655
1656        let lookup = CredentialLookup::Found(credential.clone());
1657        let debug = format!("{lookup:?}");
1658        assert!(!debug.contains(SECRET), "{debug}");
1659
1660        let advice = credential_advice(&lookup, "ghcr.io", "org/repo");
1661        assert!(!advice.contains(SECRET), "advice leaked the password");
1662        assert!(
1663            advice.contains("/home/u/.docker/config.json"),
1664            "the advice must name where the rejected credential came from: {advice}"
1665        );
1666
1667        // The Basic header is the one place the secret legitimately appears —
1668        // and it is built, never printed.
1669        assert_eq!(
1670            credential.basic_header(),
1671            format!(
1672                "Basic {}",
1673                base64_encode(format!("alice:{SECRET}").as_bytes())
1674            )
1675        );
1676
1677        // A source Debug-printed whole must not carry it either — not the
1678        // configured credential, and not the bearer token the realm issued,
1679        // which is short-lived but is still a credential.
1680        let source = RegistrySource::parse("oci://ghcr.io/org/repo")
1681            .unwrap()
1682            .with_credential("alice", SECRET);
1683        *source.token.borrow_mut() = Some("issued-bearer-token".to_string());
1684        let debug = format!("{source:?}");
1685        assert!(
1686            !debug.contains("issued-bearer-token"),
1687            "RegistrySource Debug leaked the bearer token: {debug}"
1688        );
1689        assert!(
1690            debug.contains("ghcr.io"),
1691            "the reference is not a secret and must stay legible: {debug}"
1692        );
1693        assert!(
1694            !debug.contains(SECRET),
1695            "RegistrySource Debug leaked the password: {debug}"
1696        );
1697    }
1698
1699    // ─────────────── clause 5: say which kind of nothing ───────────────
1700
1701    // rivet: verifies REQ-REGISTRY-002
1702    #[test]
1703    fn a_refusal_distinguishes_no_credential_from_a_rejected_one() {
1704        let rejected = credential_advice(
1705            &CredentialLookup::Found(Credential {
1706                username: "alice".into(),
1707                password: SECRET.into(),
1708                origin: "$VARVE_REGISTRY_AUTH".into(),
1709            }),
1710            "ghcr.io",
1711            "org/repo",
1712        );
1713        assert!(
1714            rejected.contains("rejected it"),
1715            "a rejected credential must be named as rejected: {rejected}"
1716        );
1717        assert!(!rejected.contains("offered no credential"), "{rejected}");
1718
1719        for lookup in [
1720            CredentialLookup::Absent,
1721            CredentialLookup::Malformed {
1722                origin: "$VARVE_REGISTRY_AUTH".into(),
1723            },
1724            CredentialLookup::HelperOnly {
1725                helper: "osxkeychain".into(),
1726                origin: "~/.docker/config.json".into(),
1727            },
1728        ] {
1729            let advice = credential_advice(&lookup, "ghcr.io", "org/repo");
1730            assert!(
1731                advice.contains("offered no credential"),
1732                "{lookup:?} must be reported as having offered nothing: {advice}"
1733            );
1734            assert!(
1735                advice.contains(CREDENTIAL_ENV),
1736                "every no-credential message must name the fix: {advice}"
1737            );
1738        }
1739
1740        // The helper case must say WHY varve did not run the helper, and name
1741        // the alternative — otherwise the user reads it as a varve bug.
1742        let advice = credential_advice(
1743            &CredentialLookup::HelperOnly {
1744                helper: "osxkeychain".into(),
1745                origin: "~/.docker/config.json".into(),
1746            },
1747            "ghcr.io",
1748            "org/repo",
1749        );
1750        assert!(advice.contains("osxkeychain"), "{advice}");
1751        assert!(
1752            advice.contains("does not execute credential helpers"),
1753            "{advice}"
1754        );
1755        assert!(advice.contains("REQ-SHADOW-001"), "{advice}");
1756    }
1757
1758    // ─────────────── clause 3: pagination ───────────────
1759
1760    // rivet: verifies REQ-REGISTRY-002
1761    #[test]
1762    fn a_link_header_names_the_next_page_and_only_within_the_origin() {
1763        let current = "https://reg.test/v2/org/repo/tags/list?n=100";
1764        assert_eq!(
1765            parse_link_next(
1766                r#"</v2/org/repo/tags/list?n=100&last=2026.08.9>; rel="next""#,
1767                current
1768            )
1769            .as_deref(),
1770            Some("https://reg.test/v2/org/repo/tags/list?n=100&last=2026.08.9")
1771        );
1772        // Unquoted rel, extra params, and a multi-link header.
1773        assert_eq!(
1774            parse_link_next(
1775                r#"</v2/a?x=1>; rel=prev, </v2/b?x=2>; type="text"; rel="next""#,
1776                current
1777            )
1778            .as_deref(),
1779            Some("https://reg.test/v2/b?x=2")
1780        );
1781        // An absolute same-origin link is fine.
1782        assert_eq!(
1783            parse_link_next(r#"<https://reg.test/v2/next>; rel="next""#, current).as_deref(),
1784            Some("https://reg.test/v2/next")
1785        );
1786        // A cross-origin next would hand the bearer token to another host.
1787        assert_eq!(
1788            parse_link_next(r#"<https://evil.test/v2/next>; rel="next""#, current),
1789            None,
1790            "a rel=next pointing off-origin must not be followed"
1791        );
1792        // No next link, and a rel that is not next.
1793        assert_eq!(parse_link_next(r#"</v2/a>; rel="prev""#, current), None);
1794        assert_eq!(parse_link_next("", current), None);
1795        // rel="prev next" is a next link.
1796        assert!(parse_link_next(r#"</v2/a>; rel="prev next""#, current).is_some());
1797    }
1798
1799    // rivet: verifies REQ-REGISTRY-002
1800    #[test]
1801    fn a_tags_page_is_parsed_and_a_broken_one_is_not_an_empty_repository() {
1802        assert_eq!(
1803            tags_from_page(br#"{"name":"r","tags":["a","b"]}"#).unwrap(),
1804            vec!["a".to_string(), "b".to_string()]
1805        );
1806        // `tags: null` is spec-legal for an empty page.
1807        assert_eq!(
1808            tags_from_page(br#"{"name":"r","tags":null}"#).unwrap(),
1809            Vec::<String>::new()
1810        );
1811        // Garbage is a transport failure. Returning an empty list here would
1812        // read downstream as "this repository has no such layer".
1813        assert!(tags_from_page(b"<html>502</html>").is_err());
1814    }
1815
1816    // rivet: verifies REQ-REGISTRY-002
1817    #[test]
1818    fn the_first_tags_page_asks_the_registry_to_paginate() {
1819        let url = tags_first_page_url("https://reg.test/v2/org/repo");
1820        assert_eq!(
1821            url,
1822            format!("https://reg.test/v2/org/repo/tags/list?n={TAGS_PAGE_SIZE}")
1823        );
1824        assert!(
1825            url.contains("?n="),
1826            "without ?n= a registry may answer one implementation-defined page and \
1827             the client never learns there was more: {url}"
1828        );
1829        // The page bound is what stops a registry that never says 'no more'.
1830        // Its effect is proven end-to-end by the registry_double test
1831        // `an_endless_tag_list_stops_with_an_error_rather_than_looping_or_truncating`.
1832        assert_eq!(MAX_TAG_PAGES, 64);
1833    }
1834
1835    // ─────────────── clause 4: both manifest media types ───────────────
1836
1837    // rivet: verifies REQ-REGISTRY-002
1838    #[test]
1839    fn the_manifest_accept_header_offers_the_docker_type_as_well_as_the_oci_one() {
1840        assert!(
1841            MANIFEST_ACCEPT.contains("application/vnd.oci.image.manifest.v1+json"),
1842            "{MANIFEST_ACCEPT}"
1843        );
1844        assert!(
1845            MANIFEST_ACCEPT.contains("application/vnd.docker.distribution.manifest.v2+json"),
1846            "a registry serving only the Docker type is unreachable without this: \
1847             {MANIFEST_ACCEPT}"
1848        );
1849    }
1850
1851    // ─────────────── clause 6: no Authorization across a redirect ───────────────
1852
1853    // rivet: verifies REQ-REGISTRY-002
1854    #[test]
1855    fn the_agent_never_carries_authorization_across_a_redirect() {
1856        let config = agent_config();
1857        assert_eq!(
1858            config.redirect_auth_headers(),
1859            ureq::config::RedirectAuthHeaders::Never,
1860            "blob fetches redirect to CDNs; the credential must not go with them"
1861        );
1862        assert!(
1863            !config.http_status_as_error(),
1864            "a 401 must arrive as a response so its WWW-Authenticate challenge can be read"
1865        );
1866    }
1867
1868    #[test]
1869    fn percent_encoding_escapes_what_a_scope_contains() {
1870        assert_eq!(
1871            percent_encode("repository:org/repo:pull"),
1872            "repository%3Aorg%2Frepo%3Apull"
1873        );
1874        assert_eq!(percent_encode("a-b_c.d~e"), "a-b_c.d~e");
1875        assert_eq!(percent_encode("a b"), "a%20b");
1876    }
1877}