1use std::path::{Path, PathBuf};
11use std::str::FromStr;
12
13use oci_spec::image::{
14 Descriptor, DescriptorBuilder, Digest, ImageIndex, ImageIndexBuilder, ImageManifest,
15 ImageManifestBuilder, MediaType, OciLayoutBuilder,
16};
17use sha2::{Digest as _, Sha256};
18
19use crate::artifact::{ArtifactStore, ResolvedArtifact};
20use crate::error::ControlError;
21
22const WASM_CONFIG_MT: &str = "application/vnd.wasm.config.v0+json";
23const WASM_LAYER_MT: &str = "application/wasm";
24const SIG_MT: &str = "application/vnd.plecto.signature";
25const SBOM_MT: &str = "application/vnd.plecto.sbom";
26const SBOM_SIG_MT: &str = "application/vnd.plecto.sbom.signature";
27
28const MAX_BLOB_BYTES: u64 = 512 << 20; const MAX_INDEX_BYTES: u64 = 1 << 20; fn artifact_err(source_ref: &str, reason: impl Into<String>) -> ControlError {
36 ControlError::Artifact {
37 source_ref: source_ref.to_string(),
38 reason: reason.into(),
39 }
40}
41
42fn sha256_hex(bytes: &[u8]) -> String {
43 hex::encode(Sha256::digest(bytes))
44}
45
46pub struct OciLayoutStore {
49 root: PathBuf,
50}
51
52impl OciLayoutStore {
53 pub fn new(root: impl Into<PathBuf>) -> Self {
54 Self { root: root.into() }
55 }
56}
57
58impl ArtifactStore for OciLayoutStore {
59 fn resolve(&self, source: &str, pinned_digest: &str) -> Result<ResolvedArtifact, ControlError> {
60 read_layout(&self.root.join(source), source, pinned_digest)
61 }
62}
63
64fn read_layout(
67 layout: &Path,
68 source: &str,
69 pinned: &str,
70) -> Result<ResolvedArtifact, ControlError> {
71 let index_bytes = read_capped(&layout.join("index.json"), MAX_INDEX_BYTES, source)?;
72 let index = ImageIndex::from_reader(index_bytes.as_slice())
73 .map_err(|e| artifact_err(source, format!("read index.json: {e}")))?;
74 let manifest_desc = index
75 .manifests()
76 .first()
77 .ok_or_else(|| artifact_err(source, "index.json has no manifests"))?;
78
79 let actual = manifest_desc.digest().to_string();
81 if actual != pinned {
82 return Err(ControlError::DigestMismatch {
83 source_ref: source.to_string(),
84 expected: pinned.to_string(),
85 actual,
86 });
87 }
88
89 let manifest_bytes = read_blob(layout, manifest_desc, source)?;
90 let manifest = ImageManifest::from_reader(manifest_bytes.as_slice())
91 .map_err(|e| artifact_err(source, format!("parse image manifest: {e}")))?;
92
93 let component = read_blob(
94 layout,
95 layer_by_media_type(&manifest, WASM_LAYER_MT, source)?,
96 source,
97 )?;
98 let component_signature = read_blob(
99 layout,
100 layer_by_media_type(&manifest, SIG_MT, source)?,
101 source,
102 )?;
103 let sbom = read_blob(
104 layout,
105 layer_by_media_type(&manifest, SBOM_MT, source)?,
106 source,
107 )?;
108 let sbom_signature = read_blob(
109 layout,
110 layer_by_media_type(&manifest, SBOM_SIG_MT, source)?,
111 source,
112 )?;
113
114 Ok(ResolvedArtifact {
115 component,
116 component_signature,
117 sbom,
118 sbom_signature,
119 })
120}
121
122fn layer_by_media_type<'a>(
123 manifest: &'a ImageManifest,
124 media_type: &str,
125 source: &str,
126) -> Result<&'a Descriptor, ControlError> {
127 manifest
128 .layers()
129 .iter()
130 .find(|d| matches!(d.media_type(), MediaType::Other(s) if s == media_type))
131 .ok_or_else(|| artifact_err(source, format!("missing layer of type {media_type}")))
132}
133
134fn read_capped(path: &Path, max: u64, source: &str) -> Result<Vec<u8>, ControlError> {
137 use std::io::Read;
138 let meta = std::fs::symlink_metadata(path)
139 .map_err(|e| artifact_err(source, format!("stat {}: {e}", path.display())))?;
140 if meta.file_type().is_symlink() {
141 return Err(artifact_err(
142 source,
143 format!("{} is a symlink (refused)", path.display()),
144 ));
145 }
146 let file = std::fs::File::open(path)
147 .map_err(|e| artifact_err(source, format!("open {}: {e}", path.display())))?;
148 let mut bytes = Vec::new();
149 file.take(max + 1)
150 .read_to_end(&mut bytes)
151 .map_err(|e| artifact_err(source, format!("read {}: {e}", path.display())))?;
152 if bytes.len() as u64 > max {
153 return Err(artifact_err(
154 source,
155 format!("{} exceeds the {max}-byte cap", path.display()),
156 ));
157 }
158 Ok(bytes)
159}
160
161fn read_blob(layout: &Path, desc: &Descriptor, source: &str) -> Result<Vec<u8>, ControlError> {
165 use std::io::Read;
166
167 let digest = desc.digest().to_string(); let (algo, hex) = digest
169 .split_once(':')
170 .ok_or_else(|| artifact_err(source, format!("malformed digest {digest}")))?;
171 if algo != "sha256" {
172 return Err(artifact_err(
173 source,
174 format!("unsupported digest algorithm {algo}"),
175 ));
176 }
177 if hex.len() != 64
181 || !hex
182 .bytes()
183 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
184 {
185 return Err(artifact_err(
186 source,
187 format!("invalid sha256 digest encoding {hex}"),
188 ));
189 }
190 let declared = desc.size();
192 if declared > MAX_BLOB_BYTES {
193 return Err(artifact_err(
194 source,
195 format!("blob {hex} declared size {declared} exceeds the {MAX_BLOB_BYTES}-byte cap"),
196 ));
197 }
198 let path = layout.join("blobs").join(algo).join(hex);
199 let meta = std::fs::symlink_metadata(&path)
202 .map_err(|e| artifact_err(source, format!("stat blob {hex}: {e}")))?;
203 if meta.file_type().is_symlink() {
204 return Err(artifact_err(
205 source,
206 format!("blob {hex} is a symlink (refused)"),
207 ));
208 }
209 let file = std::fs::File::open(&path)
212 .map_err(|e| artifact_err(source, format!("open blob {hex}: {e}")))?;
213 let mut bytes = Vec::new();
214 file.take(declared + 1)
215 .read_to_end(&mut bytes)
216 .map_err(|e| artifact_err(source, format!("read blob {hex}: {e}")))?;
217 if bytes.len() as u64 != declared {
218 return Err(artifact_err(
219 source,
220 format!(
221 "blob {hex} size mismatch (declared {declared}, read {})",
222 bytes.len()
223 ),
224 ));
225 }
226 let actual = sha256_hex(&bytes);
227 if actual != hex {
228 return Err(artifact_err(
229 source,
230 format!("blob {hex} content digest mismatch (computed {actual})"),
231 ));
232 }
233 Ok(bytes)
234}
235
236#[doc(hidden)]
242pub fn write_layout(layout: &Path, artifact: &ResolvedArtifact) -> Result<String, ControlError> {
243 std::fs::create_dir_all(layout.join("blobs").join("sha256"))?;
244
245 OciLayoutBuilder::default()
247 .image_layout_version("1.0.0")
248 .build()
249 .map_err(|e| artifact_err("write", format!("build oci-layout: {e}")))?
250 .to_file(layout.join("oci-layout"))
251 .map_err(|e| artifact_err("write", format!("write oci-layout: {e}")))?;
252
253 let config = write_blob(layout, b"{}", MediaType::Other(WASM_CONFIG_MT.to_string()))?;
254 let layers = vec![
255 write_blob(
256 layout,
257 &artifact.component,
258 MediaType::Other(WASM_LAYER_MT.to_string()),
259 )?,
260 write_blob(
261 layout,
262 &artifact.component_signature,
263 MediaType::Other(SIG_MT.to_string()),
264 )?,
265 write_blob(
266 layout,
267 &artifact.sbom,
268 MediaType::Other(SBOM_MT.to_string()),
269 )?,
270 write_blob(
271 layout,
272 &artifact.sbom_signature,
273 MediaType::Other(SBOM_SIG_MT.to_string()),
274 )?,
275 ];
276
277 let manifest = ImageManifestBuilder::default()
278 .schema_version(2u32)
279 .media_type(MediaType::ImageManifest)
280 .config(config)
281 .layers(layers)
282 .build()
283 .map_err(|e| artifact_err("write", format!("build manifest: {e}")))?;
284
285 let manifest_bytes = serde_json::to_vec(&manifest)
286 .map_err(|e| artifact_err("write", format!("serialize manifest: {e}")))?;
287 let manifest_desc = write_blob(layout, &manifest_bytes, MediaType::ImageManifest)?;
288
289 let index = ImageIndexBuilder::default()
290 .schema_version(2u32)
291 .manifests(vec![manifest_desc.clone()])
292 .build()
293 .map_err(|e| artifact_err("write", format!("build index: {e}")))?;
294 index
295 .to_file(layout.join("index.json"))
296 .map_err(|e| artifact_err("write", format!("write index.json: {e}")))?;
297
298 Ok(manifest_desc.digest().to_string())
299}
300
301fn write_blob(
304 layout: &Path,
305 bytes: &[u8],
306 media_type: MediaType,
307) -> Result<Descriptor, ControlError> {
308 let hex = sha256_hex(bytes);
309 std::fs::write(layout.join("blobs").join("sha256").join(&hex), bytes)?;
310 let digest = Digest::from_str(&format!("sha256:{hex}"))
311 .map_err(|e| artifact_err("write", format!("build digest: {e}")))?;
312 DescriptorBuilder::default()
313 .media_type(media_type)
314 .digest(digest)
315 .size(bytes.len() as u64)
316 .build()
317 .map_err(|e| artifact_err("write", format!("build descriptor: {e}")))
318}