Skip to main content

xbp_oci/
ref.rs

1use serde::{Deserialize, Serialize};
2
3use crate::error::{OciError, Result};
4
5/// Canonical OCI image reference.
6#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
7pub struct OciRef {
8    pub registry: String,
9    pub repository: String,
10    pub tag: Option<String>,
11    pub digest: Option<String>,
12}
13
14impl OciRef {
15    pub fn reference(&self) -> String {
16        let base = format!("{}/{}", self.registry, self.repository);
17        if let Some(digest) = &self.digest {
18            format!("{base}@{digest}")
19        } else if let Some(tag) = &self.tag {
20            format!("{base}:{tag}")
21        } else {
22            format!("{base}:latest")
23        }
24    }
25
26    pub fn repository_path(&self) -> String {
27        format!("{}/{}", self.registry, self.repository)
28    }
29
30    pub fn with_tag(&self, tag: &str) -> Self {
31        Self {
32            registry: self.registry.clone(),
33            repository: self.repository.clone(),
34            tag: Some(tag.to_string()),
35            digest: None,
36        }
37    }
38
39    pub fn with_digest(&self, digest: &str) -> Self {
40        Self {
41            registry: self.registry.clone(),
42            repository: self.repository.clone(),
43            tag: None,
44            digest: Some(digest.to_string()),
45        }
46    }
47}
48
49impl std::fmt::Display for OciRef {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        f.write_str(&self.reference())
52    }
53}
54
55/// Parse `registry/repo[:tag][@digest]`.
56pub fn parse_image_ref(input: &str) -> Result<OciRef> {
57    let input = input.trim();
58    if input.is_empty() {
59        return Err(OciError::InvalidRef("empty image reference".into()));
60    }
61
62    let (without_digest, digest) = if let Some((left, dig)) = input.split_once('@') {
63        let dig = dig.trim();
64        if dig.is_empty() {
65            return Err(OciError::InvalidRef("digest after @ is empty".into()));
66        }
67        (left, Some(dig.to_string()))
68    } else {
69        (input, None)
70    };
71
72    let (path, tag) = if digest.is_some() {
73        (without_digest, None)
74    } else if let Some((left, tag)) = without_digest.rsplit_once(':') {
75        if tag.contains('/') {
76            (without_digest, None)
77        } else {
78            (left, Some(tag.to_string()))
79        }
80    } else {
81        (without_digest, None)
82    };
83
84    let path = path.trim_start_matches('/').trim();
85    if path.is_empty() {
86        return Err(OciError::InvalidRef("empty repository path".into()));
87    }
88
89    let (registry, repository) = split_registry_repo(path)?;
90    Ok(OciRef {
91        registry,
92        repository,
93        tag: tag.filter(|t| !t.is_empty()),
94        digest,
95    })
96}
97
98fn split_registry_repo(path: &str) -> Result<(String, String)> {
99    let parts: Vec<&str> = path.split('/').filter(|p| !p.is_empty()).collect();
100    if parts.is_empty() {
101        return Err(OciError::InvalidRef("empty repository path".into()));
102    }
103    if parts.len() == 1 {
104        return Ok((
105            "docker.io".to_string(),
106            format!("library/{}", parts[0]),
107        ));
108    }
109    let first = parts[0];
110    let looks_like_registry = first.contains('.') || first.contains(':') || first == "localhost";
111    if looks_like_registry {
112        let repository = parts[1..].join("/");
113        if repository.is_empty() {
114            return Err(OciError::InvalidRef("empty repository".into()));
115        }
116        return Ok((first.to_string(), repository));
117    }
118    Ok(("docker.io".to_string(), parts.join("/")))
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn parses_ghcr_tag() {
127        let r = parse_image_ref("ghcr.io/xylex-group/athena:4.0.1").unwrap();
128        assert_eq!(r.registry, "ghcr.io");
129        assert_eq!(r.repository, "xylex-group/athena");
130        assert_eq!(r.tag.as_deref(), Some("4.0.1"));
131    }
132
133    #[test]
134    fn parses_digest() {
135        let r = parse_image_ref("ghcr.io/xylex-group/athena@sha256:abc").unwrap();
136        assert_eq!(r.digest.as_deref(), Some("sha256:abc"));
137    }
138
139    #[test]
140    fn parses_registry_port() {
141        let r = parse_image_ref("localhost:5000/my/app:dev").unwrap();
142        assert_eq!(r.registry, "localhost:5000");
143        assert_eq!(r.repository, "my/app");
144    }
145}