Skip to main content

use_oci_layout/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::{error::Error, path::PathBuf};
6
7/// Errors returned when layout metadata is invalid.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum LayoutError {
10    Empty,
11    UnsupportedVersion,
12    InvalidPath,
13}
14
15impl fmt::Display for LayoutError {
16    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
17        match self {
18            Self::Empty => formatter.write_str("OCI layout value cannot be empty"),
19            Self::UnsupportedVersion => formatter.write_str("unsupported OCI layout version"),
20            Self::InvalidPath => formatter.write_str("invalid OCI layout path"),
21        }
22    }
23}
24
25impl Error for LayoutError {}
26
27/// OCI image layout version marker.
28#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
29pub struct LayoutVersion(String);
30
31impl LayoutVersion {
32    /// Creates a layout version marker. OCI image layout v1 uses `1.0.0`.
33    pub fn new(value: impl AsRef<str>) -> Result<Self, LayoutError> {
34        let trimmed = value.as_ref().trim();
35        if trimmed == "1.0.0" {
36            Ok(Self(trimmed.to_string()))
37        } else if trimmed.is_empty() {
38            Err(LayoutError::Empty)
39        } else {
40            Err(LayoutError::UnsupportedVersion)
41        }
42    }
43
44    /// Returns the layout version text.
45    #[must_use]
46    pub fn as_str(&self) -> &str {
47        &self.0
48    }
49}
50
51impl Default for LayoutVersion {
52    fn default() -> Self {
53        Self("1.0.0".to_string())
54    }
55}
56
57impl fmt::Display for LayoutVersion {
58    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
59        formatter.write_str(self.as_str())
60    }
61}
62
63impl FromStr for LayoutVersion {
64    type Err = LayoutError;
65
66    fn from_str(value: &str) -> Result<Self, Self::Err> {
67        Self::new(value)
68    }
69}
70
71/// Marker for the `blobs` directory.
72#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
73pub struct BlobsDirectory;
74
75/// Marker for `index.json`.
76#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
77pub struct IndexFile;
78
79/// Marker for the optional `refs` directory.
80#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
81pub struct RefsDirectory;
82
83/// Lexical OCI image layout paths. This type does not create directories or files.
84#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
85pub struct OciLayoutPaths {
86    root: PathBuf,
87}
88
89impl OciLayoutPaths {
90    /// Creates layout path helpers from a lexical root path.
91    #[must_use]
92    pub fn new(root: impl Into<PathBuf>) -> Self {
93        Self { root: root.into() }
94    }
95
96    /// Returns the root path.
97    #[must_use]
98    pub fn root(&self) -> &PathBuf {
99        &self.root
100    }
101
102    /// Returns the `blobs` directory path.
103    #[must_use]
104    pub fn blobs_dir(&self) -> PathBuf {
105        self.root.join("blobs")
106    }
107
108    /// Returns the `index.json` path.
109    #[must_use]
110    pub fn index_file(&self) -> PathBuf {
111        self.root.join("index.json")
112    }
113
114    /// Returns the `refs` directory path.
115    #[must_use]
116    pub fn refs_dir(&self) -> PathBuf {
117        self.root.join("refs")
118    }
119
120    /// Returns a lexical blob path for an algorithm and encoded value.
121    pub fn blob_path(
122        &self,
123        algorithm: impl AsRef<str>,
124        encoded: impl AsRef<str>,
125    ) -> Result<PathBuf, LayoutError> {
126        let algorithm = validate_part(algorithm.as_ref())?;
127        let encoded = validate_part(encoded.as_ref())?;
128        Ok(self.blobs_dir().join(algorithm).join(encoded))
129    }
130}
131
132fn validate_part(value: &str) -> Result<&str, LayoutError> {
133    let trimmed = value.trim();
134    if trimmed.is_empty() {
135        return Err(LayoutError::Empty);
136    }
137    if trimmed.contains(['/', '\\']) || trimmed.chars().any(char::is_whitespace) {
138        return Err(LayoutError::InvalidPath);
139    }
140    Ok(trimmed)
141}
142
143#[cfg(test)]
144mod tests {
145    use super::{LayoutError, LayoutVersion, OciLayoutPaths};
146
147    #[test]
148    fn renders_layout_paths_without_mutation() -> Result<(), LayoutError> {
149        let paths = OciLayoutPaths::new("layout");
150        let blob = paths.blob_path("sha256", "abc")?;
151
152        assert_eq!(LayoutVersion::default().as_str(), "1.0.0");
153        assert!(paths.index_file().ends_with("index.json"));
154        assert!(blob.ends_with("abc"));
155        assert_eq!(
156            LayoutVersion::new("2.0.0"),
157            Err(LayoutError::UnsupportedVersion)
158        );
159        Ok(())
160    }
161}