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}")); +}