xbp-oci 10.46.0

OCI registry primitives and traits for XBP deploy.
Documentation
use serde::{Deserialize, Serialize};

use crate::error::{OciError, Result};

/// Canonical OCI image reference.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct OciRef {
    pub registry: String,
    pub repository: String,
    pub tag: Option<String>,
    pub digest: Option<String>,
}

impl OciRef {
    pub fn reference(&self) -> String {
        let base = format!("{}/{}", self.registry, self.repository);
        if let Some(digest) = &self.digest {
            format!("{base}@{digest}")
        } else if let Some(tag) = &self.tag {
            format!("{base}:{tag}")
        } else {
            format!("{base}:latest")
        }
    }

    pub fn repository_path(&self) -> String {
        format!("{}/{}", self.registry, self.repository)
    }

    pub fn with_tag(&self, tag: &str) -> Self {
        Self {
            registry: self.registry.clone(),
            repository: self.repository.clone(),
            tag: Some(tag.to_string()),
            digest: None,
        }
    }

    pub fn with_digest(&self, digest: &str) -> Self {
        Self {
            registry: self.registry.clone(),
            repository: self.repository.clone(),
            tag: None,
            digest: Some(digest.to_string()),
        }
    }
}

impl std::fmt::Display for OciRef {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.reference())
    }
}

/// Parse `registry/repo[:tag][@digest]`.
pub fn parse_image_ref(input: &str) -> Result<OciRef> {
    let input = input.trim();
    if input.is_empty() {
        return Err(OciError::InvalidRef("empty image reference".into()));
    }

    let (without_digest, digest) = if let Some((left, dig)) = input.split_once('@') {
        let dig = dig.trim();
        if dig.is_empty() {
            return Err(OciError::InvalidRef("digest after @ is empty".into()));
        }
        (left, Some(dig.to_string()))
    } else {
        (input, None)
    };

    let (path, tag) = if digest.is_some() {
        (without_digest, None)
    } else if let Some((left, tag)) = without_digest.rsplit_once(':') {
        if tag.contains('/') {
            (without_digest, None)
        } else {
            (left, Some(tag.to_string()))
        }
    } else {
        (without_digest, None)
    };

    let path = path.trim_start_matches('/').trim();
    if path.is_empty() {
        return Err(OciError::InvalidRef("empty repository path".into()));
    }

    let (registry, repository) = split_registry_repo(path)?;
    Ok(OciRef {
        registry,
        repository,
        tag: tag.filter(|t| !t.is_empty()),
        digest,
    })
}

fn split_registry_repo(path: &str) -> Result<(String, String)> {
    let parts: Vec<&str> = path.split('/').filter(|p| !p.is_empty()).collect();
    if parts.is_empty() {
        return Err(OciError::InvalidRef("empty repository path".into()));
    }
    if parts.len() == 1 {
        return Ok((
            "docker.io".to_string(),
            format!("library/{}", parts[0]),
        ));
    }
    let first = parts[0];
    let looks_like_registry = first.contains('.') || first.contains(':') || first == "localhost";
    if looks_like_registry {
        let repository = parts[1..].join("/");
        if repository.is_empty() {
            return Err(OciError::InvalidRef("empty repository".into()));
        }
        return Ok((first.to_string(), repository));
    }
    Ok(("docker.io".to_string(), parts.join("/")))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_ghcr_tag() {
        let r = parse_image_ref("ghcr.io/xylex-group/athena:4.0.1").unwrap();
        assert_eq!(r.registry, "ghcr.io");
        assert_eq!(r.repository, "xylex-group/athena");
        assert_eq!(r.tag.as_deref(), Some("4.0.1"));
    }

    #[test]
    fn parses_digest() {
        let r = parse_image_ref("ghcr.io/xylex-group/athena@sha256:abc").unwrap();
        assert_eq!(r.digest.as_deref(), Some("sha256:abc"));
    }

    #[test]
    fn parses_registry_port() {
        let r = parse_image_ref("localhost:5000/my/app:dev").unwrap();
        assert_eq!(r.registry, "localhost:5000");
        assert_eq!(r.repository, "my/app");
    }
}