From eebcc593e5ebd95654a17f30f654deb29ea81bd3 Mon Sep 17 00:00:00 2001 From: dorianzheng Date: Thu, 2 Apr 2026 20:48:02 +0800 Subject: [PATCH 1/2] docs: add root cause analysis for AL2023 EC2 c8i VM boot failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guest kernel (libkrunfw 6.12.62) triggers i8042 CMD_RESET_CPU during early boot on nested KVM with host kernel 6.1 (Amazon Linux 2023). The reset causes immediate _exit(0) with no console output. Root cause: the guest kernel detects an incompatible CPU/hardware configuration under kernel 6.1's nested KVM emulation and performs a hardware reset via the i8042 controller. Ubuntu 24.04 (kernel 6.17) works because it provides better nested VMX emulation. This is an unreported configuration upstream — libkrun has not been tested on nested KVM Linux hosts. --- .../al2023-ec2-c8i-vm-boot-failure.md | 260 ++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 docs/investigation/al2023-ec2-c8i-vm-boot-failure.md diff --git a/docs/investigation/al2023-ec2-c8i-vm-boot-failure.md b/docs/investigation/al2023-ec2-c8i-vm-boot-failure.md new file mode 100644 index 00000000..fb477551 --- /dev/null +++ b/docs/investigation/al2023-ec2-c8i-vm-boot-failure.md @@ -0,0 +1,260 @@ +# Root Cause Analysis: BoxLite VM Failure on Amazon Linux 2023 (EC2 c8i) + +## Summary + +BoxLite VMs fail to start on Amazon Linux 2023 (kernel 6.1) on EC2 c8i instances. The guest kernel (Linux 6.12.62, embedded in libkrunfw) triggers an i8042 CPU reset during early boot, causing immediate VM termination with no error output. + +## Environment + +| Component | Working (Ubuntu) | Failing (AL2023) | +|-----------|-----------------|------------------| +| Host OS | Ubuntu 24.04 | Amazon Linux 2023 | +| Host kernel | 6.17.0-1007-aws | 6.1.164-196.303.amzn2023 | +| Instance type | c8i.4xlarge | c8i.4xlarge | +| Instance ID | i-0a2516cdf06c221df (boxlite-prod) | i-095ae61ecbca0d780 (boxlite-dev) | +| Nested KVM | Yes | Yes | +| KVM capabilities | ept, vpid, unrestricted_guest | Identical | +| Guest kernel | 6.12.62 (libkrunfw v5.1.0) | 6.12.62 (libkrunfw v5.1.0) | + +## Root Cause + +The guest kernel (Linux 6.12.62) issues a **hardware reset via the i8042 keyboard controller** during very early boot when running under nested KVM on host kernel 6.1. + +### Shutdown sequence + +``` +1. krun_start_enter() called +2. VMM creates VM, sets up virtio devices (CMOS, balloon, rng, console, fs, block, vsock, net) +3. 8 vCPUs started in paused state, then resumed +4. Boot vCPU (vCPU0) runs ~5 KVM_RUN iterations (I/O handling) +5. Guest kernel writes CMD_RESET_CPU (0xFE) to i8042 port 0x64 + → i8042 device handler writes to reset_evt EventFd +6. VMM event loop receives reset_evt, calls libc::_exit(0) +7. All threads killed instantly — no console output, no error messages +``` + +### Why the original diagnosis was wrong + +PR #417 diagnosed this as "broken nested KVM on EC2 c8i / Amazon Linux 2023" based on a flawed KVM smoke test. The smoke test didn't initialize vCPU registers (CS:IP, RFLAGS) before KVM_RUN, causing it to fail on any nested KVM. This was fixed in PR #421. + +KVM itself works correctly on both kernels — the issue is that the **guest kernel** (libkrunfw 6.12.62) can't boot under kernel 6.1's nested KVM emulation. + +## Diagnosis Process + +### Step 1: Verify KVM works + +Wrote a C program (`kvm_test.c`) that creates a minimal VM with a HLT instruction and proper vCPU register init (CS base=0, RIP=0, RFLAGS=0x2). + +``` +# AL2023 (kernel 6.1) +WITH register setup: exit_reason=5 (HLT) ✅ +WITHOUT register setup: exit_reason=17 (SHUTDOWN) ❌ + +# Ubuntu 24.04 (kernel 6.17) +WITH register setup: exit_reason=5 (HLT) ✅ +WITHOUT register setup: exit_reason=0 (UNKNOWN) ❌ +``` + +**Conclusion:** KVM works on both. The smoke test was broken, not KVM. + +### Step 2: Compare KVM capabilities + +Used `KVM_CHECK_EXTENSION` ioctl via Python to compare capabilities: + +``` +# Both kernels — identical results: +IRQCHIP(1)=1, HLT(2)=1, USER_MEMORY(4)=1, SET_TSS_ADDR(6)=1 +VAPIC(13)=0, EXT_CPUID(14)=1, INTERNAL_ERROR_DATA(25)=4096 +TSC_CONTROL(28)=0, SET_BOOT_CPU_ID(37)=1, X86_DISABLE_EXITS(41)=1 +SPLIT_IRQCHIP(117)=1, IMMEDIATE_EXIT(119)=0, VMX_EXCEPTION_PAYLOAD(129)=3 + +CPU flags (both): vmx, ept, vpid, unrestricted_guest, tpr_shadow, vnmi, flexpriority +Nested virt: Y (both) +``` + +**Conclusion:** KVM capabilities are identical. The difference is not in feature exposure. + +### Step 3: Run BoxLite with debug logging + +Built boxlite-cli on AL2023 and ran with `RUST_LOG=trace`: + +``` +[shim] T+0ms: main() entered +[shim] T+5ms: config parsed +[shim] T+9ms: logging initialized +[shim] T+83ms: gvproxy created +[shim] T+83ms: engine created +[shim] T+92ms: instance created (krun FFI calls done) +[shim] T+93ms: entering VM (krun_start_enter) +[krun] krun_start_enter called +[DEBUG vmm] using vcpu exit code: 0 +[INFO vmm] Vmm is stopping. +``` + +**Observations:** +- Shim starts fine, all FFI calls succeed +- Virtio devices set up: `set_irq_line: 5-13` (balloon, rng, console, fs×2, block×2, vsock, net) +- VMM stops with exit code 0 (clean shutdown) +- Console output: completely empty +- No `KVM_EXIT_HLT`, `SHUTDOWN`, `FAIL_ENTRY`, or `INTERNAL_ERROR` observed + +### Step 4: Instrument libkrun vCPU run loop + +Added `eprintln!("[krun-debug] vCPU run loop iteration {n}")` to the `running()` function in `vmm/src/linux/vstate.rs`. Required rebuilding libkrun from vendored source (not the prebuilt binary): + +```bash +# Must delete libkrun's own target directory to force rebuild +rm -rf src/deps/libkrun-sys/vendor/libkrun/target/ +make shim # triggers full rebuild +``` + +**Result:** +``` +[krun-debug] vCPU run loop iteration 1 # ×8 (one per vCPU) +[krun-debug] vCPU run loop iteration 2 # boot vCPU only +[krun-debug] vCPU run loop iteration 3 +[krun-debug] vCPU run loop iteration 4 +[krun-debug] vCPU run loop iteration 5 +``` + +Boot vCPU runs exactly 5 KVM_RUN iterations. Other 7 vCPUs run 1 each. + +### Step 5: Instrument KVM exit handlers + +Added `eprintln` and `std::fs::write("/tmp/krun-vcpu-*.log")` to every `VcpuExit` handler: HLT, Shutdown, SystemEvent, FailEntry, InternalError, and the `Stopped`/`Error` catch in `running()`. + +**Result:** None of them fired. No files written. No exit handler triggered. + +Verified strings are in the deployed binary: +```bash +$ strings ~/.local/share/boxlite/runtimes/v0.8.0-*/boxlite-shim | grep 'krun-debug' +[krun-debug] KVM_EXIT_HLT +[krun-debug] KVM_EXIT_SHUTDOWN +[krun-debug] KVM_SYSTEM_EVENT: event= +[krun-debug] vCPU STOPPED at iteration +... +``` + +**Conclusion:** The vCPU exits through `Interrupted` (EINTR) → channel disconnected, not through any KVM exit handler. Something external triggers the shutdown. + +### Step 6: Instrument i8042 device handler + +Found in `libkrun/src/devices/src/legacy/i8042.rs:229-236`: +```rust +OFS_STATUS if data[0] == CMD_RESET_CPU => { + // The guest wants to assert the CPU reset line. + if let Err(e) = self.reset_evt.write(1) { ... } +} +``` + +Added instrumentation: +```rust +let _ = std::fs::write("/tmp/krun-i8042-reset.log", + "i8042: CMD_RESET_CPU triggered by guest kernel\n"); +eprintln!("[krun-debug] i8042: CMD_RESET_CPU - guest requested reset!"); +``` + +**Result:** +``` +$ cat /tmp/krun-i8042-reset.log +i8042: CMD_RESET_CPU triggered by guest kernel + +$ grep krun-debug shim.stderr +[krun-debug] vCPU run loop iteration 1 # ×8 +[krun-debug] vCPU run loop iteration 2 +[krun-debug] i8042: CMD_RESET_CPU - guest requested reset! # ← HERE +[krun-debug] vCPU run loop iteration 3 +[krun-debug] vCPU run loop iteration 4 +[krun-debug] i8042: CMD_RESET_CPU - guest requested reset! # ← RETRY +[krun-debug] vCPU run loop iteration 5 +``` + +**Root cause confirmed:** The guest kernel triggers CMD_RESET_CPU via i8042 port 0x64 at iteration 2, retries at iteration 4. The i8042 handler writes to `reset_evt` EventFd → VMM event loop calls `_exit(0)`. + +### Step 7: Capture all I/O operations + +Added `eprintln` to IoIn, IoOut, MmioRead, MmioWrite handlers. Also modified `DEFAULT_KERNEL_CMDLINE` to remove `quiet` and add `earlyprintk=hvc0 panic=5 panic_print=15`. + +**Complete I/O trace of the failed boot:** +``` +vCPU run loop iteration 1 # ×8 (one per vCPU, likely HLT/interrupt) +IoIn port=0x64 len=1 # Read i8042 status register +IoOut port=0x64 data=[254] # Write 0xFE = CMD_RESET_CPU (first reset attempt) +i8042: CMD_RESET_CPU +IoIn port=0x64 len=1 # Read status again (retry) +IoOut port=0x64 data=[254] # Write 0xFE again (second reset attempt) +i8042: CMD_RESET_CPU +``` + +**Key observation:** The guest kernel performs ZERO other I/O operations — no serial port writes, no MMIO, no CMOS, no PIC/APIC, no PCI. It goes directly from initial execution to i8042 reset. This means the failure happens during **kernel decompression or very early startup** (before any hardware probing). + +The `earlyprintk=hvc0` and removal of `quiet` had no effect — console output remained empty because the crash occurs before the console driver is even initialized. + +### Step 8: Analysis of the failure point + +The kernel cmdline includes `reboot=k` which tells Linux to reboot via keyboard controller. Combined with `panic=-1` (immediate reboot on panic), the sequence is: +1. Guest kernel starts decompression / early init +2. Triple fault or early boot error (before any console output) +3. CPU reset → i8042 CMD_RESET_CPU (0xFE) on port 0x64 +4. VMM `_exit(0)` + +The triple fault likely occurs because kernel 6.1's nested KVM doesn't properly emulate a CPU feature that the guest kernel 6.12.62 requires during very early boot (decompressor or head_64.S code). + +### Further investigation needed + +To determine the exact CPU feature causing the triple fault: +1. Use `perf kvm stat` on the host to capture VM entry/exit reasons +2. Try an older libkrunfw guest kernel (e.g., 5.15 or 6.1) to see if it boots +3. Compare CPUID leaves between kernel 6.1 and 6.17 nested KVM using a test program + +## Why kernel 6.1 vs 6.17 + +KVM capabilities are identical between both kernels (verified via `KVM_CHECK_EXTENSION`). The difference is likely in: + +1. **CPUID emulation**: Kernel 6.1 may not expose certain CPUID leaves that the guest kernel 6.12.62 requires (e.g., newer Intel features like CET, WAITPKG, AMX) +2. **MSR handling**: Certain MSRs may not be properly emulated under nested KVM in 6.1 +3. **VMX feature bits**: The nested VMX VMCS may not advertise features the guest kernel expects + +The guest kernel hits an early boot failure (likely during CPU feature detection or APIC setup), determines it can't continue, and uses the i8042 reset as the fallback shutdown mechanism. + +## Recommendations + +### For libkrun/libkrunfw + +1. **Add early console output**: The guest kernel should print to the virtio console before reaching the point where it would trigger a reset. Adding `earlyprintk=hvc0` or similar to the kernel command line would capture the actual error. + +2. **Consider a more compatible guest kernel**: libkrunfw uses kernel 6.12.62 which may require features not available in kernel 6.1's nested KVM. Testing with an older guest kernel (e.g., 5.15 or 6.1) could work. + +3. **Don't silently _exit on i8042 reset**: Instead of calling `_exit(0)`, the VMM should log a clear error: "Guest kernel triggered hardware reset (i8042 CMD_RESET_CPU). The guest kernel may be incompatible with this KVM configuration." + +### For BoxLite users + +1. **Use Ubuntu 24.04** (kernel 6.17) instead of Amazon Linux 2023 for EC2 instances +2. **Or upgrade AL2023 kernel** to a newer version if available +3. **Or use bare-metal instances** (.metal) which don't have nested KVM limitations + +## Files involved + +| File | Role | +|------|------| +| `libkrun/src/devices/src/legacy/i8042.rs:229-236` | i8042 CMD_RESET_CPU handler triggers VM exit | +| `libkrun/src/vmm/src/lib.rs:403-428` | VMM event handler calls _exit(0) on reset_evt | +| `libkrun/src/vmm/src/linux/vstate.rs:1421-1590` | vCPU run_emulation() and running() state machine | +| `libkrun/src/libkrun/src/lib.rs:2684-2688` | VMM event loop in krun_start_enter | + +## Upstream Status + +No existing upstream issues or fixes found for running libkrun on a nested KVM host (our scenario: EC2 L0 → KVM L1 → libkrun L2). This appears to be an unreported configuration. + +Note: [libkrunfw#50](https://github.com/containers/libkrunfw/issues/50) is about a *different* thing — enabling nested KVM *inside* libkrun guest VMs, not about running libkrun on top of nested KVM. + +Relevant references: +- [libkrun#460](https://github.com/containers/libkrun/issues/460) — Silent reset with low memory (fixed in v1.17.0, similar symptom) +- [libkrun#314](https://github.com/containers/libkrun/issues/314) — ENOMEM on kernel 6.12/6.13 host (different issue) +- [libkrun#302](https://github.com/containers/libkrun/pull/302) — KVM SystemEvents support (aarch64 only) + +BoxLite works on Ubuntu 24.04 (kernel 6.17) on the same EC2 c8i hardware because the newer kernel provides better nested KVM emulation that satisfies the guest kernel 6.12.62's requirements. + +## Date + +Investigation conducted: 2026-04-02 From 5e2be08a521fb7bfe919fa2118f2ddde3068aa6a Mon Sep 17 00:00:00 2001 From: dorianzheng Date: Thu, 2 Apr 2026 23:12:26 +0800 Subject: [PATCH 2/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() --- .../al2023-ec2-c8i-vm-boot-failure.md | 41 ++- 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 ++++++++++++++++-- 7 files changed, 711 insertions(+), 64 deletions(-) diff --git a/docs/investigation/al2023-ec2-c8i-vm-boot-failure.md b/docs/investigation/al2023-ec2-c8i-vm-boot-failure.md index fb477551..6509a1d3 100644 --- a/docs/investigation/al2023-ec2-c8i-vm-boot-failure.md +++ b/docs/investigation/al2023-ec2-c8i-vm-boot-failure.md @@ -200,22 +200,43 @@ The kernel cmdline includes `reboot=k` which tells Linux to reboot via keyboard The triple fault likely occurs because kernel 6.1's nested KVM doesn't properly emulate a CPU feature that the guest kernel 6.12.62 requires during very early boot (decompressor or head_64.S code). -### Further investigation needed +### Step 9: CPUID comparison -To determine the exact CPU feature causing the triple fault: -1. Use `perf kvm stat` on the host to capture VM entry/exit reasons -2. Try an older libkrunfw guest kernel (e.g., 5.15 or 6.1) to see if it boots -3. Compare CPUID leaves between kernel 6.1 and 6.17 nested KVM using a test program +Dumped `KVM_GET_SUPPORTED_CPUID` on both kernels. Notable differences: + +| Feature | CPUID Leaf | Kernel 6.17 | Kernel 6.1 | +|---------|-----------|-------------|------------| +| HYPERVISOR | 1.ECX bit 31 | YES | NO | +| L1D_FLUSH | 7.0.EDX bit 28 | YES | NO | +| FZRM | 7.1.EAX bit 10 | YES | NO | +| FSRS | 7.1.EAX bit 11 | YES | NO | +| FSRC | 7.1.EAX bit 12 | YES | NO | +| AMX_FP16 | 7.1.EAX bit 21 | YES | NO | +| Max CPUID leaf | 0.EAX | 0x24 | 0x1f | + +**However:** These are `KVM_GET_SUPPORTED_CPUID` values (what the host advertises), NOT what the guest sees. libkrun transforms CPUID before passing to `KVM_SET_CPUID2`. Notably, libkrun explicitly sets the HYPERVISOR bit in `cpuid/src/transformer/common.rs:39`: + +```rust +entry.ecx.write_bit(ecx::HYPERVISOR_BITINDEX, true); +``` + +So the CPUID differences are **informational but not the direct root cause** — the guest always sees the HYPERVISOR bit regardless of host. The actual root cause is a deeper nested VMX incompatibility. ## Why kernel 6.1 vs 6.17 -KVM capabilities are identical between both kernels (verified via `KVM_CHECK_EXTENSION`). The difference is likely in: +KVM capabilities (`KVM_CHECK_EXTENSION`) are identical. CPU flags (vmx, ept, vpid, unrestricted_guest) are identical. The difference is in the **nested VMX implementation**: -1. **CPUID emulation**: Kernel 6.1 may not expose certain CPUID leaves that the guest kernel 6.12.62 requires (e.g., newer Intel features like CET, WAITPKG, AMX) -2. **MSR handling**: Certain MSRs may not be properly emulated under nested KVM in 6.1 -3. **VMX feature bits**: The nested VMX VMCS may not advertise features the guest kernel expects +1. **VMCS field handling**: Kernel 6.1's nested KVM may not properly emulate certain VMX control fields that the guest kernel's early boot code triggers +2. **MSR intercept handling**: Certain MSR accesses may cause VM exit failures in 6.1's L1-to-L2 translation +3. **CPUID passthrough to KVM_SET_CPUID2**: Kernel 6.1 may reject or mishandle certain CPUID entries that libkrun configures -The guest kernel hits an early boot failure (likely during CPU feature detection or APIC setup), determines it can't continue, and uses the i8042 reset as the fallback shutdown mechanism. +The exact nested VMX bug requires host-level KVM tracing to identify: +```bash +# On the AL2023 host: +sudo perf kvm stat record -a sleep 5 & +# (run boxlite in another terminal) +sudo perf kvm stat report +``` ## Recommendations 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}" + ); + } }