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:
parent
8da7575cf7
commit
90ad2cebae
5 changed files with 832 additions and 9 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -435,6 +435,7 @@ dependencies = [
|
|||
"bzip2",
|
||||
"clap",
|
||||
"flate2",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
|
|
|
|||
14
Cargo.toml
14
Cargo.toml
|
|
@ -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"]
|
||||
|
|
|
|||
68
src/image.rs
68
src/image.rs
|
|
@ -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
235
tests/e2e.rs
Normal 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
523
tests/fixtures/mod.rs
vendored
Normal 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,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue