use super::Digest;
use crate::{Error, Result};
const HOST_SEPARATOR: char = '/';
const HOST_MARKERS: [char; 2] = ['.', ':'];
const LOCALHOST: &str = "localhost";
const DIGEST_SEPARATOR: char = '@';
const TAG_SEPARATOR: char = ':';
const DEFAULT_TAG: &str = "latest";
const DEFAULT_IMAGE_HOST: &str = "ghcr.io";
const DEFAULT_IMAGE_PATH: &str = "galaxyloop-labs/moonmchn";
const DEFAULT_IMAGE_TAG: &str = "default";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Reference {
image: String,
host: String,
path: String,
tag: String,
digest: Option<Digest>,
}
impl Reference {
pub(crate) fn parse(image: &str) -> Result<Self> {
let (host, rest) = image.split_once(HOST_SEPARATOR).unwrap_or_default();
let (repository, digest) = match rest.split_once(DIGEST_SEPARATOR) {
Some((repository, digest)) => (repository, Some(digest)),
None => (rest, None),
};
let (path, tag) = repository
.rsplit_once(TAG_SEPARATOR)
.unwrap_or((repository, DEFAULT_TAG));
if !is_registry_host(host) || path.is_empty() || tag.is_empty() {
return Err(Error::invalid_image(
image,
"a registry reference must start with its registry host",
));
}
let digest = if let Some(digest) = digest {
Some(Digest::parse_within(image, digest)?)
} else {
None
};
Ok(Self {
image: image.to_string(),
host: host.to_string(),
path: path.to_string(),
tag: tag.to_string(),
digest,
})
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.image
}
#[must_use]
pub fn host(&self) -> &str {
&self.host
}
#[must_use]
pub fn path(&self) -> &str {
&self.path
}
#[must_use]
pub fn tag(&self) -> &str {
&self.tag
}
#[must_use]
pub fn digest(&self) -> Option<&Digest> {
self.digest.as_ref()
}
#[must_use]
pub fn pinned(&self, digest: &Digest) -> String {
let Self { host, path, .. } = self;
format!("{host}{HOST_SEPARATOR}{path}{DIGEST_SEPARATOR}{digest}")
}
}
impl Default for Reference {
fn default() -> Self {
let (host, path, tag) = (DEFAULT_IMAGE_HOST, DEFAULT_IMAGE_PATH, DEFAULT_IMAGE_TAG);
Self {
image: format!("{host}{HOST_SEPARATOR}{path}{TAG_SEPARATOR}{tag}"),
host: host.to_string(),
path: path.to_string(),
tag: tag.to_string(),
digest: None,
}
}
}
fn is_registry_host(host: &str) -> bool {
host.contains(HOST_MARKERS) || host == LOCALHOST
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_references() {
let hex: String = "0123456789abcdef".chars().cycle().take(64).collect();
let lower = format!("sha256:{hex}");
let upper = format!("sha256:{}", hex.to_uppercase());
let cases = [
(
"ghcr.io/galaxyloop-labs/moonmchn:default".to_string(),
"ghcr.io",
"galaxyloop-labs/moonmchn",
"default",
None,
),
(
"localhost:5000/org/name".to_string(),
"localhost:5000",
"org/name",
"latest",
None,
),
(
"127.0.0.1:5000/org/name:v2".to_string(),
"127.0.0.1:5000",
"org/name",
"v2",
None,
),
(
format!("ghcr.io/org/name@{lower}"),
"ghcr.io",
"org/name",
"latest",
Some(Digest(lower.clone())),
),
(
format!("ghcr.io/org/name:v2@{lower}"),
"ghcr.io",
"org/name",
"v2",
Some(Digest(lower)),
),
(
"localhost/name".to_string(),
"localhost",
"name",
"latest",
None,
),
(
format!("localhost:5000/org/nested/name:v2@{upper}"),
"localhost:5000",
"org/nested/name",
"v2",
Some(Digest(upper)),
),
];
for (input, host, path, tag, digest) in cases {
let reference = Reference::parse(&input).unwrap();
assert_eq!(reference.as_str(), input);
assert_eq!(reference.host(), host, "{input}");
assert_eq!(reference.path(), path, "{input}");
assert_eq!(reference.tag(), tag, "{input}");
assert_eq!(reference.digest(), digest.as_ref(), "{input}");
}
}
#[test]
fn refuses_malformed_references() {
for input in [
"",
"alpine",
"library/alpine",
"/org/name",
"ghcr.io",
"ghcr.io/",
"ghcr.io/:latest",
"ghcr.io/org/name:",
] {
let error = Reference::parse(input).unwrap_err();
assert!(
matches!(error, Error::InvalidImage { image, reason }
if image == input
&& reason == "a registry reference must start with its registry host"),
"{input}"
);
}
}
#[test]
fn refuses_malformed_digests() {
let input = "ghcr.io/org/name@sha256:short";
let error = Reference::parse(input).unwrap_err();
assert!(
matches!(error, Error::InvalidImage { image, reason }
if image == input
&& reason == "the digest must be sha256: followed by 64 hex digits"),
"{input}"
);
}
}