use std::fmt;
use std::str::FromStr;
use oci_spec::image::{Digest as OciSpecDigest, DigestAlgorithm};
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct OciTag(String);
impl OciTag {
pub fn new(tag: impl Into<String>) -> crate::Result<Self> {
Self::try_from(tag.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
fn validate_tag(tag: &str) -> crate::Result<()> {
let bytes = tag.as_bytes();
if bytes.is_empty() || bytes.len() > 128 {
return Err(crate::new_error!(
"tag {:?} is invalid: must be 1..=128 bytes",
tag
));
}
let first = bytes[0];
if !(first.is_ascii_alphanumeric() || first == b'_') {
return Err(crate::new_error!(
"tag {:?} is invalid: first character must be alphanumeric or '_'",
tag
));
}
for &b in &bytes[1..] {
if !(b.is_ascii_alphanumeric() || b == b'_' || b == b'.' || b == b'-') {
return Err(crate::new_error!(
"tag {:?} is invalid: characters after the first must be \
alphanumeric or one of '_', '.', '-'",
tag
));
}
}
Ok(())
}
impl FromStr for OciTag {
type Err = crate::HyperlightError;
fn from_str(s: &str) -> crate::Result<Self> {
validate_tag(s)?;
Ok(Self(s.to_string()))
}
}
impl TryFrom<&str> for OciTag {
type Error = crate::HyperlightError;
fn try_from(s: &str) -> crate::Result<Self> {
s.parse()
}
}
impl TryFrom<String> for OciTag {
type Error = crate::HyperlightError;
fn try_from(s: String) -> crate::Result<Self> {
validate_tag(&s)?;
Ok(Self(s))
}
}
impl AsRef<str> for OciTag {
fn as_ref(&self) -> &str {
&self.0
}
}
impl fmt::Display for OciTag {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct OciDigest(String);
impl OciDigest {
pub fn as_str(&self) -> &str {
&self.0
}
pub(super) fn from_oci_spec_digest(digest: &OciSpecDigest) -> Self {
Self(digest.to_string())
}
}
fn validate_digest(s: &str) -> crate::Result<String> {
let digest = OciSpecDigest::from_str(s)
.map_err(|e| crate::new_error!("invalid OCI digest {:?}: {}", s, e))?;
if digest.algorithm() != &DigestAlgorithm::Sha256 {
return Err(crate::new_error!(
"OCI digest {:?} must use the sha256 algorithm, found {}",
s,
digest.algorithm()
));
}
Ok(digest.to_string())
}
impl FromStr for OciDigest {
type Err = crate::HyperlightError;
fn from_str(s: &str) -> crate::Result<Self> {
Ok(Self(validate_digest(s)?))
}
}
impl TryFrom<&str> for OciDigest {
type Error = crate::HyperlightError;
fn try_from(s: &str) -> crate::Result<Self> {
s.parse()
}
}
impl TryFrom<String> for OciDigest {
type Error = crate::HyperlightError;
fn try_from(s: String) -> crate::Result<Self> {
Ok(Self(validate_digest(&s)?))
}
}
impl AsRef<str> for OciDigest {
fn as_ref(&self) -> &str {
&self.0
}
}
impl fmt::Display for OciDigest {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum OciReference {
Tag(OciTag),
Digest(OciDigest),
}
impl From<OciTag> for OciReference {
fn from(tag: OciTag) -> Self {
OciReference::Tag(tag)
}
}
impl From<OciDigest> for OciReference {
fn from(digest: OciDigest) -> Self {
OciReference::Digest(digest)
}
}
impl FromStr for OciReference {
type Err = crate::HyperlightError;
fn from_str(s: &str) -> crate::Result<Self> {
if s.contains(':') {
Ok(OciReference::Digest(s.parse()?))
} else {
Ok(OciReference::Tag(s.parse()?))
}
}
}
impl fmt::Display for OciReference {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
OciReference::Tag(t) => fmt::Display::fmt(t, f),
OciReference::Digest(d) => fmt::Display::fmt(d, f),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const HEX64: &str = "0000000000000000000000000000000000000000000000000000000000000000";
#[test]
fn tag_accepts_grammar_and_length_bounds() {
assert!(OciTag::new("a").is_ok());
assert!(OciTag::new("Z").is_ok());
assert!(OciTag::new("9").is_ok());
assert!(OciTag::new("_").is_ok());
assert!(OciTag::new("v1.0_release-2").is_ok());
assert!(OciTag::new("a".repeat(128)).is_ok());
}
#[test]
fn tag_rejects_out_of_grammar_input() {
assert!(OciTag::new("").is_err());
assert!(OciTag::new("a".repeat(129)).is_err());
assert!(OciTag::new(".tag").is_err());
assert!(OciTag::new("-tag").is_err());
assert!(OciTag::new("a/b").is_err());
assert!(OciTag::new("a b").is_err());
}
#[test]
fn tag_never_contains_colon() {
assert!(OciTag::new("sha256:abc").is_err());
}
#[test]
fn digest_accepts_canonical_sha256() {
let s = format!("sha256:{HEX64}");
let d = OciDigest::try_from(s.as_str()).unwrap();
assert_eq!(d.as_str(), s);
}
#[test]
fn digest_rejects_non_sha256_algorithm() {
let s = format!("sha512:{}", "0".repeat(128));
assert!(OciDigest::try_from(s.as_str()).is_err());
}
#[test]
fn digest_rejects_malformed_input() {
assert!(OciDigest::try_from(HEX64).is_err());
assert!(OciDigest::try_from("sha256:abcd").is_err());
}
#[test]
fn reference_parses_tag_when_no_colon() {
let r: OciReference = "latest".parse().unwrap();
assert_eq!(r, OciReference::Tag(OciTag::new("latest").unwrap()));
}
#[test]
fn reference_parses_digest_when_colon_present() {
let s = format!("sha256:{HEX64}");
let r: OciReference = s.parse().unwrap();
assert_eq!(
r,
OciReference::Digest(OciDigest::try_from(s.as_str()).unwrap())
);
}
#[test]
fn tag_display_round_trips() {
let tag = OciTag::new("v1.2-rc1").unwrap();
assert_eq!(OciTag::new(tag.to_string()).unwrap(), tag);
}
#[test]
fn digest_display_round_trips() {
let d = OciDigest::try_from(format!("sha256:{HEX64}").as_str()).unwrap();
assert_eq!(OciDigest::try_from(d.to_string().as_str()).unwrap(), d);
}
#[test]
fn reference_display_round_trips() {
let tag_ref: OciReference = "latest".parse().unwrap();
assert_eq!(
tag_ref.to_string().parse::<OciReference>().unwrap(),
tag_ref
);
let digest_ref: OciReference = format!("sha256:{HEX64}").parse().unwrap();
assert_eq!(
digest_ref.to_string().parse::<OciReference>().unwrap(),
digest_ref
);
}
}