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