1
0
Fork 0
mirror of https://github.com/imjasonh/oci2squashfs synced 2026-07-07 00:33:35 +00:00

fix(image): fix manifest.json handling

Also add a new E2E suite which exercises CLI convert/verify.

E2E tests can be run with:

 $ cargo test --features e2e --test e2e

This is separate since we depend on mksquashfs and other things for
those code paths.

Signed-off-by: Steven Noonan <steven@edera.dev>
This commit is contained in:
Steven Noonan 2026-03-09 11:11:41 -07:00
parent 8da7575cf7
commit 90ad2cebae
No known key found for this signature in database
GPG key ID: 408EEB508ED0CD4D
5 changed files with 832 additions and 9 deletions

1
Cargo.lock generated
View file

@ -435,6 +435,7 @@ dependencies = [
"bzip2",
"clap",
"flate2",
"libc",
"serde",
"serde_json",
"sha2",

View file

@ -7,6 +7,14 @@ homepage = "https://github.com/edera-dev/oci2squashfs"
repository = "https://github.com/edera-dev/oci2squashfs"
edition = "2024"
[features]
# Enables end-to-end tests that invoke the compiled CLI binary, mksquashfs,
# squashfuse, and umoci. These are never compiled into the library or CLI
# themselves — the feature only gates code inside [[test]] targets.
#
# Usage: cargo test --features e2e --test e2e
e2e = []
[dependencies]
tokio = { version = "1", features = ["full"] }
tar = "0.4"
@ -23,6 +31,7 @@ clap = { version = "4", features = ["derive"] }
[dev-dependencies]
tokio = { version = "1", features = ["full"] }
libc = "0.2"
[lib]
name = "oci2squashfs"
@ -30,3 +39,8 @@ name = "oci2squashfs"
[[bin]]
name = "oci2squashfs_cli"
path = "bin/main.rs"
[[test]]
name = "e2e"
path = "tests/e2e.rs"
required-features = ["e2e"]

View file

@ -2,6 +2,8 @@
use anyhow::{bail, Context, Result};
use serde::Deserialize;
use std::collections::HashMap;
use std::io::Read;
use std::path::{Path, PathBuf};
#[derive(Debug, Deserialize)]
@ -29,6 +31,22 @@ pub struct LayerBlob {
pub index: usize,
}
/// Detect compression format by magic bytes. Used as a fallback when the
/// manifest does not carry a mediaType for a layer (e.g. minimal Docker save
/// layouts that omit LayerSources).
pub fn detect_media_type(path: &Path) -> Result<&'static str> {
let mut f = std::fs::File::open(path)?;
let mut magic = [0u8; 4];
f.read_exact(&mut magic)?;
Ok(match magic {
[0x1f, 0x8b, ..] => "application/vnd.oci.image.layer.v1.tar+gzip",
[0x28, 0xb5, 0x2f, 0xfd] => "application/vnd.oci.image.layer.v1.tar+zstd",
[0x42, 0x5a, 0x68, ..] => "application/vnd.oci.image.layer.v1.tar+bzip2",
[0xfd, 0x37, 0x7a, 0x58] => "application/vnd.oci.image.layer.v1.tar+xz",
_ => "application/vnd.oci.image.layer.v1.tar", // uncompressed
})
}
/// Load the OCI manifest from index.json → manifest blob.
pub fn load_manifest(image_dir: &Path) -> Result<OciManifest> {
// Try index.json first (OCI layout), fall back to manifest.json (Docker save).
@ -54,11 +72,22 @@ pub fn load_manifest(image_dir: &Path) -> Result<OciManifest> {
// Docker save manifest.json
let manifest_path = image_dir.join("manifest.json");
if manifest_path.exists() {
#[derive(Deserialize)]
struct LayerSource {
#[serde(rename = "mediaType")]
media_type: String,
}
#[derive(Deserialize)]
struct DockerManifest {
#[serde(rename = "Layers")]
layers: Vec<String>,
/// Present in Docker save layouts produced by newer Docker versions
/// and by skopeo. Maps digest ("sha256:<hex>") → layer descriptor.
#[serde(rename = "LayerSources", default)]
layer_sources: HashMap<String, LayerSource>,
}
let data = std::fs::read_to_string(&manifest_path).context("reading manifest.json")?;
let manifests: Vec<DockerManifest> =
serde_json::from_str(&data).context("parsing manifest.json")?;
@ -66,14 +95,30 @@ pub fn load_manifest(image_dir: &Path) -> Result<OciManifest> {
.into_iter()
.next()
.context("manifest.json is empty")?;
let layers = dm
.layers
.into_iter()
.map(|l| OciDescriptor {
digest: l,
media_type: "application/vnd.docker.image.rootfs.diff.tar.gzip".into(),
.map(|l| {
// Layer paths look like "blobs/sha256/<hex>".
// LayerSources keys look like "sha256:<hex>".
// Reconstruct the digest key from the path's final component.
let digest = l
.rsplit('/')
.next()
.map(|hex| format!("sha256:{hex}"))
.unwrap_or_default();
let media_type = dm
.layer_sources
.get(&digest)
.map(|s| s.media_type.clone())
// No LayerSources entry — will be resolved by magic bytes
// in resolve_layers.
.unwrap_or_default();
OciDescriptor { digest: l, media_type }
})
.collect();
return Ok(OciManifest { layers });
}
@ -95,17 +140,22 @@ pub fn resolve_layers(image_dir: &Path, manifest: &OciManifest) -> Result<Vec<La
let hex = strip_digest_prefix(&desc.digest)?;
image_dir.join("blobs").join("sha256").join(hex)
} else {
// Docker save: relative path like "abc123.../layer.tar"
// Docker save: relative path like "blobs/sha256/<hex>"
image_dir.join(&desc.digest)
};
if !path.exists() {
bail!("layer blob not found: {}", path.display());
}
Ok(LayerBlob {
path,
media_type: desc.media_type.clone(),
index: i,
})
// If the manifest didn't carry a mediaType (e.g. a Docker save
// layout without LayerSources), fall back to magic byte detection.
let media_type = if desc.media_type.is_empty() {
detect_media_type(&path)
.with_context(|| format!("detecting media type for {}", path.display()))?
.to_string()
} else {
desc.media_type.clone()
};
Ok(LayerBlob { path, media_type, index: i })
})
.collect()
}

235
tests/e2e.rs Normal file
View file

@ -0,0 +1,235 @@
//! End-to-end tests for the oci2squashfs CLI.
//!
//! These tests require the following binaries to be present on PATH:
//! - oci2squashfs_cli (built by `cargo test --features e2e`)
//! - mksquashfs (squashfs-tools, >= 4.6)
//! - squashfuse
//! - fusermount or umount
//! - umoci
//!
//! Run with:
//! cargo test --features e2e --test e2e
// ── modules ──────────────────────────────────────────────────────────────────
mod fixtures;
use fixtures::generate_fixtures;
use std::{
path::{Path, PathBuf},
process::Command,
sync::OnceLock,
};
use tempfile::TempDir;
// ── required-binary checking ─────────────────────────────────────────────────
/// Names and a short description of every external binary we depend on.
const REQUIRED_BINARIES: &[(&str, &str)] = &[
("mksquashfs", "squashfs-tools >= 4.6"),
("squashfuse", "squashfuse"),
("umoci", "umoci (OCI image unpacker)"),
];
/// Returns the path to the compiled `oci2squashfs_cli` binary produced by
/// the current `cargo test` invocation. Panics if it cannot be located.
fn cli_bin() -> PathBuf {
// CARGO_BIN_EXE_oci2squashfs_cli is set by Cargo for [[bin]] targets when
// running tests.
let var = env!("CARGO_BIN_EXE_oci2squashfs_cli");
PathBuf::from(var)
}
/// Verify that every required external binary is present on PATH (or absolute).
/// Called once at test startup; panics with a clear message if anything is missing.
fn require_binaries() {
// Check the CLI binary first — it must exist as a file.
let cli = cli_bin();
assert!(
cli.exists(),
"oci2squashfs_cli binary not found at {cli:?}; \
ensure you ran `cargo test --features e2e`"
);
let mut missing = Vec::new();
for (name, description) in REQUIRED_BINARIES {
if which(name).is_none() {
missing.push(format!(" {name} ({description})"));
}
}
// Check for at least one unmount command.
let has_unmount = which("fusermount").is_some() || which("umount").is_some();
if !has_unmount {
missing.push(" fusermount or umount (FUSE unmounting)".into());
}
if !missing.is_empty() {
panic!(
"E2E tests require the following binaries which were not found on PATH:\n{}\n\
Install them and re-run `cargo test --features e2e --test e2e`.",
missing.join("\n")
);
}
}
/// Returns the absolute path of `name` on PATH, or `None`.
fn which(name: &str) -> Option<PathBuf> {
// `which` crate is not a dependency; replicate the essential logic.
std::env::var_os("PATH")
.map(|path_var| {
std::env::split_paths(&path_var)
.map(|dir| dir.join(name))
.find(|p| p.is_file())
})
.flatten()
}
// ── shared fixture state ──────────────────────────────────────────────────────
/// Holds the temp directory (keeping it alive) and the generated image paths.
struct Fixtures {
_dir: TempDir,
oci_layout: PathBuf,
docker_save: PathBuf,
docker_save_both: PathBuf,
}
/// Generate fixtures exactly once for the whole test run.
static FIXTURES: OnceLock<Fixtures> = OnceLock::new();
fn get_fixtures() -> &'static Fixtures {
FIXTURES.get_or_init(|| {
require_binaries();
let dir = TempDir::new().expect("creating fixture temp dir");
let images = generate_fixtures(dir.path()).expect("generating OCI fixtures");
Fixtures {
oci_layout: images.oci_layout,
docker_save: images.docker_save,
docker_save_both: images.docker_save_both,
_dir: dir,
}
})
}
// ── helpers ───────────────────────────────────────────────────────────────────
/// Run `oci2squashfs_cli convert` and return the path to the output file.
fn convert(image_dir: &Path, out_dir: &Path, name: &str) -> PathBuf {
let output = out_dir.join(format!("{name}.squashfs"));
let status = Command::new(cli_bin())
.args([
"convert",
"--image",
image_dir.to_str().unwrap(),
"--output",
output.to_str().unwrap(),
])
.status()
.expect("spawning oci2squashfs_cli convert");
assert!(
status.success(),
"oci2squashfs_cli convert failed for {name} (image: {})",
image_dir.display()
);
output
}
/// Run `umoci unpack --rootless` and return the path to the unpacked bundle root.
/// The bundle directory is created inside `out_dir`.
fn umoci_unpack(oci_dir: &Path, reference_tag: &str, out_dir: &Path, name: &str) -> PathBuf {
let bundle = out_dir.join(format!("{name}-bundle"));
let status = Command::new("umoci")
.args([
"unpack",
"--rootless",
"--image",
&format!("{}:{}", oci_dir.display(), reference_tag),
bundle.to_str().unwrap(),
])
.status()
.expect("spawning umoci unpack");
assert!(
status.success(),
"umoci unpack failed for {name} (image: {}:{})",
oci_dir.display(),
reference_tag
);
// umoci produces an OCI bundle; the rootfs lives at <bundle>/rootfs.
bundle.join("rootfs")
}
/// Run `oci2squashfs_cli verify` and assert it exits successfully (no differences).
fn verify_clean(squashfs: &Path, reference: &Path, label: &str) {
let status = Command::new(cli_bin())
.args([
"verify",
"--squashfs",
squashfs.to_str().unwrap(),
"--reference",
reference.to_str().unwrap(),
])
.status()
.expect("spawning oci2squashfs_cli verify");
assert!(
status.success(),
"verify reported differences for {label}\n squashfs: {}\n reference: {}",
squashfs.display(),
reference.display()
);
}
// ── tests ─────────────────────────────────────────────────────────────────────
/// Convert the OCI-layout (index.json) image and verify it against umoci output.
#[test]
fn e2e_oci_layout_convert_and_verify() {
let fx = get_fixtures();
let work = TempDir::new().unwrap();
let squashfs = convert(&fx.oci_layout, work.path(), "oci-layout");
let reference = umoci_unpack(&fx.oci_layout, "latest", work.path(), "oci-layout");
verify_clean(&squashfs, &reference, "oci-layout");
}
/// Convert the Docker-save (manifest.json only) image and verify it.
#[test]
fn e2e_docker_save_convert_and_verify() {
let fx = get_fixtures();
let work = TempDir::new().unwrap();
let squashfs = convert(&fx.docker_save, work.path(), "docker-save");
// Docker save images have no OCI tag; use the OCI-layout image as the
// reference since they share the same layer blobs and logical content.
let reference = umoci_unpack(&fx.oci_layout, "latest", work.path(), "docker-save-ref");
verify_clean(&squashfs, &reference, "docker-save");
}
/// When both index.json and manifest.json are present, index.json takes
/// precedence. The output must match the OCI-layout reference.
#[test]
fn e2e_both_metadata_files_prefers_index_json() {
let fx = get_fixtures();
let work = TempDir::new().unwrap();
let squashfs = convert(&fx.docker_save_both, work.path(), "both");
let reference = umoci_unpack(&fx.oci_layout, "latest", work.path(), "both-ref");
verify_clean(&squashfs, &reference, "docker-save-both (index.json preferred)");
}
/// Explicitly verify the overlay semantics that the fixture layers exercise:
/// whiteouts, opaque whiteouts, and hard links all behave correctly in the
/// squashfs output compared to the umoci reference.
#[test]
fn e2e_overlay_semantics_verified() {
let fx = get_fixtures();
let work = TempDir::new().unwrap();
// This is the same convert+verify as the oci-layout test, but we keep it
// as a distinct test with a distinct label so failures here are
// unambiguously about overlay correctness rather than metadata parsing.
let squashfs = convert(&fx.oci_layout, work.path(), "overlay-semantics");
let reference = umoci_unpack(&fx.oci_layout, "latest", work.path(), "overlay-semantics-ref");
verify_clean(&squashfs, &reference, "overlay semantics");
}

523
tests/fixtures/mod.rs vendored Normal file
View file

@ -0,0 +1,523 @@
//! Fixture generation for e2e tests.
//!
//! Produces three OCI image layout directories that share the same layer blobs
//! but differ in their metadata format:
//!
//! oci-layout/ — index.json only (OCI image layout)
//! docker-save/ — manifest.json only (Docker save format)
//! docker-save-both/ — both files present (index.json takes precedence)
//!
//! The layers collectively exercise:
//! - All supported compression formats (uncompressed, gzip, zstd, bzip2, xz)
//! - File creation, overwrite (newer layer wins)
//! - Simple whiteout (.wh.<name>)
//! - Opaque whiteout (.wh..wh..opq) with directory repopulation
//! - Hard links within and across layers
//! - Long paths and symlink targets (> 100 bytes, requiring PAX headers)
//!
//! All tar entries use the current process uid/gid so that a non-root
//! `umoci unpack --rootless` and squashfuse mount produce matching ownership
//! on both sides of the verify comparison.
use anyhow::{Context, Result};
use sha2::{Digest, Sha256};
use std::{
io::{Cursor, Write}, // Cursor used in TarBuilder; Write used in compress_gzip
path::{Path, PathBuf},
};
use tar::{Builder, EntryType, Header};
// ── public types ──────────────────────────────────────────────────────────────
/// Paths to the three generated image directories.
pub struct FixtureImage {
pub oci_layout: PathBuf,
pub docker_save: PathBuf,
pub docker_save_both: PathBuf,
}
/// Generate all fixture images under `base_dir` and return their paths.
pub fn generate_fixtures(base_dir: &Path) -> Result<FixtureImage> {
let (uid, gid) = current_uid_gid();
// Build the five layers (raw uncompressed tar bytes first, then compress).
let layer0_tar = layer0_base(uid, gid);
let layer1_tar = layer1_overwrite(uid, gid);
let layer2_tar = layer2_whiteouts(uid, gid);
let layer3_tar = layer3_hardlinks(uid, gid);
let layer4_tar = layer4_long_paths(uid, gid);
// Compress each layer with a different algorithm.
let layer0_bytes = compress_uncompressed(&layer0_tar);
let layer1_bytes = compress_gzip(&layer1_tar)?;
let layer2_bytes = compress_gzip(&layer2_tar)?;
let layer3_bytes = compress_gzip(&layer3_tar)?;
let layer4_bytes = compress_gzip(&layer4_tar)?;
// Compute SHA-256 digests.
// umoci only reliably supports uncompressed, gzip, and xz layers.
// zstd and bzip2 are covered by the unit/integration tests instead.
// (uncompressed tar, compressed bytes, media_type)
let layers: Vec<(&[u8], Vec<u8>, &'static str)> = vec![
(&layer0_tar, layer0_bytes, "application/vnd.oci.image.layer.v1.tar"),
(&layer1_tar, layer1_bytes, "application/vnd.oci.image.layer.v1.tar+gzip"),
(&layer2_tar, layer2_bytes, "application/vnd.oci.image.layer.v1.tar+gzip"),
(&layer3_tar, layer3_bytes, "application/vnd.oci.image.layer.v1.tar+gzip"),
(&layer4_tar, layer4_bytes, "application/vnd.oci.image.layer.v1.tar+gzip"),
];
let digested: Vec<DigestedBlob> = layers
.into_iter()
.map(|(uncompressed, data, media_type)| DigestedBlob {
diff_id: sha256_hex(uncompressed), // digest of uncompressed tar
digest: sha256_hex(&data), // digest of compressed blob
size: data.len() as u64,
data,
media_type,
})
.collect();
// Write three image layouts.
let oci_layout = write_oci_layout(base_dir, &digested)?;
let docker_save = write_docker_save(base_dir, &digested)?;
let docker_save_both = write_docker_save_both(base_dir, &digested)?;
Ok(FixtureImage { oci_layout, docker_save, docker_save_both })
}
// ── uid / gid ─────────────────────────────────────────────────────────────────
fn current_uid_gid() -> (u32, u32) {
// SAFETY: getuid/getgid are always safe to call.
unsafe { (libc::getuid(), libc::getgid()) }
}
// ── layer builders ────────────────────────────────────────────────────────────
/// Layer 0 (uncompressed): base directory tree.
///
/// Creates:
/// data/hello.txt — regular file
/// data/overwrite-me.txt — will be overwritten by layer 1
/// data/whiteout-me.txt — will be whited out by layer 2
/// opaque-dir/ — will receive an opaque whiteout in layer 2
/// opaque-dir/old.txt
fn layer0_base(uid: u32, gid: u32) -> Vec<u8> {
let mut b = TarBuilder::new(uid, gid);
b.add_dir("data");
b.add_file("data/hello.txt", b"hello from layer 0\n", 0o644);
b.add_file("data/overwrite-me.txt", b"original content\n", 0o644);
b.add_file("data/whiteout-me.txt", b"will be deleted\n", 0o644);
b.add_dir("opaque-dir");
b.add_file("opaque-dir/old.txt", b"old content\n", 0o644);
b.finish()
}
/// Layer 1 (gzip): overwrite a file from layer 0.
///
/// Creates:
/// data/overwrite-me.txt — newer version wins
/// data/layer1.txt — new file
fn layer1_overwrite(uid: u32, gid: u32) -> Vec<u8> {
let mut b = TarBuilder::new(uid, gid);
b.add_dir("data");
b.add_file("data/overwrite-me.txt", b"overwritten by layer 1\n", 0o644);
b.add_file("data/layer1.txt", b"added in layer 1\n", 0o644);
b.finish()
}
/// Layer 2 (zstd): whiteouts and opaque whiteout with repopulation.
///
/// Whiteouts:
/// data/whiteout-me.txt — simple whiteout
/// opaque-dir/ — opaque whiteout (clears old.txt)
/// opaque-dir/new.txt — repopulated after opaque whiteout
fn layer2_whiteouts(uid: u32, gid: u32) -> Vec<u8> {
let mut b = TarBuilder::new(uid, gid);
// Simple whiteout.
b.add_file("data/.wh.whiteout-me.txt", b"", 0o644);
// Opaque whiteout then repopulate.
b.add_dir("opaque-dir");
b.add_file("opaque-dir/.wh..wh..opq", b"", 0o644);
b.add_file("opaque-dir/new.txt", b"repopulated\n", 0o644);
b.finish()
}
/// Layer 3 (bzip2): hard links.
///
/// Creates:
/// hardlinks/source.txt — regular file
/// hardlinks/link.txt — hard link to hardlinks/source.txt (same layer)
/// data/cross-link.txt — hard link to data/hello.txt (cross-layer target)
fn layer3_hardlinks(uid: u32, gid: u32) -> Vec<u8> {
let mut b = TarBuilder::new(uid, gid);
b.add_dir("hardlinks");
b.add_file("hardlinks/source.txt", b"hardlink source\n", 0o644);
b.add_hardlink("hardlinks/link.txt", "hardlinks/source.txt");
// Cross-layer hard link: target (data/hello.txt) lives in layer 0.
b.add_hardlink("data/cross-link.txt", "data/hello.txt");
b.finish()
}
/// Layer 4 (xz): long paths and symlinks requiring PAX headers.
///
/// Creates:
/// <101-char path>/file.txt — file whose directory path exceeds 100 bytes
/// long-symlink — symlink with a > 100 byte target
fn layer4_long_paths(uid: u32, gid: u32) -> Vec<u8> {
// 95 'a' chars + "/file.txt" = 104 chars total for the file path.
let long_dir: String = "a".repeat(95);
let long_file = format!("{long_dir}/file.txt");
// 101-char symlink target.
let long_target: String = "b".repeat(101);
let mut b = TarBuilder::new(uid, gid);
b.add_dir(&long_dir);
b.add_file(&long_file, b"long path file\n", 0o644);
b.add_symlink("long-symlink", &long_target);
b.finish()
}
// ── compression ───────────────────────────────────────────────────────────────
fn compress_uncompressed(data: &[u8]) -> Vec<u8> {
data.to_vec()
}
fn compress_gzip(data: &[u8]) -> Result<Vec<u8>> {
use flate2::{write::GzEncoder, Compression};
let mut enc = GzEncoder::new(Vec::new(), Compression::default());
enc.write_all(data)?;
Ok(enc.finish()?)
}
// ── digest ────────────────────────────────────────────────────────────────────
fn sha256_hex(data: &[u8]) -> String {
let mut h = Sha256::new();
h.update(data);
format!("{:x}", h.finalize())
}
struct DigestedBlob {
digest: String, // hex of compressed bytes, no "sha256:" prefix
diff_id: String, // hex of uncompressed tar bytes (OCI diff_id)
size: u64,
data: Vec<u8>,
media_type: &'static str,
}
impl DigestedBlob {
fn digest_with_prefix(&self) -> String {
format!("sha256:{}", self.digest)
}
}
// ── OCI image layout writer ───────────────────────────────────────────────────
/// Write an OCI image layout (index.json only).
fn write_oci_layout(base: &Path, layers: &[DigestedBlob]) -> Result<PathBuf> {
let dir = base.join("oci-layout");
let blobs_sha256 = dir.join("blobs").join("sha256");
std::fs::create_dir_all(&blobs_sha256)?;
// Write layer blobs.
for layer in layers {
std::fs::write(blobs_sha256.join(&layer.digest), &layer.data)
.with_context(|| format!("writing layer blob {}", layer.digest))?;
}
// Config blob: diff_ids must be one sha256:<hex> per layer, over the
// *uncompressed* tar. umoci reads this to locate and verify layers.
let config_json = make_config_json(layers);
let config_digest = sha256_hex(config_json.as_bytes());
std::fs::write(blobs_sha256.join(&config_digest), &config_json)?;
// Build manifest.
let manifest_layers: Vec<serde_json::Value> = layers
.iter()
.map(|l| {
serde_json::json!({
"mediaType": l.media_type,
"digest": l.digest_with_prefix(),
"size": l.size,
})
})
.collect();
let manifest = serde_json::json!({
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"digest": format!("sha256:{config_digest}"),
"size": config_json.len(),
},
"layers": manifest_layers,
});
let manifest_bytes = serde_json::to_vec(&manifest)?;
let manifest_digest = sha256_hex(&manifest_bytes);
std::fs::write(blobs_sha256.join(&manifest_digest), &manifest_bytes)?;
// Write index.json.
let index = serde_json::json!({
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": [{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": format!("sha256:{manifest_digest}"),
"size": manifest_bytes.len(),
"annotations": {
"org.opencontainers.image.ref.name": "latest"
}
}]
});
std::fs::write(dir.join("index.json"), serde_json::to_vec(&index)?)?;
// Write oci-layout marker (required by spec).
std::fs::write(
dir.join("oci-layout"),
r#"{"imageLayoutVersion":"1.0.0"}"#,
)?;
Ok(dir)
}
/// Write a Docker save layout (manifest.json only, no index.json).
fn write_docker_save(base: &Path, layers: &[DigestedBlob]) -> Result<PathBuf> {
let dir = base.join("docker-save");
let blobs_sha256 = dir.join("blobs").join("sha256");
std::fs::create_dir_all(&blobs_sha256)?;
for layer in layers {
std::fs::write(blobs_sha256.join(&layer.digest), &layer.data)?;
}
let config_json = make_config_json(layers);
let config_digest = sha256_hex(config_json.as_bytes());
std::fs::write(blobs_sha256.join(&config_digest), &config_json)?;
write_docker_manifest_json(&dir, layers, &config_digest)?;
Ok(dir)
}
/// Write a layout with *both* index.json and manifest.json present.
/// The layer blobs and metadata are identical to the OCI layout so that
/// verify passes regardless of which file is parsed.
fn write_docker_save_both(base: &Path, layers: &[DigestedBlob]) -> Result<PathBuf> {
// Start from a full OCI layout, then add manifest.json alongside.
let oci_dir = base.join("docker-save-both");
let blobs_sha256 = oci_dir.join("blobs").join("sha256");
std::fs::create_dir_all(&blobs_sha256)?;
for layer in layers {
std::fs::write(blobs_sha256.join(&layer.digest), &layer.data)?;
}
let config_json = make_config_json(layers);
let config_digest = sha256_hex(config_json.as_bytes());
std::fs::write(blobs_sha256.join(&config_digest), &config_json)?;
// OCI manifest + index.json.
let manifest_layers: Vec<serde_json::Value> = layers
.iter()
.map(|l| serde_json::json!({
"mediaType": l.media_type,
"digest": l.digest_with_prefix(),
"size": l.size,
}))
.collect();
let manifest = serde_json::json!({
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"digest": format!("sha256:{config_digest}"),
"size": config_json.len(),
},
"layers": manifest_layers,
});
let manifest_bytes = serde_json::to_vec(&manifest)?;
let manifest_digest = sha256_hex(&manifest_bytes);
std::fs::write(blobs_sha256.join(&manifest_digest), &manifest_bytes)?;
let index = serde_json::json!({
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": [{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": format!("sha256:{manifest_digest}"),
"size": manifest_bytes.len(),
"annotations": {
"org.opencontainers.image.ref.name": "latest"
}
}]
});
std::fs::write(oci_dir.join("index.json"), serde_json::to_vec(&index)?)?;
std::fs::write(oci_dir.join("oci-layout"), r#"{"imageLayoutVersion":"1.0.0"}"#)?;
// Also write manifest.json — our code should ignore it when index.json exists.
write_docker_manifest_json(&oci_dir, layers, &config_digest)?;
Ok(oci_dir)
}
/// Build a minimal but valid OCI image config JSON with correct diff_ids.
/// umoci validates that diff_ids match the uncompressed layer digests, so
/// this must be accurate or `umoci unpack` will panic/error.
fn make_config_json(layers: &[DigestedBlob]) -> String {
let diff_ids: Vec<String> = layers
.iter()
.map(|l| format!("sha256:{}", l.diff_id))
.collect();
serde_json::json!({
"architecture": "amd64",
"os": "linux",
"rootfs": {
"type": "layers",
"diff_ids": diff_ids,
},
"config": {},
"history": [],
})
.to_string()
}
/// Emit a Docker-save `manifest.json` into `dir`.
fn write_docker_manifest_json(dir: &Path, layers: &[DigestedBlob], config_digest: &str) -> Result<()> {
let layer_paths: Vec<String> = layers
.iter()
.map(|l| format!("blobs/sha256/{}", l.digest))
.collect();
let layer_sources: serde_json::Map<String, serde_json::Value> = layers
.iter()
.map(|l| {
let key = l.digest_with_prefix();
let val = serde_json::json!({
"mediaType": l.media_type,
"size": l.size,
"digest": key,
});
(key, val)
})
.collect();
let manifest_json = serde_json::json!([{
"Config": format!("blobs/sha256/{config_digest}"),
"RepoTags": ["oci2squashfs-fixture:latest"],
"Layers": layer_paths,
"LayerSources": layer_sources,
}]);
std::fs::write(dir.join("manifest.json"), serde_json::to_vec(&manifest_json)?)
.context("writing manifest.json")?;
Ok(())
}
// ── TarBuilder helper ─────────────────────────────────────────────────────────
/// A thin wrapper around `tar::Builder` that stamps every entry with the
/// given uid/gid and handles PAX extensions for long paths automatically.
struct TarBuilder {
inner: Builder<Vec<u8>>,
uid: u32,
gid: u32,
}
impl TarBuilder {
fn new(uid: u32, gid: u32) -> Self {
let mut b = Builder::new(Vec::new());
b.mode(tar::HeaderMode::Complete);
Self { inner: b, uid, gid }
}
fn base_header(&self, path: &str, size: u64, mode: u32, entry_type: EntryType) -> Header {
let mut h = Header::new_ustar();
h.set_entry_type(entry_type);
h.set_size(size);
h.set_mode(mode);
h.set_mtime(0);
h.set_uid(self.uid as u64);
h.set_gid(self.gid as u64);
h.set_username("").ok();
h.set_groupname("").ok();
// Best-effort short path; PAX extension carries the full value if needed.
h.set_path(truncate(path, 99)).ok();
h.set_cksum();
h
}
fn pax_if_long<'a>(path: &'a str, link: Option<&'a str>) -> Vec<(&'static str, &'a [u8])> {
let mut pax = Vec::new();
if path.len() > 99 {
pax.push(("path", path.as_bytes()));
}
if let Some(l) = link {
if l.len() > 99 {
pax.push(("linkpath", l.as_bytes()));
}
}
pax
}
fn emit_pax(&mut self, pax: Vec<(&'static str, &[u8])>) {
if !pax.is_empty() {
self.inner.append_pax_extensions(pax).unwrap();
}
}
pub fn add_dir(&mut self, path: &str) {
let pax = Self::pax_if_long(path, None);
self.emit_pax(pax);
let mut h = self.base_header(path, 0, 0o755, EntryType::Directory);
// Directories must end with '/' in USTAR.
let dir_path = if path.ends_with('/') {
path.to_string()
} else {
format!("{path}/")
};
h.set_path(truncate(&dir_path, 99)).ok();
h.set_cksum();
self.inner.append(&h, Cursor::new(b"" as &[u8])).unwrap();
}
pub fn add_file(&mut self, path: &str, data: &[u8], mode: u32) {
let pax = Self::pax_if_long(path, None);
self.emit_pax(pax);
let mut h = self.base_header(path, data.len() as u64, mode, EntryType::Regular);
h.set_cksum();
self.inner.append(&h, Cursor::new(data)).unwrap();
}
pub fn add_symlink(&mut self, path: &str, target: &str) {
let pax = Self::pax_if_long(path, Some(target));
self.emit_pax(pax);
let mut h = self.base_header(path, 0, 0o777, EntryType::Symlink);
h.set_link_name(truncate(target, 99)).ok();
h.set_cksum();
self.inner.append(&h, Cursor::new(b"" as &[u8])).unwrap();
}
pub fn add_hardlink(&mut self, path: &str, target: &str) {
let pax = Self::pax_if_long(path, Some(target));
self.emit_pax(pax);
let mut h = self.base_header(path, 0, 0o644, EntryType::Link);
h.set_link_name(truncate(target, 99)).ok();
h.set_cksum();
self.inner.append(&h, Cursor::new(b"" as &[u8])).unwrap();
}
pub fn finish(mut self) -> Vec<u8> {
self.inner.finish().unwrap();
self.inner.into_inner().unwrap()
}
}
fn truncate(s: &str, max: usize) -> &str {
match s.char_indices().nth(max) {
Some((i, _)) => &s[..i],
None => s,
}
}