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_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 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 out.sort_by(|a, b| a.statement_digest.cmp(&b.statement_digest));
1062 Ok(out)
1063 }
1064
1065 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 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 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 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 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 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 #[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 #[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 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 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 #[test]
1279 fn a_registrys_listing_for_a_line_is_that_lines_layers_and_nothing_else() {
1280 let tags = vec![
1286 "2026.08.0".to_string(),
1287 "2026.08.10".to_string(),
1288 "2026.09.0".to_string(), "line-index-2026.08".to_string(), "latest".to_string(), "2026.08.01".to_string(), "2026.08".to_string(), ];
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 assert!(layers_of_line(vec!["latest".to_string()], "2026.08").is_empty());
1308 }
1309
1310 #[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 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 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 assert_eq!(parse_bearer_challenge(r#"Basic realm="x""#), None);
1341 assert_eq!(
1344 parse_bearer_challenge("Bearer"),
1345 Some(BearerChallenge::default())
1346 );
1347 }
1348
1349 #[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 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 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 #[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 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 #[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 #[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 assert_eq!(
1447 decode_basic_auth("dXNlcjpwYXNz\n"),
1448 Some(("user".to_string(), "pass".to_string()))
1449 );
1450 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 #[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 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 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 assert_eq!(
1497 credential_from_docker_config(&config, "other.test", "/cfg"),
1498 CredentialLookup::Absent
1499 );
1500 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 #[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 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 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 #[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 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 #[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 assert_eq!(
1586 first_usable(vec![helper.clone(), found.clone()]),
1587 found,
1588 "a usable credential wins wherever it is found"
1589 );
1590 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 assert_eq!(
1602 first_usable(vec![CredentialLookup::Absent, helper.clone()]),
1603 helper
1604 );
1605 assert_eq!(first_usable(vec![]), CredentialLookup::Absent);
1606 }
1607
1608 #[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 #[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 assert_eq!(
1670 credential.basic_header(),
1671 format!(
1672 "Basic {}",
1673 base64_encode(format!("alice:{SECRET}").as_bytes())
1674 )
1675 );
1676
1677 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 #[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 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 #[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 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 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 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 assert_eq!(parse_link_next(r#"</v2/a>; rel="prev""#, current), None);
1794 assert_eq!(parse_link_next("", current), None);
1795 assert!(parse_link_next(r#"</v2/a>; rel="prev next""#, current).is_some());
1797 }
1798
1799 #[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 assert_eq!(
1808 tags_from_page(br#"{"name":"r","tags":null}"#).unwrap(),
1809 Vec::<String>::new()
1810 );
1811 assert!(tags_from_page(b"<html>502</html>").is_err());
1814 }
1815
1816 #[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 assert_eq!(MAX_TAG_PAGES, 64);
1833 }
1834
1835 #[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 #[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}