Skip to main content

osdk_core/container/
reference.rs

1//! Strict, canonical OCI image-reference and platform values.
2//!
3//! This module is deliberately protocol-only. It performs no registry I/O and
4//! retains no rejected input in its errors. Every public value is validated at
5//! construction, so canonical strings are safe to use as identity components
6//! in later anonymous registry diagnostics.
7
8use std::fmt;
9use std::net::{Ipv4Addr, Ipv6Addr};
10use std::str::FromStr;
11
12use serde::{Deserialize, Deserializer, Serialize};
13
14const DOCKER_HUB_REGISTRY: &str = "docker.io";
15const DEFAULT_DOCKER_TAG: &str = "latest";
16const DOCKER_HUB_LIBRARY_NAMESPACE: &str = "library";
17const MAX_REPOSITORY_NAME_LENGTH: usize = 255;
18const MAX_REPOSITORY_COMPONENT_LENGTH: usize = MAX_REPOSITORY_NAME_LENGTH;
19const MAX_TAG_LENGTH: usize = 128;
20const SHA256_HEX_LENGTH: usize = 64;
21
22/// A secret-safe parse error for OCI identity values.
23///
24/// Variants intentionally carry no source text. Callers may display or log an
25/// error without accidentally echoing credentials or query parameters from a
26/// rejected URL-shaped input.
27#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
28pub enum ReferenceError {
29    #[error("invalid registry name")]
30    InvalidRegistryName,
31    #[error("invalid repository name")]
32    InvalidRepositoryName,
33    #[error("invalid image tag")]
34    InvalidTag,
35    #[error("invalid OCI digest")]
36    InvalidDigest,
37    #[error("unsupported OCI digest algorithm")]
38    UnsupportedDigestAlgorithm,
39    #[error("invalid OCI image reference")]
40    InvalidImageReference,
41    #[error("an image reference cannot contain both a tag and a digest")]
42    TagAndDigest,
43    #[error("OCI image reference is too long")]
44    ImageReferenceTooLong,
45    #[error("invalid OCI platform")]
46    InvalidPlatform,
47}
48
49/// A canonical registry authority: a DNS host, IPv4 address, or explicitly
50/// bracketed IPv6 literal, optionally followed by a TCP port.
51///
52/// DNS names and IP literals are rendered in lowercase/canonical form. Docker
53/// Hub's transport aliases (`index.docker.io` and `registry-1.docker.io`) map
54/// to the logical registry name `docker.io`. Every explicit port is retained,
55/// including `80` and `443`, because a scheme-free registry name has no default
56/// port; only redundant leading zeroes in the decimal spelling are removed.
57#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
58#[serde(transparent)]
59pub struct RegistryName(String);
60
61impl RegistryName {
62    pub fn parse(value: &str) -> Result<Self, ReferenceError> {
63        if value.is_empty()
64            || !value.is_ascii()
65            || value.bytes().any(|byte| byte.is_ascii_whitespace())
66            || value
67                .bytes()
68                .any(|byte| matches!(byte, b'/' | b'\\' | b'@' | b'?' | b'#' | b'%'))
69        {
70            return Err(ReferenceError::InvalidRegistryName);
71        }
72
73        if value.starts_with('[') {
74            return parse_bracketed_ipv6_registry(value);
75        }
76        if value.contains(['[', ']']) {
77            return Err(ReferenceError::InvalidRegistryName);
78        }
79
80        let colon_count = value.bytes().filter(|byte| *byte == b':').count();
81        if colon_count > 1 {
82            // IPv6 literals must always use brackets so a port is unambiguous.
83            return Err(ReferenceError::InvalidRegistryName);
84        }
85        let (raw_host, port) = if colon_count == 1 {
86            let (host, raw_port) = value
87                .split_once(':')
88                .ok_or(ReferenceError::InvalidRegistryName)?;
89            (host, Some(parse_port(raw_port)?))
90        } else {
91            (value, None)
92        };
93
94        let mut host = canonical_non_ipv6_host(raw_host)?;
95        if matches!(host.as_str(), "index.docker.io" | "registry-1.docker.io") {
96            host = DOCKER_HUB_REGISTRY.to_owned();
97        }
98
99        let canonical = match port {
100            Some(port) => format!("{host}:{port}"),
101            None => host,
102        };
103        Ok(Self(canonical))
104    }
105
106    /// The complete canonical registry authority, including brackets and port.
107    pub fn as_str(&self) -> &str {
108        &self.0
109    }
110
111    /// The canonical host without IPv6 brackets or a port.
112    pub fn host(&self) -> &str {
113        if self.0.starts_with('[') {
114            let close = self
115                .0
116                .find(']')
117                .expect("validated IPv6 registry has a closing bracket");
118            &self.0[1..close]
119        } else {
120            self.0
121                .split_once(':')
122                .map_or(self.0.as_str(), |(host, _)| host)
123        }
124    }
125
126    pub fn port(&self) -> Option<u16> {
127        if self.0.starts_with('[') {
128            let close = self.0.find(']')?;
129            self.0
130                .get(close + 1..)?
131                .strip_prefix(':')
132                .and_then(|port| port.parse().ok())
133        } else {
134            self.0
135                .split_once(':')
136                .and_then(|(_, port)| port.parse().ok())
137        }
138    }
139
140    pub fn is_ipv6_literal(&self) -> bool {
141        self.0.starts_with('[')
142    }
143
144    /// Whether this authority is the default, portless Docker Hub identity.
145    /// An explicit port remains a distinct registry identity.
146    pub fn is_docker_hub(&self) -> bool {
147        self.0 == DOCKER_HUB_REGISTRY
148    }
149}
150
151impl FromStr for RegistryName {
152    type Err = ReferenceError;
153
154    fn from_str(value: &str) -> Result<Self, Self::Err> {
155        Self::parse(value)
156    }
157}
158
159impl fmt::Display for RegistryName {
160    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
161        formatter.write_str(&self.0)
162    }
163}
164
165impl<'de> Deserialize<'de> for RegistryName {
166    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
167    where
168        D: Deserializer<'de>,
169    {
170        String::deserialize(deserializer)?
171            .parse()
172            .map_err(serde::de::Error::custom)
173    }
174}
175
176fn parse_bracketed_ipv6_registry(value: &str) -> Result<RegistryName, ReferenceError> {
177    let close = value.find(']').ok_or(ReferenceError::InvalidRegistryName)?;
178    let raw_address = value
179        .get(1..close)
180        .ok_or(ReferenceError::InvalidRegistryName)?;
181    if raw_address.is_empty() || raw_address.contains('%') {
182        // Zone identifiers are local-interface state and are not registry
183        // identity. They are deliberately unsupported, encoded or otherwise.
184        return Err(ReferenceError::InvalidRegistryName);
185    }
186    let address = raw_address
187        .parse::<Ipv6Addr>()
188        .map_err(|_| ReferenceError::InvalidRegistryName)?;
189    let suffix = value
190        .get(close + 1..)
191        .ok_or(ReferenceError::InvalidRegistryName)?;
192    let port = if suffix.is_empty() {
193        None
194    } else {
195        Some(parse_port(
196            suffix
197                .strip_prefix(':')
198                .ok_or(ReferenceError::InvalidRegistryName)?,
199        )?)
200    };
201    let canonical = match port {
202        Some(port) => format!("[{address}]:{port}"),
203        None => format!("[{address}]"),
204    };
205    Ok(RegistryName(canonical))
206}
207
208fn parse_port(value: &str) -> Result<u16, ReferenceError> {
209    if value.is_empty() || value.len() > 5 || !value.bytes().all(|byte| byte.is_ascii_digit()) {
210        return Err(ReferenceError::InvalidRegistryName);
211    }
212    let port = value
213        .parse::<u16>()
214        .map_err(|_| ReferenceError::InvalidRegistryName)?;
215    if port == 0 {
216        return Err(ReferenceError::InvalidRegistryName);
217    }
218    Ok(port)
219}
220
221fn canonical_non_ipv6_host(value: &str) -> Result<String, ReferenceError> {
222    if value.is_empty() || value.len() > 253 {
223        return Err(ReferenceError::InvalidRegistryName);
224    }
225    if let Ok(address) = value.parse::<Ipv4Addr>() {
226        return Ok(address.to_string());
227    }
228    // Reject numeric forms which different URL/network stacks may interpret as
229    // non-canonical IPv4 addresses (for example, `127.1` or octal forms).
230    if value
231        .bytes()
232        .all(|byte| byte.is_ascii_digit() || byte == b'.')
233    {
234        return Err(ReferenceError::InvalidRegistryName);
235    }
236
237    let value = value.to_ascii_lowercase();
238    for label in value.split('.') {
239        if label.is_empty()
240            || label.len() > 63
241            || !label
242                .bytes()
243                .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
244            || !label
245                .as_bytes()
246                .first()
247                .is_some_and(u8::is_ascii_alphanumeric)
248            || !label
249                .as_bytes()
250                .last()
251                .is_some_and(u8::is_ascii_alphanumeric)
252        {
253            return Err(ReferenceError::InvalidRegistryName);
254        }
255    }
256    Ok(value)
257}
258
259/// A lowercase OCI Distribution repository path without a registry or selector.
260#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
261#[serde(transparent)]
262pub struct RepositoryName(String);
263
264impl RepositoryName {
265    pub fn parse(value: &str) -> Result<Self, ReferenceError> {
266        if value.is_empty()
267            || value.len() > MAX_REPOSITORY_NAME_LENGTH
268            || !value.is_ascii()
269            || value.split('/').any(|component| {
270                component.is_empty()
271                    || component.len() > MAX_REPOSITORY_COMPONENT_LENGTH
272                    || component == "."
273                    || component == ".."
274                    || !is_repository_component(component)
275            })
276        {
277            return Err(ReferenceError::InvalidRepositoryName);
278        }
279        Ok(Self(value.to_owned()))
280    }
281
282    pub fn as_str(&self) -> &str {
283        &self.0
284    }
285
286    pub fn components(&self) -> impl DoubleEndedIterator<Item = &str> {
287        self.0.split('/')
288    }
289
290    pub fn component_count(&self) -> usize {
291        self.components().count()
292    }
293}
294
295impl FromStr for RepositoryName {
296    type Err = ReferenceError;
297
298    fn from_str(value: &str) -> Result<Self, Self::Err> {
299        Self::parse(value)
300    }
301}
302
303impl fmt::Display for RepositoryName {
304    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
305        formatter.write_str(&self.0)
306    }
307}
308
309impl<'de> Deserialize<'de> for RepositoryName {
310    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
311    where
312        D: Deserializer<'de>,
313    {
314        String::deserialize(deserializer)?
315            .parse()
316            .map_err(serde::de::Error::custom)
317    }
318}
319
320fn is_repository_component(value: &str) -> bool {
321    let bytes = value.as_bytes();
322    let mut cursor = 0;
323    if !consume_lower_alphanumeric(bytes, &mut cursor) {
324        return false;
325    }
326
327    while cursor < bytes.len() {
328        match bytes[cursor] {
329            b'.' => cursor += 1,
330            b'_' => {
331                cursor += 1;
332                if bytes.get(cursor) == Some(&b'_') {
333                    cursor += 1;
334                }
335            }
336            b'-' => {
337                cursor += 1;
338                while bytes.get(cursor) == Some(&b'-') {
339                    cursor += 1;
340                }
341            }
342            _ => return false,
343        }
344        if !consume_lower_alphanumeric(bytes, &mut cursor) {
345            return false;
346        }
347    }
348    true
349}
350
351fn consume_lower_alphanumeric(bytes: &[u8], cursor: &mut usize) -> bool {
352    let start = *cursor;
353    while bytes
354        .get(*cursor)
355        .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
356    {
357        *cursor += 1;
358    }
359    *cursor > start
360}
361
362/// A validated image tag. Tags are case-sensitive and retain their spelling.
363#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
364#[serde(transparent)]
365pub struct ImageTag(String);
366
367impl ImageTag {
368    pub fn parse(value: &str) -> Result<Self, ReferenceError> {
369        let bytes = value.as_bytes();
370        if value.is_empty()
371            || value.len() > MAX_TAG_LENGTH
372            || !value.is_ascii()
373            || !bytes
374                .first()
375                .is_some_and(|byte| byte.is_ascii_alphanumeric() || *byte == b'_')
376            || !bytes
377                .iter()
378                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
379        {
380            return Err(ReferenceError::InvalidTag);
381        }
382        Ok(Self(value.to_owned()))
383    }
384
385    pub fn as_str(&self) -> &str {
386        &self.0
387    }
388}
389
390impl FromStr for ImageTag {
391    type Err = ReferenceError;
392
393    fn from_str(value: &str) -> Result<Self, Self::Err> {
394        Self::parse(value)
395    }
396}
397
398impl fmt::Display for ImageTag {
399    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
400        formatter.write_str(&self.0)
401    }
402}
403
404impl<'de> Deserialize<'de> for ImageTag {
405    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
406    where
407        D: Deserializer<'de>,
408    {
409        String::deserialize(deserializer)?
410            .parse()
411            .map_err(serde::de::Error::custom)
412    }
413}
414
415/// An immutable OCI content digest. The initial implementation intentionally
416/// admits only SHA-256 with exactly 64 lowercase hexadecimal digits.
417#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
418#[serde(transparent)]
419pub struct OciDigest(String);
420
421impl OciDigest {
422    pub fn parse(value: &str) -> Result<Self, ReferenceError> {
423        let (algorithm, encoded) = value.split_once(':').ok_or(ReferenceError::InvalidDigest)?;
424        if algorithm != "sha256" {
425            return Err(ReferenceError::UnsupportedDigestAlgorithm);
426        }
427        if encoded.len() != SHA256_HEX_LENGTH
428            || !encoded
429                .bytes()
430                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
431        {
432            return Err(ReferenceError::InvalidDigest);
433        }
434        Ok(Self(value.to_owned()))
435    }
436
437    pub fn as_str(&self) -> &str {
438        &self.0
439    }
440
441    pub const fn algorithm(&self) -> &'static str {
442        "sha256"
443    }
444
445    pub fn encoded(&self) -> &str {
446        &self.0["sha256:".len()..]
447    }
448}
449
450impl FromStr for OciDigest {
451    type Err = ReferenceError;
452
453    fn from_str(value: &str) -> Result<Self, Self::Err> {
454        Self::parse(value)
455    }
456}
457
458impl fmt::Display for OciDigest {
459    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
460        formatter.write_str(&self.0)
461    }
462}
463
464impl<'de> Deserialize<'de> for OciDigest {
465    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
466    where
467        D: Deserializer<'de>,
468    {
469        String::deserialize(deserializer)?
470            .parse()
471            .map_err(serde::de::Error::custom)
472    }
473}
474
475/// The mutually exclusive mutable or immutable selector of an image.
476#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
477#[serde(
478    deny_unknown_fields,
479    tag = "kind",
480    content = "value",
481    rename_all = "kebab-case"
482)]
483pub enum ImageSelector {
484    Tag(ImageTag),
485    Digest(OciDigest),
486}
487
488impl ImageSelector {
489    pub fn tag(value: &str) -> Result<Self, ReferenceError> {
490        Ok(Self::Tag(ImageTag::parse(value)?))
491    }
492
493    pub fn digest(value: &str) -> Result<Self, ReferenceError> {
494        Ok(Self::Digest(OciDigest::parse(value)?))
495    }
496
497    pub fn as_str(&self) -> &str {
498        match self {
499            Self::Tag(tag) => tag.as_str(),
500            Self::Digest(digest) => digest.as_str(),
501        }
502    }
503
504    pub fn as_tag(&self) -> Option<&ImageTag> {
505        match self {
506            Self::Tag(tag) => Some(tag),
507            Self::Digest(_) => None,
508        }
509    }
510
511    pub fn as_digest(&self) -> Option<&OciDigest> {
512        match self {
513            Self::Digest(digest) => Some(digest),
514            Self::Tag(_) => None,
515        }
516    }
517
518    pub fn is_immutable(&self) -> bool {
519        matches!(self, Self::Digest(_))
520    }
521}
522
523impl fmt::Display for ImageSelector {
524    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
525        formatter.write_str(self.as_str())
526    }
527}
528
529impl FromStr for ImageSelector {
530    type Err = ReferenceError;
531
532    fn from_str(value: &str) -> Result<Self, Self::Err> {
533        if value.contains(':') {
534            Self::digest(value)
535        } else {
536            Self::tag(value)
537        }
538    }
539}
540
541/// A canonical registry/repository image identity and exactly one selector.
542#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
543pub struct ImageReference {
544    registry: RegistryName,
545    repository: RepositoryName,
546    selector: ImageSelector,
547}
548
549impl ImageReference {
550    pub fn parse(value: &str) -> Result<Self, ReferenceError> {
551        if value.is_empty()
552            || !value.is_ascii()
553            || value.bytes().any(|byte| byte.is_ascii_whitespace())
554            || value.contains("://")
555            || value
556                .bytes()
557                .any(|byte| matches!(byte, b'\\' | b'?' | b'#' | b'%'))
558        {
559            return Err(ReferenceError::InvalidImageReference);
560        }
561
562        let at_count = value.bytes().filter(|byte| *byte == b'@').count();
563        if at_count > 1 {
564            return Err(ReferenceError::InvalidImageReference);
565        }
566
567        let (name, selector) = if at_count == 1 {
568            let (name, raw_digest) = value
569                .split_once('@')
570                .ok_or(ReferenceError::InvalidImageReference)?;
571            if tag_separator(name).is_some() {
572                return Err(ReferenceError::TagAndDigest);
573            }
574            (name, ImageSelector::Digest(OciDigest::parse(raw_digest)?))
575        } else if let Some(separator) = tag_separator(value) {
576            let name = value
577                .get(..separator)
578                .ok_or(ReferenceError::InvalidImageReference)?;
579            let raw_tag = value
580                .get(separator + 1..)
581                .ok_or(ReferenceError::InvalidTag)?;
582            (name, ImageSelector::Tag(ImageTag::parse(raw_tag)?))
583        } else {
584            (value, ImageSelector::tag(DEFAULT_DOCKER_TAG)?)
585        };
586
587        if name.is_empty() {
588            return Err(ReferenceError::InvalidImageReference);
589        }
590        let (registry, repository) = split_registry_and_repository(name)?;
591        Self::new(registry, repository, selector)
592    }
593
594    pub fn new(
595        registry: RegistryName,
596        mut repository: RepositoryName,
597        selector: ImageSelector,
598    ) -> Result<Self, ReferenceError> {
599        if registry.is_docker_hub() && repository.component_count() == 1 {
600            repository = RepositoryName::parse(&format!(
601                "{DOCKER_HUB_LIBRARY_NAMESPACE}/{}",
602                repository.as_str()
603            ))?;
604        }
605        if registry.as_str().len() + 1 + repository.as_str().len() > MAX_REPOSITORY_NAME_LENGTH {
606            return Err(ReferenceError::ImageReferenceTooLong);
607        }
608        Ok(Self {
609            registry,
610            repository,
611            selector,
612        })
613    }
614
615    pub fn registry(&self) -> &RegistryName {
616        &self.registry
617    }
618
619    pub fn repository(&self) -> &RepositoryName {
620        &self.repository
621    }
622
623    pub fn selector(&self) -> &ImageSelector {
624        &self.selector
625    }
626
627    pub fn tag(&self) -> Option<&ImageTag> {
628        self.selector.as_tag()
629    }
630
631    pub fn digest(&self) -> Option<&OciDigest> {
632        self.selector.as_digest()
633    }
634
635    pub fn is_immutable(&self) -> bool {
636        self.selector.is_immutable()
637    }
638}
639
640impl FromStr for ImageReference {
641    type Err = ReferenceError;
642
643    fn from_str(value: &str) -> Result<Self, Self::Err> {
644        Self::parse(value)
645    }
646}
647
648impl fmt::Display for ImageReference {
649    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
650        write!(formatter, "{}/{}", self.registry, self.repository)?;
651        match &self.selector {
652            ImageSelector::Tag(tag) => write!(formatter, ":{tag}"),
653            ImageSelector::Digest(digest) => write!(formatter, "@{digest}"),
654        }
655    }
656}
657
658impl<'de> Deserialize<'de> for ImageReference {
659    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
660    where
661        D: Deserializer<'de>,
662    {
663        #[derive(Deserialize)]
664        #[serde(deny_unknown_fields)]
665        struct WireReference {
666            registry: RegistryName,
667            repository: RepositoryName,
668            selector: ImageSelector,
669        }
670
671        let wire = WireReference::deserialize(deserializer)?;
672        Self::new(wire.registry, wire.repository, wire.selector).map_err(serde::de::Error::custom)
673    }
674}
675
676fn tag_separator(value: &str) -> Option<usize> {
677    let colon = value.rfind(':')?;
678    let slash = value.rfind('/');
679    (slash.is_none() || slash.is_some_and(|slash| colon > slash)).then_some(colon)
680}
681
682fn split_registry_and_repository(
683    value: &str,
684) -> Result<(RegistryName, RepositoryName), ReferenceError> {
685    if let Some((first, remainder)) = value.split_once('/') {
686        if is_explicit_registry_component(first) {
687            return Ok((
688                RegistryName::parse(first)?,
689                RepositoryName::parse(remainder)?,
690            ));
691        }
692    }
693    Ok((
694        RegistryName::parse(DOCKER_HUB_REGISTRY)?,
695        RepositoryName::parse(value)?,
696    ))
697}
698
699fn is_explicit_registry_component(value: &str) -> bool {
700    value.eq_ignore_ascii_case("localhost")
701        || value.starts_with('[')
702        || value.contains('.')
703        || value.contains(':')
704}
705
706/// A canonical OCI target platform independent of any native runtime adapter.
707///
708/// The string form is `os/architecture[/variant]`. Common client spellings
709/// such as `macos`, `x86_64`, `x64`, and `aarch64` normalize to OCI values.
710#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
711pub struct OciPlatform {
712    os: String,
713    architecture: String,
714    #[serde(skip_serializing_if = "Option::is_none")]
715    variant: Option<String>,
716}
717
718impl OciPlatform {
719    pub fn parse(value: &str) -> Result<Self, ReferenceError> {
720        let mut components = value.split('/');
721        let os = components.next().ok_or(ReferenceError::InvalidPlatform)?;
722        let architecture = components.next().ok_or(ReferenceError::InvalidPlatform)?;
723        let variant = components.next();
724        if components.next().is_some() {
725            return Err(ReferenceError::InvalidPlatform);
726        }
727        Self::new(os, architecture, variant)
728    }
729
730    pub fn new(
731        os: &str,
732        architecture: &str,
733        variant: Option<&str>,
734    ) -> Result<Self, ReferenceError> {
735        let os = canonical_platform_os(os)?;
736        let (architecture, inferred_variant) = canonical_platform_architecture(architecture)?;
737        let explicit_variant = variant.map(canonical_platform_component).transpose()?;
738        let variant = match (explicit_variant, inferred_variant) {
739            (Some(explicit), Some(inferred)) if explicit != inferred => {
740                return Err(ReferenceError::InvalidPlatform);
741            }
742            (Some(explicit), _) => Some(explicit),
743            (None, inferred) => inferred,
744        };
745        Ok(Self {
746            os,
747            architecture,
748            variant,
749        })
750    }
751
752    pub fn os(&self) -> &str {
753        &self.os
754    }
755
756    pub fn architecture(&self) -> &str {
757        &self.architecture
758    }
759
760    pub fn variant(&self) -> Option<&str> {
761        self.variant.as_deref()
762    }
763}
764
765impl FromStr for OciPlatform {
766    type Err = ReferenceError;
767
768    fn from_str(value: &str) -> Result<Self, Self::Err> {
769        Self::parse(value)
770    }
771}
772
773impl fmt::Display for OciPlatform {
774    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
775        write!(formatter, "{}/{}", self.os, self.architecture)?;
776        if let Some(variant) = &self.variant {
777            write!(formatter, "/{variant}")?;
778        }
779        Ok(())
780    }
781}
782
783impl<'de> Deserialize<'de> for OciPlatform {
784    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
785    where
786        D: Deserializer<'de>,
787    {
788        #[derive(Deserialize)]
789        #[serde(deny_unknown_fields)]
790        struct WirePlatform {
791            os: String,
792            architecture: String,
793            #[serde(default)]
794            variant: Option<String>,
795        }
796
797        let wire = WirePlatform::deserialize(deserializer)?;
798        Self::new(&wire.os, &wire.architecture, wire.variant.as_deref())
799            .map_err(serde::de::Error::custom)
800    }
801}
802
803fn canonical_platform_os(value: &str) -> Result<String, ReferenceError> {
804    let value = canonical_platform_component(value)?;
805    Ok(match value.as_str() {
806        "macos" | "macosx" | "osx" => "darwin".to_owned(),
807        "win" => "windows".to_owned(),
808        _ => value,
809    })
810}
811
812fn canonical_platform_architecture(
813    value: &str,
814) -> Result<(String, Option<String>), ReferenceError> {
815    let value = canonical_platform_component(value)?;
816    let (architecture, variant) = match value.as_str() {
817        "x86_64" | "x64" => ("amd64", None),
818        "aarch64" => ("arm64", None),
819        "x86" | "i386" | "i686" => ("386", None),
820        "armv5" => ("arm", Some("v5")),
821        "armv6" => ("arm", Some("v6")),
822        "armv7" | "armv7l" => ("arm", Some("v7")),
823        _ => return Ok((value, None)),
824    };
825    Ok((architecture.to_owned(), variant.map(str::to_owned)))
826}
827
828fn canonical_platform_component(value: &str) -> Result<String, ReferenceError> {
829    if value.is_empty() || value.len() > 64 || !value.is_ascii() {
830        return Err(ReferenceError::InvalidPlatform);
831    }
832    let value = value.to_ascii_lowercase();
833    let bytes = value.as_bytes();
834    if !bytes
835        .first()
836        .is_some_and(|byte| byte.is_ascii_alphanumeric())
837        || !bytes
838            .last()
839            .is_some_and(|byte| byte.is_ascii_alphanumeric())
840        || !bytes
841            .iter()
842            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
843        || bytes
844            .windows(2)
845            .any(|pair| !pair[0].is_ascii_alphanumeric() && !pair[1].is_ascii_alphanumeric())
846    {
847        return Err(ReferenceError::InvalidPlatform);
848    }
849    Ok(value)
850}
851
852#[cfg(test)]
853mod tests {
854    use super::*;
855
856    const SHA256: &str = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
857
858    #[test]
859    fn registry_names_canonicalize_hosts_ports_and_docker_aliases() {
860        for (raw, expected, host, port) in [
861            ("GHCR.IO", "ghcr.io", "ghcr.io", None),
862            (
863                "registry.example:05000",
864                "registry.example:5000",
865                "registry.example",
866                Some(5000),
867            ),
868            (
869                "registry.example:443",
870                "registry.example:443",
871                "registry.example",
872                Some(443),
873            ),
874            ("INDEX.DOCKER.IO", "docker.io", "docker.io", None),
875            (
876                "registry-1.docker.io:443",
877                "docker.io:443",
878                "docker.io",
879                Some(443),
880            ),
881            ("127.0.0.1:5000", "127.0.0.1:5000", "127.0.0.1", Some(5000)),
882        ] {
883            let registry = RegistryName::parse(raw).unwrap();
884            assert_eq!(registry.as_str(), expected, "{raw}");
885            assert_eq!(registry.host(), host, "{raw}");
886            assert_eq!(registry.port(), port, "{raw}");
887        }
888    }
889
890    #[test]
891    fn ipv6_registries_require_brackets_and_are_canonical() {
892        let registry = RegistryName::parse("[2001:0DB8:0:0:0:0:0:1]:05000").unwrap();
893        assert_eq!(registry.as_str(), "[2001:db8::1]:5000");
894        assert_eq!(registry.host(), "2001:db8::1");
895        assert_eq!(registry.port(), Some(5000));
896        assert!(registry.is_ipv6_literal());
897
898        for invalid in [
899            "2001:db8::1",
900            "[2001:db8::1",
901            "2001:db8::1]",
902            "[2001:db8::1]extra",
903            "[2001:db8::1]:",
904            "[2001:db8::1]:70000",
905            "[fe80::1%25eth0]",
906            "[127.0.0.1]:5000",
907        ] {
908            assert_eq!(
909                RegistryName::parse(invalid),
910                Err(ReferenceError::InvalidRegistryName),
911                "{invalid}"
912            );
913        }
914    }
915
916    #[test]
917    fn malformed_registry_authorities_are_rejected() {
918        for invalid in [
919            "",
920            "https://ghcr.io",
921            "user@ghcr.io",
922            "ghcr.io/path",
923            "ghcr.io?token=x",
924            "ghcr.io#fragment",
925            "ghcr.io:0",
926            "ghcr.io:65536",
927            "ghcr.io:000080",
928            "ghcr.io:port",
929            "ghcr.io:",
930            "-example.test",
931            "example-.test",
932            "example..test",
933            "example.test.",
934            "under_score.test",
935            "127.1",
936            "999.999.999.999",
937        ] {
938            assert!(RegistryName::parse(invalid).is_err(), "{invalid}");
939        }
940    }
941
942    #[test]
943    fn repository_grammar_is_strict_and_lowercase() {
944        for valid in [
945            "library/ubuntu",
946            "org/app",
947            "team-name/app_name",
948            "team__name/app.release",
949            "a/b--c",
950        ] {
951            assert_eq!(RepositoryName::parse(valid).unwrap().as_str(), valid);
952        }
953        for invalid in [
954            "",
955            "Ubuntu",
956            "org/App",
957            "/ubuntu",
958            "ubuntu/",
959            "org//app",
960            "org/./app",
961            "org/../app",
962            "org/%2e%2e/app",
963            "org\\app",
964            "org:tag",
965            "org/_app",
966            "org/app_",
967            "org/app...next",
968        ] {
969            assert_eq!(
970                RepositoryName::parse(invalid),
971                Err(ReferenceError::InvalidRepositoryName),
972                "{invalid}"
973            );
974        }
975        assert!(RepositoryName::parse(&"a".repeat(MAX_REPOSITORY_NAME_LENGTH)).is_ok());
976        assert_eq!(
977            RepositoryName::parse(&"a".repeat(MAX_REPOSITORY_NAME_LENGTH + 1)),
978            Err(ReferenceError::InvalidRepositoryName)
979        );
980    }
981
982    #[test]
983    fn tags_are_bounded_case_sensitive_and_delimiter_free() {
984        for valid in [
985            "latest",
986            "Release-1.2",
987            "_internal",
988            &"a".repeat(MAX_TAG_LENGTH),
989        ] {
990            assert_eq!(ImageTag::parse(valid).unwrap().as_str(), valid);
991        }
992        for invalid in [
993            "",
994            ".latest",
995            "-latest",
996            "release/latest",
997            "release:latest",
998            "release@digest",
999            "release?token=x",
1000            "release#fragment",
1001            &"a".repeat(MAX_TAG_LENGTH + 1),
1002        ] {
1003            assert_eq!(
1004                ImageTag::parse(invalid),
1005                Err(ReferenceError::InvalidTag),
1006                "{invalid}"
1007            );
1008        }
1009        assert_eq!(
1010            ImageReference::parse("ubuntu:"),
1011            Err(ReferenceError::InvalidTag)
1012        );
1013    }
1014
1015    #[test]
1016    fn digest_is_sha256_only_and_lowercase() {
1017        let uppercase = format!("sha256:{}", "AB".repeat(32));
1018        assert_eq!(
1019            OciDigest::parse(&uppercase),
1020            Err(ReferenceError::InvalidDigest)
1021        );
1022        let digest = OciDigest::parse(SHA256).unwrap();
1023        assert_eq!(digest.as_str(), SHA256);
1024        assert_eq!(digest.algorithm(), "sha256");
1025        assert_eq!(digest.encoded().len(), 64);
1026
1027        assert_eq!(
1028            OciDigest::parse(&format!("sha512:{}", "a".repeat(128))),
1029            Err(ReferenceError::UnsupportedDigestAlgorithm)
1030        );
1031        for invalid in [
1032            "sha256".to_owned(),
1033            "sha256:".to_owned(),
1034            format!("sha256:{}", "a".repeat(63)),
1035            format!("sha256:{}", "a".repeat(65)),
1036            format!("sha256:{}g", "a".repeat(63)),
1037        ] {
1038            assert_eq!(
1039                OciDigest::parse(&invalid),
1040                Err(ReferenceError::InvalidDigest),
1041                "{invalid}"
1042            );
1043        }
1044    }
1045
1046    #[test]
1047    fn docker_shorthand_and_aliases_have_one_canonical_identity() {
1048        for (raw, expected) in [
1049            ("ubuntu", "docker.io/library/ubuntu:latest"),
1050            ("ubuntu:24.04", "docker.io/library/ubuntu:24.04"),
1051            ("owner/image", "docker.io/owner/image:latest"),
1052            ("docker.io/ubuntu", "docker.io/library/ubuntu:latest"),
1053            ("index.docker.io/ubuntu", "docker.io/library/ubuntu:latest"),
1054            (
1055                "registry-1.docker.io/owner/image:V1",
1056                "docker.io/owner/image:V1",
1057            ),
1058        ] {
1059            let reference = ImageReference::parse(raw).unwrap();
1060            assert_eq!(reference.to_string(), expected, "{raw}");
1061        }
1062
1063        // An explicit port is part of registry identity, so Docker Hub's
1064        // implicit `library/` namespace is not inferred for this spelling.
1065        assert_eq!(
1066            ImageReference::parse("docker.io:443/ubuntu")
1067                .unwrap()
1068                .to_string(),
1069            "docker.io:443/ubuntu:latest"
1070        );
1071    }
1072
1073    #[test]
1074    fn explicit_registries_ports_ipv6_tags_and_digests_parse() {
1075        let ghcr = ImageReference::parse("GHCR.IO/org/app:Release-1").unwrap();
1076        assert_eq!(ghcr.to_string(), "ghcr.io/org/app:Release-1");
1077        assert_eq!(ghcr.registry().host(), "ghcr.io");
1078        assert_eq!(ghcr.repository().as_str(), "org/app");
1079        assert_eq!(ghcr.tag().unwrap().as_str(), "Release-1");
1080        assert!(!ghcr.is_immutable());
1081
1082        let port = ImageReference::parse("registry.example:05000/team/app:v1").unwrap();
1083        assert_eq!(port.to_string(), "registry.example:5000/team/app:v1");
1084
1085        let ipv6 = ImageReference::parse("[2001:db8::1]:5000/team/app:v1").unwrap();
1086        assert_eq!(ipv6.to_string(), "[2001:db8::1]:5000/team/app:v1");
1087        assert_eq!(
1088            ImageReference::parse("[::1]/team/app").unwrap().to_string(),
1089            "[::1]/team/app:latest"
1090        );
1091
1092        let immutable = ImageReference::parse(&format!("ghcr.io/org/app@{SHA256}")).unwrap();
1093        assert_eq!(immutable.digest().unwrap().as_str(), SHA256);
1094        assert!(immutable.tag().is_none());
1095        assert!(immutable.is_immutable());
1096    }
1097
1098    #[test]
1099    fn url_syntax_traversal_encodings_and_conflicting_selectors_are_rejected() {
1100        for invalid in [
1101            "https://ghcr.io/org/app:latest",
1102            "user:password@ghcr.io/org/app",
1103            "ghcr.io/org/app?token=secret",
1104            "ghcr.io/org/app#fragment",
1105            "ghcr.io/org/../app",
1106            "ghcr.io/org/%2e%2e/app",
1107            "ghcr.io/org/%2Fapp",
1108            "ghcr.io/org/%252e%252e/app",
1109            "ghcr.io/org\\app",
1110            "ghcr.io//app",
1111            "ghcr.io/org/App",
1112            "ghcr.io:bad/org/app",
1113            "2001:db8::1/org/app",
1114        ] {
1115            assert!(ImageReference::parse(invalid).is_err(), "{invalid}");
1116        }
1117
1118        assert_eq!(
1119            ImageReference::parse(&format!("ghcr.io/org/app:v1@{SHA256}")),
1120            Err(ReferenceError::TagAndDigest)
1121        );
1122    }
1123
1124    #[test]
1125    fn errors_never_retain_or_echo_rejected_input() {
1126        let secret = "super-secret-password";
1127        let input = format!("https://alice:{secret}@ghcr.io/org/app?token={secret}");
1128        let error = ImageReference::parse(&input).unwrap_err();
1129        let rendered = format!("{error:?}: {error}");
1130        assert!(!rendered.contains(secret));
1131        assert!(!rendered.contains("alice"));
1132    }
1133
1134    #[test]
1135    fn platforms_canonicalize_aliases_and_cover_windows() {
1136        for (raw, expected) in [
1137            ("linux/amd64", "linux/amd64"),
1138            ("Linux/X86_64", "linux/amd64"),
1139            ("linux/aarch64/v8", "linux/arm64/v8"),
1140            ("linux/armv7", "linux/arm/v7"),
1141            ("macos/x64", "darwin/amd64"),
1142            ("windows/amd64", "windows/amd64"),
1143            ("WIN/ARM64", "windows/arm64"),
1144        ] {
1145            let platform = OciPlatform::parse(raw).unwrap();
1146            assert_eq!(platform.to_string(), expected, "{raw}");
1147        }
1148
1149        let windows = OciPlatform::parse("windows/amd64").unwrap();
1150        assert_eq!(windows.os(), "windows");
1151        assert_eq!(windows.architecture(), "amd64");
1152        assert_eq!(windows.variant(), None);
1153    }
1154
1155    #[test]
1156    fn malformed_platforms_are_rejected() {
1157        for invalid in [
1158            "",
1159            "linux",
1160            "linux/",
1161            "/amd64",
1162            "linux/amd64/",
1163            "linux/amd64/v8/extra",
1164            "linux/../amd64",
1165            "linux/%61md64",
1166            "linux/amd64?x",
1167            "linux/amd64-",
1168            "linux/amd..64",
1169            "linux/armv7/v6",
1170        ] {
1171            assert_eq!(
1172                OciPlatform::parse(invalid),
1173                Err(ReferenceError::InvalidPlatform),
1174                "{invalid}"
1175            );
1176        }
1177    }
1178
1179    #[test]
1180    fn serde_is_canonical_validated_and_stable() {
1181        let reference = ImageReference::parse("ubuntu:24.04").unwrap();
1182        let json = serde_json::to_string(&reference).unwrap();
1183        assert_eq!(
1184            json,
1185            r#"{"registry":"docker.io","repository":"library/ubuntu","selector":{"kind":"tag","value":"24.04"}}"#
1186        );
1187        assert_eq!(
1188            serde_json::from_str::<ImageReference>(&json).unwrap(),
1189            reference
1190        );
1191
1192        let platform = OciPlatform::parse("Windows/X64").unwrap();
1193        let json = serde_json::to_string(&platform).unwrap();
1194        assert_eq!(json, r#"{"os":"windows","architecture":"amd64"}"#);
1195        assert_eq!(
1196            serde_json::from_str::<OciPlatform>(&json).unwrap(),
1197            platform
1198        );
1199
1200        assert!(serde_json::from_str::<RegistryName>(r#""https://ghcr.io""#).is_err());
1201        assert!(serde_json::from_str::<ImageReference>(
1202            r#"{"registry":"docker.io","repository":"Ubuntu","selector":{"kind":"tag","value":"latest"}}"#
1203        )
1204        .is_err());
1205        assert!(serde_json::from_str::<ImageSelector>(
1206            r#"{"kind":"tag","value":"latest","extra":true}"#
1207        )
1208        .is_err());
1209    }
1210}