1use 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#[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#[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 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 pub fn as_str(&self) -> &str {
108 &self.0
109 }
110
111 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 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 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 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#[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#[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#[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#[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#[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#[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 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}