Skip to main content

oci_zero/
registry.rs

1//! Transport-neutral OCI Distribution request and response policy.
2
3use core::{fmt, str};
4
5use crate::{
6    buffer::BufferWriter,
7    digest::Digest,
8    json::Value,
9    metadata::{JsonString, MetadataError},
10    reference::{Reference, ReferenceError, Repository, Scheme, Selector},
11};
12
13pub const MANIFEST_ACCEPT: &str = concat!(
14    "application/vnd.oci.image.index.v1+json, ",
15    "application/vnd.oci.image.manifest.v1+json, ",
16    "application/vnd.docker.distribution.manifest.list.v2+json, ",
17    "application/vnd.docker.distribution.manifest.v2+json"
18);
19
20pub const OCI_INDEX_ACCEPT: &str = "application/vnd.oci.image.index.v1+json";
21
22#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23pub enum Method {
24    Get,
25    Head,
26}
27
28#[derive(Clone, Copy, Debug, Eq, PartialEq)]
29pub struct Target<'a> {
30    pub scheme: Scheme,
31    pub authority: &'a str,
32    pub path_and_query: &'a str,
33}
34
35impl<'a> Target<'a> {
36    pub fn parse(url: &'a str) -> Result<Self, RegistryError> {
37        let (scheme, remainder) = if let Some(value) = url.strip_prefix("https://") {
38            (Scheme::Https, value)
39        } else if let Some(value) = url.strip_prefix("http://") {
40            (Scheme::Http, value)
41        } else {
42            return Err(RegistryError::InvalidUrl);
43        };
44        let (authority, path_and_query) = match remainder.find('/') {
45            Some(index) => (&remainder[..index], &remainder[index..]),
46            None => (remainder, "/"),
47        };
48        if authority.is_empty()
49            || !authority.is_ascii()
50            || authority
51                .bytes()
52                .any(|byte| byte.is_ascii_whitespace() || matches!(byte, b'@' | b'#'))
53            || !path_and_query.starts_with('/')
54        {
55            return Err(RegistryError::InvalidUrl);
56        }
57        Ok(Self {
58            scheme,
59            authority,
60            path_and_query,
61        })
62    }
63
64    pub fn same_origin(&self, other: &Self) -> bool {
65        self.scheme == other.scheme && self.authority.eq_ignore_ascii_case(other.authority)
66    }
67}
68
69#[derive(Clone, Copy, Debug, Eq, PartialEq)]
70pub struct Request<'a> {
71    pub method: Method,
72    pub target: Target<'a>,
73    pub accept: &'a str,
74    pub authorization: Option<&'a str>,
75}
76
77#[derive(Clone, Copy, Debug, Eq, PartialEq)]
78pub enum ReadOperation {
79    Probe,
80    Catalog,
81    Manifest,
82    Blob,
83    Referrers,
84    Tags,
85}
86
87pub struct RequestPlanner<'reference> {
88    repository: Repository<'reference>,
89    selector: Option<Selector<'reference>>,
90    scheme: Scheme,
91}
92
93impl<'reference> RequestPlanner<'reference> {
94    pub const fn new(reference: Reference<'reference>) -> Self {
95        Self::with_scheme(reference, Scheme::Https)
96    }
97
98    pub const fn with_scheme(reference: Reference<'reference>, scheme: Scheme) -> Self {
99        Self {
100            repository: reference.as_repository(),
101            selector: Some(reference.selector()),
102            scheme,
103        }
104    }
105
106    pub const fn for_repository(repository: Repository<'reference>) -> Self {
107        Self::for_repository_with_scheme(repository, Scheme::Https)
108    }
109
110    pub const fn for_repository_with_scheme(
111        repository: Repository<'reference>,
112        scheme: Scheme,
113    ) -> Self {
114        Self {
115            repository,
116            selector: None,
117            scheme,
118        }
119    }
120
121    pub fn probe<'buffer>(
122        &'buffer self,
123        buffer: &'buffer mut [u8],
124    ) -> Result<Request<'buffer>, RegistryError> {
125        copy_request(
126            buffer,
127            self.scheme,
128            self.repository.registry(),
129            "/v2/",
130            "*/*",
131        )
132    }
133
134    pub fn manifest<'buffer>(
135        &'buffer self,
136        buffer: &'buffer mut [u8],
137    ) -> Result<Request<'buffer>, RegistryError> {
138        let selector = self.selector.ok_or(ReferenceError::MissingSelector)?;
139        Ok(self.request(
140            self.repository.manifest_path(selector, buffer)?,
141            MANIFEST_ACCEPT,
142        ))
143    }
144
145    pub fn head_manifest<'buffer>(
146        &'buffer self,
147        buffer: &'buffer mut [u8],
148    ) -> Result<Request<'buffer>, RegistryError> {
149        let mut request = self.manifest(buffer)?;
150        request.method = Method::Head;
151        Ok(request)
152    }
153
154    pub fn manifest_by_digest<'buffer>(
155        &'buffer self,
156        digest: Digest,
157        buffer: &'buffer mut [u8],
158    ) -> Result<Request<'buffer>, RegistryError> {
159        Ok(self.request(
160            self.repository.manifest_digest_path(digest, buffer)?,
161            MANIFEST_ACCEPT,
162        ))
163    }
164
165    pub fn blob<'buffer>(
166        &'buffer self,
167        digest: Digest,
168        accept: &'buffer str,
169        buffer: &'buffer mut [u8],
170    ) -> Result<Request<'buffer>, RegistryError> {
171        Ok(self.request(self.repository.blob_path(digest, buffer)?, accept))
172    }
173
174    pub fn head_blob<'buffer>(
175        &'buffer self,
176        digest: Digest,
177        accept: &'buffer str,
178        buffer: &'buffer mut [u8],
179    ) -> Result<Request<'buffer>, RegistryError> {
180        let mut request = self.blob(digest, accept, buffer)?;
181        request.method = Method::Head;
182        Ok(request)
183    }
184
185    pub fn referrers<'buffer>(
186        &'buffer self,
187        digest: Digest,
188        buffer: &'buffer mut [u8],
189    ) -> Result<Request<'buffer>, RegistryError> {
190        Ok(self.request(
191            self.repository.referrers_path(digest, buffer)?,
192            OCI_INDEX_ACCEPT,
193        ))
194    }
195
196    pub fn tags<'buffer>(
197        &'buffer self,
198        buffer: &'buffer mut [u8],
199    ) -> Result<Request<'buffer>, RegistryError> {
200        Ok(self.request(self.repository.tags_path(buffer)?, "application/json"))
201    }
202
203    pub fn tags_page<'buffer>(
204        &'buffer self,
205        page_size: u16,
206        buffer: &'buffer mut [u8],
207    ) -> Result<Request<'buffer>, RegistryError> {
208        if page_size == 0 {
209            return Err(RegistryError::InvalidPageSize);
210        }
211        Ok(self.request(
212            self.repository.tags_page_path(page_size, buffer)?,
213            "application/json",
214        ))
215    }
216
217    fn request<'buffer>(
218        &'buffer self,
219        path: &'buffer str,
220        accept: &'buffer str,
221    ) -> Request<'buffer> {
222        get_request(self.scheme, self.repository.registry(), path, accept)
223    }
224
225    pub fn referrers_fallback<'buffer>(
226        &'buffer self,
227        digest: Digest,
228        buffer: &'buffer mut [u8],
229    ) -> Result<Request<'buffer>, RegistryError> {
230        let mut digest_text = [0; 71];
231        let length = write_display(&mut digest_text, digest)?;
232        digest_text[6] = b'-';
233        let mut path = [0; 384];
234        let prefix = b"/v2/";
235        let middle = b"/manifests/";
236        let needed = prefix.len() + self.repository.repository().len() + middle.len() + length;
237        if needed > path.len() {
238            return Err(RegistryError::BufferTooSmall);
239        }
240        let mut position = 0;
241        for part in [
242            prefix.as_slice(),
243            self.repository.repository().as_bytes(),
244            middle.as_slice(),
245            &digest_text[..length],
246        ] {
247            path[position..position + part.len()].copy_from_slice(part);
248            position += part.len();
249        }
250        let path = str::from_utf8(&path[..position]).map_err(|_| RegistryError::InvalidUrl)?;
251        copy_request(
252            buffer,
253            self.scheme,
254            self.repository.registry(),
255            path,
256            MANIFEST_ACCEPT,
257        )
258    }
259}
260
261/// Plans registry-wide requests that do not require a repository name.
262pub struct RegistryRequestPlanner<'authority> {
263    authority: &'authority str,
264    scheme: Scheme,
265}
266
267impl<'authority> RegistryRequestPlanner<'authority> {
268    pub fn new(authority: &'authority str) -> Result<Self, RegistryError> {
269        Self::with_scheme(authority, Scheme::Https)
270    }
271
272    pub fn with_scheme(authority: &'authority str, scheme: Scheme) -> Result<Self, RegistryError> {
273        validate_authority(authority)?;
274        Ok(Self { authority, scheme })
275    }
276
277    pub fn probe<'buffer>(
278        &'buffer self,
279        buffer: &'buffer mut [u8],
280    ) -> Result<Request<'buffer>, RegistryError> {
281        copy_request(buffer, self.scheme, self.authority, "/v2/", "*/*")
282    }
283
284    pub fn catalog<'buffer>(
285        &'buffer self,
286        buffer: &'buffer mut [u8],
287    ) -> Result<Request<'buffer>, RegistryError> {
288        copy_request(
289            buffer,
290            self.scheme,
291            self.authority,
292            "/v2/_catalog",
293            "application/json",
294        )
295    }
296
297    pub fn catalog_page<'buffer>(
298        &'buffer self,
299        page_size: u16,
300        buffer: &'buffer mut [u8],
301    ) -> Result<Request<'buffer>, RegistryError> {
302        if page_size == 0 {
303            return Err(RegistryError::InvalidPageSize);
304        }
305        const PREFIX: &[u8] = b"/v2/_catalog?n=";
306        if buffer.len() < PREFIX.len() {
307            return Err(RegistryError::BufferTooSmall);
308        }
309        buffer[..PREFIX.len()].copy_from_slice(PREFIX);
310        let digits = write_display(&mut buffer[PREFIX.len()..], page_size)?;
311        request_from_path(
312            buffer,
313            PREFIX.len() + digits,
314            self.scheme,
315            self.authority,
316            "application/json",
317        )
318    }
319}
320
321#[derive(Clone, Copy, Debug, Eq, PartialEq)]
322pub struct Header<'a> {
323    pub name: &'a str,
324    pub value: &'a [u8],
325}
326
327#[derive(Clone, Copy, Debug, Eq, PartialEq)]
328pub struct ResponseHead<'a> {
329    pub status: u16,
330    pub headers: &'a [Header<'a>],
331}
332
333impl<'a> ResponseHead<'a> {
334    pub fn header(&self, name: &str) -> Option<&'a [u8]> {
335        self.headers
336            .iter()
337            .find(|header| header.name.eq_ignore_ascii_case(name))
338            .map(|header| header.value)
339    }
340
341    pub fn classify(&self, allow_not_found: bool) -> Result<ResponseAction<'a>, RegistryError> {
342        match self.status {
343            200..=299 => Ok(ResponseAction::Success),
344            301 | 302 | 303 | 307 | 308 => {
345                let location = self
346                    .header("location")
347                    .ok_or(RegistryError::MissingHeader("Location"))?;
348                Ok(ResponseAction::Redirect(
349                    str::from_utf8(location).map_err(|_| RegistryError::InvalidHeader)?,
350                ))
351            }
352            401 => {
353                let challenge = self
354                    .header("www-authenticate")
355                    .ok_or(RegistryError::MissingHeader("WWW-Authenticate"))?;
356                Ok(ResponseAction::Authenticate(AuthChallenge::parse(
357                    str::from_utf8(challenge).map_err(|_| RegistryError::InvalidHeader)?,
358                )?))
359            }
360            404 if allow_not_found => Ok(ResponseAction::NotFound),
361            429 | 500..=599 => Ok(ResponseAction::Retry(RetryAdvice {
362                status: self.status,
363                after: self
364                    .header("retry-after")
365                    .map(parse_retry_after)
366                    .transpose()?,
367            })),
368            status => Err(RegistryError::HttpStatus(status)),
369        }
370    }
371
372    pub fn next_link(&self) -> Result<Option<&'a str>, RegistryError> {
373        let Some(value) = self.header("link") else {
374            return Ok(None);
375        };
376        let value = str::from_utf8(value).map_err(|_| RegistryError::InvalidHeader)?;
377        for link in value.split(',') {
378            let link = link.trim();
379            let Some(end) = link.find('>') else {
380                continue;
381            };
382            if !link.starts_with('<') {
383                continue;
384            }
385            if link[end + 1..].split(';').any(|parameter| {
386                parameter.trim().eq_ignore_ascii_case("rel=\"next\"")
387                    || parameter.trim().eq_ignore_ascii_case("rel=next")
388            }) {
389                return Ok(Some(&link[1..end]));
390            }
391        }
392        Ok(None)
393    }
394}
395
396#[derive(Clone, Copy, Debug, Eq, PartialEq)]
397pub enum ResponseAction<'a> {
398    Success,
399    Redirect(&'a str),
400    Authenticate(AuthChallenge<'a>),
401    NotFound,
402    Retry(RetryAdvice<'a>),
403}
404
405#[derive(Clone, Copy, Debug, Eq, PartialEq)]
406pub struct RetryAdvice<'a> {
407    pub status: u16,
408    pub after: Option<RetryAfter<'a>>,
409}
410
411#[derive(Clone, Copy, Debug, Eq, PartialEq)]
412pub enum RetryAfter<'a> {
413    Seconds(u64),
414    HttpDate(&'a str),
415}
416
417#[derive(Clone, Copy, Debug, Eq, PartialEq)]
418pub enum AuthChallenge<'a> {
419    Bearer(BearerChallenge<'a>),
420    Basic { realm: Option<&'a str> },
421}
422
423#[derive(Clone, Copy, Debug, Eq, PartialEq)]
424pub struct BearerChallenge<'a> {
425    pub realm: &'a str,
426    pub service: Option<&'a str>,
427    pub scope: Option<&'a str>,
428}
429
430impl<'a> AuthChallenge<'a> {
431    pub fn parse(value: &'a str) -> Result<Self, RegistryError> {
432        let (scheme, parameters) = value
433            .split_once(' ')
434            .map(|(scheme, parameters)| (scheme, parameters.trim()))
435            .unwrap_or((value, ""));
436        if scheme.eq_ignore_ascii_case("bearer") {
437            let mut realm = None;
438            let mut service = None;
439            let mut scope = None;
440            let mut remaining = parameters;
441            while !remaining.trim_start().is_empty() {
442                remaining = remaining.trim_start();
443                let equals = remaining.find('=').ok_or(RegistryError::InvalidChallenge)?;
444                let name = remaining[..equals].trim();
445                remaining = &remaining[equals + 1..];
446                let (parameter, rest) = quoted_parameter(remaining)?;
447                match name {
448                    "realm" => realm = unique(realm, parameter)?,
449                    "service" => service = unique(service, parameter)?,
450                    "scope" => scope = unique(scope, parameter)?,
451                    _ => {}
452                }
453                remaining = rest.trim_start();
454                if let Some(rest) = remaining.strip_prefix(',') {
455                    remaining = rest;
456                } else if !remaining.is_empty() {
457                    return Err(RegistryError::InvalidChallenge);
458                }
459            }
460            Ok(Self::Bearer(BearerChallenge {
461                realm: realm.ok_or(RegistryError::InvalidChallenge)?,
462                service,
463                scope,
464            }))
465        } else if scheme.eq_ignore_ascii_case("basic") {
466            let realm = parameters
467                .strip_prefix("realm=")
468                .map(quoted_parameter)
469                .transpose()?
470                .map(|value| value.0);
471            Ok(Self::Basic { realm })
472        } else {
473            Err(RegistryError::UnsupportedAuthentication)
474        }
475    }
476}
477
478#[derive(Clone, Copy, Debug, Eq, PartialEq)]
479pub struct TokenResponse<'a> {
480    pub token: JsonString<'a>,
481    pub expires_in: Option<u64>,
482}
483
484impl<'a> TokenResponse<'a> {
485    pub fn parse(bytes: &'a [u8]) -> Result<Self, RegistryError> {
486        let object = Value::parse_document(bytes)?.object()?;
487        let token = match (object.get("token")?, object.get("access_token")?) {
488            (Some(token), _) | (None, Some(token)) => token.string()?,
489            (None, None) => return Err(RegistryError::InvalidTokenResponse),
490        };
491        let expires_in = object.get("expires_in")?.map(Value::u64).transpose()?;
492        Ok(Self { token, expires_in })
493    }
494
495    /// Writes an HTTP `Bearer` authorization value into caller-owned storage.
496    pub fn bearer_authorization(self, buffer: &mut [u8]) -> Result<&str, RegistryError> {
497        const PREFIX: &[u8] = b"Bearer ";
498        if buffer.len() < PREFIX.len() {
499            return Err(RegistryError::BufferTooSmall);
500        }
501        buffer[..PREFIX.len()].copy_from_slice(PREFIX);
502        let token_length = self
503            .token
504            .decode_into(&mut buffer[PREFIX.len()..])
505            .map_err(MetadataError::from)?
506            .len();
507        let length = PREFIX.len() + token_length;
508        let token = &buffer[PREFIX.len()..length];
509        if token.is_empty() || !token.iter().all(|byte| (b'!'..=b'~').contains(byte)) {
510            return Err(RegistryError::InvalidTokenResponse);
511        }
512        str::from_utf8(&buffer[..length]).map_err(|_| RegistryError::InvalidTokenResponse)
513    }
514}
515
516#[derive(Clone, Copy, Debug, Eq, PartialEq)]
517pub struct Credentials<'a> {
518    pub username: &'a str,
519    pub password: &'a str,
520}
521
522/// Writes an HTTP Basic authorization value into caller-owned storage.
523pub fn basic_authorization<'buffer>(
524    credentials: Credentials<'_>,
525    buffer: &'buffer mut [u8],
526) -> Result<&'buffer str, RegistryError> {
527    const PREFIX: &[u8] = b"Basic ";
528    if credentials.username.contains(':')
529        || credentials
530            .username
531            .bytes()
532            .chain(credentials.password.bytes())
533            .any(|byte| matches!(byte, b'\r' | b'\n'))
534    {
535        return Err(RegistryError::InvalidCredentials);
536    }
537    let mut input = credentials
538        .username
539        .bytes()
540        .chain(core::iter::once(b':'))
541        .chain(credentials.password.bytes());
542    let mut output = PREFIX.len();
543    buffer
544        .get_mut(..PREFIX.len())
545        .ok_or(RegistryError::BufferTooSmall)?
546        .copy_from_slice(PREFIX);
547    while let Some(first) = input.next() {
548        let second = input.next();
549        let third = input.next();
550        let bytes = [first, second.unwrap_or(0), third.unwrap_or(0)];
551        let length = 1 + usize::from(second.is_some()) + usize::from(third.is_some());
552        let end = output.checked_add(4).ok_or(RegistryError::BufferTooSmall)?;
553        let destination = buffer
554            .get_mut(output..end)
555            .ok_or(RegistryError::BufferTooSmall)?;
556        use base64ct::{Base64, Encoding};
557        Base64::encode(&bytes[..length], destination).map_err(|_| RegistryError::BufferTooSmall)?;
558        output = end;
559    }
560    str::from_utf8(&buffer[..output]).map_err(|_| RegistryError::InvalidCredentials)
561}
562
563/// Builds the anonymous OAuth token URL described by a Bearer challenge.
564pub fn bearer_token_url<'buffer>(
565    challenge: BearerChallenge<'_>,
566    buffer: &'buffer mut [u8],
567) -> Result<&'buffer str, RegistryError> {
568    let mut length = 0;
569    append(buffer, &mut length, challenge.realm.as_bytes())?;
570    let mut separator = if challenge.realm.contains('?') {
571        b'&'
572    } else {
573        b'?'
574    };
575    for (name, value) in [("service", challenge.service), ("scope", challenge.scope)] {
576        let Some(value) = value else {
577            continue;
578        };
579        append(buffer, &mut length, &[separator])?;
580        append(buffer, &mut length, name.as_bytes())?;
581        append(buffer, &mut length, b"=")?;
582        for byte in value.bytes() {
583            if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
584                append(buffer, &mut length, &[byte])?;
585            } else {
586                append(
587                    buffer,
588                    &mut length,
589                    &[b'%', HEX[(byte >> 4) as usize], HEX[(byte & 0x0f) as usize]],
590                )?;
591            }
592        }
593        separator = b'&';
594    }
595    str::from_utf8(&buffer[..length]).map_err(|_| RegistryError::InvalidChallenge)
596}
597
598const HEX: &[u8; 16] = b"0123456789ABCDEF";
599
600pub trait CredentialProvider {
601    type Error;
602
603    fn credentials<'a>(
604        &'a mut self,
605        authority: &str,
606        scope: Option<&str>,
607    ) -> Result<Option<Credentials<'a>>, Self::Error>;
608}
609
610pub trait ExternalUrlPolicy {
611    fn allow(&mut self, target: &Target<'_>) -> bool;
612}
613
614pub fn validate_redirect<'a>(
615    current: Target<'_>,
616    location: &'a str,
617    allow_insecure_downgrade: bool,
618) -> Result<Redirect<'a>, RegistryError> {
619    let target = if location.starts_with("https://") || location.starts_with("http://") {
620        Target::parse(location)?
621    } else {
622        return Err(RegistryError::RelativeRedirectRequiresResolution);
623    };
624    if current.scheme == Scheme::Https && target.scheme == Scheme::Http && !allow_insecure_downgrade
625    {
626        return Err(RegistryError::InsecureRedirect);
627    }
628    Ok(Redirect {
629        strip_authorization: !current.same_origin(&target),
630        target,
631    })
632}
633
634#[derive(Clone, Copy, Debug, Eq, PartialEq)]
635pub struct Redirect<'a> {
636    pub target: Target<'a>,
637    pub strip_authorization: bool,
638}
639
640#[derive(Clone, Copy, Debug, Eq, PartialEq, derive_more::Display, derive_more::From)]
641pub enum RegistryError {
642    #[display("invalid registry reference: {_0}")]
643    #[from]
644    Reference(ReferenceError),
645    #[display("invalid registry metadata: {_0}")]
646    #[from(MetadataError, crate::json::JsonError)]
647    Metadata(MetadataError),
648    #[display("invalid registry URL")]
649    InvalidUrl,
650    #[display("invalid registry response header")]
651    InvalidHeader,
652    #[display("registry response lacks {_0}")]
653    MissingHeader(&'static str),
654    #[display("invalid authentication challenge")]
655    InvalidChallenge,
656    #[display("unsupported registry authentication scheme")]
657    UnsupportedAuthentication,
658    #[display("invalid registry token response")]
659    InvalidTokenResponse,
660    #[display("registry returned HTTP {_0}")]
661    HttpStatus(u16),
662    #[display("registry scratch buffer is too small")]
663    BufferTooSmall,
664    #[display("registry page size must be non-zero")]
665    InvalidPageSize,
666    #[display("HTTPS to HTTP redirect was rejected")]
667    InsecureRedirect,
668    #[display("relative redirect requires transport URL resolution")]
669    RelativeRedirectRequiresResolution,
670    #[display("invalid registry credentials")]
671    InvalidCredentials,
672}
673
674fn request_from_path<'a>(
675    buffer: &'a mut [u8],
676    path_length: usize,
677    scheme: Scheme,
678    authority: &'a str,
679    accept: &'a str,
680) -> Result<Request<'a>, RegistryError> {
681    let path = str::from_utf8(&buffer[..path_length]).map_err(|_| RegistryError::InvalidUrl)?;
682    Ok(get_request(scheme, authority, path, accept))
683}
684
685fn get_request<'a>(
686    scheme: Scheme,
687    authority: &'a str,
688    path: &'a str,
689    accept: &'a str,
690) -> Request<'a> {
691    Request {
692        method: Method::Get,
693        target: Target {
694            scheme,
695            authority,
696            path_and_query: path,
697        },
698        accept,
699        authorization: None,
700    }
701}
702
703fn validate_authority(authority: &str) -> Result<(), RegistryError> {
704    if authority.is_empty()
705        || !authority.is_ascii()
706        || authority
707            .bytes()
708            .any(|byte| byte.is_ascii_whitespace() || matches!(byte, b'/' | b'@' | b'?' | b'#'))
709    {
710        return Err(RegistryError::InvalidUrl);
711    }
712    Ok(())
713}
714
715fn copy_request<'a>(
716    buffer: &'a mut [u8],
717    scheme: Scheme,
718    authority: &'a str,
719    path: &str,
720    accept: &'a str,
721) -> Result<Request<'a>, RegistryError> {
722    let destination = buffer
723        .get_mut(..path.len())
724        .ok_or(RegistryError::BufferTooSmall)?;
725    destination.copy_from_slice(path.as_bytes());
726    request_from_path(buffer, path.len(), scheme, authority, accept)
727}
728
729fn append(buffer: &mut [u8], length: &mut usize, bytes: &[u8]) -> Result<(), RegistryError> {
730    crate::buffer::append(buffer, length, bytes).map_err(|_| RegistryError::BufferTooSmall)
731}
732
733fn parse_retry_after(value: &[u8]) -> Result<RetryAfter<'_>, RegistryError> {
734    let value = str::from_utf8(value).map_err(|_| RegistryError::InvalidHeader)?;
735    Ok(match value.trim().parse() {
736        Ok(seconds) => RetryAfter::Seconds(seconds),
737        Err(_) => RetryAfter::HttpDate(value.trim()),
738    })
739}
740
741fn quoted_parameter(value: &str) -> Result<(&str, &str), RegistryError> {
742    let value = value.trim_start();
743    if !value.starts_with('"') {
744        return Err(RegistryError::InvalidChallenge);
745    }
746    let bytes = value.as_bytes();
747    let mut index = 1;
748    while index < bytes.len() {
749        match bytes[index] {
750            b'\\' => {
751                index = index
752                    .checked_add(2)
753                    .ok_or(RegistryError::InvalidChallenge)?
754            }
755            b'"' => return Ok((&value[1..index], &value[index + 1..])),
756            _ => index += 1,
757        }
758    }
759    Err(RegistryError::InvalidChallenge)
760}
761
762fn unique<'a>(current: Option<&'a str>, value: &'a str) -> Result<Option<&'a str>, RegistryError> {
763    if current.is_some() {
764        Err(RegistryError::InvalidChallenge)
765    } else {
766        Ok(Some(value))
767    }
768}
769
770fn write_display(buffer: &mut [u8], value: impl fmt::Display) -> Result<usize, RegistryError> {
771    let mut writer = BufferWriter::new(buffer);
772    fmt::write(&mut writer, format_args!("{value}")).map_err(|_| RegistryError::BufferTooSmall)?;
773    Ok(writer.len())
774}
775
776#[cfg(test)]
777mod tests {
778    use super::{
779        basic_authorization, bearer_token_url, validate_redirect, AuthChallenge, Credentials,
780        Header, Redirect, RegistryError, RegistryRequestPlanner, RequestPlanner, ResponseAction,
781        ResponseHead, RetryAfter, Target, TokenResponse,
782    };
783    use crate::reference::{Repository, Scheme};
784
785    #[test]
786    fn plans_paginated_catalogs_and_tags() {
787        let registry = RegistryRequestPlanner::new("registry.example").unwrap();
788        let mut catalog_path = [0; 64];
789        assert_eq!(
790            registry
791                .catalog_page(100, &mut catalog_path)
792                .unwrap()
793                .target
794                .path_and_query,
795            "/v2/_catalog?n=100"
796        );
797        assert!(matches!(
798            registry.catalog_page(0, &mut catalog_path),
799            Err(RegistryError::InvalidPageSize)
800        ));
801
802        let repository = Repository::parse("oci://registry.example/team/image").unwrap();
803        let planner = RequestPlanner::for_repository(repository);
804        let mut tags_path = [0; 64];
805        assert_eq!(
806            planner
807                .tags_page(50, &mut tags_path)
808                .unwrap()
809                .target
810                .path_and_query,
811            "/v2/team/image/tags/list?n=50"
812        );
813        assert!(matches!(
814            planner.manifest(&mut tags_path),
815            Err(RegistryError::Reference(
816                crate::reference::ReferenceError::MissingSelector
817            ))
818        ));
819    }
820
821    #[test]
822    fn parses_bearer_challenges() {
823        let challenge = AuthChallenge::parse(
824            r#"Bearer realm="https://auth.example/token",service="registry",scope="repository:a/b:pull""#,
825        )
826        .unwrap();
827        let AuthChallenge::Bearer(challenge) = challenge else {
828            panic!("expected bearer challenge");
829        };
830        assert_eq!(challenge.realm, "https://auth.example/token");
831        assert_eq!(challenge.service, Some("registry"));
832        assert_eq!(challenge.scope, Some("repository:a/b:pull"));
833    }
834
835    #[test]
836    fn classifies_redirect_auth_and_retry_responses() {
837        let headers = [Header {
838            name: "Retry-After",
839            value: b"12",
840        }];
841        assert!(matches!(
842            ResponseHead {
843                status: 429,
844                headers: &headers
845            }
846            .classify(false),
847            Ok(ResponseAction::Retry(advice)) if advice.after == Some(RetryAfter::Seconds(12))
848        ));
849    }
850
851    #[test]
852    fn strips_auth_across_origins_and_rejects_downgrades() {
853        let current = Target::parse("https://registry.example/v2/blob").unwrap();
854        assert_eq!(
855            validate_redirect(current, "https://objects.example/blob", false).unwrap(),
856            Redirect {
857                target: Target::parse("https://objects.example/blob").unwrap(),
858                strip_authorization: true,
859            }
860        );
861        assert!(validate_redirect(current, "http://registry.example/blob", false).is_err());
862        assert_eq!(current.scheme, Scheme::Https);
863    }
864
865    #[test]
866    fn parses_token_alias_and_pagination_link() {
867        let token = TokenResponse::parse(br#"{"access_token":"abc","expires_in":60}"#).unwrap();
868        assert_eq!(token.token.as_str(), Some("abc"));
869        assert_eq!(token.expires_in, Some(60));
870        let mut authorization = [0; 32];
871        assert_eq!(
872            token.bearer_authorization(&mut authorization).unwrap(),
873            "Bearer abc"
874        );
875        let aliases =
876            TokenResponse::parse(br#"{"token":"preferred","access_token":"alias"}"#).unwrap();
877        let mut authorization = [0; 32];
878        assert_eq!(
879            aliases.bearer_authorization(&mut authorization).unwrap(),
880            "Bearer preferred"
881        );
882
883        let headers = [Header {
884            name: "Link",
885            value: br#"</v2/name/tags/list?n=2&last=b>; rel="next""#,
886        }];
887        assert_eq!(
888            ResponseHead {
889                status: 200,
890                headers: &headers
891            }
892            .next_link()
893            .unwrap(),
894            Some("/v2/name/tags/list?n=2&last=b")
895        );
896    }
897
898    #[test]
899    fn builds_bearer_urls_and_basic_credentials() {
900        let AuthChallenge::Bearer(challenge) = AuthChallenge::parse(
901            r#"Bearer realm="https://auth.example/token",service="registry.example",scope="repository:team/image:pull""#,
902        )
903        .unwrap()
904        else {
905            panic!("expected bearer challenge");
906        };
907        let mut url = [0; 192];
908        assert_eq!(
909            bearer_token_url(challenge, &mut url).unwrap(),
910            "https://auth.example/token?service=registry.example&scope=repository%3Ateam%2Fimage%3Apull"
911        );
912
913        let mut authorization = [0; 64];
914        assert_eq!(
915            basic_authorization(
916                Credentials {
917                    username: "user",
918                    password: "p@ss",
919                },
920                &mut authorization,
921            )
922            .unwrap(),
923            "Basic dXNlcjpwQHNz"
924        );
925    }
926}