1use std::cell::{OnceCell, RefCell};
47use std::path::PathBuf;
48
49use crate::source::{LayerRef, LayerSource, SourceError};
50
51pub const LAYER_ARTIFACT_TYPE: &str = "application/vnd.pulseengine.varve.layer.v1+json";
53pub const ANN_ROLE: &str = "eu.pulseengine.varve.role";
55pub const ROLE_ENVELOPE: &str = "envelope";
56pub const ROLE_PAYLOAD: &str = "payload";
57pub const ROLE_LINE_STATUS: &str = "line-status";
61pub const ROLE_LINE_INDEX: &str = "line-index";
66pub const ROLE_ATTESTATION_STATEMENT: &str = "attestation-statement";
70pub const ROLE_ATTESTATION_BYTES: &str = "attestation-bytes";
75
76pub const CREDENTIAL_ENV: &str = "VARVE_REGISTRY_AUTH";
79
80pub 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
88const TAGS_PAGE_SIZE: u32 = 100;
90const MAX_TAG_PAGES: usize = 64;
95
96const MAX_BODY_BYTES: u64 = 8 * 1024 * 1024 * 1024;
100const MAX_TOKEN_BYTES: u64 = 1024 * 1024;
102
103fn 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
134const 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
164fn 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#[derive(Clone, PartialEq, Eq)]
197struct Credential {
198 username: String,
199 password: String,
200 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#[derive(Debug, Clone, PartialEq, Eq)]
229enum CredentialLookup {
230 Found(Credential),
231 HelperOnly {
234 helper: String,
235 origin: String,
236 },
237 Malformed {
239 origin: String,
240 },
241 Absent,
242}
243
244fn 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
263fn credential_from_env_value(value: &str) -> CredentialLookup {
265 let origin = format!("${CREDENTIAL_ENV}");
266 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
283fn 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
294fn 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(®istry.as_str()))
308}
309
310fn 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 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
368fn 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
387fn 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
414fn 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
447struct BearerChallenge {
448 realm: Option<String>,
449 service: Option<String>,
450 scope: Option<String>,
451}
452
453fn 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
536fn 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 let separator = if realm.contains('?') { '&' } else { '?' };
555 Some(format!("{realm}{separator}{}", query.join("&")))
556}
557
558fn 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 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
582fn 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
592fn 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
609fn 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
650fn 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
659fn tags_first_page_url(base: &str) -> String {
663 format!("{base}/tags/list?n={TAGS_PAGE_SIZE}")
664}
665
666fn 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#[derive(Debug, Clone, PartialEq, Eq)]
686pub struct RegistryRef {
687 pub registry: String,
688 pub repository: String,
689 pub scheme: String,
692}
693
694impl RegistryRef {
695 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
725fn 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
747struct Fetched {
749 status: u16,
750 bytes: Vec<u8>,
751 link: Option<String>,
752 challenge: Option<String>,
753}
754
755pub struct RegistrySource {
757 reference: RegistryRef,
758 agent: ureq::Agent,
759 token: RefCell<Option<String>>,
762 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 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 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 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 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 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 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 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 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 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 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 out.sort_by(|a, b| a.statement_digest.cmp(&b.statement_digest));
1081 Ok(out)
1082 }
1083
1084 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 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 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 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 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 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 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 #[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 #[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 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 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 #[test]
1308 fn a_registrys_listing_for_a_line_is_that_lines_layers_and_nothing_else() {
1309 let tags = vec![
1315 "2026.08.0".to_string(),
1316 "2026.08.10".to_string(),
1317 "2026.09.0".to_string(), "line-index-2026.08".to_string(), "latest".to_string(), "2026.08.01".to_string(), "2026.08".to_string(), ];
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 assert!(layers_of_line(vec!["latest".to_string()], "2026.08").is_empty());
1337 }
1338
1339 #[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 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 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 assert_eq!(parse_bearer_challenge(r#"Basic realm="x""#), None);
1370 assert_eq!(
1373 parse_bearer_challenge("Bearer"),
1374 Some(BearerChallenge::default())
1375 );
1376 }
1377
1378 #[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 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 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 #[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 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 #[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 #[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 assert_eq!(
1476 decode_basic_auth("dXNlcjpwYXNz\n"),
1477 Some(("user".to_string(), "pass".to_string()))
1478 );
1479 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 #[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 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 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 assert_eq!(
1526 credential_from_docker_config(&config, "other.test", "/cfg"),
1527 CredentialLookup::Absent
1528 );
1529 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 #[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 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 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 #[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 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 #[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 assert_eq!(
1615 first_usable(vec![helper.clone(), found.clone()]),
1616 found,
1617 "a usable credential wins wherever it is found"
1618 );
1619 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 assert_eq!(
1631 first_usable(vec![CredentialLookup::Absent, helper.clone()]),
1632 helper
1633 );
1634 assert_eq!(first_usable(vec![]), CredentialLookup::Absent);
1635 }
1636
1637 #[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 #[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 assert_eq!(
1699 credential.basic_header(),
1700 format!(
1701 "Basic {}",
1702 base64_encode(format!("alice:{SECRET}").as_bytes())
1703 )
1704 );
1705
1706 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 #[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 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 #[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 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 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 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 assert_eq!(parse_link_next(r#"</v2/a>; rel="prev""#, current), None);
1823 assert_eq!(parse_link_next("", current), None);
1824 assert!(parse_link_next(r#"</v2/a>; rel="prev next""#, current).is_some());
1826 }
1827
1828 #[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 assert_eq!(
1837 tags_from_page(br#"{"name":"r","tags":null}"#).unwrap(),
1838 Vec::<String>::new()
1839 );
1840 assert!(tags_from_page(b"<html>502</html>").is_err());
1843 }
1844
1845 #[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 assert_eq!(MAX_TAG_PAGES, 64);
1862 }
1863
1864 #[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 #[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}