use std::fmt;
use std::str::FromStr;
use ferro_blob_store::Digest;
use crate::error::{OciError, OciErrorCode};
pub const MAX_NAME_LENGTH: usize = 255;
pub const MAX_TAG_LENGTH: usize = 128;
pub fn validate_name(name: &str) -> Result<(), OciError> {
if name.is_empty() {
return Err(OciError::new(
OciErrorCode::NameInvalid,
"repository name must not be empty",
));
}
if name.len() > MAX_NAME_LENGTH {
return Err(OciError::new(
OciErrorCode::NameInvalid,
format!("repository name exceeds {MAX_NAME_LENGTH} characters"),
));
}
for component in name.split('/') {
validate_component(component)
.map_err(|msg| OciError::new(OciErrorCode::NameInvalid, msg))?;
}
Ok(())
}
fn validate_component(component: &str) -> Result<(), String> {
if component.is_empty() {
return Err("path component must not be empty".to_owned());
}
let bytes = component.as_bytes();
if !is_alnum(bytes[0]) {
return Err(format!("component `{component}` must start with [a-z0-9]"));
}
if !is_alnum(bytes[bytes.len() - 1]) {
return Err(format!("component `{component}` must end with [a-z0-9]"));
}
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
if is_alnum(c) {
i += 1;
continue;
}
let start = i;
while i < bytes.len() && !is_alnum(bytes[i]) {
i += 1;
}
let sep = &component[start..i];
if !is_valid_separator(sep) {
return Err(format!(
"component `{component}` contains invalid separator `{sep}`"
));
}
}
Ok(())
}
const fn is_alnum(b: u8) -> bool {
b.is_ascii_digit() || b.is_ascii_lowercase()
}
fn is_valid_separator(s: &str) -> bool {
if s == "." || s == "_" || s == "__" {
return true;
}
!s.is_empty() && s.bytes().all(|b| b == b'-')
}
fn is_valid_tag(tag: &str) -> bool {
if tag.is_empty() || tag.len() > MAX_TAG_LENGTH {
return false;
}
let bytes = tag.as_bytes();
let first_ok = bytes[0].is_ascii_alphanumeric() || bytes[0] == b'_';
if !first_ok {
return false;
}
bytes[1..]
.iter()
.all(|&b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-'))
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Reference {
Tag(String),
Digest(Digest),
}
impl Reference {
#[must_use]
pub const fn is_tag(&self) -> bool {
matches!(self, Self::Tag(_))
}
#[must_use]
pub const fn is_digest(&self) -> bool {
matches!(self, Self::Digest(_))
}
#[must_use]
pub const fn as_digest(&self) -> Option<&Digest> {
match self {
Self::Digest(d) => Some(d),
Self::Tag(_) => None,
}
}
#[must_use]
pub fn as_tag(&self) -> Option<&str> {
match self {
Self::Tag(t) => Some(t.as_str()),
Self::Digest(_) => None,
}
}
}
impl fmt::Display for Reference {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Tag(t) => f.write_str(t),
Self::Digest(d) => fmt::Display::fmt(d, f),
}
}
}
impl FromStr for Reference {
type Err = OciError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if let Some((algo, _hex)) = s.split_once(':') {
if algo == "sha256" || algo == "sha512" {
let d: Digest = s.parse().map_err(|e: ferro_blob_store::DigestParseError| {
OciError::new(OciErrorCode::DigestInvalid, e.to_string())
})?;
return Ok(Self::Digest(d));
}
return Err(OciError::new(
OciErrorCode::ManifestInvalid,
format!("invalid reference: `{s}`"),
));
}
if !is_valid_tag(s) {
return Err(OciError::new(
OciErrorCode::ManifestInvalid,
format!("invalid tag: `{s}`"),
));
}
Ok(Self::Tag(s.to_owned()))
}
}
#[cfg(test)]
mod tests {
use super::{Reference, validate_name};
#[test]
fn simple_single_component_name_is_valid() {
assert!(validate_name("alpine").is_ok());
}
#[test]
fn nested_path_name_is_valid() {
assert!(validate_name("library/alpine").is_ok());
assert!(validate_name("my-org/sub-project/app").is_ok());
}
#[test]
fn underscore_and_dot_and_dash_separators_are_valid() {
assert!(validate_name("foo_bar").is_ok());
assert!(validate_name("foo__bar").is_ok());
assert!(validate_name("foo.bar").is_ok());
assert!(validate_name("foo-bar").is_ok());
assert!(validate_name("foo---bar").is_ok());
}
#[test]
fn uppercase_is_rejected() {
let err = validate_name("Alpine").expect_err("uppercase invalid");
assert_eq!(err.code.as_str(), "NAME_INVALID");
}
#[test]
fn leading_separator_is_rejected() {
assert!(validate_name("-alpine").is_err());
assert!(validate_name(".alpine").is_err());
assert!(validate_name("_alpine").is_err());
}
#[test]
fn trailing_separator_is_rejected() {
assert!(validate_name("alpine-").is_err());
assert!(validate_name("alpine.").is_err());
}
#[test]
fn empty_component_is_rejected() {
assert!(validate_name("foo//bar").is_err());
assert!(validate_name("/foo").is_err());
assert!(validate_name("foo/").is_err());
}
#[test]
fn too_long_name_is_rejected() {
let s = "a".repeat(256);
assert!(validate_name(&s).is_err());
}
#[test]
fn tag_reference_parses() {
let r: Reference = "v1.2.3-rc1".parse().expect("tag parse");
assert!(r.is_tag());
assert_eq!(r.as_tag(), Some("v1.2.3-rc1"));
}
#[test]
fn digest_reference_parses() {
let digest = format!("sha256:{}", "a".repeat(64));
let r: Reference = digest.parse().expect("digest parse");
assert!(r.is_digest());
assert_eq!(r.to_string(), digest);
}
#[test]
fn bad_digest_reference_is_rejected() {
assert!("sha256:beef".parse::<Reference>().is_err());
}
#[test]
fn tag_with_colon_is_rejected_as_invalid_reference() {
assert!("some:weird".parse::<Reference>().is_err());
}
}