1use crate::error::MicrosandboxError;
2use getset::{Getters, Setters};
3use microsandbox_utils::{env, DEFAULT_OCI_REFERENCE_REPO_NAMESPACE, DEFAULT_OCI_REFERENCE_TAG};
4use oci_spec::image::Digest;
5use regex::Regex;
6use serde;
7use std::{fmt, str::FromStr};
8
9#[derive(Debug, Clone, PartialEq, Eq, Getters, Setters)]
18#[getset(get = "pub with_prefix", set = "pub with_prefix")]
19#[derive(serde::Serialize, serde::Deserialize)]
20#[serde(try_from = "String")]
21#[serde(into = "String")]
22pub struct Reference {
23 registry: String,
25
26 repository: String,
28
29 selector: ReferenceSelector,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum ReferenceSelector {
38 Tag {
40 tag: String,
42
43 digest: Option<Digest>,
45 },
46 Digest(Digest),
48}
49
50impl ReferenceSelector {
55 pub fn tag(tag: impl Into<String>) -> Self {
57 Self::Tag {
58 tag: tag.into(),
59 digest: None,
60 }
61 }
62
63 pub fn tag_with_digest(tag: impl Into<String>, digest: impl Into<Digest>) -> Self {
65 Self::Tag {
66 tag: tag.into(),
67 digest: Some(digest.into()),
68 }
69 }
70
71 pub fn digest(digest: impl Into<Digest>) -> Self {
73 Self::Digest(digest.into())
74 }
75}
76
77impl FromStr for Reference {
82 type Err = MicrosandboxError;
83
84 fn from_str(s: &str) -> Result<Self, Self::Err> {
100 let s = s.trim();
101 let default_registry = env::get_oci_registry();
102
103 if s.is_empty() {
104 return Err(MicrosandboxError::ImageReferenceError(
105 "input string is empty".into(),
106 ));
107 }
108
109 if let Some(at_idx) = s.find('@') {
110 let potential_digest = &s[at_idx + 1..];
111 if potential_digest.contains(":") {
112 let (pre, digest_part) = s.split_at(at_idx);
114 let digest_str = &digest_part[1..]; let parsed_digest = digest_str.parse::<Digest>().map_err(|e| {
116 MicrosandboxError::ImageReferenceError(format!("invalid digest: {}", e))
117 })?;
118
119 let (registry, remainder) = extract_registry_and_path(pre, &default_registry);
120 let (repository, tag) = extract_repository_and_tag(remainder)?;
121
122 validate_registry(®istry)?;
124 validate_repository(&repository)?;
125 validate_tag(&tag)?;
126
127 Ok(Reference {
128 registry,
129 repository,
130 selector: ReferenceSelector::tag_with_digest(tag, parsed_digest),
131 })
132 } else {
133 return Err(MicrosandboxError::ImageReferenceError(format!(
134 "invalid digest: {}",
135 potential_digest
136 )));
137 }
138 } else {
139 let (registry, remainder) = extract_registry_and_path(s, &default_registry);
140 let (repository, tag) = extract_repository_and_tag(remainder)?;
141
142 validate_registry(®istry)?;
144 validate_repository(&repository)?;
145 validate_tag(&tag)?;
146
147 Ok(Reference {
148 registry,
149 repository,
150 selector: ReferenceSelector::tag(tag),
151 })
152 }
153 }
154}
155
156impl fmt::Display for Reference {
157 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159 write!(f, "{}/{}", self.registry, self.repository)?;
160 match &self.selector {
161 ReferenceSelector::Tag {
162 tag,
163 digest: Some(d),
164 } => write!(f, ":{}@{}", tag, d),
165 ReferenceSelector::Tag { tag, digest: None } => write!(f, ":{}", tag),
166 ReferenceSelector::Digest(d) => write!(f, "@{}", d),
167 }
168 }
169}
170
171impl From<Reference> for String {
172 fn from(reference: Reference) -> String {
173 reference.to_string()
174 }
175}
176
177impl TryFrom<String> for Reference {
178 type Error = MicrosandboxError;
179
180 fn try_from(s: String) -> Result<Self, Self::Error> {
181 s.parse()
182 }
183}
184
185fn validate_registry(registry: &str) -> Result<(), MicrosandboxError> {
194 let re = Regex::new(r"^[a-zA-Z0-9.-]+(:[0-9]+)?$").unwrap();
195 if re.is_match(registry) {
196 Ok(())
197 } else {
198 Err(MicrosandboxError::ImageReferenceError(format!(
199 "invalid registry: {}",
200 registry
201 )))
202 }
203}
204
205fn validate_repository(repository: &str) -> Result<(), MicrosandboxError> {
210 let repo_re =
211 Regex::new(r"^([a-z0-9]+(?:[._-][a-z0-9]+)*)(/[a-z0-9]+(?:[._-][a-z0-9]+)*)*$").unwrap();
212 if repo_re.is_match(repository) {
213 Ok(())
214 } else {
215 Err(MicrosandboxError::ImageReferenceError(format!(
216 "invalid repository: {}",
217 repository
218 )))
219 }
220}
221
222fn validate_tag(tag: &str) -> Result<(), MicrosandboxError> {
227 let tag_re = Regex::new(r"^\w[\w.-]{0,127}$").unwrap();
228 if tag_re.is_match(tag) {
229 Ok(())
230 } else {
231 Err(MicrosandboxError::ImageReferenceError(format!(
232 "invalid tag: {}",
233 tag
234 )))
235 }
236}
237
238fn extract_registry_and_path<'a>(reference: &'a str, default_registry: &str) -> (String, &'a str) {
241 let segments: Vec<&str> = reference.splitn(2, '/').collect();
242 if segments.len() > 1
243 && (segments[0].contains('.') || segments[0].contains(':') || segments[0] == "localhost")
244 {
245 (segments[0].to_string(), segments[1])
246 } else {
247 (default_registry.to_string(), reference)
248 }
249}
250
251fn extract_repository_and_tag(path: &str) -> Result<(String, String), MicrosandboxError> {
255 if let Some(idx) = path.rfind(':') {
256 let repo_part = &path[..idx];
257 let tag_part = &path[idx + 1..];
258 if repo_part.is_empty() {
259 return Err(MicrosandboxError::ImageReferenceError(
260 "repository is empty".into(),
261 ));
262 }
263 let repository = if !repo_part.contains('/') {
264 format!("{}/{}", DEFAULT_OCI_REFERENCE_REPO_NAMESPACE, repo_part)
265 } else {
266 repo_part.to_string()
267 };
268 Ok((repository, tag_part.to_string()))
269 } else {
270 let repository = if !path.contains('/') {
271 format!("{}/{}", DEFAULT_OCI_REFERENCE_REPO_NAMESPACE, path)
272 } else {
273 path.to_string()
274 };
275 Ok((repository, DEFAULT_OCI_REFERENCE_TAG.to_string()))
276 }
277}
278
279#[cfg(test)]
284mod tests {
285 use super::*;
286
287 #[test]
288 fn test_reference_valid_reference_with_registry_and_tag() {
289 let s = "docker.io/library/alpine:3.12";
290 let reference = s.parse::<Reference>().unwrap();
291 assert_eq!(reference.registry, "docker.io");
292 assert_eq!(reference.repository, "library/alpine");
293 match reference.selector {
294 ReferenceSelector::Tag {
295 ref tag,
296 ref digest,
297 } => {
298 assert_eq!(tag, "3.12");
299 assert!(digest.is_none());
300 }
301 _ => panic!("Expected Tag variant without digest"),
302 }
303 assert_eq!(reference.to_string(), "docker.io/library/alpine:3.12");
304 }
305
306 #[test]
307 fn test_reference_default_registry_and_tag() {
308 let s = "library/alpine";
309 let reference = s.parse::<Reference>().unwrap();
310 let expected_registry = env::get_oci_registry();
311 assert_eq!(reference.registry, expected_registry);
312 assert_eq!(reference.repository, "library/alpine");
313 match reference.selector {
314 ReferenceSelector::Tag {
315 ref tag,
316 ref digest,
317 } => {
318 assert_eq!(tag, DEFAULT_OCI_REFERENCE_TAG);
319 assert!(digest.is_none());
320 }
321 _ => panic!("Expected Tag variant without digest"),
322 }
323 let expected_string = format!(
324 "{}/library/alpine:{}",
325 expected_registry, DEFAULT_OCI_REFERENCE_TAG
326 );
327 assert_eq!(reference.to_string(), expected_string);
328 }
329
330 #[test]
331 fn test_reference_without_tag() {
332 let s = "docker.io/library/alpine";
333 let reference = s.parse::<Reference>().unwrap();
334 assert_eq!(reference.registry, "docker.io");
335 assert_eq!(reference.repository, "library/alpine");
336 match reference.selector {
337 ReferenceSelector::Tag {
338 ref tag,
339 ref digest,
340 } => {
341 assert_eq!(tag, DEFAULT_OCI_REFERENCE_TAG);
342 assert!(digest.is_none());
343 }
344 _ => panic!("Expected Tag variant without digest"),
345 }
346 let expected = format!("docker.io/library/alpine:{}", DEFAULT_OCI_REFERENCE_TAG);
347 assert_eq!(reference.to_string(), expected);
348 }
349
350 #[test]
351 fn test_reference_with_digest_and_tag() {
352 let valid_digest = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
353 let s = format!("registry.example.com/myrepo:mytag@sha256:{}", valid_digest);
354 let reference = s.parse::<Reference>().unwrap();
355 assert_eq!(reference.registry, "registry.example.com");
356 assert_eq!(reference.repository, "library/myrepo");
357 match reference.selector {
358 ReferenceSelector::Tag {
359 ref tag,
360 ref digest,
361 } => {
362 assert_eq!(tag, "mytag");
363 let d = digest.as_ref().expect("Expected a digest");
364 assert_eq!(d.to_string(), format!("sha256:{}", valid_digest));
365 }
366 _ => panic!("Expected Tag variant with digest"),
367 }
368 let expected = format!(
369 "registry.example.com/library/myrepo:mytag@sha256:{}",
370 valid_digest
371 );
372 assert_eq!(reference.to_string(), expected);
373 }
374
375 #[test]
376 fn test_reference_with_digest_only() {
377 let valid_digest = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
378 let s = format!("registry.example.com/myrepo@sha256:{}", valid_digest);
379 let reference = s.parse::<Reference>().unwrap();
380 assert_eq!(reference.registry, "registry.example.com");
381 assert_eq!(reference.repository, "library/myrepo");
382 match reference.selector {
383 ReferenceSelector::Tag {
384 ref tag,
385 ref digest,
386 } => {
387 assert_eq!(tag, DEFAULT_OCI_REFERENCE_TAG);
388 let d = digest.as_ref().expect("Expected a digest");
389 assert_eq!(d.to_string(), format!("sha256:{}", valid_digest));
390 }
391 _ => panic!("Expected Tag variant with digest"),
392 }
393 let expected = format!(
394 "registry.example.com/library/myrepo:{}@sha256:{}",
395 DEFAULT_OCI_REFERENCE_TAG, valid_digest
396 );
397 assert_eq!(reference.to_string(), expected);
398 }
399
400 #[test]
401 fn test_reference_registry_with_port() {
402 let s = "registry.example.com:5000/myrepo:1.0";
403 let reference = s.parse::<Reference>().unwrap();
404 assert_eq!(reference.registry, "registry.example.com:5000");
405 assert_eq!(reference.repository, "library/myrepo");
406 match reference.selector {
407 ReferenceSelector::Tag {
408 ref tag,
409 ref digest,
410 } => {
411 assert_eq!(tag, "1.0");
412 assert!(digest.is_none());
413 }
414 _ => panic!("Expected Tag variant without digest"),
415 }
416 assert_eq!(
417 reference.to_string(),
418 "registry.example.com:5000/library/myrepo:1.0"
419 );
420 }
421
422 #[test]
423 fn test_reference_single_segment_registry() {
424 let s = "docker.io/alpine";
425 let reference = s.parse::<Reference>().unwrap();
426 assert_eq!(reference.registry, "docker.io");
427 assert_eq!(
428 reference.repository,
429 format!("{}/alpine", DEFAULT_OCI_REFERENCE_REPO_NAMESPACE)
430 );
431 match reference.selector {
432 ReferenceSelector::Tag {
433 ref tag,
434 ref digest,
435 } => {
436 assert_eq!(tag, DEFAULT_OCI_REFERENCE_TAG);
437 assert!(digest.is_none());
438 }
439 _ => panic!("Expected Tag variant"),
440 }
441 assert_eq!(
442 reference.to_string(),
443 format!(
444 "docker.io/{}/alpine:{}",
445 DEFAULT_OCI_REFERENCE_REPO_NAMESPACE, DEFAULT_OCI_REFERENCE_TAG
446 )
447 );
448 }
449
450 #[test]
451 fn test_reference_no_registry_single_segment() {
452 let s = "alpine";
453 let reference = s.parse::<Reference>().unwrap();
454 let default_registry = env::get_oci_registry();
455 assert_eq!(reference.registry, default_registry);
456 assert_eq!(
457 reference.repository,
458 format!("{}/alpine", DEFAULT_OCI_REFERENCE_REPO_NAMESPACE)
459 );
460 match reference.selector {
461 ReferenceSelector::Tag {
462 ref tag,
463 ref digest,
464 } => {
465 assert_eq!(tag, DEFAULT_OCI_REFERENCE_TAG);
466 assert!(digest.is_none());
467 }
468 _ => panic!("Expected Tag variant"),
469 }
470 let expected = format!(
471 "{}/{}:{}",
472 default_registry,
473 format!("{}/alpine", DEFAULT_OCI_REFERENCE_REPO_NAMESPACE),
474 DEFAULT_OCI_REFERENCE_TAG
475 );
476 assert_eq!(reference.to_string(), expected);
477 }
478
479 #[test]
480 fn test_reference_no_registry_multi_segment() {
481 let s = "myorg/myrepo:stable";
482 let reference = s.parse::<Reference>().unwrap();
483 let default_registry = env::get_oci_registry();
484 assert_eq!(reference.registry, default_registry);
485 assert_eq!(reference.repository, "myorg/myrepo");
486 match reference.selector {
487 ReferenceSelector::Tag {
488 ref tag,
489 ref digest,
490 } => {
491 assert_eq!(tag, "stable");
492 assert!(digest.is_none());
493 }
494 _ => panic!("Expected Tag variant"),
495 }
496 let expected = format!("{}/myorg/myrepo:stable", default_registry);
497 assert_eq!(reference.to_string(), expected);
498 }
499
500 #[test]
501 fn test_reference_digest_single_segment() {
502 let valid_digest = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
503 let s = format!("docker.io/alpine@sha256:{}", valid_digest);
504 let reference = s.parse::<Reference>().unwrap();
505 assert_eq!(reference.registry, "docker.io");
506 assert_eq!(
507 reference.repository,
508 format!("{}/alpine", DEFAULT_OCI_REFERENCE_REPO_NAMESPACE)
509 );
510 match reference.selector {
511 ReferenceSelector::Tag {
512 ref tag,
513 ref digest,
514 } => {
515 assert_eq!(tag, DEFAULT_OCI_REFERENCE_TAG);
516 let d = digest.as_ref().expect("Expected digest");
517 assert_eq!(d.to_string(), format!("sha256:{}", valid_digest));
518 }
519 _ => panic!("Expected Tag variant with digest"),
520 }
521 let expected = format!(
522 "docker.io/{}/alpine:{}@sha256:{}",
523 DEFAULT_OCI_REFERENCE_REPO_NAMESPACE, DEFAULT_OCI_REFERENCE_TAG, valid_digest
524 );
525 assert_eq!(reference.to_string(), expected);
526 }
527
528 #[test]
529 fn test_reference_digest_multi_segment() {
530 let valid_digest = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
531 let s = format!("docker.io/myorg/myrepo:stable@sha256:{}", valid_digest);
532 let reference = s.parse::<Reference>().unwrap();
533 assert_eq!(reference.registry, "docker.io");
534 assert_eq!(reference.repository, "myorg/myrepo");
535 match reference.selector {
536 ReferenceSelector::Tag {
537 ref tag,
538 ref digest,
539 } => {
540 assert_eq!(tag, "stable");
541 let d = digest.as_ref().expect("Expected digest");
542 assert_eq!(d.to_string(), format!("sha256:{}", valid_digest));
543 }
544 _ => panic!("Expected Tag variant with digest"),
545 }
546 let expected = format!("docker.io/myorg/myrepo:stable@sha256:{}", valid_digest);
547 assert_eq!(reference.to_string(), expected);
548 }
549
550 #[test]
551 fn test_reference_complex_path() {
552 let s = "registry.io/v2/image:tag";
553 let reference = s.parse::<Reference>().unwrap();
554 assert_eq!(reference.registry, "registry.io");
555 assert_eq!(reference.repository, "v2/image");
556 match reference.selector {
557 ReferenceSelector::Tag {
558 ref tag,
559 ref digest,
560 } => {
561 assert_eq!(tag, "tag");
562 assert!(digest.is_none());
563 }
564 _ => panic!("Expected Tag variant"),
565 }
566 assert_eq!(reference.to_string(), "registry.io/v2/image:tag");
567 }
568
569 #[test]
570 fn test_reference_multi_slash_repository() {
571 let s = "docker.io/a/b/c:1.0";
572 let reference = s.parse::<Reference>().unwrap();
573 assert_eq!(reference.registry, "docker.io");
574 assert_eq!(reference.repository, "a/b/c");
575 match reference.selector {
576 ReferenceSelector::Tag {
577 ref tag,
578 ref digest,
579 } => {
580 assert_eq!(tag, "1.0");
581 assert!(digest.is_none());
582 }
583 _ => panic!("Expected Tag variant"),
584 }
585 assert_eq!(reference.to_string(), "docker.io/a/b/c:1.0");
586 }
587
588 #[test]
589 fn test_empty_input() {
590 let s = "";
591 let err = s.parse::<Reference>().unwrap_err();
592 let err_str = err.to_string();
593 assert!(err_str.contains("input string is empty"));
594 }
595
596 #[test]
597 fn test_empty_repository() {
598 let s = "registry.example.com/:tag";
599 let err = s.parse::<Reference>().unwrap_err();
600 let err_str = err.to_string();
601 assert!(err_str.contains("repository is empty"));
602 }
603
604 #[test]
605 fn test_reference_registry_ip_port_single_segment() {
606 let s = "192.168.1.1:5000/ubuntu:18.04";
607 let reference = s.parse::<Reference>().unwrap();
608 assert_eq!(reference.registry, "192.168.1.1:5000");
609 assert_eq!(
610 reference.repository,
611 format!("{}/ubuntu", DEFAULT_OCI_REFERENCE_REPO_NAMESPACE)
612 );
613 match reference.selector {
614 ReferenceSelector::Tag {
615 ref tag,
616 ref digest,
617 } => {
618 assert_eq!(tag, "18.04");
619 assert!(digest.is_none());
620 }
621 _ => panic!("Expected Tag variant"),
622 }
623 let expected = format!(
624 "192.168.1.1:5000/{}/ubuntu:18.04",
625 DEFAULT_OCI_REFERENCE_REPO_NAMESPACE
626 );
627 assert_eq!(reference.to_string(), expected);
628 }
629
630 #[test]
631 fn test_reference_registry_ip_port_multi_segment() {
632 let s = "192.168.1.1:5000/org/repo:version";
633 let reference = s.parse::<Reference>().unwrap();
634 assert_eq!(reference.registry, "192.168.1.1:5000");
635 assert_eq!(reference.repository, "org/repo");
636 match reference.selector {
637 ReferenceSelector::Tag {
638 ref tag,
639 ref digest,
640 } => {
641 assert_eq!(tag, "version");
642 assert!(digest.is_none());
643 }
644 _ => panic!("Expected Tag variant"),
645 }
646 let expected = "192.168.1.1:5000/org/repo:version".to_string();
647 assert_eq!(reference.to_string(), expected);
648 }
649
650 #[test]
651 fn test_reference_invalid_registry() {
652 let s = "inva!id-registry.com/library/alpine:3.12";
654 let err = s.parse::<Reference>().unwrap_err();
655 assert!(err.to_string().contains("invalid registry"));
656 }
657
658 #[test]
659 fn test_reference_invalid_repository() {
660 let s = "docker.io/Library/alpine:3.12";
662 let err = s.parse::<Reference>().unwrap_err();
663 assert!(err.to_string().contains("invalid repository"));
664 }
665
666 #[test]
667 fn test_reference_invalid_tag() {
668 let s = "docker.io/library/alpine:t!ag";
670 let err = s.parse::<Reference>().unwrap_err();
671 let err_str = err.to_string();
672 assert!(err_str.contains("invalid tag"));
673 }
674
675 #[test]
676 fn test_reference_tag_length_exceeds_limit() {
677 let long_tag = "a".repeat(129);
679 let s = format!("docker.io/library/alpine:{}", long_tag);
680 let err = s.parse::<Reference>().unwrap_err();
681 assert!(err.to_string().contains("invalid tag"));
682 }
683}