1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::{error::Error, path::PathBuf};
6
7#[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
29pub struct LayoutVersion(String);
30
31impl LayoutVersion {
32 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 #[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#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
73pub struct BlobsDirectory;
74
75#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
77pub struct IndexFile;
78
79#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
81pub struct RefsDirectory;
82
83#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
85pub struct OciLayoutPaths {
86 root: PathBuf,
87}
88
89impl OciLayoutPaths {
90 #[must_use]
92 pub fn new(root: impl Into<PathBuf>) -> Self {
93 Self { root: root.into() }
94 }
95
96 #[must_use]
98 pub fn root(&self) -> &PathBuf {
99 &self.root
100 }
101
102 #[must_use]
104 pub fn blobs_dir(&self) -> PathBuf {
105 self.root.join("blobs")
106 }
107
108 #[must_use]
110 pub fn index_file(&self) -> PathBuf {
111 self.root.join("index.json")
112 }
113
114 #[must_use]
116 pub fn refs_dir(&self) -> PathBuf {
117 self.root.join("refs")
118 }
119
120 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}