Skip to main content

plecto_control/
oci.rs

1//! Offline OCI image-layout `ArtifactStore` (ADR 000007). Reads a local image-layout
2//! directory — no registry, no network — per the CNCF Wasm OCI Artifact layout, verifies the
3//! image-manifest digest against the manifest pin, and extracts the component plus its
4//! bundled signature / SBOM layers (custom mediaTypes). Remote fetch (`wkg`) is an
5//! out-of-band operator step that produces such a layout.
6//!
7//! Hand-rolled over `oci-spec` types + `sha2` (no openssl / tokio): the spec-correct types do
8//! the structure; we own the sha256 digest verification we are fail-closed on anyway.
9
10use 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
28/// Sanity ceiling on any blob the loader reads (CWE-770): a wasm component plus its
29/// signature / SBOM layers are well under this. Bounds the read so a malicious or oversized
30/// layout cannot OOM the shared data-plane process before the digest check runs.
31const MAX_BLOB_BYTES: u64 = 512 << 20; // 512 MiB
32/// Cap on `index.json` — it carries a single image-manifest descriptor (a few KiB at most).
33const MAX_INDEX_BYTES: u64 = 1 << 20; // 1 MiB
34
35fn 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
46/// An `ArtifactStore` backed by local OCI image-layout directories under `root`. A manifest
47/// `source` is a path (relative to `root`) to one image-layout directory.
48pub 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
64/// Read + verify one image-layout: pin-check the image-manifest digest, then extract and
65/// digest-verify each bundled layer.
66fn 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    // Content pinning (ADR 000007): the image-manifest digest must equal the manifest's pin.
80    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
134/// Read a file with a hard byte ceiling, refusing symlinks (CWE-59) and anything over `max`
135/// (CWE-770). Used for control-plane inputs whose on-disk size we do not trust.
136fn 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
161/// Read a digest-addressed blob and verify its content hashes to the descriptor's digest. The
162/// read is bounded by the descriptor's declared size and a sanity ceiling, symlinks are refused,
163/// and the digest hex is validated as a path component before the `join`.
164fn 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(); // "sha256:<hex>"
168    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    // Defense-in-depth (CWE-22): the hex becomes a filesystem path component, so verify it
178    // is exactly 64 lowercase hex chars locally instead of relying solely on oci-spec's `Digest`
179    // parser — a `..`/`/`-bearing digest can then never reach `Path::join`.
180    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    // The descriptor's declared size bounds the read; reject an over-ceiling blob up front.
191    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    // Refuse a symlinked blob (CWE-59): a layout pointing `blobs/sha256/<hex>` at `/dev/zero` or
200    // any host file must not be followed and read unbounded.
201    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    // Read at most `declared` bytes (+1 to detect an oversized file), so even a file swapped after
210    // the stat cannot grow the buffer past the bound; the digest check below rejects any mismatch.
211    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/// Write a filter as an offline OCI image-layout under `layout` (the wasm component plus its
237/// signature / SBOM bundled as custom-mediaType layers). Returns the `sha256:...`
238/// image-manifest digest to pin it by in a manifest. Test / dev / tooling helper —
239/// production artifacts come from `wkg` (out-of-band). `doc(hidden)` keeps it off the
240/// documented embedder surface (DECREE §2: the pub face stays minimal and deliberate).
241#[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    // oci-layout marker file.
246    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
301/// Hash `bytes`, write them to `blobs/sha256/<hex>`, and return a descriptor (digest + size +
302/// mediaType) over them.
303fn 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}