From 928147b5f8517bc70748885badc4a8d7bde0ebbb Mon Sep 17 00:00:00 2001 From: dorianzheng Date: Thu, 2 Apr 2026 23:39:50 +0800 Subject: [PATCH 1/2] feat(images): harden OCI image pull security with size, URL, and DiffID verification Add four security improvements to the OCI image pull pipeline, closing gaps identified by comparing with Docker (containerd) and Podman (containers/image): - Size validation: LayerInfo now carries expected size from manifest descriptors; StagedDownload.commit() rejects blobs with mismatched size before hash check (prevents disk exhaustion from oversized blobs) - Foreign layer URL rejection: layers_from_image() rejects layers with non-distributable media types or foreign URLs (CVE-2020-15157 mitigation) - HashingWriter: new AsyncWrite wrapper computes SHA256 inline during download, eliminating the post-download re-read and halving I/O while maintaining independent verification from oci-client - DiffID verification: verify_diff_id() decompresses and hashes layer tarballs to verify uncompressed content matches rootfs.diff_ids from the image config, called during layer_extracted() --- src/boxlite/src/images/archive/mod.rs | 1 + src/boxlite/src/images/archive/tar.rs | 163 +++++++++++++++ src/boxlite/src/images/manager.rs | 6 + src/boxlite/src/images/object.rs | 53 ++++- src/boxlite/src/images/storage.rs | 233 ++++++++++++++++++--- src/boxlite/src/images/store.rs | 278 ++++++++++++++++++++++++-- 6 files changed, 680 insertions(+), 54 deletions(-) diff --git a/src/boxlite/src/images/archive/mod.rs b/src/boxlite/src/images/archive/mod.rs index 9bf7a251..41bb3a2e 100644 --- a/src/boxlite/src/images/archive/mod.rs +++ b/src/boxlite/src/images/archive/mod.rs @@ -9,3 +9,4 @@ mod time; #[allow(unused_imports)] pub use tar::extract_layer_tarball_streaming; +pub use tar::verify_diff_id; diff --git a/src/boxlite/src/images/archive/tar.rs b/src/boxlite/src/images/archive/tar.rs index e6ced73c..9ba8104f 100644 --- a/src/boxlite/src/images/archive/tar.rs +++ b/src/boxlite/src/images/archive/tar.rs @@ -998,6 +998,87 @@ fn to_cstring(path: &Path) -> io::Result { }) } +/// Verify a layer's DiffID by decompressing and hashing the uncompressed content. +/// +/// DiffID is the SHA256 of the uncompressed tar stream, as specified in the +/// OCI image config's `rootfs.diff_ids` array. This verifies that the actual +/// filesystem content matches what the image author intended. +/// +/// # Arguments +/// * `tarball_path` - Path to the compressed layer tarball +/// * `expected_diff_id` - Expected DiffID (e.g., "sha256:abc123...") +/// +/// # Returns +/// `Ok(true)` if the DiffID matches, `Ok(false)` if it doesn't. +pub fn verify_diff_id(tarball_path: &Path, expected_diff_id: &str) -> BoxliteResult { + use sha2::{Digest, Sha256}; + + let expected_hash = expected_diff_id + .strip_prefix("sha256:") + .ok_or_else(|| BoxliteError::Storage("Invalid diff_id format, expected sha256:".into()))?; + + let file = fs::File::open(tarball_path).map_err(|e| { + BoxliteError::Storage(format!( + "Failed to open layer tarball {}: {}", + tarball_path.display(), + e + )) + })?; + + // Detect compression format + let mut header = [0u8; 2]; + { + let file_ref = &file; + file_ref + .take(2) + .read_exact(&mut header) + .map_err(|e| BoxliteError::Storage(format!("Failed to read layer header: {}", e)))?; + } + + // Re-open to read from beginning + let file = fs::File::open(tarball_path).map_err(|e| { + BoxliteError::Storage(format!( + "Failed to reopen layer tarball {}: {}", + tarball_path.display(), + e + )) + })?; + + // Create decompressing reader (same logic as extract_layer_tarball_streaming) + let mut reader: Box = if header == [0x1f, 0x8b] { + Box::new(GzDecoder::new(BufReader::new(file))) + } else { + Box::new(BufReader::new(file)) + }; + + // Hash the entire decompressed stream + let mut hasher = Sha256::new(); + let mut buffer = vec![0u8; 64 * 1024]; + loop { + let n = reader.read(&mut buffer).map_err(|e| { + BoxliteError::Storage(format!("Failed to read decompressed layer: {}", e)) + })?; + if n == 0 { + break; + } + hasher.update(&buffer[..n]); + } + + let computed_hash = format!("{:x}", hasher.finalize()); + + if computed_hash != expected_hash { + tracing::error!( + "DiffID mismatch for {}:\n Expected: {}\n Computed: sha256:{}", + tarball_path.display(), + expected_diff_id, + computed_hash + ); + return Ok(false); + } + + Ok(true) +} + #[cfg(test)] mod tests { use super::*; @@ -1607,4 +1688,86 @@ mod tests { let target = std::fs::read_link(&link_path).unwrap(); assert_eq!(target, PathBuf::from("target.txt")); } + + // ======================================================================== + // DiffID Verification Tests + // ======================================================================== + + #[test] + fn test_verify_diff_id_correct_hash() { + use sha2::Digest; + + let temp_dir = tempfile::tempdir().unwrap(); + + // Create an uncompressed tar with known content + let entries = vec![TestEntry { + path: "hello.txt".to_string(), + entry_type: TestEntryType::File { + content: b"hello".to_vec(), + }, + }]; + let tar_data = create_test_tar(entries); + + // Compute expected DiffID (hash of uncompressed tar) + let expected_diff_id = format!("sha256:{:x}", sha2::Sha256::digest(&tar_data)); + + // Gzip-compress and write to file + let mut gz = GzEncoder::new(Vec::new(), Compression::default()); + gz.write_all(&tar_data).unwrap(); + let gzipped = gz.finish().unwrap(); + + let tarball_path = temp_dir.path().join("layer.tar.gz"); + std::fs::write(&tarball_path, &gzipped).unwrap(); + + assert!(verify_diff_id(&tarball_path, &expected_diff_id).unwrap()); + } + + #[test] + fn test_verify_diff_id_wrong_hash() { + let temp_dir = tempfile::tempdir().unwrap(); + + let entries = vec![TestEntry { + path: "hello.txt".to_string(), + entry_type: TestEntryType::File { + content: b"hello".to_vec(), + }, + }]; + let tar_data = create_test_tar(entries); + + let mut gz = GzEncoder::new(Vec::new(), Compression::default()); + gz.write_all(&tar_data).unwrap(); + let gzipped = gz.finish().unwrap(); + + let tarball_path = temp_dir.path().join("layer.tar.gz"); + std::fs::write(&tarball_path, &gzipped).unwrap(); + + // Use a wrong diff_id + let wrong_diff_id = + "sha256:0000000000000000000000000000000000000000000000000000000000000000"; + assert!(!verify_diff_id(&tarball_path, wrong_diff_id).unwrap()); + } + + #[test] + fn test_verify_diff_id_uncompressed_tarball() { + use sha2::Digest; + + let temp_dir = tempfile::tempdir().unwrap(); + + let entries = vec![TestEntry { + path: "test.txt".to_string(), + entry_type: TestEntryType::File { + content: b"uncompressed test".to_vec(), + }, + }]; + let tar_data = create_test_tar(entries); + + // DiffID of uncompressed tar = hash of the tar itself (no compression layer) + let expected_diff_id = format!("sha256:{:x}", sha2::Sha256::digest(&tar_data)); + + // Write uncompressed tar directly + let tarball_path = temp_dir.path().join("layer.tar"); + std::fs::write(&tarball_path, &tar_data).unwrap(); + + assert!(verify_diff_id(&tarball_path, &expected_diff_id).unwrap()); + } } diff --git a/src/boxlite/src/images/manager.rs b/src/boxlite/src/images/manager.rs index 6fbc4e20..fb883a39 100644 --- a/src/boxlite/src/images/manager.rs +++ b/src/boxlite/src/images/manager.rs @@ -33,12 +33,18 @@ pub(super) struct ImageManifest { pub(super) manifest_digest: String, pub(super) layers: Vec, pub(super) config_digest: String, + /// DiffIDs from image config's `rootfs.diff_ids` (SHA256 of uncompressed layers). + /// Empty if not available (e.g., config not yet downloaded, or empty in config). + pub(super) diff_ids: Vec, } #[derive(Debug, Clone)] pub(super) struct LayerInfo { pub(super) digest: String, pub(super) media_type: String, + /// Expected size from manifest descriptor (bytes). + /// Values <= 0 mean "unknown" and skip size validation. + pub(super) size: i64, } // ============================================================================ diff --git a/src/boxlite/src/images/object.rs b/src/boxlite/src/images/object.rs index 7f31f124..9855ad06 100644 --- a/src/boxlite/src/images/object.rs +++ b/src/boxlite/src/images/object.rs @@ -162,7 +162,58 @@ impl ImageObject { .map(|l| l.digest.clone()) .collect(); - self.blob_source.extract_layers(&digests).await + let extracted = self.blob_source.extract_layers(&digests).await?; + + // Verify DiffIDs if available + self.verify_diff_ids()?; + + Ok(extracted) + } + + /// Verify layer DiffIDs against the image config's rootfs.diff_ids. + /// + /// DiffIDs are SHA256 hashes of the uncompressed layer tar content. + /// This ensures the decompressed filesystem content matches what the + /// image author intended. + fn verify_diff_ids(&self) -> BoxliteResult<()> { + use crate::images::archive::verify_diff_id; + + let diff_ids = &self.manifest.diff_ids; + if diff_ids.is_empty() { + return Ok(()); + } + + let layers = &self.manifest.layers; + if diff_ids.len() != layers.len() { + tracing::warn!( + "DiffID count ({}) doesn't match layer count ({}), skipping verification", + diff_ids.len(), + layers.len() + ); + return Ok(()); + } + + for (i, (layer, diff_id)) in layers.iter().zip(diff_ids.iter()).enumerate() { + let tarball_path = self.blob_source.layer_tarball_path(&layer.digest); + match verify_diff_id(&tarball_path, diff_id) { + Ok(true) => { + tracing::debug!("DiffID verified for layer {}: {}", i, layer.digest); + } + Ok(false) => { + return Err(BoxliteError::Image(format!( + "DiffID verification failed for layer {} ({}): \ + uncompressed content does not match expected diff_id {}", + i, layer.digest, diff_id + ))); + } + Err(e) => { + tracing::warn!("DiffID verification error for layer {}: {}", i, e); + // Don't fail the pull on verification errors (e.g., unsupported format) + } + } + } + + Ok(()) } /// Compute a stable digest for this image based on its layers. diff --git a/src/boxlite/src/images/storage.rs b/src/boxlite/src/images/storage.rs index 4286510f..f96ea267 100644 --- a/src/boxlite/src/images/storage.rs +++ b/src/boxlite/src/images/storage.rs @@ -275,7 +275,11 @@ impl ImageStorage { /// /// Returns a StagedDownload handle that manages the temp file lifecycle. /// Use `staged.file()` to get the file for writing. - pub async fn stage_layer_download(&self, digest: &str) -> BoxliteResult { + pub async fn stage_layer_download( + &self, + digest: &str, + expected_size: i64, + ) -> BoxliteResult { // Extract expected hash from digest let expected_hash = digest .strip_prefix("sha256:") @@ -302,6 +306,7 @@ impl ImageStorage { staged_path, self.layer_tarball_path(digest), expected_hash, + expected_size, file, )) } @@ -402,6 +407,7 @@ impl ImageStorage { staged_path, config_path, expected_hash, + 0, // Config size not tracked; skip size validation file, )) } @@ -446,6 +452,74 @@ impl ImageStorage { } } +// ============================================================================ +// HASHING WRITER +// ============================================================================ + +/// AsyncWrite wrapper that computes SHA256 of all bytes written through it. +/// +/// Feeds every successfully written byte through a SHA256 hasher, providing +/// inline digest verification without requiring a post-download re-read. +/// +/// Compatible with `oci-client`'s `pull_blob` which requires `T: AsyncWrite + Unpin`. +pub struct HashingWriter { + inner: W, + hasher: sha2::Sha256, + bytes_written: u64, +} + +impl HashingWriter { + pub fn new(inner: W) -> Self { + use sha2::Digest; + Self { + inner, + hasher: sha2::Sha256::new(), + bytes_written: 0, + } + } + + /// Consume the writer and return (inner_writer, hex_hash, bytes_written). + pub fn finalize(self) -> (W, String, u64) { + use sha2::Digest; + let hash = format!("{:x}", self.hasher.finalize()); + (self.inner, hash, self.bytes_written) + } +} + +impl tokio::io::AsyncWrite for HashingWriter { + fn poll_write( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> std::task::Poll> { + use sha2::Digest; + let this = self.get_mut(); + match std::pin::Pin::new(&mut this.inner).poll_write(cx, buf) { + std::task::Poll::Ready(Ok(n)) => { + // Only hash bytes that were actually written to the inner writer + this.hasher.update(&buf[..n]); + this.bytes_written += n as u64; + std::task::Poll::Ready(Ok(n)) + } + other => other, + } + } + + fn poll_flush( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::pin::Pin::new(&mut self.get_mut().inner).poll_flush(cx) + } + + fn poll_shutdown( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::pin::Pin::new(&mut self.get_mut().inner).poll_shutdown(cx) + } +} + // ============================================================================ // STAGED DOWNLOAD // ============================================================================ @@ -471,7 +545,9 @@ pub struct StagedDownload { staged_path: PathBuf, final_path: PathBuf, expected_hash: String, - file: Option, + /// Expected blob size from manifest descriptor. Values <= 0 skip size validation. + expected_size: i64, + writer: Option>, } impl StagedDownload { @@ -480,19 +556,24 @@ impl StagedDownload { staged_path: PathBuf, final_path: PathBuf, expected_hash: String, + expected_size: i64, file: tokio::fs::File, ) -> Self { Self { staged_path, final_path, expected_hash, - file: Some(file), + expected_size, + writer: Some(HashingWriter::new(file)), } } - /// Get mutable reference to the file for writing - pub fn file(&mut self) -> &mut tokio::fs::File { - self.file.as_mut().expect("file already consumed") + /// Get mutable reference to the hashing writer for writing blob data. + /// + /// The writer computes SHA256 inline as bytes are written, eliminating + /// the need for a post-download re-read. + pub fn file(&mut self) -> &mut HashingWriter { + self.writer.as_mut().expect("writer already consumed") } /// Get the staged file path (for debugging/logging) @@ -506,17 +587,22 @@ impl StagedDownload { &self.final_path } - /// Verify integrity and atomically move to final location + /// Verify integrity and atomically move to final location. + /// + /// Reads the hash computed inline by `HashingWriter` during the download — + /// no post-download re-read is needed. This is an independent verification + /// layer from `oci-client`'s own inline digest check. /// /// Returns Ok(true) if verification passed and file was committed, /// Ok(false) if verification failed (temp file is cleaned up). /// Consumes self to prevent further use after commit. pub async fn commit(mut self) -> BoxliteResult { - use sha2::{Digest, Sha256}; - use tokio::io::AsyncReadExt; - - // Drop the write handle before reading - self.file.take(); + // Finalize the hashing writer to get computed hash and byte count + let writer = self + .writer + .take() + .ok_or_else(|| BoxliteError::Storage("writer already consumed".into()))?; + let (_file, computed_hash, bytes_written) = writer.finalize(); if !self.staged_path.exists() { return Err(BoxliteError::Storage(format!( @@ -525,26 +611,17 @@ impl StagedDownload { ))); } - // Verify integrity - let mut file = tokio::fs::File::open(&self.staged_path) - .await - .map_err(|e| BoxliteError::Storage(format!("Failed to open temp file: {}", e)))?; - - let mut hasher = Sha256::new(); - let mut buffer = vec![0u8; 64 * 1024]; - loop { - let n = file - .read(&mut buffer) - .await - .map_err(|e| BoxliteError::Storage(format!("Failed to read temp file: {}", e)))?; - if n == 0 { - break; - } - hasher.update(&buffer[..n]); + // Size validation (fail fast before hash comparison) + if self.expected_size > 0 && bytes_written != self.expected_size as u64 { + tracing::error!( + "Blob size mismatch: expected {} bytes, got {} bytes", + self.expected_size, + bytes_written + ); + let _ = tokio::fs::remove_file(&self.staged_path).await; + return Ok(false); } - let computed_hash = format!("{:x}", hasher.finalize()); - if computed_hash != self.expected_hash { // Verification failed - clean up temp file let _ = tokio::fs::remove_file(&self.staged_path).await; @@ -570,7 +647,7 @@ impl StagedDownload { /// /// Call this on download failure or cancellation. pub async fn abort(mut self) { - self.file.take(); + self.writer.take(); let _ = tokio::fs::remove_file(&self.staged_path).await; } } @@ -676,6 +753,100 @@ mod tests { assert_eq!(config, r#"{"foo": "bar"}"#); } + #[tokio::test] + async fn test_hashing_writer_produces_correct_sha256() { + use sha2::Digest; + use tokio::io::AsyncWriteExt; + + let data = b"hello world - hashing writer test"; + let expected_hash = format!("{:x}", sha2::Sha256::digest(data)); + + let buf = Vec::new(); + let mut writer = HashingWriter::new(buf); + writer.write_all(data).await.unwrap(); + + let (inner, hash, bytes_written) = writer.finalize(); + assert_eq!(hash, expected_hash); + assert_eq!(bytes_written, data.len() as u64); + assert_eq!(inner, data.to_vec()); + } + + /// Helper: create a staged download with known content, expected hash, and expected size. + /// Returns (StagedDownload, actual_content_bytes). + async fn create_staged_with_content( + store: &ImageStorage, + content: &[u8], + expected_size: i64, + ) -> StagedDownload { + use sha2::Digest; + use tokio::io::AsyncWriteExt; + + let hash = format!("{:x}", sha2::Sha256::digest(content)); + let digest = format!("sha256:{}", hash); + let mut staged = store + .stage_layer_download(&digest, expected_size) + .await + .unwrap(); + staged.file().write_all(content).await.unwrap(); + staged.file().flush().await.unwrap(); + staged + } + + #[tokio::test] + async fn test_staged_download_commit_correct_size() { + let temp_dir = tempfile::tempdir().unwrap(); + let store = ImageStorage::new(temp_dir.path().to_path_buf()).unwrap(); + + let content = b"hello world"; + let staged = create_staged_with_content(&store, content, content.len() as i64).await; + assert!( + staged.commit().await.unwrap(), + "commit should succeed with correct size and hash" + ); + } + + #[tokio::test] + async fn test_staged_download_commit_wrong_size() { + let temp_dir = tempfile::tempdir().unwrap(); + let store = ImageStorage::new(temp_dir.path().to_path_buf()).unwrap(); + + let content = b"hello world"; + // Expect 5 bytes but write 11 + let staged = create_staged_with_content(&store, content, 5).await; + assert!( + !staged.commit().await.unwrap(), + "commit should fail with wrong size" + ); + } + + #[tokio::test] + async fn test_staged_download_commit_zero_size_skips_validation() { + let temp_dir = tempfile::tempdir().unwrap(); + let store = ImageStorage::new(temp_dir.path().to_path_buf()).unwrap(); + + let content = b"hello world"; + // size=0 means unknown, should skip size validation + let staged = create_staged_with_content(&store, content, 0).await; + assert!( + staged.commit().await.unwrap(), + "commit should succeed when size=0 (skip validation)" + ); + } + + #[tokio::test] + async fn test_staged_download_commit_negative_size_skips_validation() { + let temp_dir = tempfile::tempdir().unwrap(); + let store = ImageStorage::new(temp_dir.path().to_path_buf()).unwrap(); + + let content = b"hello world"; + // size=-1 means unknown, should skip size validation + let staged = create_staged_with_content(&store, content, -1).await; + assert!( + staged.commit().await.unwrap(), + "commit should succeed when size<0 (skip validation)" + ); + } + #[test] fn test_verify_blobs_exist() { let temp_dir = tempfile::tempdir().unwrap(); diff --git a/src/boxlite/src/images/store.rs b/src/boxlite/src/images/store.rs index 97a3fd97..8e89b6bf 100644 --- a/src/boxlite/src/images/store.rs +++ b/src/boxlite/src/images/store.rs @@ -310,6 +310,7 @@ impl ImageStore { manifest_digest: manifest_digest.to_string(), layers, config_digest: config_digest_str, + diff_ids: Vec::new(), // Populated later if config is available }) } @@ -473,7 +474,7 @@ impl ImageStore { let (layers, config_digest) = match manifest { oci_client::manifest::OciManifest::Image(ref img) => { - let layers = Self::layers_from_image(img); + let layers = Self::layers_from_image(img)?; let config_digest = img.config.digest.clone(); (layers, config_digest) } @@ -484,13 +485,49 @@ impl ImageStore { } }; + // Load diff_ids from config if available + let diff_ids = self.load_diff_ids_from_config(inner, &config_digest); + Ok(ImageManifest { manifest_digest: cached.manifest_digest.clone(), layers, config_digest, + diff_ids, }) } + /// Load diff_ids from image config on disk. Returns empty vec on any failure. + fn load_diff_ids_from_config( + &self, + inner: &ImageStoreInner, + config_digest: &str, + ) -> Vec { + let config_path = inner.storage.config_path(config_digest); + let config_json = match std::fs::read_to_string(&config_path) { + Ok(json) => json, + Err(e) => { + tracing::debug!("Cannot read config for diff_ids: {}", e); + return Vec::new(); + } + }; + + let image_config: oci_spec::image::ImageConfiguration = + match serde_json::from_str(&config_json) { + Ok(c) => c, + Err(e) => { + tracing::debug!("Cannot parse config for diff_ids: {}", e); + return Vec::new(); + } + }; + + image_config + .rootfs() + .diff_ids() + .iter() + .map(|d| d.to_string()) + .collect() + } + // ======================================================================== // INTERNAL: Registry Operations (releases lock during I/O) // ======================================================================== @@ -516,7 +553,7 @@ impl ImageStore { } // Step 3: Extract image manifest (may pull platform-specific manifest for multi-platform images) - let image_manifest = self + let mut image_manifest = self .extract_image_manifest(reference, &manifest, manifest_digest_str) .await?; @@ -528,6 +565,13 @@ impl ImageStore { self.download_config(reference, &image_manifest.config_digest) .await?; + // Step 5b: Parse diff_ids from config for DiffID verification + { + let inner = self.inner.read().await; + image_manifest.diff_ids = + self.load_diff_ids_from_config(&inner, &image_manifest.config_digest); + } + // Step 6: Update index using reference.whole() as the cache key self.update_index(&reference.whole(), &image_manifest) .await?; @@ -565,12 +609,13 @@ impl ImageStore { ) -> BoxliteResult { match manifest { oci_client::manifest::OciManifest::Image(img) => { - let layers = Self::layers_from_image(img); + let layers = Self::layers_from_image(img)?; let config_digest = img.config.digest.clone(); Ok(ImageManifest { manifest_digest, layers, config_digest, + diff_ids: Vec::new(), // Populated after config download }) } oci_client::manifest::OciManifest::ImageIndex(index) => { @@ -579,15 +624,35 @@ impl ImageStore { } } - fn layers_from_image(image: &oci_client::manifest::OciImageManifest) -> Vec { - image - .layers - .iter() - .map(|layer| LayerInfo { + fn layers_from_image( + image: &oci_client::manifest::OciImageManifest, + ) -> BoxliteResult> { + let mut layers = Vec::with_capacity(image.layers.len()); + for layer in &image.layers { + // Reject non-distributable / foreign layers (CVE-2020-15157 mitigation) + if layer.media_type.contains("nondistributable") || layer.media_type.contains("foreign") + { + return Err(BoxliteError::Image(format!( + "Refusing non-distributable layer {}: media_type={} — \ + boxlite does not support foreign layer URLs", + layer.digest, layer.media_type + ))); + } + if layer.urls.as_ref().is_some_and(|urls| !urls.is_empty()) { + return Err(BoxliteError::Image(format!( + "Refusing layer {} with foreign URLs: {:?} — \ + foreign layer URLs are rejected for security (CVE-2020-15157)", + layer.digest, layer.urls + ))); + } + + layers.push(LayerInfo { digest: layer.digest.clone(), media_type: layer.media_type.clone(), - }) - .collect() + size: layer.size, + }); + } + Ok(layers) } async fn extract_platform_manifest( @@ -631,12 +696,13 @@ impl ImageStore { match platform_image { oci_client::manifest::OciManifest::Image(img) => { - let layers = Self::layers_from_image(&img); + let layers = Self::layers_from_image(&img)?; let config_digest = img.config.digest.clone(); Ok(ImageManifest { manifest_digest: platform_digest, layers, config_digest, + diff_ids: Vec::new(), // Populated after config download }) } _ => Err(BoxliteError::Storage( @@ -774,7 +840,11 @@ impl ImageStore { // Stage download (quick read lock for path computation) let mut staged = { let inner = self.inner.read().await; - match inner.storage.stage_layer_download(&layer.digest).await { + match inner + .storage + .stage_layer_download(&layer.digest, layer.size) + .await + { Ok(result) => result, Err(e) => { last_error = Some(format!( @@ -794,7 +864,7 @@ impl ImageStore { &OciDescriptor { digest: layer.digest.clone(), media_type: layer.media_type.clone(), - size: 0, + size: layer.size, urls: None, annotations: None, }, @@ -910,15 +980,7 @@ impl ImageStore { .map_err(|e| BoxliteError::Storage(format!("Failed to parse {}: {}", context, e)))?; let config_digest_str = oci_manifest.config.digest.clone(); - - let layers: Vec = oci_manifest - .layers - .iter() - .map(|layer| LayerInfo { - digest: layer.digest.clone(), - media_type: layer.media_type.clone(), - }) - .collect(); + let layers = Self::layers_from_image(&oci_manifest)?; Ok((config_digest_str, layers)) } @@ -1164,4 +1226,176 @@ mod tests { let err = result.unwrap_err().to_string(); assert!(err.contains("index.json")); } + + // ======================================================================== + // Foreign Layer URL Rejection Tests (Phase 1B) + // ======================================================================== + + #[test] + fn test_layers_from_image_rejects_nondistributable_media_type() { + let manifest = ClientOciImageManifest { + schema_version: 2, + config: OciDescriptor { + digest: "sha256:config".into(), + media_type: "application/vnd.oci.image.config.v1+json".into(), + size: 100, + urls: None, + annotations: None, + }, + layers: vec![OciDescriptor { + digest: "sha256:layer1".into(), + media_type: "application/vnd.oci.image.layer.nondistributable.v1.tar+gzip".into(), + size: 1000, + urls: None, + annotations: None, + }], + media_type: None, + annotations: None, + artifact_type: None, + subject: None, + }; + + let result = ImageStore::layers_from_image(&manifest); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("nondistributable"), + "error should mention nondistributable: {err}" + ); + } + + #[test] + fn test_layers_from_image_rejects_foreign_urls() { + let manifest = ClientOciImageManifest { + schema_version: 2, + config: OciDescriptor { + digest: "sha256:config".into(), + media_type: "application/vnd.oci.image.config.v1+json".into(), + size: 100, + urls: None, + annotations: None, + }, + layers: vec![OciDescriptor { + digest: "sha256:layer1".into(), + media_type: "application/vnd.oci.image.layer.v1.tar+gzip".into(), + size: 1000, + urls: Some(vec!["https://evil.example.com/blob".into()]), + annotations: None, + }], + media_type: None, + annotations: None, + artifact_type: None, + subject: None, + }; + + let result = ImageStore::layers_from_image(&manifest); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("foreign"), + "error should mention foreign URLs: {err}" + ); + } + + #[test] + fn test_layers_from_image_accepts_normal_layers() { + let manifest = ClientOciImageManifest { + schema_version: 2, + config: OciDescriptor { + digest: "sha256:config".into(), + media_type: "application/vnd.oci.image.config.v1+json".into(), + size: 100, + urls: None, + annotations: None, + }, + layers: vec![ + OciDescriptor { + digest: "sha256:layer1".into(), + media_type: "application/vnd.oci.image.layer.v1.tar+gzip".into(), + size: 1000, + urls: None, + annotations: None, + }, + OciDescriptor { + digest: "sha256:layer2".into(), + media_type: "application/vnd.docker.image.rootfs.diff.tar.gzip".into(), + size: 2000, + urls: None, + annotations: None, + }, + ], + media_type: None, + annotations: None, + artifact_type: None, + subject: None, + }; + + let result = ImageStore::layers_from_image(&manifest).unwrap(); + assert_eq!(result.len(), 2); + assert_eq!(result[0].digest, "sha256:layer1"); + assert_eq!(result[0].size, 1000); + assert_eq!(result[1].digest, "sha256:layer2"); + assert_eq!(result[1].size, 2000); + } + + #[test] + fn test_layers_from_image_accepts_empty_urls() { + let manifest = ClientOciImageManifest { + schema_version: 2, + config: OciDescriptor { + digest: "sha256:config".into(), + media_type: "application/vnd.oci.image.config.v1+json".into(), + size: 100, + urls: None, + annotations: None, + }, + layers: vec![OciDescriptor { + digest: "sha256:layer1".into(), + media_type: "application/vnd.oci.image.layer.v1.tar+gzip".into(), + size: 500, + urls: Some(vec![]), // Empty URLs vec should be OK + annotations: None, + }], + media_type: None, + annotations: None, + artifact_type: None, + subject: None, + }; + + let result = ImageStore::layers_from_image(&manifest).unwrap(); + assert_eq!(result.len(), 1); + } + + #[test] + fn test_layers_from_image_rejects_docker_foreign_media_type() { + let manifest = ClientOciImageManifest { + schema_version: 2, + config: OciDescriptor { + digest: "sha256:config".into(), + media_type: "application/vnd.oci.image.config.v1+json".into(), + size: 100, + urls: None, + annotations: None, + }, + layers: vec![OciDescriptor { + digest: "sha256:layer1".into(), + media_type: "application/vnd.docker.image.rootfs.foreign.diff.tar.gzip".into(), + size: 1000, + urls: None, + annotations: None, + }], + media_type: None, + annotations: None, + artifact_type: None, + subject: None, + }; + + let result = ImageStore::layers_from_image(&manifest); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("foreign") || err.contains("nondistributable"), + "error should indicate rejection: {err}" + ); + } } From 5dc074cc09180b8a0e25e82cdec5ebda895a9cfd Mon Sep 17 00:00:00 2001 From: dorianzheng Date: Fri, 3 Apr 2026 10:07:06 +0800 Subject: [PATCH 2/2] test(mount_security): mark research test as #[ignore] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mount_security_integration test installs gcc + musl-dev + linux-headers inside a VM and compiles a C program to probe ID-mapped mount feasibility. This takes >10 minutes and consistently times out in the pre-push hook's 600s limit. This is a research/feasibility probe, not a regression guard — mark it #[ignore] so it only runs when explicitly requested: cargo test -p boxlite --test mount_security -- --ignored --nocapture --- src/boxlite/tests/mount_security.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/boxlite/tests/mount_security.rs b/src/boxlite/tests/mount_security.rs index 7ba5de57..c237554d 100644 --- a/src/boxlite/tests/mount_security.rs +++ b/src/boxlite/tests/mount_security.rs @@ -59,7 +59,11 @@ async fn exec_exit_code(bx: &LiteBox, cmd: BoxCommand) -> i32 { // SINGLE TEST ENTRY POINT — one VM, all cases // ============================================================================ +/// This is a research/feasibility test for ID-mapped mounts, not a regression guard. +/// It installs gcc inside the VM and compiles a C program, which takes >10 minutes. +/// Run explicitly with: `cargo test -p boxlite --test mount_security -- --ignored --nocapture` #[tokio::test(flavor = "multi_thread")] +#[ignore = "research test: installs gcc in VM, takes >10 minutes"] async fn mount_security_integration() { let home = boxlite_test_utils::home::PerTestBoxHome::new(); let runtime = BoxliteRuntime::new(BoxliteOptions {