From e5787dc0faea53dc6db7a9a2f2be92d2f9a07d6a Mon Sep 17 00:00:00 2001 From: Luca Stocchi Date: Thu, 22 Oct 2026 12:29:39 +0200 Subject: [PATCH] test: add end-to-end image checks and CI coverage Add CLI integration tests that build real ext4 images and inspect them with e2fsck and debugfs. Cover host imports, directory indexes, extent trees, ownership, Unix metadata and symlinks, and invalid inputs. Add ignored OCI tests for registry and Docker daemon sources. Add GitHub Actions jobs for formatting and clippy, unit and CLI tests on Linux and Windows, and OCI tests on Linux. Document the test commands and coverage, and add tempfile for test fixtures. Note the known debugfs htree checksum diagnostic in AGENTS.md. Local checks: cargo fmt --check and cargo clippy --all-targets pass; 67 unit tests and 9 CLI tests pass. The Docker Hub --from test passed. The Docker engine test and GitHub Actions workflow have not run yet. Assisted-by: Codex: GPT-5.6 Sol Signed-off-by: Luca Stocchi --- .github/workflows/ci.yml | 70 ++++++++++ AGENTS.md | 27 +++- Cargo.lock | 1 + Cargo.toml | 3 + README.md | 13 ++ src/host/tree.rs | 9 +- src/xattr.rs | 2 +- tests/cli.rs | 271 +++++++++++++++++++++++++++++++++++++++ tests/common/mod.rs | 250 ++++++++++++++++++++++++++++++++++++ tests/oci.rs | 60 +++++++++ 10 files changed, 695 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/cli.rs create mode 100644 tests/common/mod.rs create mode 100644 tests/oci.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..43d57e5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,70 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -D warnings + +jobs: + lint: + name: fmt and clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo fmt --check + - run: cargo clippy --all-targets + + test: + name: test (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + # Windows is the primary target; Linux also runs every image through + # e2fsck, which the Windows runners lack. + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Install e2fsprogs + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y e2fsprogs + echo "EXT4KIT_REQUIRE_E2FSPROGS=1" >> "$GITHUB_ENV" + - name: Unit tests + run: cargo test --lib --bins + - name: Integration tests + run: cargo test --test cli + + oci: + name: OCI integration tests + runs-on: ubuntu-latest + env: + EXT4KIT_REQUIRE_E2FSPROGS: "1" + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Install e2fsprogs + run: | + sudo apt-get update + sudo apt-get install -y e2fsprogs + - name: Pull the base image into the Docker engine + run: docker pull docker.io/library/alpine:3.20 + - name: OCI tests (registry and docker-daemon) + run: cargo test --test oci -- --ignored diff --git a/AGENTS.md b/AGENTS.md index b0a741c..51ca70d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,10 @@ on-disk change has to be verified with e2fsprogs (see [Verifying images](#verify ```sh cargo build -cargo test # ~70 unit tests, run offline in well under a second +cargo test --lib --bins # unit tests, offline, well under a second +cargo test --test cli # end-to-end: builds images and runs e2fsck (~15 s) +cargo test # both of the above +cargo test --test oci -- --ignored # --from tests; need network and Docker cargo test # a single test cargo fmt # CI-style check: cargo fmt --check cargo clippy --all-targets # must report zero warnings @@ -87,17 +90,26 @@ streaming import design. ## Tests -- Unit tests live in `#[cfg(test)] mod tests` beside the code. There is no - `tests/` directory. +- Unit tests live in `#[cfg(test)] mod tests` beside the code. +- End-to-end tests live in `tests/`. `cli.rs` runs the real binary on + generated host trees and checks every image with `e2fsck -fn` and + `debugfs`, using the helpers in `tests/common/mod.rs` (`create`, then + `Image::check` and `cat`/`stat`/`owner`/`list`). e2fsprogs is found + natively or through WSL, like the verify script. When it is missing the + checks are skipped with a note; CI sets `EXT4KIT_REQUIRE_E2FSPROGS=1` so + they fail instead. `oci.rs` holds `#[ignore]`d `--from` tests that need + Docker Hub or a Docker engine. - Build small images in memory with `Ext4ImageBuilder::new(16)`, then call `create_root_dir()`, `create_lost_and_found()` and `reserve_inodes()` (see `fresh_builder()` in `core/directory.rs`). Assert by reading back through the builder (`lookup_dir_entry`, `inode`, `directory_entries`, `get_dir_blocks`) and by comparing free counts before and after. - OCI tests build synthetic tar/gzip layers in memory (`layer()` helper in - `oci/mod.rs`). Tests must not need the network, Docker or Podman. -- Unit tests can't catch a structurally wrong image. For on-disk changes you - also need an end-to-end check with `e2fsck`, described next. + `oci/mod.rs`). Tests that are not `#[ignore]`d must not need the network, + Docker or Podman. +- Unit tests can't catch a structurally wrong image. For a new on-disk feature, + add a case to `tests/cli.rs` whose fixture exercises it, and use the + verify script described next for ad-hoc checks. ## Verifying images @@ -129,6 +141,9 @@ trees. The `/verify-image` skill in `.claude/skills/` walks through this. Windows path list, and backslashes passed to `wsl` vanish. Give `--add` a `C:/...` path from `cygpath -m` (as in the snippet above), use `MSYS_NO_PATHCONV=1` for `wsl`, or just use the verify script. +- `debugfs -R 'htree DIR'` prints "Directory block does not have space for + checksum" on every indexed directory. Images built by `mke2fs` without + `metadata_csum` get the same message, so it is not an ext4kit bug. - The ext4 hash seed is random per image, so dx-directory layouts differ from run to run. Do not write tests that compare whole-image bytes. - `--from` without `docker://` queries the local engine first. Behavior can diff --git a/Cargo.lock b/Cargo.lock index cd4131d..18ce447 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -488,6 +488,7 @@ dependencies = [ "serde_json", "sha2", "tar", + "tempfile", "tokio", "tokio-util", "zerocopy", diff --git a/Cargo.toml b/Cargo.toml index 7e956e0..25af8ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,3 +20,6 @@ tokio = { version = "1", features = ["rt-multi-thread", "io-util"] } tokio-util = { version = "0.7", features = ["io-util"] } zerocopy = { version = "0.8.59", features = ["derive"] } zstd = "0.13" + +[dev-dependencies] +tempfile = "3" diff --git a/README.md b/README.md index efadcf7..9583338 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,19 @@ files, run `debugfs -R 'stat /path/to/file' output.img` to inspect the extent tree. Run `debugfs -R 'stat /path/to/directory' output.img` to confirm a grown directory's size, block count, and directory extents. +## Testing + +```sh +cargo test # unit tests plus end-to-end image tests +cargo test --test oci -- --ignored # --from tests (Docker Hub and a Docker engine) +``` + +The end-to-end tests in `tests/` run the CLI and check every image with +`e2fsck` and `debugfs`. They find e2fsprogs on Linux, or through WSL on +Windows, and skip those checks with a note when it is missing. CI +(`.github/workflows/ci.yml`) runs the tests on Linux and Windows, and runs the +`--from` tests on Linux. + ## Documentation - [Architecture](docs/architecture.md) diff --git a/src/host/tree.rs b/src/host/tree.rs index e9759b5..cbe31de 100644 --- a/src/host/tree.rs +++ b/src/host/tree.rs @@ -9,7 +9,6 @@ use std::{ collections::HashMap, fs, path::{Path, PathBuf}, - time::{SystemTime, UNIX_EPOCH}, }; #[derive(Clone, Copy, Default)] @@ -56,10 +55,10 @@ pub(super) struct HostMetadata { impl HostMetadata { pub(super) fn from_std(metadata: &fs::Metadata, is_dir: bool) -> Self { - let default_mode = if is_dir { 0o755 } else { 0o644 }; #[cfg(unix)] { use std::os::unix::fs::MetadataExt; + let _ = is_dir; // Unix hosts supply real permission bits. Self { size: metadata.len(), uid: metadata.uid(), @@ -72,6 +71,7 @@ impl HostMetadata { } #[cfg(not(unix))] { + let default_mode = if is_dir { 0o755 } else { 0o644 }; let mode = if metadata.permissions().readonly() { default_mode & !0o222 } else { @@ -106,9 +106,10 @@ fn clamp_unix_timestamp(seconds: i64) -> u32 { seconds.clamp(0, i64::from(u32::MAX)) as u32 } -fn system_time_seconds(time: std::io::Result) -> u32 { +#[cfg(not(unix))] +fn system_time_seconds(time: std::io::Result) -> u32 { time.ok() - .and_then(|value| value.duration_since(UNIX_EPOCH).ok()) + .and_then(|value| value.duration_since(std::time::UNIX_EPOCH).ok()) .map_or(0, |value| value.as_secs().min(u64::from(u32::MAX)) as u32) } diff --git a/src/xattr.rs b/src/xattr.rs index 451009c..4d544dd 100644 --- a/src/xattr.rs +++ b/src/xattr.rs @@ -156,7 +156,7 @@ pub(crate) fn posix_acl_to_ext4_disk(value: &[u8]) -> Result> { } let mut disk = Vec::with_capacity(value.len()); disk.extend_from_slice(&1u32.to_le_bytes()); - for entry in value[4..].chunks_exact(8) { + for entry in value[4..].as_chunks::<8>().0 { let tag = u16::from_le_bytes(entry[..2].try_into().unwrap()); let perm = u16::from_le_bytes(entry[2..4].try_into().unwrap()); if perm > 0o7 { diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..7135626 --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,271 @@ +//! End-to-end tests for `ext4kit create` with host directories. Each test +//! builds a real image file and has e2fsck judge it; see `common` for how +//! e2fsprogs is located. + +mod common; + +use common::{Image, arg, create, ext4kit}; +use std::{fs, path::Path}; +use tempfile::TempDir; + +/// A small tree with nested directories, empty and non-empty files and a +/// maximum-length name. +fn sample_tree(root: &Path) { + fs::create_dir_all(root.join("app/config/nested")).unwrap(); + fs::create_dir_all(root.join("app/empty-dir")).unwrap(); + fs::write(root.join("app/README"), "hello from ext4kit\n").unwrap(); + fs::write(root.join("app/config/settings.toml"), "enabled = true\n").unwrap(); + fs::write(root.join("app/config/nested/empty"), "").unwrap(); + fs::write(root.join("app/with space.txt"), "spaced\n").unwrap(); + fs::write(root.join("app").join("n".repeat(255)), "long\n").unwrap(); +} + +fn assert_sample_tree(image: &Image, at: &str) { + let mut entries = image.list(at); + entries.sort(); + let mut expected = vec![ + "README".to_owned(), + "config".to_owned(), + "empty-dir".to_owned(), + "n".repeat(255), + "with space.txt".to_owned(), + ]; + expected.sort(); + assert_eq!(entries, expected); + assert_eq!(image.cat(&format!("{at}/README")), "hello from ext4kit\n"); + assert_eq!( + image.cat(&format!("{at}/config/settings.toml")), + "enabled = true\n" + ); + assert_eq!(image.cat(&format!("{at}/with space.txt")), "spaced\n"); + assert_eq!(image.cat(&format!("{at}/config/nested/empty")), ""); + assert!(image.list(&format!("{at}/empty-dir")).is_empty()); + assert_eq!(image.mode(&format!("{at}/config")), 0o755); +} + +#[test] +fn host_tree_with_sizing_pass_is_clean() { + let tmp = TempDir::new().unwrap(); + sample_tree(&tmp.path().join("src")); + let out = tmp.path().join("out.img"); + create( + &[ + "--add", + &format!("{}:/opt", arg(&tmp.path().join("src/app"))), + ], + &out, + ); + let Some(image) = Image::check(&out) else { + return; + }; + assert_sample_tree(&image, "/opt/app"); + let mut root = image.list("/"); + root.sort(); + assert_eq!(root, ["lost+found", "opt"]); +} + +#[test] +fn host_tree_with_explicit_size_is_clean() { + let tmp = TempDir::new().unwrap(); + sample_tree(&tmp.path().join("src")); + let out = tmp.path().join("out.img"); + create( + &[ + "--size", + "32", + "--add", + &format!("{}:/srv/data", arg(&tmp.path().join("src/app"))), + ], + &out, + ); + assert_eq!(fs::metadata(&out).unwrap().len(), 32 * 1024 * 1024); + let Some(image) = Image::check(&out) else { + return; + }; + assert_sample_tree(&image, "/srv/data/app"); +} + +#[test] +fn single_file_lands_in_destination_directory() { + let tmp = TempDir::new().unwrap(); + let file = tmp.path().join("hosts"); + fs::write(&file, "127.0.0.1 localhost\n").unwrap(); + let out = tmp.path().join("out.img"); + create(&["--add", &format!("{}:/etc", arg(&file))], &out); + let Some(image) = Image::check(&out) else { + return; + }; + assert_eq!(image.cat("/etc/hosts"), "127.0.0.1 localhost\n"); +} + +#[test] +fn large_directory_gets_an_htree_index() { + let tmp = TempDir::new().unwrap(); + let big = tmp.path().join("big"); + fs::create_dir(&big).unwrap(); + for i in 0..5000 { + fs::write(big.join(format!("file-{i:05}-{}", "x".repeat(40))), "").unwrap(); + } + let out = tmp.path().join("out.img"); + create(&["--add", &arg(&big)], &out); + let Some(image) = Image::check(&out) else { + return; + }; + // Without metadata_csum, debugfs still complains that dx blocks lack + // checksum space; mke2fs-built images get the same message, so only the + // "not indexed" error counts. + let (htree, error) = image.debugfs_with_stderr("htree /big"); + assert!(!error.contains("Not a hash-indexed"), "/big: {error}"); + assert!(htree.contains("Root node dump"), "{htree}"); + assert_eq!(image.list("/big").len(), 5000); +} + +#[test] +fn large_file_uses_an_extent_tree_block() { + // An extent covers at most 128 MiB and the inode holds four extents, so a + // file of more than 512 MiB needs an extent-tree block. The source file is + // sparse, so the host side costs no disk space. + let tmp = TempDir::new().unwrap(); + let src = tmp.path().join("src"); + fs::create_dir(&src).unwrap(); + let large = fs::File::create(src.join("large.bin")).unwrap(); + large.set_len(600 * 1024 * 1024).unwrap(); + drop(large); + let out = tmp.path().join("out.img"); + create(&["--add", &arg(&src)], &out); + let Some(image) = Image::check(&out) else { + return; + }; + let stat = image.stat("/src/large.bin"); + assert!(stat.contains("Size: 629145600"), "{stat}"); + assert!( + stat.contains("(ETB0)"), + "expected an extent-tree block:\n{stat}" + ); +} + +#[test] +fn ownership_flags_apply_to_additions_and_root() { + let tmp = TempDir::new().unwrap(); + sample_tree(&tmp.path().join("src")); + let out = tmp.path().join("out.img"); + create( + &[ + "--chown", + "1000:1001", + "--root-owner", + "2000", + "--add", + &format!("{}:/home/user", arg(&tmp.path().join("src/app"))), + ], + &out, + ); + let Some(image) = Image::check(&out) else { + return; + }; + assert_eq!(image.owner("/"), (2000, 2000)); + assert_eq!(image.owner("/lost+found"), (0, 0)); + for path in [ + "/home", + "/home/user", + "/home/user/app", + "/home/user/app/config/nested/empty", + ] { + assert_eq!(image.owner(path), (1000, 1001), "owner of {path}"); + } +} + +#[test] +fn directory_addition_can_host_a_later_addition() { + // `--add b:/a` provides `/a/b`, which a later `--add c:/a/b` targets, + // regardless of the order on the command line. + let tmp = TempDir::new().unwrap(); + let b = tmp.path().join("b"); + fs::create_dir(&b).unwrap(); + fs::write(b.join("from-b"), "b\n").unwrap(); + let c = tmp.path().join("c"); + fs::write(&c, "c\n").unwrap(); + let out = tmp.path().join("out.img"); + create( + &[ + "--add", + &format!("{}:/a/b", arg(&c)), + "--add", + &format!("{}:/a", arg(&b)), + ], + &out, + ); + let Some(image) = Image::check(&out) else { + return; + }; + let mut entries = image.list("/a/b"); + entries.sort(); + assert_eq!(entries, ["c", "from-b"]); + assert_eq!(image.cat("/a/b/c"), "c\n"); +} + +#[cfg(unix)] +#[test] +fn unix_metadata_and_symlinks_are_preserved() { + use std::os::unix::fs::{PermissionsExt, symlink}; + + let tmp = TempDir::new().unwrap(); + let src = tmp.path().join("src"); + fs::create_dir(&src).unwrap(); + let script = src.join("run.sh"); + fs::write(&script, "#!/bin/sh\n").unwrap(); + fs::set_permissions(&script, fs::Permissions::from_mode(0o750)).unwrap(); + // 59 bytes is the longest inline target; 60 and longer use a data block. + for len in [1, 59, 60, 4095] { + symlink("t".repeat(len), src.join(format!("link-{len}"))).unwrap(); + } + let out = tmp.path().join("out.img"); + create(&["--add", &arg(&src)], &out); + let Some(image) = Image::check(&out) else { + return; + }; + assert_eq!(image.mode("/src/run.sh"), 0o750); + for len in [1, 59] { + let stat = image.stat(&format!("/src/link-{len}")); + assert!(stat.contains("Fast link dest"), "{stat}"); + } + for len in [60, 4095] { + let stat = image.stat(&format!("/src/link-{len}")); + assert!(stat.contains("Type: symlink"), "{stat}"); + assert!(stat.contains("EXTENTS:"), "{stat}"); + } +} + +#[test] +fn rejects_an_image_below_the_minimum_size() { + let tmp = TempDir::new().unwrap(); + let src = tmp.path().join("src"); + fs::create_dir(&src).unwrap(); + let out = tmp.path().join("out.img"); + let result = ext4kit(&["create", "--size", "8", "--add", &arg(&src), &arg(&out)]); + assert!(!result.status.success()); + assert!(String::from_utf8_lossy(&result.stderr).contains("at least 16 MiB")); + assert!( + !out.exists(), + "a rejected run must not leave an image behind" + ); +} + +#[test] +fn rejects_colliding_additions() { + let tmp = TempDir::new().unwrap(); + for dir in ["one/data", "two/data"] { + fs::create_dir_all(tmp.path().join(dir)).unwrap(); + } + let out = tmp.path().join("out.img"); + let result = ext4kit(&[ + "create", + "--add", + &arg(&tmp.path().join("one/data")), + "--add", + &arg(&tmp.path().join("two/data")), + &arg(&out), + ]); + assert!(!result.status.success()); + assert!(String::from_utf8_lossy(&result.stderr).contains("multiple additions target /data")); +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..629be6b --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,250 @@ +//! Shared helpers for the end-to-end tests: run the `ext4kit` binary, then +//! judge the image with e2fsprogs, natively or through WSL on Windows. +//! +//! When e2fsprogs is missing the e2fsprogs checks are skipped with a notice, +//! unless `EXT4KIT_REQUIRE_E2FSPROGS` is set (as in CI), which turns the +//! absence into a test failure. + +#![allow(dead_code)] // Each test crate uses a different subset of the helpers. + +use std::{ + path::{Path, PathBuf}, + process::{Command, Output}, + sync::OnceLock, +}; + +/// Runs `ext4kit` with `args` and returns its output without judging it. +pub fn ext4kit(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_ext4kit")) + .args(args) + .output() + .expect("failed to spawn ext4kit") +} + +/// Runs `ext4kit create ARGS OUTPUT` and panics with its output on failure. +pub fn create(args: &[&str], output: &Path) { + let output_arg = arg(output); + let mut full = vec!["create"]; + full.extend_from_slice(args); + full.push(&output_arg); + let result = ext4kit(&full); + assert!( + result.status.success(), + "ext4kit {full:?} failed with {}\nstdout:\n{}\nstderr:\n{}", + result.status, + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr), + ); +} + +/// Formats a host path as a CLI argument. Forward slashes keep `--add` +/// destination parsing unambiguous on Windows. +pub fn arg(path: &Path) -> String { + path.to_str() + .expect("test paths are UTF-8") + .replace('\\', "/") +} + +/// The e2fsprogs installation used to inspect images. +enum E2fsprogs { + Native { sbin: Option }, + Wsl { distro: String }, +} + +impl E2fsprogs { + fn locate() -> Option { + if let Some(found) = Self::native() { + return Some(found); + } + if cfg!(windows) { + return Self::wsl(); + } + None + } + + /// e2fsck may live in an sbin directory that is not on an unprivileged PATH. + fn native() -> Option { + let candidates = [None, Some("/usr/sbin"), Some("/sbin")]; + candidates.into_iter().find_map(|sbin| { + let sbin = sbin.map(PathBuf::from); + let program = match &sbin { + Some(dir) => dir.join("e2fsck"), + None => PathBuf::from("e2fsck"), + }; + Command::new(program) + .arg("-V") + .output() + .ok() + .filter(|output| output.status.success()) + .map(|_| Self::Native { sbin }) + }) + } + + fn wsl() -> Option { + let has_e2fsck = |distro: &str| { + Command::new("wsl") + .args(["-d", distro, "-e", "sh", "-c", "command -v e2fsck"]) + .output() + .is_ok_and(|output| output.status.success()) + }; + if let Ok(distro) = std::env::var("EXT4KIT_WSL_DISTRO") { + return has_e2fsck(&distro).then_some(Self::Wsl { distro }); + } + // `wsl -l` prints UTF-16LE; dropping NUL bytes is enough for distro names. + let listing = Command::new("wsl").args(["-l", "-q"]).output().ok()?; + let listing: String = String::from_utf8_lossy(&listing.stdout) + .chars() + .filter(|&c| c != '\0') + .collect(); + listing + .lines() + .map(str::trim) + .filter(|name| !name.is_empty()) + .find(|name| has_e2fsck(name)) + .map(|distro| Self::Wsl { + distro: distro.to_owned(), + }) + } + + fn command(&self, program: &str) -> Command { + match self { + Self::Native { sbin } => Command::new(match sbin { + Some(dir) => dir.join(program), + None => PathBuf::from(program), + }), + Self::Wsl { distro } => { + let mut command = Command::new("wsl"); + command.args(["-d", distro, "-e", program]); + command + } + } + } + + /// Converts a host path into the path the e2fsprogs tools see. + fn target(&self, image: &Path) -> String { + match self { + Self::Native { .. } => arg(image), + Self::Wsl { distro } => { + let output = Command::new("wsl") + .args(["-d", distro, "-e", "wslpath", "-a", &arg(image)]) + .output() + .expect("failed to run wslpath"); + assert!(output.status.success(), "wslpath failed: {output:?}"); + String::from_utf8(output.stdout) + .expect("wslpath prints UTF-8") + .trim() + .to_owned() + } + } + } +} + +fn e2fsprogs() -> Option<&'static E2fsprogs> { + static FOUND: OnceLock> = OnceLock::new(); + let found = FOUND.get_or_init(E2fsprogs::locate).as_ref(); + if found.is_none() { + if std::env::var_os("EXT4KIT_REQUIRE_E2FSPROGS").is_some() { + panic!("EXT4KIT_REQUIRE_E2FSPROGS is set but e2fsck was not found"); + } + eprintln!("note: e2fsprogs not found; skipping e2fsck/debugfs checks"); + } + found +} + +/// An image produced by a test, inspected through e2fsprogs. +pub struct Image { + tools: Option<&'static E2fsprogs>, + target: String, +} + +impl Image { + /// Runs `e2fsck -fn` and panics on any finding. Returns `None` when + /// e2fsprogs is unavailable and the caller should skip its inspections. + pub fn check(path: &Path) -> Option { + let tools = e2fsprogs()?; + let target = tools.target(path); + // -f forces a full check of a clean filesystem; -n opens it read-only. + let output = tools + .command("e2fsck") + .args(["-fn", &target]) + .output() + .expect("failed to run e2fsck"); + assert!( + output.status.success(), + "e2fsck reported problems ({}):\n{}{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + Some(Self { + tools: Some(tools), + target, + }) + } + + /// Runs one `debugfs -R` request and returns its stdout. debugfs reports + /// request errors on stderr with a zero exit code, so those are returned + /// separately for callers that expect them. + pub fn debugfs_with_stderr(&self, request: &str) -> (String, String) { + let tools = self.tools.expect("Image is only built with tools"); + let output = tools + .command("debugfs") + .args(["-R", request, &self.target]) + .output() + .expect("failed to run debugfs"); + assert!(output.status.success(), "debugfs {request:?}: {output:?}"); + let stderr = String::from_utf8_lossy(&output.stderr) + .lines() + .filter(|line| !line.starts_with("debugfs ")) + .collect::>() + .join("\n"); + (String::from_utf8_lossy(&output.stdout).into_owned(), stderr) + } + + /// Runs one `debugfs -R` request and panics if it reports an error. + pub fn debugfs(&self, request: &str) -> String { + let (stdout, stderr) = self.debugfs_with_stderr(request); + assert!(stderr.trim().is_empty(), "debugfs {request:?}: {stderr}"); + stdout + } + + /// Returns the raw bytes of the file at `path` inside the image. + pub fn cat(&self, path: &str) -> String { + self.debugfs(&format!("cat \"{path}\"")) + } + + /// Returns the `debugfs stat` output for `path`. + pub fn stat(&self, path: &str) -> String { + self.debugfs(&format!("stat \"{path}\"")) + } + + /// Returns `(uid, gid)` parsed from `debugfs stat`. + pub fn owner(&self, path: &str) -> (u32, u32) { + let stat = self.stat(path); + let field = |name: &str| -> u32 { + let rest = stat + .split(name) + .nth(1) + .unwrap_or_else(|| panic!("no {name} in stat output:\n{stat}")); + rest.split_whitespace().next().unwrap().parse().unwrap() + }; + (field("User:"), field("Group:")) + } + + /// Returns the octal permission bits parsed from `debugfs stat`. + pub fn mode(&self, path: &str) -> u32 { + let stat = self.stat(path); + let rest = stat.split("Mode:").nth(1).expect("no Mode in stat output"); + u32::from_str_radix(rest.split_whitespace().next().unwrap(), 8).unwrap() + } + + /// Returns the entry names of directory `path`, excluding `.` and `..`. + pub fn list(&self, path: &str) -> Vec { + // `ls -p` prints one `/inode/mode/uid/gid/name/size/` record per line. + self.debugfs(&format!("ls -p \"{path}\"")) + .lines() + .filter_map(|line| line.split('/').nth(5).map(str::to_owned)) + .filter(|name| !name.is_empty() && name != "." && name != "..") + .collect() + } +} diff --git a/tests/oci.rs b/tests/oci.rs new file mode 100644 index 0000000..d04f0f6 --- /dev/null +++ b/tests/oci.rs @@ -0,0 +1,60 @@ +//! End-to-end tests for `ext4kit create --from`. They need a registry or a +//! container engine, so they are ignored by default. Run them with +//! `cargo test --test oci -- --ignored`; the docker-daemon test expects +//! `docker pull alpine:3.20` to have run first. + +mod common; + +use common::{Image, arg, create}; +use std::fs; +use tempfile::TempDir; + +const ALPINE: &str = "docker.io/library/alpine:3.20"; + +/// Builds an image from `from` with a host `etc` directory overlaid on the +/// base image's `/etc`, then checks both survived. +fn assert_overlaid_image(from: &str) { + let tmp = TempDir::new().unwrap(); + let etc = tmp.path().join("etc"); + fs::create_dir(&etc).unwrap(); + fs::write(etc.join("ext4kit.conf"), "overlay=yes\n").unwrap(); + let out = tmp.path().join("out.img"); + create( + &[ + "--from", + from, + "--add", + &format!("{}:/", arg(&etc)), + "--root-owner", + "1000", + "--chown", + "1000", + ], + &out, + ); + let Some(image) = Image::check(&out) else { + return; + }; + assert!(image.cat("/etc/os-release").contains("Alpine Linux")); + assert_eq!(image.cat("/etc/ext4kit.conf"), "overlay=yes\n"); + // The overlaid directory keeps the base image's ownership and --chown + // applies only to the added file. + assert_eq!(image.owner("/etc"), (0, 0)); + assert_eq!(image.owner("/etc/ext4kit.conf"), (1000, 1000)); + assert_eq!(image.owner("/"), (1000, 1000)); + assert_eq!(image.owner("/bin/busybox"), (0, 0)); + let sh = image.stat("/bin/sh"); + assert!(sh.contains("Type: symlink"), "{sh}"); +} + +#[test] +#[ignore = "pulls from Docker Hub"] +fn registry_image_with_host_overlay_is_clean() { + assert_overlaid_image(&format!("docker://{ALPINE}")); +} + +#[test] +#[ignore = "needs a Docker engine with alpine:3.20 pulled"] +fn docker_daemon_image_with_host_overlay_is_clean() { + assert_overlaid_image(&format!("docker-daemon:{ALPINE}")); +}