#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]
use core::{fmt, str::FromStr};
use std::{error::Error, path::PathBuf};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum LayoutError {
Empty,
UnsupportedVersion,
InvalidPath,
}
impl fmt::Display for LayoutError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => formatter.write_str("OCI layout value cannot be empty"),
Self::UnsupportedVersion => formatter.write_str("unsupported OCI layout version"),
Self::InvalidPath => formatter.write_str("invalid OCI layout path"),
}
}
}
impl Error for LayoutError {}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct LayoutVersion(String);
impl LayoutVersion {
pub fn new(value: impl AsRef<str>) -> Result<Self, LayoutError> {
let trimmed = value.as_ref().trim();
if trimmed == "1.0.0" {
Ok(Self(trimmed.to_string()))
} else if trimmed.is_empty() {
Err(LayoutError::Empty)
} else {
Err(LayoutError::UnsupportedVersion)
}
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl Default for LayoutVersion {
fn default() -> Self {
Self("1.0.0".to_string())
}
}
impl fmt::Display for LayoutVersion {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for LayoutVersion {
type Err = LayoutError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::new(value)
}
}
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct BlobsDirectory;
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct IndexFile;
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct RefsDirectory;
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct OciLayoutPaths {
root: PathBuf,
}
impl OciLayoutPaths {
#[must_use]
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
#[must_use]
pub fn root(&self) -> &PathBuf {
&self.root
}
#[must_use]
pub fn blobs_dir(&self) -> PathBuf {
self.root.join("blobs")
}
#[must_use]
pub fn index_file(&self) -> PathBuf {
self.root.join("index.json")
}
#[must_use]
pub fn refs_dir(&self) -> PathBuf {
self.root.join("refs")
}
pub fn blob_path(
&self,
algorithm: impl AsRef<str>,
encoded: impl AsRef<str>,
) -> Result<PathBuf, LayoutError> {
let algorithm = validate_part(algorithm.as_ref())?;
let encoded = validate_part(encoded.as_ref())?;
Ok(self.blobs_dir().join(algorithm).join(encoded))
}
}
fn validate_part(value: &str) -> Result<&str, LayoutError> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(LayoutError::Empty);
}
if trimmed.contains(['/', '\\']) || trimmed.chars().any(char::is_whitespace) {
return Err(LayoutError::InvalidPath);
}
Ok(trimmed)
}
#[cfg(test)]
mod tests {
use super::{LayoutError, LayoutVersion, OciLayoutPaths};
#[test]
fn renders_layout_paths_without_mutation() -> Result<(), LayoutError> {
let paths = OciLayoutPaths::new("layout");
let blob = paths.blob_path("sha256", "abc")?;
assert_eq!(LayoutVersion::default().as_str(), "1.0.0");
assert!(paths.index_file().ends_with("index.json"));
assert!(blob.ends_with("abc"));
assert_eq!(
LayoutVersion::new("2.0.0"),
Err(LayoutError::UnsupportedVersion)
);
Ok(())
}
}