threecpio-0.11.0/.cargo/config.toml000064400000000000000000000004271046102023000152030ustar 00000000000000[env] # Needed because cpio timestamsp are unsigned 32-bit # This approach is based off the very recent libc work at # https://github.com/rust-lang/libc/pull/4433 # This should probably be revisited once they solve this properly (if ever) RUST_LIBC_UNSTABLE_GNU_TIME_BITS = "64" threecpio-0.11.0/.cargo_vcs_info.json0000644000000001360000000000100130750ustar { "git": { "sha1": "d850afdd62e23660017d23fcc6e0199da7b4f1b2" }, "path_in_vcs": "" }threecpio-0.11.0/.github/workflows/ci.yaml000064400000000000000000000073261046102023000165510ustar 00000000000000--- name: Cargo Build & Test on: # yamllint disable-line rule:truthy push: pull_request: env: CARGO_TERM_COLOR: always # Make sure CI fails on all warnings, including Clippy lints RUSTFLAGS: "-Dwarnings" jobs: build_and_test: name: Rust project - latest runs-on: ubuntu-latest strategy: matrix: toolchain: - stable - beta - nightly steps: - name: Install dependencies run: > sudo apt-get update && sudo apt-get install --no-install-recommends --yes bzip2 lz4 lzop xz-utils zstd - uses: actions/checkout@v4 - run: > rustup update ${{ matrix.toolchain }} && rustup default ${{ matrix.toolchain }} - name: Install cargo-llvm-cov uses: taiki-e/install-action@cargo-llvm-cov - run: cargo build --verbose - run: cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info - name: Upload coverage uses: actions/upload-artifact@v4 with: name: coverage-${{ matrix.toolchain }} path: ./lcov.info build_and_test_minimal: name: Build and test with minimal deps runs-on: ubuntu-latest steps: - name: Install dependencies run: > sudo apt-get update && sudo apt-get install --no-install-recommends --yes bzip2 lz4 xz-utils zstd - uses: actions/checkout@v4 - run: > rustup update stable && rustup default stable - name: Install cargo-llvm-cov uses: taiki-e/install-action@cargo-llvm-cov - run: cargo build --verbose - run: cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info - name: Upload coverage uses: actions/upload-artifact@v4 with: name: coverage-minimal path: ./lcov.info build_and_test_root: name: Build and test as root runs-on: ubuntu-latest steps: - name: Install dependencies run: > sudo apt-get update && sudo apt-get install --no-install-recommends --yes bzip2 lz4 lzop xz-utils zstd - uses: actions/checkout@v4 - run: > rustup update stable && rustup default stable - name: Install cargo-llvm-cov uses: taiki-e/install-action@cargo-llvm-cov - run: cargo build --verbose - run: > CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUNNER='sudo -E' cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info - name: Upload coverage uses: actions/upload-artifact@v4 with: name: coverage-root path: ./lcov.info clippy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Run Clippy run: cargo clippy --all-targets --all-features rustfmt: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Run rustfmt run: cargo fmt --all --check upload-to-codecov: if: ${{ always() }} needs: - build_and_test - build_and_test_minimal - build_and_test_root runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Download artifacts uses: actions/download-artifact@v4 - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: true man: runs-on: ubuntu-latest steps: - name: Install asciidoctor run: > sudo apt-get update && sudo apt-get install --no-install-recommends --yes asciidoctor - uses: actions/checkout@v4 - name: Build man pages run: asciidoctor -b manpage man/3cpio.1.adoc threecpio-0.11.0/.gitignore000064400000000000000000000000141046102023000136500ustar 00000000000000*.1 /target threecpio-0.11.0/Cargo.lock0000644000000014020000000000100110450ustar # This file is automatically @generated by Cargo. # It is not intended for manual editing. version = 4 [[package]] name = "glob" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "lexopt" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa0e2a1fcbe2f6be6c42e342259976206b383122fc152e872795338b5a3f3a7" [[package]] name = "libc" version = "0.2.175" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" [[package]] name = "threecpio" version = "0.11.0" dependencies = [ "glob", "lexopt", "libc", ] threecpio-0.11.0/Cargo.toml0000644000000023640000000000100111000ustar # THIS FILE IS AUTOMATICALLY GENERATED BY CARGO # # When uploading crates to the registry Cargo will automatically # "normalize" Cargo.toml files for maximal compatibility # with all versions of Cargo and also rewrite `path` dependencies # to registry (e.g., crates.io) dependencies. # # If you are reading this file be aware that the original Cargo.toml # will likely look very different (and much more reasonable). # See Cargo.toml.orig for the original contents. [package] edition = "2021" name = "threecpio" version = "0.11.0" authors = ["Benjamin Drung "] build = false autolib = false autobins = false autoexamples = false autotests = false autobenches = false description = "manage initrd cpio archives" homepage = "https://github.com/bdrung/3cpio" readme = "README.md" keywords = [ "archive", "cpio", "initrd", ] categories = [ "command-line-utilities", "compression", "encoding", "filesystem", ] license = "ISC" repository = "https://github.com/bdrung/3cpio" [lib] name = "threecpio" path = "src/lib.rs" [[bin]] name = "3cpio" path = "src/main.rs" [[test]] name = "cli" path = "tests/cli.rs" [dependencies.glob] version = "0.3" [dependencies.lexopt] version = "0.3" [dependencies.libc] version = "0.2.173" threecpio-0.11.0/Cargo.toml.orig000064400000000000000000000010441046102023000145530ustar 00000000000000[package] edition = "2021" name = "threecpio" version = "0.11.0" authors = ["Benjamin Drung "] description = "manage initrd cpio archives" homepage = "https://github.com/bdrung/3cpio" readme = "README.md" keywords = [ "archive", "cpio", "initrd", ] categories = [ "command-line-utilities", "compression", "encoding", "filesystem", ] license = "ISC" repository = "https://github.com/bdrung/3cpio" [[bin]] name = "3cpio" path = "src/main.rs" [dependencies] glob = "0.3" libc = "0.2.173" lexopt = "0.3" threecpio-0.11.0/LICENSE000075500000000000000000000014161046102023000126770ustar 000000000000003cpio is licensed under ISC: Copyright (C) 2024, Benjamin Drung Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. threecpio-0.11.0/NEWS.md000064400000000000000000000107521046102023000127700ustar 00000000000000This file summarizes the major and interesting changes for each release. For a detailed list of changes, please see the git history. 0.11.0 (2025-08-26) ------------------- ### What's new * set `--stream-size` on `zstd` compressor command ([issue #15](https://github.com/bdrung/3cpio/issues/15)) * doc: document all public const/functions * introduce a `Logger` struct and macros and use an enum for log level * let `--examine` print the size and extracted size ([issue #13](https://github.com/bdrung/3cpio/issues/13)) * Add `--raw` option to `--examine` for the current format and make a human readable format the default. 0.10.2 (2025-08-13) ------------------- ### Fixed * skip data alignment for files where `PATH_MAX` is exceeded 0.10.1 (2025-08-13) ------------------- ### Fixed * flush stdout at the end of each cpio 0.10.0 (2025-08-13) ------------------- ### What's new * add `--data-align` option to the `--create` mode 0.9.1 (2025-08-11) ------------------ ### Fixed * limit filename length to `PATH_MAX` * Support running tests without lzop installed 0.9.0 (2025-08-09) ------------------ ### What's new * support extract/list cpio based on globbing patterns * support `--to-stdout` on extraction * limit compression level to min/max (instead of ignoring invalid levels) * support `--parts` on `--extract` and `--list` ([issue #14](https://github.com/bdrung/3cpio/issues/14)) * add `--make-directories` option for `--extract` * build: enable t64 on 32-bits architectures ### Fixed * test: - manifest: allow for EPIPE as a valid compression failure mode - set current directory only if needed (to avoid race conditions) 0.8.1 (2025-07-31) ------------------ ### Fixed * test: - use temporary directory for write tests - canonicalize `/dev/console` ([bug #16](https://github.com/bdrung/3cpio/issues/16)) 0.8.0 (2025-07-11) ------------------ ### What's new * Use a write buffer for `--create` for a massive performance improvement ### Fixed * Check exit status of compressor commands * test: - Use `gzip` instead of `true` which might be a symlink - Skip `test_file_from_line_location_*` if required file is missing 0.7.0 (2025-07-10) ------------------ ### What's new * Add support for creating cpio archives from a manifest file ([feature #3](https://github.com/bdrung/3cpio/issues/3)) * Print inode on `--list --debug` 0.6.0 (2025-06-30) ------------------ ### What's new * doc: add 3cpio man page ### Fixed * Fix "No such file or directory" error when using `--subdir` * test: fix race condition in tests by using a lock 0.5.1 (2025-04-11) ------------------ ### Fixed * Fix directory traversal vulnerability: Prevent extracting CPIOs outside of the destination directory to prevent directory traversal attacks. This new behaviour is similar to `cpio --no-absolute-filenames`. 0.5.0 (2025-03-30) ------------------ ### What's new * add `--count` parameter 0.4.0 (2025-03-11) ------------------ ### What's new * add support for extracting character devices ### Fixed * print major/minor of character devices in long format 0.3.2 (2024-08-19) ------------------ ### What's new * Support lzma compression ([bug #8](https://github.com/bdrung/3cpio/issues/8)) ### Fixed * Avoid `timespec` struct literal ([LP: #2076903](https://launchpad.net/bugs/2076903)) * Include missing helper program name in error message ([bug #4](https://github.com/bdrung/3cpio/issues/4)) 0.3.1 (2024-08-06) ------------------ ### What's new * Various changes to speed up `3cpio --list --verbose` to make 3cpio faster than bsdcpio in all benchmarks. 0.3.0 (2024-08-03) ------------------ ### What's new * support preserving the owner/group of symlinks * Add `--verbose` mode to `--list` mode. The output will be similar to `cpio --list --verbose` and `ls -l`. ### Fixed * 3cpio: fix setting the directory/file permissions ([bug #5](https://github.com/bdrung/3cpio/issues/5)) 0.2.0 (2024-07-05) ------------------ ### What's new * Add support for extracting (`--extract`) cpio archives. New parameters are `--directory`, `--preserve-permissions`, and `--subdir`. * Add `--verbose` and `--debug` log levels ### Changed * Replace command line argument parser `gumdrop` by `lexopt`, because the latter has no dependencies. * Drop `assert_cmd` and `predicates` dev dependencies. ### Fixed * 3cpio: fix binary name in `--version` output 0.1.0 (2024-04-18) ------------------ Initial release. 3cpio only supports examining (`--examine`) and listing (`--list`) the content of the initramfs cpio. threecpio-0.11.0/README.md000064400000000000000000000233501046102023000131470ustar 000000000000003cpio ===== 3cpio is a tool to manage initramfs cpio files for the Linux kernel. The Linux kernel's [initramfs buffer format](https://www.kernel.org/doc/html/latest/driver-api/early-userspace/buffer-format.html) is based around the `newc` or `crc` cpio formats. Multiple cpio archives can be concatenated and the last archive can be compressed. Different compression algorithms can be used depending on what support was compiled into the Linux kernel. 3cpio is tailored to initramfs cpio files and will not gain support for other cpio formats. 3cpio supports creating, examining, listing, and extracting the content of the initramfs cpio. **Note**: The Rust crate is named threecpio, because package names are not allowed to start with numbers. Usage examples -------------- List the number of cpio archives that an initramfs file contains: ``` $ 3cpio --count /boot/initrd.img 4 ``` Examine the content of the initramfs cpio on an Ubuntu 24.04 system: ``` $ 3cpio --examine /boot/initrd.img Start End Size Compr. Extracted 0 B 148 kB 148 kB cpio 147 kB 148 kB 13.3 MB 13.1 MB cpio 13.1 MB 13.3 MB 55.2 MB 41.9 MB cpio 41.7 MB 55.2 MB 62.0 MB 6.74 MB zstd 15.6 MB ``` There is also a machine-readable output format available: ``` $ 3cpio --examine --raw /boot/initrd.img 0 148480 148480 cpio 147350 148480 13275136 13126656 cpio 13125632 13275136 55215104 41939968 cpio 41692226 55215104 61956920 6741816 zstd 15616306 ``` This initramfs cpio consists of three uncompressed cpio archives followed by a Zstandard-compressed cpio archive. List the content of the initramfs cpio on an Ubuntu 24.04 system: ``` $ 3cpio --list /boot/initrd.img . kernel kernel/x86 kernel/x86/microcode kernel/x86/microcode/AuthenticAMD.bin kernel kernel/x86 kernel/x86/microcode kernel/x86/microcode/.enuineIntel.align.0123456789abc kernel/x86/microcode/GenuineIntel.bin . usr usr/lib usr/lib/firmware usr/lib/firmware/3com usr/lib/firmware/3com/typhoon.bin.zst [...] ``` The first cpio contains only the AMD microcode. The second cpio contains only the Intel microcode. The third cpio contains firmware files and kernel modules. Extract the content of the initramfs cpio to the `initrd` subdirectory on an Ubuntu 24.04 system: ``` $ 3cpio --extract -C initrd /boot/initrd.img $ ls initrd bin cryptroot init lib lib.usr-is-merged run scripts var conf etc kernel lib64 libx32 sbin usr ``` Create a cpio archive similar to the other cpio tools using the `find` command: ``` $ cd inputdir && find . | sort | 3cpio --create ../example.cpio ``` Due to its manifest file format support, 3cpio can create cpio archives without the need of copying files into a temporary directory first. Example for creating an early microcode cpio image directly using the system installed files: ``` $ cat manifest - kernel dir 755 0 0 1751654557 - kernel/x86 dir 755 0 0 1752011622 /usr/lib/firmware/amd-ucode kernel/x86/microcode /usr/lib/firmware/amd-ucode/microcode_amd_fam19h.bin kernel/x86/microcode/AuthenticAMD.bin $ 3cpio --create amd-ucode.img < manifest $ 3cpio --list --verbose amd-ucode.img drwxr-xr-x 2 root root 0 Jul 4 20:42 kernel drwxr-xr-x 2 root root 0 Jul 8 23:53 kernel/x86 drwxr-xr-x 2 root root 0 Jun 10 10:51 kernel/x86/microcode -rw-r--r-- 1 root root 100684 Mar 23 22:42 kernel/x86/microcode/AuthenticAMD.bin ``` Example for creating an initrd image containing of an uncompressed early microcode cpio followed by a Zstandard-compressed cpio: ``` $ cat manifest #cpio - kernel dir 755 0 0 1751654557 - kernel/x86 dir 755 0 0 1752011622 /usr/lib/firmware/amd-ucode kernel/x86/microcode /usr/lib/firmware/amd-ucode/microcode_amd_fam19h.bin kernel/x86/microcode/AuthenticAMD.bin #cpio: zstd -9 / /bin /usr /usr/bin /usr/bin/bash # This is a comment. Leaving the remaining files as task for the reader. $ 3cpio --create initrd.img < manifest $ 3cpio --examine initrd.img Start End Size Compr. Extracted 0 B 101 kB 101 kB cpio 101 kB 101 kB 786 kB 685 kB zstd 1.45 MB $ 3cpio --list --verbose initrd.img drwxr-xr-x 2 root root 0 Jul 4 20:42 kernel drwxr-xr-x 2 root root 0 Jul 8 23:53 kernel/x86 drwxr-xr-x 2 root root 0 Jun 10 10:51 kernel/x86/microcode -rw-r--r-- 1 root root 100684 Mar 23 22:42 kernel/x86/microcode/AuthenticAMD.bin drwxr-xr-x 2 root root 0 Jun 5 14:11 . lrwxrwxrwx 1 root root 7 Mar 20 2022 bin -> usr/bin drwxr-xr-x 2 root root 0 Apr 20 2023 usr drwxr-xr-x 2 root root 0 Jul 9 09:56 usr/bin -rwxr-xr-x 1 root root 1446024 Mar 31 2024 usr/bin/bash ``` Benchmark results ----------------- ### Listing the content of the initrd Runtime comparison measured with `time` over five runs on different initramfs cpios: | System | Kernel | Comp. | Size | Files | 3cpio | lsinitramfs | lsinitrd | | ---------------- | ---------------- | -------- | ------ | ----- | ------ | ----------- | -------- | | Ryzen 7 5700G | 6.5.0-27-generic | zstd¹ | 102 MB | 3496 | 0.052s | 14.243s | –³ | | Ryzen 7 5700G VM | 6.8.0-22-generic | zstd¹ | 63 MB | 1934 | 0.042s | 7.239s | –³ | | Ryzen 7 5700G VM | 6.8.0-22-generic | zstd² | 53 MB | 1783 | 0.061s | 0.452s | 0.560s | | RasPi Zero 2W | 6.5.0-1012-raspi | zstd¹ | 24 MB | 1538 | 0.647s | 56.253s | –³ | | RasPi Zero 2W | 6.5.0-1012-raspi | zstd² | 30 MB | 2028 | 1.141s | 2.286s | 6.118s | | RasPi Zero 2W | 6.8.0-1002-raspi | zstd¹ | 51 MB | 2532 | 0.713s | 164.575s | –³ | | RasPi Zero 2W | 6.8.0-1002-raspi | zstd -1² | 47 MB | 2778 | 1.156s | 2.842s | 9.508s | | RasPi Zero 2W | 6.8.0-1002-raspi | xz² | 41 MB | 2778 | 6.922s | 13.451s | 35.184s | **Legend**: 1. generated by initramfs-tools 2. generated by `dracut --force --${compression}`. On Raspberry Pi Zero 2W there is not enough memory for the default `zstd -15`. So using the default from initramfs-tools there: `dracut --force --compress "zstd -1 -q -T0"` 3. lsinitrd only reads the first two cpio archives of the file, but the initramfs consists of four cpios. **Results**: * 3cpio is 87 to 274 times faster than lsinitramfs for images generated by initramfs-tools. * 3cpio is two to eight times faster than lsinitramfs for images generated by dracut. * 3cpio five to nine times faster than lsinitrd for images generated by dracut. Commands used: ``` 3cpio -t /boot/initrd.img-${version} | wc -l time 3cpio -t /boot/initrd.img-${version} > /dev/null time lsinitramfs /boot/initrd.img-${version} > /dev/null time lsinitrd /boot/initrd.img-${version} > /dev/null ``` List the content of single cpio archive that is not compressed (see [doc/Benchmarks.md](doc/Benchmarks.md) for details) on a Raspberry Pi Zero 2W: | Command | Mean [ms] | Min [ms] | Max [ms] | Relative | |:---|---:|---:|---:|---:| | `3cpio -t initrd.img` | 84.3 ± 1.1 | 82.1 | 87.0 | 1.00 | | `bsdcpio -itF initrd.img` | 98.4 ± 0.9 | 96.4 | 101.0 | 1.17 ± 0.02 | | `cpio -t --file initrd.img` | 1321.2 ± 2.8 | 1314.6 | 1327.6 | 15.68 ± 0.20 | | Command | Mean [ms] | Min [ms] | Max [ms] | Relative | |:---|---:|---:|---:|---:| | `3cpio -tv initrd.img` | 109.2 ± 1.1 | 106.9 | 111.7 | 1.00 | | `bsdcpio -itvF initrd.img` | 114.9 ± 1.1 | 112.6 | 117.4 | 1.05 ± 0.01 | | `cpio -tv --file initrd.img` | 1423.0 ± 3.5 | 1417.1 | 1440.6 | 13.03 ± 0.13 | ### Extracting the content of the initrd Benchmarking the time to extraction initrd: | System | Distro | Kernel | Size | Files | 3cpio | unmkinitramfs | | ------------- | -------- | ---------------- | ------ | ----- | ------ | ------------- | | Ryzen 7 5700G | noble | 6.8.0-35-generic | 70 MB | 2097 | 0.107s | 6.698s | | Ryzen 7 5700G | jammy | 6.8.0-35-generic | 112 MB | 3789 | 0.455s | 2.217s | | Ryzen 7 5700G | bookworm | 6.1.0-21-amd64 | 62 MB | 2935 | 0.268s | 1.362s | | RasPi Zero 2W | noble | 6.8.0-1005-raspi | 53 MB | 2534 | 5.075s | 173.847s | Raw measurements can be found in [doc/Benchmarks.md](doc/Benchmarks.md). ### Creating cpio archives 3cpio is the fastest tool by far in all tested scenarios (the other tools are 1.13 to 4.48 times slower with a cold cache and 1.52 to 5.87 times slower with a warm cache): | System | Distro | Kernel | Size | Cache | 3cpio | bsdcpio | cpio | | ------------- | ------ | ----------------- | ------ | ----- | ------- | ------- | ------- | | Ryzen 7 5700G | noble* | 6.8.0-63-generic | 84 MB | warm | 0.061s | 0.237s | 0.323s | | Ryzen 7 5700G | noble* | 6.8.0-63-generic | 84 MB | cold | 0.068s | 0.257s | 0.337s | | Ryzen 7 5700G | plucky | 6.14.0-23-generic | 68 MB | warm | 0.065s | 0.299s | 0.383s | | Ryzen 7 5700G | plucky | 6.14.0-23-generic | 68 MB | cold | 0.257s | 0.491s | 0.559s | | RasPi Zero 2W | noble | 6.8.0-1030-raspi | 80 MB | warm | 2.460s | 3.733s | 4.833s | | RasPi Zero 2W | noble | 6.8.0-1030-raspi | 80 MB | cold | 10.743s | 12.200s | 12.154s | The Ryzen 7 5700G noble tests were done in chroots with tmpfs. Raw measurements can be found in [doc/Benchmarks.md](doc/Benchmarks.md). Naming and alternatives ----------------------- The tool is named 3cpio because it is the third cpio tool besides [GNU cpio](https://www.gnu.org/software/cpio/) and `bsdcpio` provided by [libarchive](https://www.libarchive.org/). 3cpio is also the third tool that can list the content of initramfs cpio archives besides `lsinitramfs` from [initramfs-tools](https://tracker.debian.org/pkg/initramfs-tools) and `lsinitrd` from [dracut](https://github.com/dracut-ng/dracut-ng). threecpio-0.11.0/doc/Benchmarks.md000064400000000000000000001101701046102023000150310ustar 00000000000000Benchmarks ========== This page contains the raw measurements. Raspberry Pi Zero 2W -------------------- Benchmark results on a Raspberry Pi Zero 2W running Ubuntu 24.04 (noble) arm64 on 2024-06-05: ``` $ ls -l /boot/initrd.img* lrwxrwxrwx 1 root root 27 Jun 4 06:50 /boot/initrd.img -> initrd.img-6.8.0-1005-raspi -rw-r--r-- 1 root root 52794656 Jun 4 18:29 /boot/initrd.img-6.8.0-1005-raspi $ 3cpio -t /boot/initrd.img | wc -l 2534 $ hyperfine -p "rm -rf initrd" "3cpio -x /boot/initrd.img -C initrd" "unmkinitramfs /boot/initrd.img initrd" --export-markdown extract.md Benchmark 1: 3cpio -x /boot/initrd.img -C initrd Time (mean ± σ): 5.075 s ± 0.247 s [User: 0.116 s, System: 1.932 s] Range (min … max): 4.631 s … 5.591 s 10 runs Benchmark 2: unmkinitramfs /boot/initrd.img initrd Time (mean ± σ): 173.847 s ± 8.368 s [User: 31.155 s, System: 269.939 s] Range (min … max): 162.180 s … 183.792 s 10 runs Summary 3cpio -x /boot/initrd.img -C initrd ran 34.25 ± 2.34 times faster than unmkinitramfs /boot/initrd.img initrd ``` | Command | Mean [s] | Min [s] | Max [s] | Relative | |:---|---:|---:|---:|---:| | `3cpio -x /boot/initrd.img -C initrd` | 5.075 ± 0.247 | 4.631 | 5.591 | 1.00 | | `unmkinitramfs /boot/initrd.img initrd` | 173.847 ± 8.368 | 162.180 | 183.792 | 34.25 ± 2.34 | ``` $ hyperfine --warmup 1 "3cpio -t /boot/initrd.img" "lsinitramfs /boot/initrd.img" -u second --export-markdown list.md Benchmark 1: 3cpio -t /boot/initrd.img Time (mean ± σ): 0.697 s ± 0.003 s [User: 0.039 s, System: 0.265 s] Range (min … max): 0.692 s … 0.703 s 10 runs Benchmark 2: lsinitramfs /boot/initrd.img Time (mean ± σ): 165.425 s ± 7.986 s [User: 30.696 s, System: 259.767 s] Range (min … max): 154.661 s … 176.996 s 10 runs Summary 3cpio -t /boot/initrd.img ran 237.45 ± 11.51 times faster than lsinitramfs /boot/initrd.img ``` | Command | Mean [s] | Min [s] | Max [s] | Relative | |:---|---:|---:|---:|---:| | `3cpio -t /boot/initrd.img` | 0.697 ± 0.003 | 0.692 | 0.703 | 1.00 | | `lsinitramfs /boot/initrd.img` | 165.425 ± 7.986 | 154.661 | 176.996 | 237.45 ± 11.51 | Benchmark results on a Raspberry Pi Zero 2W running Ubuntu 24.04 (noble) arm64 on 2024-08-06: ``` $ sudo 3cpio -x /boot/initrd.img -C /var/tmp/initrd $ ( cd /var/tmp/initrd && find . | LC_ALL=C sort | sudo cpio --reproducible --quiet -o -H newc ) > initrd.img $ ls -l initrd.img -rw-rw-r-- 1 user user 75868160 Aug 3 02:10 initrd.img $ 3cpio -t initrd.img | wc -l 2529 $ 3cpio -e initrd.img 0 cpio $ hyperfine -N -w 2 -r 100 "3cpio -t initrd.img" "bsdcpio -itF initrd.img" "cpio -t --file initrd.img" --export-markdown list.md Benchmark 1: 3cpio -t initrd.img Time (mean ± σ): 84.3 ms ± 1.1 ms [User: 25.6 ms, System: 57.5 ms] Range (min … max): 82.1 ms … 87.0 ms 100 runs Benchmark 2: bsdcpio -itF initrd.img Time (mean ± σ): 98.4 ms ± 0.9 ms [User: 29.1 ms, System: 67.6 ms] Range (min … max): 96.4 ms … 101.0 ms 100 runs Benchmark 3: cpio -t --file initrd.img Time (mean ± σ): 1.321 s ± 0.003 s [User: 0.277 s, System: 1.039 s] Range (min … max): 1.315 s … 1.328 s 100 runs Summary 3cpio -t initrd.img ran 1.17 ± 0.02 times faster than bsdcpio -itF initrd.img 15.68 ± 0.20 times faster than cpio -t --file initrd.img $ hyperfine -N -w 2 -r 100 "3cpio -tv initrd.img" "bsdcpio -itvF initrd.img" "cpio -tv --file initrd.img" --export-markdown list-verbose.md Benchmark 1: 3cpio -tv initrd.img Time (mean ± σ): 109.2 ms ± 1.1 ms [User: 46.3 ms, System: 61.7 ms] Range (min … max): 106.9 ms … 111.7 ms 100 runs Benchmark 2: bsdcpio -itvF initrd.img Time (mean ± σ): 114.9 ms ± 1.1 ms [User: 44.2 ms, System: 69.0 ms] Range (min … max): 112.6 ms … 117.4 ms 100 runs Benchmark 3: cpio -tv --file initrd.img Time (mean ± σ): 1.423 s ± 0.004 s [User: 0.318 s, System: 1.099 s] Range (min … max): 1.417 s … 1.441 s 100 runs Summary 3cpio -tv initrd.img ran 1.05 ± 0.01 times faster than bsdcpio -itvF initrd.img 13.03 ± 0.13 times faster than cpio -tv --file initrd.img ``` | Command | Mean [ms] | Min [ms] | Max [ms] | Relative | |:---|---:|---:|---:|---:| | `3cpio -t initrd.img` | 84.3 ± 1.1 | 82.1 | 87.0 | 1.00 | | `bsdcpio -itF initrd.img` | 98.4 ± 0.9 | 96.4 | 101.0 | 1.17 ± 0.02 | | `cpio -t --file initrd.img` | 1321.2 ± 2.8 | 1314.6 | 1327.6 | 15.68 ± 0.20 | | Command | Mean [ms] | Min [ms] | Max [ms] | Relative | |:---|---:|---:|---:|---:| | `3cpio -tv initrd.img` | 109.2 ± 1.1 | 106.9 | 111.7 | 1.00 | | `bsdcpio -itvF initrd.img` | 114.9 ± 1.1 | 112.6 | 117.4 | 1.05 ± 0.01 | | `cpio -tv --file initrd.img` | 1423.0 ± 3.5 | 1417.1 | 1440.6 | 13.03 ± 0.13 | Benchmark results on a Raspberry Pi Zero 2W running Ubuntu 24.04 (noble) arm64 on 2025-07-06: ``` $ sudo 3cpio -x /boot/initrd.img -C /var/tmp/initrd $ ( cd /var/tmp/initrd && find . | LC_ALL=C sort | sudo cpio --reproducible --quiet -o -H newc ) > initrd.img $ ls -l initrd.img -rw-rw-r-- 1 user user 80422400 Jul 6 11:49 initrd.img $ 3cpio -t initrd.img | wc -l 2542 $ 3cpio -e initrd.img 0 cpio $ 3cpio -e /boot/initrd.img 0 cpio 42943488 zstd $ hyperfine -N -w 2 -r 100 "3cpio -t initrd.img" "3cpio -tv initrd.img" "3cpio -t --debug initrd.img" "3cpio -t /boot/initrd.img" "3cpio -tv /boot/initrd.img" "3cpio -t --debug /boot/initrd.img" --export-markdown list-variants.md Benchmark 1: 3cpio -t initrd.img Time (mean ± σ): 89.7 ms ± 1.1 ms [User: 26.8 ms, System: 61.7 ms] Range (min … max): 87.6 ms … 92.8 ms 100 runs Benchmark 2: 3cpio -tv initrd.img Time (mean ± σ): 112.4 ms ± 1.2 ms [User: 47.8 ms, System: 63.4 ms] Range (min … max): 110.4 ms … 115.2 ms 100 runs Benchmark 3: 3cpio -t --debug initrd.img Time (mean ± σ): 114.3 ms ± 1.1 ms [User: 49.4 ms, System: 63.6 ms] Range (min … max): 112.1 ms … 117.7 ms 100 runs Benchmark 4: 3cpio -t /boot/initrd.img Time (mean ± σ): 703.8 ms ± 2.6 ms [User: 39.4 ms, System: 267.5 ms] Range (min … max): 699.1 ms … 712.0 ms 100 runs Benchmark 5: 3cpio -tv /boot/initrd.img Time (mean ± σ): 722.5 ms ± 3.1 ms [User: 61.9 ms, System: 268.3 ms] Range (min … max): 715.9 ms … 742.8 ms 100 runs Benchmark 6: 3cpio -t --debug /boot/initrd.img Time (mean ± σ): 724.4 ms ± 2.5 ms [User: 65.6 ms, System: 267.1 ms] Range (min … max): 719.2 ms … 733.6 ms 100 runs Summary 3cpio -t initrd.img ran 1.25 ± 0.02 times faster than 3cpio -tv initrd.img 1.27 ± 0.02 times faster than 3cpio -t --debug initrd.img 7.85 ± 0.10 times faster than 3cpio -t /boot/initrd.img 8.05 ± 0.10 times faster than 3cpio -tv /boot/initrd.img 8.08 ± 0.10 times faster than 3cpio -t --debug /boot/initrd.img ``` | Command | Mean [ms] | Min [ms] | Max [ms] | Relative | |:---|---:|---:|---:|---:| | `3cpio -t initrd.img` | 89.7 ± 1.1 | 87.6 | 92.8 | 1.00 | | `3cpio -tv initrd.img` | 112.4 ± 1.2 | 110.4 | 115.2 | 1.25 ± 0.02 | | `3cpio -t --debug initrd.img` | 114.3 ± 1.1 | 112.1 | 117.7 | 1.27 ± 0.02 | | `3cpio -t /boot/initrd.img` | 703.8 ± 2.6 | 699.1 | 712.0 | 7.85 ± 0.10 | | `3cpio -tv /boot/initrd.img` | 722.5 ± 3.1 | 715.9 | 742.8 | 8.05 ± 0.10 | | `3cpio -t --debug /boot/initrd.img` | 724.4 ± 2.5 | 719.2 | 733.6 | 8.08 ± 0.10 | Benchmark results on a Raspberry Pi Zero 2W running Ubuntu 24.04 (noble) arm64 on 2025-07-10: ``` $ ls -l /boot/initrd.img* lrwxrwxrwx 1 root root 27 Jul 3 08:18 /boot/initrd.img -> initrd.img-6.8.0-1030-raspi -rw-r--r-- 1 root root 57286143 Jul 3 08:23 /boot/initrd.img-6.8.0-1030-raspi $ sudo 3cpio -x /boot/initrd.img -C initrd $ ( cd initrd && find . ) | sed -e 's,\./,,g' | sort > files $ wc -l < files 2542 $ sudo hyperfine -w 1 -p "rm -f initrd.img && sync && echo 3 > /proc/sys/vm/drop_caches" "3cpio -c initrd.img -C initrd < files" "cd initrd && bsdcpio -o -H newc > ../initrd.img < ../files" "cd initrd && cpio -o -H newc --reproducible > ../initrd.img < ../files" --export-markdown create-cold.md Benchmark 1: 3cpio -c initrd.img -C initrd < files Time (mean ± σ): 10.743 s ± 0.213 s [User: 0.140 s, System: 2.264 s] Range (min … max): 10.470 s … 11.176 s 10 runs Benchmark 2: cd initrd && bsdcpio -o -H newc > ../initrd.img < ../files Time (mean ± σ): 12.200 s ± 0.339 s [User: 0.576 s, System: 4.840 s] Range (min … max): 11.603 s … 12.749 s 10 runs Benchmark 3: cd initrd && cpio -o -H newc --reproducible > ../initrd.img < ../files Time (mean ± σ): 12.154 s ± 0.502 s [User: 0.839 s, System: 5.494 s] Range (min … max): 11.549 s … 12.946 s 10 runs Summary 3cpio -c initrd.img -C initrd < files ran 1.13 ± 0.05 times faster than cd initrd && cpio -o -H newc --reproducible > ../initrd.img < ../files 1.14 ± 0.04 times faster than cd initrd && bsdcpio -o -H newc > ../initrd.img < ../files $ sudo hyperfine -w 2 -p "rm -f initrd.img && sync" "3cpio -c initrd.img -C initrd < files" "cd initrd && bsdcpio -o -H newc > ../initrd.img < ../files" "cd initrd && cpio -o -H newc --reproducible > ../initrd.img < ../files" --export-markdown create-warm.md Benchmark 1: 3cpio -c initrd.img -C initrd < files Time (mean ± σ): 2.460 s ± 0.192 s [User: 0.103 s, System: 1.129 s] Range (min … max): 2.266 s … 2.778 s 10 runs Benchmark 2: cd initrd && bsdcpio -o -H newc > ../initrd.img < ../files Time (mean ± σ): 3.733 s ± 0.013 s [User: 0.453 s, System: 3.257 s] Range (min … max): 3.716 s … 3.762 s 10 runs Benchmark 3: cd initrd && cpio -o -H newc --reproducible > ../initrd.img < ../files Time (mean ± σ): 4.833 s ± 0.009 s [User: 0.737 s, System: 4.069 s] Range (min … max): 4.821 s … 4.845 s 10 runs Summary 3cpio -c initrd.img -C initrd < files ran 1.52 ± 0.12 times faster than cd initrd && bsdcpio -o -H newc > ../initrd.img < ../files 1.96 ± 0.15 times faster than cd initrd && cpio -o -H newc --reproducible > ../initrd.img < ../files $ stat -c %s initrd.img 80422400 $ { echo "#cpio: zstd -1" && cat files; } > manifest $ sudo hyperfine -w 1 -p "rm -f initrd.img && sync && echo 3 > /proc/sys/vm/drop_caches" "3cpio -c initrd.img -C initrd < manifest" "cd initrd && bsdcpio -o -H newc < ../files | zstd -q1 -T0 > ../initrd.img" "cd initrd && cpio -o -H newc --reproducible < ../files | zstd -q1 -T0 > ../initrd.img" --export-markdown create-cold.md Benchmark 1: 3cpio -c initrd.img -C initrd < manifest Time (mean ± σ): 8.667 s ± 0.291 s [User: 0.197 s, System: 2.036 s] Range (min … max): 8.364 s … 9.127 s 10 runs Benchmark 2: cd initrd && bsdcpio -o -H newc < ../files | zstd -q1 -T0 > ../initrd.img Time (mean ± σ): 10.367 s ± 0.891 s [User: 3.300 s, System: 5.913 s] Range (min … max): 9.507 s … 11.742 s 10 runs Benchmark 3: cd initrd && cpio -o -H newc --reproducible < ../files | zstd -q1 -T0 > ../initrd.img Time (mean ± σ): 10.276 s ± 0.921 s [User: 3.612 s, System: 7.762 s] Range (min … max): 9.461 s … 12.092 s 10 runs Summary 3cpio -c initrd.img -C initrd < manifest ran 1.19 ± 0.11 times faster than cd initrd && cpio -o -H newc --reproducible < ../files | zstd -q1 -T0 > ../initrd.img 1.20 ± 0.11 times faster than cd initrd && bsdcpio -o -H newc < ../files | zstd -q1 -T0 > ../initrd.img $ sudo hyperfine -w 1 -p "rm -f initrd.img && sync" "3cpio -c initrd.img -C initrd < manifest" "cd initrd && bsdcpio -o -H newc < ../files | zstd -q1 -T0 > ../initrd.img" "cd initrd && cpio -o -H newc --reproducible < ../files | zstd -q1 -T0 > ../initrd.img" --export-markdown create-warm.md Benchmark 1: 3cpio -c initrd.img -C initrd < manifest Time (mean ± σ): 2.107 s ± 0.087 s [User: 0.153 s, System: 0.942 s] Range (min … max): 2.024 s … 2.260 s 10 runs Benchmark 2: cd initrd && bsdcpio -o -H newc < ../files | zstd -q1 -T0 > ../initrd.img Time (mean ± σ): 2.874 s ± 0.029 s [User: 3.237 s, System: 4.182 s] Range (min … max): 2.801 s … 2.903 s 10 runs Benchmark 3: cd initrd && cpio -o -H newc --reproducible < ../files | zstd -q1 -T0 > ../initrd.img Time (mean ± σ): 3.785 s ± 0.012 s [User: 3.428 s, System: 5.966 s] Range (min … max): 3.767 s … 3.801 s 10 runs Summary 3cpio -c initrd.img -C initrd < manifest ran 1.36 ± 0.06 times faster than cd initrd && bsdcpio -o -H newc < ../files | zstd -q1 -T0 > ../initrd.img 1.80 ± 0.07 times faster than cd initrd && cpio -o -H newc --reproducible < ../files | zstd -q1 -T0 > ../initrd.img $ stat -c %s initrd.img 57021773 ``` Cold caches: | Command | Mean [s] | Min [s] | Max [s] | Relative | |:---|---:|---:|---:|---:| | `3cpio -c initrd.img -C initrd < files` | 10.743 ± 0.213 | 10.470 | 11.176 | 1.00 | | `cd initrd && bsdcpio -o -H newc > ../initrd.img < ../files` | 12.200 ± 0.339 | 11.603 | 12.749 | 1.14 ± 0.04 | | `cd initrd && cpio -o -H newc --reproducible > ../initrd.img < ../files` | 12.154 ± 0.502 | 11.549 | 12.946 | 1.13 ± 0.05 | | Command | Mean [s] | Min [s] | Max [s] | Relative | |:---|---:|---:|---:|---:| | `3cpio -c initrd.img -C initrd < manifest` | 8.667 ± 0.291 | 8.364 | 9.127 | 1.00 | | `cd initrd && bsdcpio -o -H newc < ../files \| zstd -q1 -T0 > ../initrd.img` | 10.367 ± 0.891 | 9.507 | 11.742 | 1.20 ± 0.11 | | `cd initrd && cpio -o -H newc --reproducible < ../files \| zstd -q1 -T0 > ../initrd.img` | 10.276 ± 0.921 | 9.461 | 12.092 | 1.19 ± 0.11 | Warm caches (results rely heavily on the available amount of memory): | Command | Mean [s] | Min [s] | Max [s] | Relative | |:---|---:|---:|---:|---:| | `3cpio -c initrd.img -C initrd < files` | 2.460 ± 0.192 | 2.266 | 2.778 | 1.00 | | `cd initrd && bsdcpio -o -H newc > ../initrd.img < ../files` | 3.733 ± 0.013 | 3.716 | 3.762 | 1.52 ± 0.12 | | `cd initrd && cpio -o -H newc --reproducible > ../initrd.img < ../files` | 4.833 ± 0.009 | 4.821 | 4.845 | 1.96 ± 0.15 | | Command | Mean [s] | Min [s] | Max [s] | Relative | |:---|---:|---:|---:|---:| | `3cpio -c initrd.img -C initrd < manifest` | 2.107 ± 0.087 | 2.024 | 2.260 | 1.00 | | `cd initrd && bsdcpio -o -H newc < ../files \| zstd -q1 -T0 > ../initrd.img` | 2.874 ± 0.029 | 2.801 | 2.903 | 1.36 ± 0.06 | | `cd initrd && cpio -o -H newc --reproducible < ../files \| zstd -q1 -T0 > ../initrd.img` | 3.785 ± 0.012 | 3.767 | 3.801 | 1.80 ± 0.07 | The manifest parsing in 3cpio took 740 ms with a cold cache and 140 ms with a warm cache. AMD Ryzen 7 5700G ----------------- Benchmark results on a desktop machine with an AMD Ryzen 7 5700G running Ubuntu 24.04 (noble) on 2024-06-09. The tests were done in chroots that use overlayfs on tmpfs for writes. ``` $ schroot-wrapper -p initramfs-tools,linux-image-generic,zstd,busybox-initramfs,cryptsetup-initramfs,kbd,lvm2,mdadm,ntfs-3g,plymouth,plymouth-theme-spinner,hyperfine -u root -c noble (noble)root@desktop:~# ls -l /boot/initrd.img* lrwxrwxrwx 1 root root 27 Jun 4 23:37 /boot/initrd.img -> initrd.img-6.8.0-35-generic -rw-r--r-- 1 root root 70220742 Jun 4 23:37 /boot/initrd.img-6.8.0-35-generic (noble)root@desktop:~# 3cpio -t /boot/initrd.img | wc -l 2097 (noble)root@desktop:~# 3cpio -e /boot/initrd.img 0 cpio 77312 cpio 8033792 cpio 51411456 zstd (noble)root@desktop:~# hyperfine -p "rm -rf initrd" "3cpio -x /boot/initrd.img -C initrd" "unmkinitramfs /boot/initrd.img initrd" --export-markdown extract.md Benchmark 1: 3cpio -x /boot/initrd.img -C initrd Time (mean ± σ): 107.2 ms ± 1.0 ms [User: 3.8 ms, System: 91.9 ms] Range (min … max): 105.8 ms … 110.2 ms 27 runs Benchmark 2: unmkinitramfs /boot/initrd.img initrd Time (mean ± σ): 6.698 s ± 0.026 s [User: 5.106 s, System: 5.639 s] Range (min … max): 6.648 s … 6.724 s 10 runs Summary 3cpio -x /boot/initrd.img -C initrd ran 62.48 ± 0.62 times faster than unmkinitramfs /boot/initrd.img initrd ``` | Command | Mean [ms] | Min [ms] | Max [ms] | Relative | |:---|---:|---:|---:|---:| | `3cpio -x /boot/initrd.img -C initrd` | 107.2 ± 1.0 | 105.8 | 110.2 | 1.00 | | `unmkinitramfs /boot/initrd.img initrd` | 6697.5 ± 25.6 | 6647.8 | 6723.6 | 62.48 ± 0.62 | ``` (noble)root@desktop:~# hyperfine --warmup 1 "3cpio -t /boot/initrd.img" "lsinitramfs /boot/initrd.img" --export-markdown list.md Benchmark 1: 3cpio -t /boot/initrd.img Time (mean ± σ): 42.9 ms ± 0.5 ms [User: 1.7 ms, System: 13.9 ms] Range (min … max): 42.0 ms … 43.9 ms 68 runs Benchmark 2: lsinitramfs /boot/initrd.img Time (mean ± σ): 6.471 s ± 0.041 s [User: 5.054 s, System: 5.323 s] Range (min … max): 6.408 s … 6.536 s 10 runs Summary 3cpio -t /boot/initrd.img ran 150.68 ± 1.88 times faster than lsinitramfs /boot/initrd.img ``` | Command | Mean [ms] | Min [ms] | Max [ms] | Relative | |:---|---:|---:|---:|---:| | `3cpio -t /boot/initrd.img` | 42.9 ± 0.5 | 42.0 | 43.9 | 1.00 | | `lsinitramfs /boot/initrd.img` | 6471.0 ± 41.0 | 6408.1 | 6536.3 | 150.68 ± 1.88 | ``` $ schroot-wrapper -p initramfs-tools,linux-image-generic,zstd,busybox-initramfs,cryptsetup-initramfs,kbd,lvm2,mdadm,ntfs-3g,plymouth,plymouth-theme-spinner,hyperfine -u root -c jammy (jammy)root@desktop:~# ls -l /boot/initrd.img* lrwxrwxrwx 1 root root 29 Jun 4 23:49 /boot/initrd.img -> initrd.img-5.15.0-107-generic -rw-r--r-- 1 root root 112100650 Jun 4 23:50 /boot/initrd.img-5.15.0-107-generic (jammy)root@desktop:~# 3cpio -t /boot/initrd.img | wc -l 3789 (jammy)root@desktop:~# 3cpio -e /boot/initrd.img 0 cpio 77312 cpio 8033792 zstd (jammy)root@desktop:~# hyperfine -p "rm -rf initrd" "3cpio -x /boot/initrd.img -C initrd" "unmkinitramfs /boot/initrd.img initrd" --export-markdown extract.md Benchmark 1: 3cpio -x /boot/initrd.img -C initrd Time (mean ± σ): 455.1 ms ± 3.6 ms [User: 10.7 ms, System: 263.4 ms] Range (min … max): 451.5 ms … 464.5 ms 10 runs Benchmark 2: unmkinitramfs /boot/initrd.img initrd Time (mean ± σ): 2.217 s ± 0.008 s [User: 0.878 s, System: 2.264 s] Range (min … max): 2.198 s … 2.227 s 10 runs Summary '3cpio -x /boot/initrd.img -C initrd' ran 4.87 ± 0.04 times faster than 'unmkinitramfs /boot/initrd.img initrd' ``` | Command | Mean [ms] | Min [ms] | Max [ms] | Relative | |:---|---:|---:|---:|---:| | `3cpio -x /boot/initrd.img -C initrd` | 455.1 ± 3.6 | 451.5 | 464.5 | 1.00 | | `unmkinitramfs /boot/initrd.img initrd` | 2216.5 ± 8.3 | 2198.2 | 2227.3 | 4.87 ± 0.04 | ``` (jammy)root@desktop:~# hyperfine --warmup 1 "3cpio -t /boot/initrd.img" "lsinitramfs /boot/initrd.img" --export-markdown list.md Benchmark 1: 3cpio -t /boot/initrd.img Time (mean ± σ): 336.0 ms ± 6.3 ms [User: 5.5 ms, System: 77.8 ms] Range (min … max): 326.5 ms … 345.0 ms 10 runs Benchmark 2: lsinitramfs /boot/initrd.img Time (mean ± σ): 1.374 s ± 0.010 s [User: 0.725 s, System: 1.050 s] Range (min … max): 1.354 s … 1.393 s 10 runs Summary '3cpio -t /boot/initrd.img' ran 4.09 ± 0.08 times faster than 'lsinitramfs /boot/initrd.img' ``` | Command | Mean [ms] | Min [ms] | Max [ms] | Relative | |:---|---:|---:|---:|---:| | `3cpio -t /boot/initrd.img` | 336.0 ± 6.3 | 326.5 | 345.0 | 1.00 | | `lsinitramfs /boot/initrd.img` | 1374.3 ± 10.3 | 1354.0 | 1392.9 | 4.09 ± 0.08 | ``` $ schroot-wrapper -p initramfs-tools,linux-image-generic,firmware-linux,zstd,cryptsetup-initramfs,lvm2,kbd,mdadm,ntfs-3g,plymouth,console-setup,hyperfine -u root -c bookworm (bookworm)root@desktop:~# ( cd /boot && ln -s initrd.img-* initrd.img ) (bookworm)root@desktop:~# ls -l /boot/initrd.img* lrwxrwxrwx 1 root root 25 Jun 9 15:55 /boot/initrd.img -> initrd.img-6.1.0-21-amd64 -rw-r--r-- 1 root root 62448197 Jun 9 15:53 /boot/initrd.img-6.1.0-21-amd64 (bookworm)root@desktop:~# 3cpio -t /boot/initrd.img | wc -l 2935 (bookworm)root@desktop:~# 3cpio -e /boot/initrd.img 0 zstd (bookworm)root@desktop:~# hyperfine -p "rm -rf initrd" "3cpio -x /boot/initrd.img -C initrd" "unmkinitramfs /boot/initrd.img-6.1.0-21-amd64 initrd" --export-markdown extract.md Benchmark 1: 3cpio -x /boot/initrd.img -C initrd Time (mean ± σ): 267.5 ms ± 2.4 ms [User: 7.6 ms, System: 209.0 ms] Range (min … max): 264.8 ms … 273.2 ms 10 runs Benchmark 2: unmkinitramfs /boot/initrd.img-6.1.0-21-amd64 initrd Time (mean ± σ): 1.362 s ± 0.004 s [User: 0.681 s, System: 1.513 s] Range (min … max): 1.355 s … 1.368 s 10 runs Summary '3cpio -x /boot/initrd.img -C initrd' ran 5.09 ± 0.05 times faster than 'unmkinitramfs /boot/initrd.img-6.1.0-21-amd64 initrd' ``` | Command | Mean [ms] | Min [ms] | Max [ms] | Relative | |:---|---:|---:|---:|---:| | `3cpio -x /boot/initrd.img -C initrd` | 267.5 ± 2.4 | 264.8 | 273.2 | 1.00 | | `unmkinitramfs /boot/initrd.img-6.1.0-21-amd64 initrd` | 1361.7 ± 4.4 | 1354.6 | 1368.4 | 5.09 ± 0.05 | ``` (bookworm)root@desktop:~# hyperfine --warmup 1 "3cpio -t /boot/initrd.img" "lsinitramfs /boot/initrd.img-6.1.0-21-amd64" --export-markdown list.md Benchmark 1: 3cpio -t /boot/initrd.img Time (mean ± σ): 210.0 ms ± 2.3 ms [User: 4.8 ms, System: 66.2 ms] Range (min … max): 207.1 ms … 214.7 ms 14 runs Benchmark 2: lsinitramfs /boot/initrd.img-6.1.0-21-amd64 Time (mean ± σ): 571.8 ms ± 1.9 ms [User: 515.7 ms, System: 496.8 ms] Range (min … max): 568.7 ms … 574.5 ms 10 runs Summary '3cpio -t /boot/initrd.img' ran 2.72 ± 0.03 times faster than 'lsinitramfs /boot/initrd.img-6.1.0-21-amd64' ``` | Command | Mean [ms] | Min [ms] | Max [ms] | Relative | |:---|---:|---:|---:|---:| | `3cpio -t /boot/initrd.img` | 210.0 ± 2.3 | 207.1 | 214.7 | 1.00 | | `lsinitramfs /boot/initrd.img-6.1.0-21-amd64` | 571.8 ± 1.9 | 568.7 | 574.5 | 2.72 ± 0.03 | Benchmark results on a desktop machine with an AMD Ryzen 7 5700G running Ubuntu 24.04 (noble) on 2024-08-06. The tests were done in chroots that use overlayfs on tmpfs for writes: ``` $ schroot-wrapper -p initramfs-tools,linux-image-generic,firmware-linux,zstd,cryptsetup-initramfs,lvm2,kbd,mdadm,ntfs-3g,plymouth,console-setup,libarchive-tools,hyperfine -u root -c bookworm (bookworm)root@desktop:~# mv /boot/initrd.img-6.1.0-23-amd64{,.zstd} (bookworm)root@desktop:~# zstd --rm -d /boot/initrd.img-6.1.0-23-amd64.zstd (bookworm)root@desktop:~# ( cd /boot && ln -s initrd.img-* initrd.img ) (bookworm)root@desktop:~# ls -l /boot/initrd.img* lrwxrwxrwx 1 root root 25 Aug 6 01:57 /boot/initrd.img -> initrd.img-6.1.0-23-amd64 -rw-r--r-- 1 root root 282020864 Aug 6 01:56 /boot/initrd.img-6.1.0-23-amd64 (bookworm)root@desktop:~# 3cpio -t /boot/initrd.img | wc -l 2935 (bookworm)root@desktop:~# 3cpio -e /boot/initrd.img 0 cpio (bookworm)root@desktop:~# hyperfine -N -w 2 -r 100 "3cpio -t /boot/initrd.img" "bsdcpio -itF /boot/initrd.img" "cpio -t --file /boot/initrd.img" --export-markdown list.md Benchmark 1: 3cpio -t /boot/initrd.img Time (mean ± σ): 7.1 ms ± 0.1 ms [User: 1.4 ms, System: 5.6 ms] Range (min … max): 6.9 ms … 7.4 ms 100 runs Benchmark 2: bsdcpio -itF /boot/initrd.img Time (mean ± σ): 12.2 ms ± 0.3 ms [User: 2.4 ms, System: 9.7 ms] Range (min … max): 11.4 ms … 13.0 ms 100 runs Benchmark 3: cpio -t --file /boot/initrd.img Time (mean ± σ): 370.8 ms ± 2.7 ms [User: 41.7 ms, System: 329.0 ms] Range (min … max): 366.7 ms … 381.3 ms 100 runs Summary '3cpio -t /boot/initrd.img' ran 1.70 ± 0.05 times faster than 'bsdcpio -itF /boot/initrd.img' 51.96 ± 0.82 times faster than 'cpio -t --file /boot/initrd.img' (bookworm)root@desktop:~# hyperfine -N -w 2 -r 100 "3cpio -tv /boot/initrd.img" "bsdcpio -itvF /boot/initrd.img" "cpio -tv --file /boot/initrd.img" --export-markdown list-verbose.md Benchmark 1: 3cpio -tv /boot/initrd.img Time (mean ± σ): 9.1 ms ± 0.1 ms [User: 2.9 ms, System: 6.2 ms] Range (min … max): 8.8 ms … 9.5 ms 100 runs Benchmark 2: bsdcpio -itvF /boot/initrd.img Time (mean ± σ): 13.5 ms ± 0.4 ms [User: 4.1 ms, System: 9.3 ms] Range (min … max): 12.7 ms … 14.9 ms 100 runs Benchmark 3: cpio -tv --file /boot/initrd.img Time (mean ± σ): 383.3 ms ± 2.2 ms [User: 45.1 ms, System: 338.1 ms] Range (min … max): 379.6 ms … 390.0 ms 100 runs Summary '3cpio -tv /boot/initrd.img' ran 1.48 ± 0.05 times faster than 'bsdcpio -itvF /boot/initrd.img' 42.14 ± 0.58 times faster than 'cpio -tv --file /boot/initrd.img' ``` | Command | Mean [ms] | Min [ms] | Max [ms] | Relative | |:---|---:|---:|---:|---:| | `3cpio -t /boot/initrd.img` | 7.2 ± 0.1 | 6.9 | 7.5 | 1.00 | | `bsdcpio -itF /boot/initrd.img` | 12.6 ± 0.6 | 11.3 | 14.0 | 1.77 ± 0.09 | | `cpio -t --file /boot/initrd.img` | 375.1 ± 4.8 | 368.2 | 390.6 | 52.45 ± 1.00 | | Command | Mean [ms] | Min [ms] | Max [ms] | Relative | |:---|---:|---:|---:|---:| | `3cpio -tv /boot/initrd.img` | 9.1 ± 0.1 | 8.8 | 9.5 | 1.00 | | `bsdcpio -itvF /boot/initrd.img` | 13.5 ± 0.4 | 12.7 | 14.9 | 1.48 ± 0.05 | | `cpio -tv --file /boot/initrd.img` | 383.3 ± 2.2 | 379.6 | 390.0 | 42.14 ± 0.58 | Benchmark results on a desktop machine with an AMD Ryzen 7 5700G running Ubuntu 25.04 (plucky) on 2025-07-10. The tests were done in chroots that use overlayfs on tmpfs for writes. ``` $ schroot-wrapper -p initramfs-tools,linux-image-generic,cryptsetup-initramfs,lvm2,kbd,mdadm,ntfs-3g,plymouth,console-setup,libarchive-tools,hyperfine -u root -c noble (noble)root@desktop:~# ls -l /boot/initrd.img* lrwxrwxrwx 1 root root 27 Jul 9 23:47 /boot/initrd.img -> initrd.img-6.8.0-63-generic -rw-r--r-- 1 root root 67139659 Jul 9 23:47 /boot/initrd.img-6.8.0-63-generic (noble)root@desktop:~# 3cpio -x /boot/initrd.img -C initrd (noble)root@desktop:~# ( cd initrd && find . ) | sed -e 's,\./,,g' | sort > files (noble)root@desktop:~# wc -l < files 1901 (noble)root@desktop:~# hyperfine -w 2 -r 100 -p "rm -f initrd.img && sync && echo 3 > /proc/sys/vm/drop_caches" "3cpio -c initrd.img -C initrd < files" "cd initrd && bsdcpio -o -H newc > ../initrd.img < ../files" "cd initrd && cpio -o -H newc --reproducible > ../initrd.img < ../files" --export-markdown create-cold.md Benchmark 1: 3cpio -c initrd.img -C initrd < files Time (mean ± σ): 75.1 ms ± 4.3 ms [User: 3.9 ms, System: 63.8 ms] Range (min … max): 67.5 ms … 80.7 ms 100 runs Benchmark 2: cd initrd && bsdcpio -o -H newc > ../initrd.img < ../files Time (mean ± σ): 270.0 ms ± 6.5 ms [User: 19.1 ms, System: 230.5 ms] Range (min … max): 256.6 ms … 281.4 ms 100 runs Benchmark 3: cd initrd && cpio -o -H newc --reproducible > ../initrd.img < ../files Time (mean ± σ): 336.7 ms ± 5.0 ms [User: 31.2 ms, System: 298.6 ms] Range (min … max): 328.2 ms … 348.8 ms 100 runs Summary 3cpio -c initrd.img -C initrd < files ran 3.59 ± 0.22 times faster than cd initrd && bsdcpio -o -H newc > ../initrd.img < ../files 4.48 ± 0.27 times faster than cd initrd && cpio -o -H newc --reproducible > ../initrd.img < ../files (noble)root@desktop:~# hyperfine -w 2 -r 100 -p "rm -f initrd.img && sync" "3cpio -c initrd.img -C initrd < files" "cd initrd && bsdcpio -o -H newc > ../initrd.img < ../files" "cd initrd && cpio -o -H newc --reproducible > ../initrd.img < ../files" --export-markdown create-warm.md Benchmark 1: 3cpio -c initrd.img -C initrd < files Time (mean ± σ): 60.8 ms ± 0.9 ms [User: 3.9 ms, System: 56.9 ms] Range (min … max): 58.5 ms … 62.5 ms 100 runs Benchmark 2: cd initrd && bsdcpio -o -H newc > ../initrd.img < ../files Time (mean ± σ): 237.2 ms ± 3.2 ms [User: 18.3 ms, System: 218.8 ms] Range (min … max): 231.6 ms … 244.8 ms 100 runs Benchmark 3: cd initrd && cpio -o -H newc --reproducible > ../initrd.img < ../files Time (mean ± σ): 322.5 ms ± 4.1 ms [User: 29.6 ms, System: 292.8 ms] Range (min … max): 315.4 ms … 336.1 ms 100 runs Summary 3cpio -c initrd.img -C initrd < files ran 3.90 ± 0.08 times faster than cd initrd && bsdcpio -o -H newc > ../initrd.img < ../files 5.30 ± 0.10 times faster than cd initrd && cpio -o -H newc --reproducible > ../initrd.img < ../files (noble)root@desktop:~# stat -c %s initrd.img 83589120 (noble)root@desktop:~# { echo "#cpio: zstd -1" && cat files; } > manifest (noble)root@desktop:~# hyperfine -w 2 -r 100 -p "rm -f initrd.img && sync && echo 3 > /proc/sys/vm/drop_caches" "3cpio -c initrd.img -C initrd < manifest" "cd initrd && bsdcpio -o -H newc < ../files | zstd -q1 -T0 > ../initrd.img" "cd initrd && cpio -o -H newc --reproducible < ../files | zstd -q1 -T0 > ../initrd.img" --export-markdown create-cold.md Benchmark 1: 3cpio -c initrd.img -C initrd < manifest Time (mean ± σ): 80.8 ms ± 4.6 ms [User: 6.0 ms, System: 62.7 ms] Range (min … max): 72.9 ms … 89.6 ms 100 runs Benchmark 2: cd initrd && bsdcpio -o -H newc < ../files | zstd -q1 -T0 > ../initrd.img Time (mean ± σ): 179.6 ms ± 6.2 ms [User: 161.9 ms, System: 260.8 ms] Range (min … max): 167.9 ms … 192.9 ms 100 runs Benchmark 3: cd initrd && cpio -o -H newc --reproducible < ../files | zstd -q1 -T0 > ../initrd.img Time (mean ± σ): 322.6 ms ± 4.8 ms [User: 193.4 ms, System: 464.3 ms] Range (min … max): 312.5 ms … 333.1 ms 100 runs Summary 3cpio -c initrd.img -C initrd < manifest ran 2.22 ± 0.15 times faster than cd initrd && bsdcpio -o -H newc < ../files | zstd -q1 -T0 > ../initrd.img 3.99 ± 0.24 times faster than cd initrd && cpio -o -H newc --reproducible < ../files | zstd -q1 -T0 > ../initrd.img (noble)root@desktop:~# hyperfine -w 2 -r 100 -p "rm -f initrd.img && sync" "3cpio -c initrd.img -C initrd < manifest" "cd initrd && bsdcpio -o -H newc < ../files | zstd -q1 -T0 > ../initrd.img" "cd initrd && cpio -o -H newc --reproducible < ../files | zstd -q1 -T0 > ../initrd.img" --export-markdown create-warm.md Benchmark 1: 3cpio -c initrd.img -C initrd < manifest Time (mean ± σ): 63.2 ms ± 1.7 ms [User: 6.6 ms, System: 54.1 ms] Range (min … max): 60.0 ms … 68.1 ms 100 runs Benchmark 2: cd initrd && bsdcpio -o -H newc < ../files | zstd -q1 -T0 > ../initrd.img Time (mean ± σ): 151.7 ms ± 2.3 ms [User: 160.4 ms, System: 248.2 ms] Range (min … max): 147.1 ms … 158.2 ms 100 runs Benchmark 3: cd initrd && cpio -o -H newc --reproducible < ../files | zstd -q1 -T0 > ../initrd.img Time (mean ± σ): 306.1 ms ± 3.0 ms [User: 194.7 ms, System: 456.0 ms] Range (min … max): 300.2 ms … 313.6 ms 100 runs Summary 3cpio -c initrd.img -C initrd < manifest ran 2.40 ± 0.07 times faster than cd initrd && bsdcpio -o -H newc < ../files | zstd -q1 -T0 > ../initrd.img 4.84 ± 0.14 times faster than cd initrd && cpio -o -H newc --reproducible < ../files | zstd -q1 -T0 > ../initrd.img (noble)root@desktop:~# stat -c %s initrd.img 67215993 ``` Cold cache: | Command | Mean [ms] | Min [ms] | Max [ms] | Relative | |:---|---:|---:|---:|---:| | `3cpio -c initrd.img -C initrd < files` | 75.1 ± 4.3 | 67.5 | 80.7 | 1.00 | | `cd initrd && bsdcpio -o -H newc > ../initrd.img < ../files` | 270.0 ± 6.5 | 256.6 | 281.4 | 3.59 ± 0.22 | | `cd initrd && cpio -o -H newc --reproducible > ../initrd.img < ../files` | 336.7 ± 5.0 | 328.2 | 348.8 | 4.48 ± 0.27 | | Command | Mean [ms] | Min [ms] | Max [ms] | Relative | |:---|---:|---:|---:|---:| | `3cpio -c initrd.img -C initrd < manifest` | 80.8 ± 4.6 | 72.9 | 89.6 | 1.00 | | `cd initrd && bsdcpio -o -H newc < ../files \| zstd -q1 -T0 > ../initrd.img` | 179.6 ± 6.2 | 167.9 | 192.9 | 2.22 ± 0.15 | | `cd initrd && cpio -o -H newc --reproducible < ../files \| zstd -q1 -T0 > ../initrd.img` | 322.6 ± 4.8 | 312.5 | 333.1 | 3.99 ± 0.24 | Warm cache: | Command | Mean [ms] | Min [ms] | Max [ms] | Relative | |:---|---:|---:|---:|---:| | `3cpio -c initrd.img -C initrd < files` | 60.8 ± 0.9 | 58.5 | 62.5 | 1.00 | | `cd initrd && bsdcpio -o -H newc > ../initrd.img < ../files` | 237.2 ± 3.2 | 231.6 | 244.8 | 3.90 ± 0.08 | | `cd initrd && cpio -o -H newc --reproducible > ../initrd.img < ../files` | 322.5 ± 4.1 | 315.4 | 336.1 | 5.30 ± 0.10 | | Command | Mean [ms] | Min [ms] | Max [ms] | Relative | |:---|---:|---:|---:|---:| | `3cpio -c initrd.img -C initrd < manifest` | 63.2 ± 1.7 | 60.0 | 68.1 | 1.00 | | `cd initrd && bsdcpio -o -H newc < ../files \| zstd -q1 -T0 > ../initrd.img` | 151.7 ± 2.3 | 147.1 | 158.2 | 2.40 ± 0.07 | | `cd initrd && cpio -o -H newc --reproducible < ../files \| zstd -q1 -T0 > ../initrd.img` | 306.1 ± 3.0 | 300.2 | 313.6 | 4.84 ± 0.14 | The manifest parsing in 3cpio took 21 ms with a cold cache and 6 ms with a warm cache. Benchmark results on a desktop machine with an AMD Ryzen 7 5700G running Ubuntu 25.04 (plucky) on a Samsung SSD 980 PRO NMVe with Dracut on 2025-07-10: ``` $ ls -l /boot/initrd.img* lrwxrwxrwx 1 root root 28 Jul 2 11:35 /boot/initrd.img -> initrd.img-6.14.0-23-generic -rw------- 1 root root 28276693 Jul 4 09:58 /boot/initrd.img-6.14.0-23-generic $ sudo 3cpio -x /boot/initrd.img -C initrd $ ( cd initrd && find . ) | sed -e 's,\./,,g' | sort > files $ wc -l < files 1714 $ sudo hyperfine -w 1 -p "rm -f initrd.img && sync && echo 3 > /proc/sys/vm/drop_caches" "3cpio -c initrd.img -C initrd < files" "cd initrd && bsdcpio -o -H newc > ../initrd.img < ../files" "cd initrd && cpio -o -H newc --reproducible > ../initrd.img < ../files" --export-markdown create-cold.md Benchmark 1: 3cpio -c initrd.img -C initrd < files Time (mean ± σ): 257.2 ms ± 3.4 ms [User: 5.4 ms, System: 111.2 ms] Range (min … max): 252.8 ms … 262.4 ms 10 runs Benchmark 2: cd initrd && bsdcpio -o -H newc > ../initrd.img < ../files Time (mean ± σ): 490.5 ms ± 5.8 ms [User: 19.7 ms, System: 341.8 ms] Range (min … max): 482.1 ms … 497.7 ms 10 runs Benchmark 3: cd initrd && cpio -o -H newc --reproducible > ../initrd.img < ../files Time (mean ± σ): 559.3 ms ± 6.3 ms [User: 33.9 ms, System: 416.1 ms] Range (min … max): 547.5 ms … 570.8 ms 10 runs Summary 3cpio -c initrd.img -C initrd < files ran 1.91 ± 0.03 times faster than cd initrd && bsdcpio -o -H newc > ../initrd.img < ../files 2.17 ± 0.04 times faster than cd initrd && cpio -o -H newc --reproducible > ../initrd.img < ../files $ sudo hyperfine -w 2 -p "rm -f initrd.img && sync" "3cpio -c initrd.img -C initrd < files" "cd initrd && bsdcpio -o -H newc > ../initrd.img < ../files" "cd initrd && cpio -o -H newc --reproducible > ../initrd.img < ../files" --export-markdown create-warm.md Benchmark 1: 3cpio -c initrd.img -C initrd < files Time (mean ± σ): 65.1 ms ± 1.4 ms [User: 3.6 ms, System: 61.4 ms] Range (min … max): 63.1 ms … 68.2 ms 33 runs Benchmark 2: cd initrd && bsdcpio -o -H newc > ../initrd.img < ../files Time (mean ± σ): 298.8 ms ± 3.2 ms [User: 16.0 ms, System: 282.7 ms] Range (min … max): 295.6 ms … 304.3 ms 10 runs Benchmark 3: cd initrd && cpio -o -H newc --reproducible > ../initrd.img < ../files Time (mean ± σ): 382.6 ms ± 7.6 ms [User: 30.8 ms, System: 351.7 ms] Range (min … max): 370.2 ms … 393.2 ms 10 runs Summary 3cpio -c initrd.img -C initrd < files ran 4.59 ± 0.11 times faster than cd initrd && bsdcpio -o -H newc > ../initrd.img < ../files 5.87 ± 0.17 times faster than cd initrd && cpio -o -H newc --reproducible > ../initrd.img < ../files $ stat -c %s initrd.img 68406784 ``` Cold cache: | Command | Mean [ms] | Min [ms] | Max [ms] | Relative | |:---|---:|---:|---:|---:| | `3cpio -c initrd.img -C initrd < files` | 257.2 ± 3.4 | 252.8 | 262.4 | 1.00 | | `cd initrd && bsdcpio -o -H newc > ../initrd.img < ../files` | 490.5 ± 5.8 | 482.1 | 497.7 | 1.91 ± 0.03 | | `cd initrd && cpio -o -H newc --reproducible > ../initrd.img < ../files` | 559.3 ± 6.3 | 547.5 | 570.8 | 2.17 ± 0.04 | Warm cache: | Command | Mean [ms] | Min [ms] | Max [ms] | Relative | |:---|---:|---:|---:|---:| | `3cpio -c initrd.img -C initrd < files` | 65.1 ± 1.4 | 63.1 | 68.2 | 1.00 | | `cd initrd && bsdcpio -o -H newc > ../initrd.img < ../files` | 298.8 ± 3.2 | 295.6 | 304.3 | 4.59 ± 0.11 | | `cd initrd && cpio -o -H newc --reproducible > ../initrd.img < ../files` | 382.6 ± 7.6 | 370.2 | 393.2 | 5.87 ± 0.17 | The manifest parsing in 3cpio took 40 ms with a cold cache and 5 ms with a warm cache. threecpio-0.11.0/man/3cpio.1.adoc000064400000000000000000000333251046102023000144520ustar 000000000000003cpio(1) ======== Benjamin Drung :doctype: manpage :manmanual: 3cpio :mansource: 3cpio 0.5.1 :manversion: 0.5.1 == Name 3cpio - manage initrd cpio archives == Synopsis *3cpio* *--count* _ARCHIVE_ *3cpio* {*-c*|*--create*} [*-v*|*--debug*] [*-C* _DIR_] [*--data-align* _ALIGNMENT_] [_ARCHIVE_] < _manifest_ *3cpio* {*-e*|*--examine*} [*--raw*] _ARCHIVE_ *3cpio* {*-t*|*--list*} [*-v*|*--debug*] [*-P* _LIST_] _ARCHIVE_ [_pattern_...] *3cpio* {*-x*|*--extract*} [*-v*|*--debug*] [*-C* _DIR_] [*-P* _LIST_] [*-p*] [*-s* _NAME_] [*--to-stdout*] [*--force*] _ARCHIVE_ [_pattern_...] *3cpio* {*-V*|*--version*} *3cpio* {*-h*|*--help*} == Description *3cpio* is a tool to manage initramfs cpio files for the Linux kernel. The Linux kernel's https://www.kernel.org/doc/html/latest/driver-api/early-userspace/buffer-format.html[initramfs buffer format] is based around the `newc` or `crc` cpio formats. Multiple cpio archives can be concatenated and the last archive can be compressed. Different compression algorithms can be used depending on what support was compiled into the Linux kernel. *3cpio* is tailored to initramfs cpio files and will not gain support for other cpio formats. Following compression formats are supported: bzip2, gzip, lz4, lzma, lzop, xz, zstd. == Modes *--count* _ARCHIVE_:: Print the number of concatenated cpio archives. *-c*, *--create* [_ARCHIVE_]:: Create a new cpio archive. Read the manifest from the standard input. See the MANIFEST section for the description of the manifest format. Write the cpio archive to standard output or to the specified _ARCHIVE_ file if provided. The permission of the _ARCHIVE_ file will be determined by the permission of the input files (to avoid leaking sensitive information). *-e*, *--examine* _ARCHIVE_:: List the offsets of the cpio archives and their compression in a formatted table. SI prefixes are used for the size unit (e. g. 1 kB = 1000 bytes). Do not rely on the output to have a specific layout. Use the *--raw* option for a machine-readable output. Then each line will contain these five values tab-separated: Start::: The beginning of the cpio archive in bytes. End::: The end of the cpio archive in bytes. Size::: The on-disk size of the cpio archive in bytes (end - start). Compression::: Compression of the cpio archive (`cpio` in case it is not compressed). Extracted size::: Size of the files inside the cpio archive in bytes. *-t*, *--list* _ARCHIVE_ [_pattern_...]:: List the contents of the cpio archives. By default only the file names are printed. If *--verbose* is specified, the long listing format is used (similar to ls --long). If *--debug* is specified, the inode is printed in addition to the long format. If one or more __pattern__s are supplied, list only file names matching at least one of those __pattern__s. These __pattern__s are shell wildcard patterns (see *glob*(7)). *-x*, *--extract* _ARCHIVE_ [_pattern_...]:: Extract cpio archives. If one or more __pattern__s are supplied, extract only files matching at least one of those __pattern__s. These __pattern__s are shell wildcard patterns (see *glob*(7)). *-V*, *--version*:: Print version number. *-h*, *--help*:: Print help message. == Options *--data-align* _ALIGNMENT_:: When creating a cpio archive, pad the cpio metadata to align the file data on _ALIGNMENT_ in bytes. This option is useful to reflink cpio file data on file systems that support reflinks. This padding/alignment will be skipped for files that are smaller than _ALIGNMENT_, files where the padded namesize field would exceed `PATH_MAX`, and for cpio archives that are compressed. _ALIGNMENT_ must be a multiple of 4 bytes. This option is only taken into account in the *--create* mode. + The resulting cpio archive may be highly fragmented, which can lead to performance degradation when reading/extracting the image from devices with slow random IO (e.g. spinning disk). + **Note**: Using this option will "bend" the cpio newc spec a bit to inject zeros after the filename to provide data segment alignment. These zeros are accounted for in the namesize, but some applications may only expect a single zero-terminator (and 4 byte alignment). GNU cpio and Linux initramfs handle this fine as long as `PATH_MAX` is not exceeded. + The following command can be used to determine the optimal transfer size of the file system (where _$path_ is the path the cpio archive will be written to): + stat --file-system -c "%s" -- "$path" *-C* _DIR_, *--directory*=_DIR_:: Change directory before performing any operation, but after opening the _ARCHIVE_. This option is only taken into account in the *--extract* mode. *--make-directories*: Create leading directories where needed. This option is only taken into account in the *--extract* mode. *-P* _LIST_, *--parts* _LIST_:: Only operate on the cpio archives that matches _LIST_. This option is only taken into account in the *--list* and *--extract* modes. _LIST_ is made up of one range, or many ranges separated by commas. Each range is one of: N::: N'th cpio archive, counted from 1 N-::: from N'th (included) to last cpio archive N-M::: from N'th to M'th (included) cpio archive -M::: from first to M'th (included) cpio archive *-p*, *--preserve-permissions*:: Set permissions of extracted files to those recorded in the archive (default for superuser). This option is only taken into account in the *--extract* mode. *--raw*:: Use a machine-readable output format. This format is designed for easy parsing and is intended for being used in scripts. This option is only taken into account in the *--examine* mode. *-s* _NAME_, *--subdir*=_NAME_:: Extract the cpio archives into separate sub-directories (using the given _NAME_ plus an incrementing number). This option is only taken into account in the *--extract* mode. *--to-stdout*:: Extract files to standard output. Only the content of the files will be written to stdout. Directories and symlinks will be ignored. This option is only taken into account in the *--extract* mode. *-v*, *--verbose*:: Verbose output. This option is only taken into account in the *--extract* and *--list* modes. *--debug*:: Debug output. This option is only taken into account in the *--extract* and *--list* modes. *--force*:: Force overwriting existing files. This option is only taken into account in the *--extract* mode. == Manifest When generating initrd cpio archives, following manifest format will be used. The manifest is a text format that is parsed line by line. If the line starts with _#cpio_ it is interpreted as section marker to start a new cpio. A compression may be specified by adding a colon followed by the compression format and an optional compression level. Example for a Zstandard-compressed cpio with compression level 9: ---- #cpio: zstd -9 ---- All lines starting with _#_ excluding _#cpio_ (see above) will be treated as comments and will be ignored. Each element in the line is separated by a tab and is expected to be one of the following file types: ---- file dir block char link fifo sock ---- fifo is also known as named pipe (see fifo(7)). In case an element is empty or equal to - it is treated as not specified and it is derived from the input file. :: Path of the input file. It can be left unspecified in case all other needed fields are specified (and the file is otherwise empty). *Limitation*: The path must not start with #, be equal to -, or contain tabs. :: Path of the file inside the cpio. If the name is left unspecified it will be derived from . *Limitation*: The path must not be equal to - or contain tabs. :: File mode specified in octal. :: User ID (owner) of the file specified in decimal. :: Group ID of the file specified in decimal. :: Modification time of the file specified as seconds since the Epoch (1970-01-01 00:00 UTC). The specified time might be clamped by the time set in the SOURCE_DATE_EPOCH environment variable. :: Size of the input file in bytes. 3cpio will fail in case the input file is smaller than the provided file size. :: Major block/character device number in decimal. :: Minor block/character device number in decimal. :: Target of the symbolic link. *Limitation*: The target path must not be equal to - or contain tabs. *Limitations*: Files cannot start with # (will be treated as comment), be equal to - (will be treated as not specified), or contain tabs (will be split by tabs). These limitations of the manifest file are not expected to cause problems in practice. == Environment variables SOURCE_DATE_EPOCH:: This environment variable will be taken into account when creating cpio archive. All modification times that are newer than the time specified in "SOURCE_DATE_EPOCH" will be clamped. Compressors will run with only one thread in case their multithreading implementation is not reproducible. The created cpio archive will be reproducible across multiple runs. == Exit status *0*:: Success. *1*:: Failure. *2*:: Failure during command line argument parsing. == Examples List the number of cpio archives that an initramfs file contains: [example,shell] ---- $ 3cpio --count /boot/initrd.img 4 ---- Examine the content of the initramfs cpio on an Ubuntu 24.04 system: [example,shell] ---- $ 3cpio --examine /boot/initrd.img Start End Size Compr. Extracted 0 B 148 kB 148 kB cpio 147 kB 148 kB 13.3 MB 13.1 MB cpio 13.1 MB 13.3 MB 55.2 MB 41.9 MB cpio 41.7 MB 55.2 MB 62.0 MB 6.74 MB zstd 15.6 MB ---- There is also a machine-readable output format available: [example,shell] ---- $ 3cpio --examine --raw /boot/initrd.img 0 148480 148480 cpio 147350 148480 13275136 13126656 cpio 13125632 13275136 55215104 41939968 cpio 41692226 55215104 61956920 6741816 zstd 15616306 ---- This initramfs cpio consists of three uncompressed cpio archives followed by a Zstandard-compressed cpio archive. List the content of the initramfs cpio on an Ubuntu 24.04 system: [example,shell] ---- $ 3cpio --list /boot/initrd.img . kernel kernel/x86 kernel/x86/microcode kernel/x86/microcode/AuthenticAMD.bin kernel kernel/x86 kernel/x86/microcode kernel/x86/microcode/.enuineIntel.align.0123456789abc kernel/x86/microcode/GenuineIntel.bin . usr usr/lib usr/lib/firmware usr/lib/firmware/3com usr/lib/firmware/3com/typhoon.bin.zst [...] ---- The first cpio contains only the AMD microcode. The second cpio contains only the Intel microcode. The third cpio contains firmware files and kernel modules. Extract the content of the initramfs cpio to the initrd subdirectory on an Ubuntu 24.04 system: [example,shell] ---- $ 3cpio --extract -C initrd /boot/initrd.img $ ls initrd bin cryptroot init lib lib.usr-is-merged run scripts var conf etc kernel lib64 libx32 sbin usr ---- Create a cpio archive similar to the other cpio tools using the `find` command: [example,shell] ---- $ cd inputdir && find . | sort | 3cpio --create ../example.cpio ---- Due to its manifest file format support, 3cpio can create cpio archives without the need of copying files into a temporary directory first. Example for creating an early microcode cpio image directly using the system installed files: [example,shell] ---- $ cat manifest - kernel dir 755 0 0 1751654557 - kernel/x86 dir 755 0 0 1752011622 /usr/lib/firmware/amd-ucode kernel/x86/microcode /usr/lib/firmware/amd-ucode/microcode_amd_fam19h.bin kernel/x86/microcode/AuthenticAMD.bin $ 3cpio --create amd-ucode.img < manifest $ 3cpio --list --verbose amd-ucode.img drwxr-xr-x 2 root root 0 Jul 4 20:42 kernel drwxr-xr-x 2 root root 0 Jul 8 23:53 kernel/x86 drwxr-xr-x 2 root root 0 Jun 10 10:51 kernel/x86/microcode -rw-r--r-- 1 root root 100684 Mar 23 22:42 kernel/x86/microcode/AuthenticAMD.bin ---- Example for creating an initrd image containing of an uncompressed early microcode cpio followed by a Zstandard-compressed cpio: [example,shell] ---- $ cat manifest #cpio - kernel dir 755 0 0 1751654557 - kernel/x86 dir 755 0 0 1752011622 /usr/lib/firmware/amd-ucode kernel/x86/microcode /usr/lib/firmware/amd-ucode/microcode_amd_fam19h.bin kernel/x86/microcode/AuthenticAMD.bin #cpio: zstd -9 / /bin /usr /usr/bin /usr/bin/bash # This is a comment. Leaving the remaining files as task for the reader. $ 3cpio --create initrd.img < manifest $ 3cpio --examine initrd.img Start End Size Compr. Extracted 0 B 101 kB 101 kB cpio 101 kB 101 kB 786 kB 685 kB zstd 1.45 MB $ 3cpio --list --verbose initrd.img drwxr-xr-x 2 root root 0 Jul 4 20:42 kernel drwxr-xr-x 2 root root 0 Jul 8 23:53 kernel/x86 drwxr-xr-x 2 root root 0 Jun 10 10:51 kernel/x86/microcode -rw-r--r-- 1 root root 100684 Mar 23 22:42 kernel/x86/microcode/AuthenticAMD.bin drwxr-xr-x 2 root root 0 Jun 5 14:11 . lrwxrwxrwx 1 root root 7 Mar 20 2022 bin -> usr/bin drwxr-xr-x 2 root root 0 Apr 20 2023 usr drwxr-xr-x 2 root root 0 Jul 9 09:56 usr/bin -rwxr-xr-x 1 root root 1446024 Mar 31 2024 usr/bin/bash ---- == See also bsdcpio(1), cpio(1), lsinitramfs(8), lsinitrd(1) == Copying Copyright (C) 2024-2025 Benjamin Drung. Free use of this software is granted under the terms of the ISC License. threecpio-0.11.0/man/README.md000064400000000000000000000002361046102023000137200ustar 000000000000003cpio man pages =============== The 3cpio man page can be build with [Asciidoctor](https://asciidoctor.org/): ```sh asciidoctor -b manpage 3cpio.1.adoc ``` threecpio-0.11.0/src/compression.rs000064400000000000000000000277301046102023000153740ustar 00000000000000// Copyright (C) 2025, Benjamin Drung // SPDX-License-Identifier: ISC use std::fs::File; use std::io::{Error, ErrorKind, Read, Result, Seek, SeekFrom}; use std::process::{Child, ChildStdout, Command, Stdio}; #[derive(Debug, PartialEq)] pub(crate) enum Compression { Uncompressed, Bzip2 { level: Option, }, Gzip { level: Option, }, Lz4 { level: Option, }, Lzma { level: Option, }, Lzop { level: Option, }, Xz { level: Option, }, Zstd { level: Option, }, #[cfg(test)] NonExistent, #[cfg(test)] Failing, } impl Compression { pub(crate) fn from_magic_number(magic_number: [u8; 4]) -> Result { let compression = match magic_number { [0x42, 0x5A, 0x68, _] => Compression::Bzip2 { level: None }, [0x30, 0x37, 0x30, 0x37] => Compression::Uncompressed, [0x1F, 0x8B, _, _] => Compression::Gzip { level: None }, // Different magic numbers (little endian) for lz4: // v0.1-v0.9: 0x184C2102 // v1.0-v1.3: 0x184C2103 // v1.4+: 0x184D2204 [0x02, 0x21, 0x4C, 0x18] | [0x03, 0x21, 0x4C, 0x18] | [0x04, 0x22, 0x4D, 0x18] => { Compression::Lz4 { level: None } } [0x5D, _, _, _] => Compression::Lzma { level: None }, // Full magic number for lzop: [0x89, 0x4C, 0x5A, 0x4F, 0x00, 0x0D, 0x0A, 0x1A, 0x0A] [0x89, 0x4C, 0x5A, 0x4F] => Compression::Lzop { level: None }, // Full magic number for xz: [0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00] [0xFD, 0x37, 0x7A, 0x58] => Compression::Xz { level: None }, [0x28, 0xB5, 0x2F, 0xFD] => Compression::Zstd { level: None }, _ => { return Err(Error::new( ErrorKind::InvalidData, format!( "Failed to determine CPIO or compression magic number: 0x{:02x}{:02x}{:02x}{:02x} (big endian)", magic_number[0], magic_number[1], magic_number[2], magic_number[3] ), )); } }; Ok(compression) } fn from_str(name: &str) -> Result { let compression = match name { "" => Self::Uncompressed, "bzip2" => Self::Bzip2 { level: None }, "gzip" => Self::Gzip { level: None }, "lz4" => Self::Lz4 { level: None }, "lzma" => Self::Lzma { level: None }, "lzop" => Self::Lzop { level: None }, "xz" => Self::Xz { level: None }, "zstd" => Self::Zstd { level: None }, _ => { return Err(Error::new( ErrorKind::InvalidData, format!("Unknown compression format: {name}"), )); } }; Ok(compression) } fn set_level(&mut self, new_level: u32) { match self { Self::Bzip2 { level } | Self::Gzip { level } | Self::Lz4 { level } | Self::Lzma { level } | Self::Lzop { level } | Self::Xz { level } | Self::Zstd { level } => { *level = Some(new_level); } Self::Uncompressed => {} #[cfg(test)] Self::NonExistent | Self::Failing => {} }; } pub(crate) fn from_command_line(line: &str) -> Result { let mut iter = line.split_whitespace(); let mut compression = if let Some(cmd) = iter.next() { Self::from_str(cmd)? } else { Self::Uncompressed }; for parameter in iter { match parameter.strip_prefix("-") { Some(value) => { if let Ok(mut level) = value.parse::() { let (min, max) = match compression { Self::Uncompressed => (0, 0), Self::Bzip2 { level: _ } => (1, 9), Self::Gzip { level: _ } => (1, 9), Self::Lz4 { level: _ } => (1, 12), Self::Lzma { level: _ } => (0, 9), Self::Lzop { level: _ } => (1, 9), Self::Xz { level: _ } => (0, 9), Self::Zstd { level: _ } => (1, 19), #[cfg(test)] Self::NonExistent | Self::Failing => (0, 0), }; if level < min { eprintln!( "Compression level {level} lower than minimum, raising to {min}." ); level = min; } else if level > max { eprintln!( "Compression level {level} higher than maximum, reducing to {max}." ); level = max; } compression.set_level(level.try_into().unwrap()); } else { eprintln!( "Unknown/unsupported compression parameter '{parameter}'. Ignoring it.", ) } } None => { eprintln!( "Unknown/unsupported compression parameter '{parameter}'. Ignoring it.", ) } } } Ok(compression) } pub(crate) fn command(&self) -> &str { match self { Self::Uncompressed => "cpio", Self::Bzip2 { level: _ } => "bzip2", Self::Gzip { level: _ } => "gzip", Self::Lz4 { level: _ } => "lz4", Self::Lzma { level: _ } => "lzma", Self::Lzop { level: _ } => "lzop", Self::Xz { level: _ } => "xz", Self::Zstd { level: _ } => "zstd", #[cfg(test)] Self::NonExistent => "non-existing-program", #[cfg(test)] Self::Failing => "false", } } pub(crate) fn compress( &self, file: Option, source_date_epoch: Option, size: impl FnOnce() -> u64, ) -> Result { let mut command = self.compress_command(source_date_epoch, size); // TODO: Propper error message if spawn fails command.stdin(Stdio::piped()); if let Some(file) = file { command.stdout(file); } let cmd = command.spawn().map_err(|e| match e.kind() { ErrorKind::NotFound => Error::other(format!( "Program '{}' not found in PATH.", command.get_program().to_str().unwrap() )), _ => e, })?; Ok(cmd) } fn compress_command( &self, source_date_epoch: Option, size: impl FnOnce() -> u64, ) -> Command { let mut command = Command::new(self.command()); match self { Self::Gzip { level: _ } => { command.arg("-n"); } Self::Lz4 { level: _ } => { command.arg("-l"); } Self::Xz { level: _ } => { command.arg("--check=crc32"); } Self::Zstd { level: _ } => { command.arg("-q"); } Self::Uncompressed | Self::Bzip2 { level: _ } | Self::Lzma { level: _ } | Self::Lzop { level: _ } => {} #[cfg(test)] Self::NonExistent | Self::Failing => {} }; match self { Self::Bzip2 { level: Some(level) } | Self::Gzip { level: Some(level) } | Self::Lz4 { level: Some(level) } | Self::Lzma { level: Some(level) } | Self::Lzop { level: Some(level) } | Self::Xz { level: Some(level) } | Self::Zstd { level: Some(level) } => { command.arg(format!("-{level}")); } Self::Uncompressed | Self::Bzip2 { level: None } | Self::Gzip { level: None } | Self::Lz4 { level: None } | Self::Lzma { level: None } | Self::Lzop { level: None } | Self::Xz { level: None } | Self::Zstd { level: None } => {} #[cfg(test)] Self::NonExistent | Self::Failing => {} }; // If we're not doing a reproducible build, enable multithreading if source_date_epoch.is_none() && matches!( self, Self::Lzma { level: _ } | Self::Xz { level: _ } | Self::Zstd { level: _ } ) { command.arg("-T0"); } else if source_date_epoch.is_some() && matches!(self, Self::Lzma { level: _ } | Self::Xz { level: _ }) { command.arg("-T1"); } if matches!(self, Self::Zstd { level: _ }) { command.arg(format!("--stream-size={}", size())); } command } pub(crate) fn decompress(&self, file: File) -> Result { let mut command = self.decompress_command(); // TODO: Propper error message if spawn fails let cmd = command .stdin(file) .stdout(Stdio::piped()) .spawn() .map_err(|e| match e.kind() { ErrorKind::NotFound => Error::other(format!( "Program '{}' not found in PATH.", command.get_program().to_str().unwrap() )), _ => e, })?; // TODO: Should unwrap be replaced by returning Result? Ok(cmd.stdout.unwrap()) } fn decompress_command(&self) -> Command { let mut command = Command::new(self.command()); match self { Self::Bzip2 { level: _ } | Self::Gzip { level: _ } | Self::Lz4 { level: _ } | Self::Lzma { level: _ } | Self::Lzop { level: _ } | Self::Xz { level: _ } => { command.arg("-cd"); } Self::Zstd { level: _ } => { command.arg("-cdq"); } Self::Uncompressed => {} #[cfg(test)] Self::NonExistent | Self::Failing => {} }; command } pub(crate) fn is_uncompressed(&self) -> bool { matches!(self, Self::Uncompressed) } } pub(crate) fn read_magic_header(file: &mut R) -> Result> { let mut buffer = [0; 4]; while buffer == [0, 0, 0, 0] { match file.read_exact(&mut buffer) { Ok(()) => {} Err(e) => match e.kind() { ErrorKind::UnexpectedEof => return Ok(None), _ => return Err(e), }, }; } file.seek(SeekFrom::Current(-4))?; let compression = Compression::from_magic_number(buffer)?; Ok(Some(compression)) } #[cfg(test)] mod tests { use super::*; use crate::tests::tests_path; #[test] fn test_compression_decompress_program_not_found() { let archive = File::open(tests_path("single.cpio")).expect("test cpio should be present"); let compression = Compression::NonExistent; let got = compression.decompress(archive).unwrap_err(); assert_eq!(got.kind(), ErrorKind::Other); assert_eq!( got.to_string(), "Program 'non-existing-program' not found in PATH." ); } #[test] fn test_compression_from_command_line_lz4() { let compression = Compression::from_command_line(" lz4 ").unwrap(); assert_eq!(compression, Compression::Lz4 { level: None }); } #[test] fn test_compression_from_command_line_xz_6() { let compression = Compression::from_command_line(" xz \t -6 ").unwrap(); assert_eq!(compression, Compression::Xz { level: Some(6) }); } } threecpio-0.11.0/src/examine.rs000064400000000000000000000121721046102023000144530ustar 00000000000000// Copyright (C) 2025, Benjamin Drung // SPDX-License-Identifier: ISC use std::fs::File; use std::io::{Read, Result, Seek, Write}; use std::os::unix::fs::MetadataExt; use crate::compression::read_magic_header; use crate::header::{read_file_name_and_size_from_next_cpio_object, TRAILER_FILENAME}; use crate::seek_forward::SeekForward; struct Examination<'a> { start: u64, end: u64, compression: &'a str, extracted_size: u64, } impl<'a> Examination<'a> { fn new(start: u64, end: u64, compression: &'a str, extracted_size: u64) -> Self { Examination { start, end, compression, extracted_size, } } fn write(&self, out: &mut W, raw: bool) -> Result<()> { if raw { writeln!( out, "{}\t{}\t{}\t{}\t{}", self.start, self.end, self.end - self.start, self.compression, self.extracted_size, ) } else { writeln!( out, "{:<7} {:<7} {:<7} {:<6} {}", format_bytes(self.start), format_bytes(self.end), format_bytes(self.end - self.start), self.compression, format_bytes(self.extracted_size), ) } } fn write_header(out: &mut W, raw: bool) -> Result<()> { if !raw { writeln!(out, "Start End Size Compr. Extracted")?; } Ok(()) } } const fn div_round(value: u64, divisor: u64) -> u64 { (value + divisor / 2) / divisor } /// List the offsets of the cpio archives and their compression. /// /// **Warning**: This function was designed for the `3cpio` command-line application. /// The API can change between releases and no stability promises are given. /// Please get in contact to support your use case and make the API for this function stable. pub fn examine_cpio_content(mut archive: File, out: &mut W, raw: bool) -> Result<()> { Examination::write_header(out, raw)?; let mut end = archive.stream_position()?; let mut magic_header = read_magic_header(&mut archive)?; while let Some(compression) = magic_header { let start = end; let size = if compression.is_uncompressed() { read_file_sizes(&mut archive)? } else { // Assume that the compressor command will read the file to the end. let end = archive.metadata()?.size(); let mut decompressed = compression.decompress(archive)?; let size = read_file_sizes(&mut decompressed)?; let examination = Examination::new(start, end, compression.command(), size); examination.write(out, raw)?; break; }; magic_header = read_magic_header(&mut archive)?; end = archive.stream_position()?; let examination = Examination::new(start, end, compression.command(), size); examination.write(out, raw)?; } Ok(()) } fn format_bytes(value: u64) -> String { if value < 1000 { format!("{} B", value) } else if value < 10000 { format!("{:.2} kB", f64::from(value as u32) / 1000.0) } else if value < 100000 { format!("{:.1} kB", f64::from(value as u32) / 1000.0) } else if value < 1000000 { format!("{} kB", div_round(value, 1000)) } else if value < 10000000 { format!("{:.2} MB", f64::from(value as u32) / 1000000.0) } else if value < 100000000 { format!("{:.1} MB", f64::from(value as u32) / 1000000.0) } else { format!("{} MB", div_round(value, 1000000)) } } fn read_file_sizes(archive: &mut R) -> Result { let mut file_sizes = 0; loop { let (filename, size) = read_file_name_and_size_from_next_cpio_object(archive)?; file_sizes += u64::from(size); if filename == TRAILER_FILENAME { break; } } Ok(file_sizes) } #[cfg(test)] mod tests { use super::*; use crate::tests::tests_path; #[test] fn test_examine_cpio_content() { let archive = File::open(tests_path("bigdata.cpio")).unwrap(); let mut output = Vec::new(); examine_cpio_content(archive, &mut output, false).unwrap(); assert_eq!( String::from_utf8(output).unwrap(), "Start End Size Compr. Extracted\n\ 0 B 512 B 512 B cpio 8 B\n\ 512 B 1.54 kB 1.02 kB cpio 56 B\n\ 1.54 kB 4.83 kB 3.29 kB zstd 103 MB\n" ); } #[test] fn test_format_bytes_kilobytes_with_dot() { assert_eq!(format_bytes(12345), "12.3 kB"); } #[test] fn test_format_bytes_kilobytes_without_dot() { assert_eq!(format_bytes(543210), "543 kB"); } #[test] fn test_format_bytes_megabytes_two_decimal_places() { assert_eq!(format_bytes(7415000), "7.42 MB"); } #[test] fn test_format_bytes_megabytes_one_decimal_place() { assert_eq!(format_bytes(83684618), "83.7 MB"); } } threecpio-0.11.0/src/extended_error.rs000064400000000000000000000010561046102023000160350ustar 00000000000000// Copyright (C) 2025, Benjamin Drung // SPDX-License-Identifier: ISC use std::io::Error; pub(crate) trait ExtendedError { fn add_prefix>(self, filename: S) -> Self; fn add_line(self, line_number: usize) -> Self; } impl ExtendedError for Error { fn add_prefix>(self, prefix: S) -> Self { Self::new(self.kind(), format!("{}: {self}", prefix.as_ref())) } fn add_line(self, line_number: usize) -> Self { Self::new(self.kind(), format!("line {line_number}: {self}")) } } threecpio-0.11.0/src/extract.rs000064400000000000000000000734101046102023000145010ustar 00000000000000// Copyright (C) 2024-2025, Benjamin Drung // SPDX-License-Identifier: ISC use std::collections::{BTreeMap, HashMap}; use std::fs::{ create_dir, create_dir_all, hard_link, remove_file, set_permissions, symlink_metadata, File, OpenOptions, }; use std::io::{prelude::*, Error, ErrorKind, Result}; use std::os::unix::fs::{chown, fchown, lchown, symlink}; use std::path::{Path, PathBuf}; use std::time::SystemTime; use glob::Pattern; use crate::compression::read_magic_header; use crate::filetype::*; use crate::header::Header; use crate::libc::{mknod, set_modified}; use crate::logger::Logger; use crate::ranges::Ranges; use crate::seek_forward::SeekForward; use crate::{filename_matches, seek_to_cpio_end, TRAILER_FILENAME}; // TODO: Document hardlink structure pub(crate) type SeenFiles = HashMap; /// Options for extracting cpio archives. /// /// **Warning**: This struct was designed for the `extract_cpio_archive` function. /// The API can change between releases and no stability promises are given. /// Please get in contact to support your use case and make the API for this function stable. #[derive(Clone, Debug, PartialEq)] pub struct ExtractOptions { make_directories: bool, parts: Option, patterns: Vec, preserve_permissions: bool, subdir: Option, } impl ExtractOptions { /// Create a new extract options structure. /// /// **Warning**: This function was designed for the `3cpio` command-line application. /// The API can change between releases and no stability promises are given. /// Please get in contact to support your use case and make the API for this function stable. pub fn new( make_directories: bool, parts: Option, patterns: Vec, preserve_permissions: bool, subdir: Option, ) -> Self { Self { make_directories, parts, patterns, preserve_permissions, subdir, } } } impl Default for ExtractOptions { fn default() -> Self { Self::new(false, None, Vec::new(), false, None) } } struct Extractor { seen_files: SeenFiles, mtimes: BTreeMap, } impl Extractor { fn new() -> Extractor { Extractor { seen_files: SeenFiles::new(), mtimes: BTreeMap::new(), } } fn set_modified_times(&self, logger: &mut Logger) -> Result<()> { for (path, mtime) in self.mtimes.iter().rev() { debug!(logger, "set mtime {mtime} for '{path}'")?; set_modified(path, *mtime)?; } Ok(()) } } fn absolute_parent_directory>(path: S, base_dir: &Path) -> Result where PathBuf: From, { let abspath = if path.as_ref().starts_with("/") { PathBuf::from(path) } else { base_dir.join(path.as_ref()) }; match abspath.parent() { Some(d) => Ok(d.into()), // TODO: Use ErrorKind::InvalidFilename once stable. None => Err(Error::new( ErrorKind::InvalidData, format!("Path {abspath:#?} has no parent directory."), )), } } fn check_path_is_canonical_subdir + std::fmt::Display>( path: S, dir: &Path, base_dir: &PathBuf, ) -> Result { let canonicalized_path = dir.canonicalize()?; if !canonicalized_path.starts_with(base_dir) { return Err(Error::new( ErrorKind::InvalidData, format!( "The parent directory of \"{path}\" (resolved to {canonicalized_path:#?}) \ is not within the directory {base_dir:#?}.", ), )); } Ok(canonicalized_path) } fn create_dir_ignore_existing>(path: P) -> Result<()> { if let Err(e) = create_dir(&path) { if e.kind() != ErrorKind::AlreadyExists { return Err(e); } let stat = symlink_metadata(&path)?; if !stat.is_dir() { remove_file(&path)?; create_dir(&path)?; } }; Ok(()) } /// Extract cpio archives. /// /// **Warning**: This function was designed for the `3cpio` command-line application. /// The API can change between releases and no stability promises are given. /// Please get in contact to support your use case and make the API for this function stable. pub fn extract_cpio_archive( mut archive: File, mut out: Option<&mut W>, options: &ExtractOptions, logger: &mut Logger, ) -> Result<()> { let mut count = 0; let base_dir = std::env::current_dir()?; loop { count += 1; let compression = match read_magic_header(&mut archive)? { None => return Ok(()), Some(x) => x, }; if options.parts.as_ref().is_some_and(|f| !f.contains(&count)) { if compression.is_uncompressed() && options.parts.as_ref().unwrap().has_more(&count) { seek_to_cpio_end(&mut archive)?; continue; } break; } let mut dir = base_dir.clone(); if let Some(ref s) = options.subdir { dir.push(format!("{s}{count}")); create_dir_ignore_existing(&dir)?; std::env::set_current_dir(&dir)?; } if compression.is_uncompressed() { read_cpio_and_extract(&mut archive, &dir, &mut out, options, logger)?; } else { let mut decompressed = compression.decompress(archive)?; read_cpio_and_extract(&mut decompressed, &dir, &mut out, options, logger)?; break; } } Ok(()) } fn from_mtime(mtime: u32) -> SystemTime { std::time::UNIX_EPOCH + std::time::Duration::from_secs(mtime.into()) } fn read_cpio_and_extract( archive: &mut R, base_dir: &PathBuf, out: &mut Option, options: &ExtractOptions, logger: &mut Logger, ) -> Result<()> { let mut extractor = Extractor::new(); let mut previous_checked_dir = PathBuf::new(); loop { let header = match Header::read(archive) { Ok(header) => { if header.filename == TRAILER_FILENAME { break; } else { header } } Err(e) => return Err(e), }; debug!(logger, "{header:?}")?; if !options.patterns.is_empty() && !filename_matches(&header.filename, &options.patterns) { header.skip_file_content(archive)?; continue; } info!(logger, "{}", header.filename)?; if out.is_none() && !header.is_root_directory() { // TODO: use dirfd once stable: https://github.com/rust-lang/rust/issues/120426 let absdir = absolute_parent_directory(&header.filename, base_dir)?; // canonicalize() is an expensive call. So cache the previously resolved // parent directory. Skip the path traversal check in case the absolute // parent directory has no symlinks and matches the previouly checked directory. if absdir != previous_checked_dir { if options.make_directories { create_dir_all(&absdir)?; } previous_checked_dir = check_path_is_canonical_subdir(&header.filename, &absdir, base_dir)?; } } if let Some(out) = out { if header.filesize == 0 { continue; } match header.mode & MODE_FILETYPE_MASK { FILETYPE_DIRECTORY | FILETYPE_SYMLINK => { header.skip_file_content(archive)?; } FILETYPE_REGULAR_FILE => write_file_content(archive, out, &header)?, FILETYPE_CHARACTER_DEVICE | FILETYPE_FIFO | FILETYPE_BLOCK_DEVICE | FILETYPE_SOCKET => { unimplemented!( "Mode {:o} (file {}) not implemented. Please open a bug report requesting support for this type.", header.mode, header.filename ) } _ => { return Err(Error::new( ErrorKind::InvalidData, format!( "Invalid/unknown filetype {:o}: {}", header.mode, header.filename ), )) } } } else { match header.mode & MODE_FILETYPE_MASK { FILETYPE_CHARACTER_DEVICE => { write_character_device(&header, options.preserve_permissions, logger)? } FILETYPE_DIRECTORY => write_directory( &header, options.preserve_permissions, logger, &mut extractor.mtimes, )?, FILETYPE_REGULAR_FILE => write_file( archive, &header, options.preserve_permissions, &mut extractor.seen_files, logger, )?, FILETYPE_SYMLINK => { write_symbolic_link(archive, &header, options.preserve_permissions, logger)? } FILETYPE_FIFO | FILETYPE_BLOCK_DEVICE | FILETYPE_SOCKET => { unimplemented!( "Mode {:o} (file {}) not implemented. Please open a bug report requesting support for this type.", header.mode, header.filename ) } _ => { return Err(Error::new( ErrorKind::InvalidData, format!( "Invalid/unknown filetype {:o}: {}", header.mode, header.filename ), )) } } }; } extractor.set_modified_times(logger)?; Ok(()) } fn write_character_device( header: &Header, preserve_permissions: bool, logger: &mut Logger, ) -> Result<()> { if header.filesize != 0 { return Err(Error::new( ErrorKind::InvalidData, format!( "Invalid size for character device '{}': {} bytes instead of 0.", header.filename, header.filesize ), )); }; debug!( logger, "Creating character device '{}' with mode {:o}", header.filename, header.mode_perm(), )?; if let Err(e) = mknod(&header.filename, header.mode, header.rmajor, header.rminor) { match e.kind() { ErrorKind::AlreadyExists => { remove_file(&header.filename)?; mknod(&header.filename, header.mode, header.rmajor, header.rminor)?; } _ => { return Err(e); } } }; if preserve_permissions { lchown(&header.filename, Some(header.uid), Some(header.gid))?; }; set_permissions(&header.filename, header.permission())?; set_modified(&header.filename, header.mtime.into())?; Ok(()) } fn write_directory( header: &Header, preserve_permissions: bool, logger: &mut Logger, mtimes: &mut BTreeMap, ) -> Result<()> { if header.filesize != 0 { return Err(Error::new( ErrorKind::InvalidData, format!( "Invalid size for directory '{}': {} bytes instead of 0.", header.filename, header.filesize ), )); }; debug!( logger, "Creating directory '{}' with mode {:o}{}", header.filename, header.mode_perm(), if preserve_permissions { format!(" and owner {}:{}", header.uid, header.gid) } else { String::new() }, )?; create_dir_ignore_existing(&header.filename)?; if preserve_permissions { chown(&header.filename, Some(header.uid), Some(header.gid))?; } set_permissions(&header.filename, header.permission())?; mtimes.insert(header.filename.to_string(), header.mtime.into()); Ok(()) } fn write_file( archive: &mut R, header: &Header, preserve_permissions: bool, seen_files: &mut SeenFiles, logger: &mut Logger, ) -> Result<()> { let mut file; if let Some(target) = header.try_get_hard_link_target(seen_files) { debug!( logger, "Creating hard-link '{}' -> '{}' with permission {:o}{} and {} bytes", header.filename, target, header.mode_perm(), if preserve_permissions { format!(" and owner {}:{}", header.uid, header.gid) } else { String::new() }, header.filesize, )?; if let Err(e) = hard_link(target, &header.filename) { match e.kind() { ErrorKind::AlreadyExists => { remove_file(&header.filename)?; hard_link(target, &header.filename)?; } _ => { return Err(e); } } } file = OpenOptions::new().write(true).open(&header.filename)? } else { debug!( logger, "Creating file '{}' with permission {:o}{} and {} bytes", header.filename, header.mode_perm(), if preserve_permissions { format!(" and owner {}:{}", header.uid, header.gid) } else { String::new() }, header.filesize, )?; file = File::create(&header.filename)? }; header.mark_seen(seen_files); // TODO: check writing hard-link with length == 0 // TODO: check overwriting existing files/hardlinks write_file_content(archive, &mut file, header)?; if preserve_permissions { fchown(&file, Some(header.uid), Some(header.gid))?; } file.set_permissions(header.permission())?; file.set_modified(from_mtime(header.mtime))?; Ok(()) } fn write_file_content( archive: &mut R, output_file: &mut W, header: &Header, ) -> Result<()> { let mut reader = archive.take(header.filesize.into()); let written = std::io::copy(&mut reader, output_file)?; if written != header.filesize.into() { return Err(Error::other(format!( "Wrong amound of bytes written to '{}': {} != {}.", header.filename, written, header.filesize ))); } header.skip_file_content_padding(archive) } fn write_symbolic_link( archive: &mut R, header: &Header, preserve_permissions: bool, logger: &mut Logger, ) -> Result<()> { let target = header.read_symlink_target(archive)?; debug!( logger, "Creating symlink '{}' -> '{}' with mode {:o}", header.filename, &target, header.mode_perm(), )?; if let Err(e) = symlink(&target, &header.filename) { match e.kind() { ErrorKind::AlreadyExists => { remove_file(&header.filename)?; symlink(&target, &header.filename)?; } _ => { return Err(e); } } } if preserve_permissions { lchown(&header.filename, Some(header.uid), Some(header.gid))?; } if header.mode_perm() != 0o777 { return Err(Error::new( ErrorKind::Unsupported, format!( "Symlink '{}' has mode {:o}, but only mode 777 is supported.", header.filename, header.mode_perm() ), )); }; set_modified(&header.filename, header.mtime.into())?; Ok(()) } #[cfg(test)] mod tests { use std::io::Stdout; use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt}; use super::*; use crate::libc::{major, minor}; use crate::logger::Level; use crate::temp_dir::TempDir; use crate::tests::{tests_path, TEST_LOCK}; fn getgid() -> u32 { unsafe { ::libc::getgid() } } fn getuid() -> u32 { unsafe { ::libc::getuid() } } #[test] fn test_absolute_parent_directory() { let base_dir = Path::new("/nonexistent/arthur"); assert_eq!( absolute_parent_directory("usr/bin/true", base_dir).unwrap(), PathBuf::from("/nonexistent/arthur/usr/bin") ); assert_eq!( absolute_parent_directory("/usr/bin/true", base_dir).unwrap(), PathBuf::from("/usr/bin") ); assert_eq!( absolute_parent_directory(".", base_dir).unwrap(), PathBuf::from("/nonexistent") ); } #[test] fn test_absolute_parent_directory_error() { let got = absolute_parent_directory(".", Path::new("/")).unwrap_err(); assert_eq!(got.kind(), ErrorKind::InvalidData); assert_eq!(got.to_string(), "Path \"/.\" has no parent directory."); } #[test] fn test_extract_cpio_archive_compressed_make_directories_with_pattern() { let _lock = TEST_LOCK.lock().unwrap(); let archive = File::open(tests_path("lz4.cpio")).unwrap(); let tempdir = TempDir::new_and_set_current_dir().unwrap(); let patterns = vec![Pattern::new("p?th/f*").unwrap()]; let options = ExtractOptions::new(true, None, patterns, false, None); let mut logger = Logger::new_vec(Level::Info); extract_cpio_archive(archive, None::<&mut Stdout>, &options, &mut logger).unwrap(); assert!(tempdir.path.join("path").is_dir()); assert!(tempdir.path.join("path/file").exists()); assert!(!tempdir.path.join("usr").exists()); assert_eq!(logger.get_logs(), "path/file\n"); } #[test] fn test_extract_cpio_archive_compressed_parts_to_stdout() { let archive = File::open(tests_path("lzma.cpio")).unwrap(); let mut output = Vec::new(); let options = ExtractOptions::new( false, Some("-1".parse::().unwrap()), Vec::new(), false, None, ); let mut logger = Logger::new_vec(Level::Info); extract_cpio_archive(archive, Some(&mut output), &options, &mut logger).unwrap(); assert_eq!(String::from_utf8(output).unwrap(), "content\n"); assert_eq!(logger.get_logs(), ".\npath\npath/file\n"); } #[test] fn test_extract_cpio_archive_compressed_to_stdout() { let archive = File::open(tests_path("bzip2.cpio")).unwrap(); let mut output = Vec::new(); let options = ExtractOptions::default(); let mut logger = Logger::new_vec(Level::Warning); extract_cpio_archive(archive, Some(&mut output), &options, &mut logger).unwrap(); assert_eq!( String::from_utf8(output).unwrap(), "content\nThis is a fake busybox binary to simulate a POSIX shell\n" ); assert_eq!(logger.get_logs(), ""); } #[test] fn test_extract_cpio_archive_compressed_with_pattern() { let _lock = TEST_LOCK.lock().unwrap(); let archive = File::open(tests_path("zstd.cpio")).unwrap(); let tempdir = TempDir::new_and_set_current_dir().unwrap(); let patterns = vec![Pattern::new("p?th").unwrap()]; let options = ExtractOptions::new(false, None, patterns, false, None); let mut logger = Logger::new_vec(Level::Debug); extract_cpio_archive(archive, None::<&mut Stdout>, &options, &mut logger).unwrap(); assert!(tempdir.path.join("path").is_dir()); assert!(!tempdir.path.join("path/file").exists()); assert_eq!( logger.get_logs(), "Header { ino: 0, mode: 16893, uid: 0, gid: 0, nlink: 2, mtime: 1713104326, filesize: 0, \ major: 0, minor: 0, rmajor: 0, rminor: 0, filename: \".\" }\n\ Header { ino: 1, mode: 16893, uid: 0, gid: 0, nlink: 2, mtime: 1713104326, filesize: 0, \ major: 0, minor: 0, rmajor: 0, rminor: 0, filename: \"path\" }\n\ path\n\ Creating directory 'path' with mode 775\n\ Header { ino: 2, mode: 33204, uid: 0, gid: 0, nlink: 1, mtime: 1713104326, filesize: 8, \ major: 0, minor: 0, rmajor: 0, rminor: 0, filename: \"path/file\" }\n\ set mtime 1713104326 for 'path'\n\ Header { ino: 0, mode: 16893, uid: 0, gid: 0, nlink: 2, mtime: 1713104326, filesize: 0, \ major: 0, minor: 0, rmajor: 0, rminor: 0, filename: \".\" }\n\ Header { ino: 1, mode: 16893, uid: 0, gid: 0, nlink: 2, mtime: 1713104326, filesize: 0, \ major: 0, minor: 0, rmajor: 0, rminor: 0, filename: \"usr\" }\n\ Header { ino: 2, mode: 16893, uid: 0, gid: 0, nlink: 2, mtime: 1713104326, filesize: 0, \ major: 0, minor: 0, rmajor: 0, rminor: 0, filename: \"usr/bin\" }\n\ Header { ino: 3, mode: 33204, uid: 0, gid: 0, nlink: 1, mtime: 1713104326, filesize: 56, \ major: 0, minor: 0, rmajor: 0, rminor: 0, filename: \"usr/bin/sh\" }\n" ); } #[test] fn test_extract_cpio_archive_compressed_with_pattern_to_stdout() { let archive = File::open(tests_path("gzip.cpio")).unwrap(); let patterns: Vec = vec![Pattern::new("*/b?n/sh").unwrap()]; let mut output = Vec::new(); let options = ExtractOptions::new(false, None, patterns, false, None); let mut logger = Logger::new_vec(Level::Info); extract_cpio_archive(archive, Some(&mut output), &options, &mut logger).unwrap(); assert_eq!( String::from_utf8(output).unwrap(), "This is a fake busybox binary to simulate a POSIX shell\n" ); assert_eq!(logger.get_logs(), "usr/bin/sh\n"); } #[test] fn test_extract_cpio_archive_uncompressed_with_pattern() { let _lock = TEST_LOCK.lock().unwrap(); let archive = File::open(tests_path("single.cpio")).unwrap(); let tempdir = TempDir::new_and_set_current_dir().unwrap(); let patterns = vec![Pattern::new("path").unwrap()]; let options = ExtractOptions::new(false, None, patterns, false, None); let mut logger = Logger::new_vec(Level::Info); extract_cpio_archive(archive, None::<&mut Stdout>, &options, &mut logger).unwrap(); assert!(tempdir.path.join("path").is_dir()); assert!(!tempdir.path.join("path/file").exists()); assert_eq!(logger.get_logs(), "path\n"); } #[test] fn test_extract_cpio_archive_with_subdir() { let _lock = TEST_LOCK.lock().unwrap(); let archive = File::open(tests_path("single.cpio")).unwrap(); let tempdir = TempDir::new_and_set_current_dir().unwrap(); let options = ExtractOptions::new(false, None, Vec::new(), false, Some("cpio".into())); let mut logger = Logger::new_vec(Level::Info); extract_cpio_archive(archive, None::<&mut Stdout>, &options, &mut logger).unwrap(); let path = tempdir.path.join("cpio1/path/file"); assert!(path.exists()); assert_eq!(logger.get_logs(), ".\npath\npath/file\n"); } // Test detecting path traversal attacks like CVE-2015-1197 #[test] fn test_read_cpio_and_extract_path_traversal() { let _lock = TEST_LOCK.lock().unwrap(); let mut archive = File::open(tests_path("path-traversal.cpio")).unwrap(); let tempdir = TempDir::new_and_set_current_dir().unwrap(); let mut logger = Logger::new_vec(Level::Info); let got = read_cpio_and_extract( &mut archive, &tempdir.path, &mut None::, &ExtractOptions::default(), &mut logger, ) .unwrap_err(); assert_eq!(got.kind(), ErrorKind::InvalidData); assert_eq!(got.to_string(), format!( "The parent directory of \"tmp/trav.txt\" (resolved to \"/tmp\") is not within the directory {:#?}.", &tempdir.path )); assert_eq!(logger.get_logs(), ".\ntmp\ntmp/trav.txt\n"); } #[test] fn test_read_cpio_and_extract_path_traversal_to_stdout() { let mut archive = File::open(tests_path("path-traversal.cpio")).unwrap(); let base_dir = std::env::current_dir().unwrap(); let mut output = Vec::new(); let mut logger = Logger::new_vec(Level::Info); read_cpio_and_extract( &mut archive, &base_dir, &mut Some(&mut output), &ExtractOptions::default(), &mut logger, ) .unwrap(); assert_eq!(String::from_utf8(output).unwrap(), "TEST Traversal\n"); assert_eq!(logger.get_logs(), ".\ntmp\ntmp/trav.txt\n"); } #[test] fn test_write_character_device() { let _lock = TEST_LOCK.lock().unwrap(); if getuid() != 0 { // This test needs to run as root. return; } let _tempdir = TempDir::new_and_set_current_dir().unwrap(); let mut header = Header::new(1, 0o20_644, 0, 0, 0, 1740402179, 0, 0, 0, "./null"); header.rmajor = 1; header.rminor = 3; let mut logger = Logger::new_vec(Level::Debug); write_character_device(&header, true, &mut logger).unwrap(); let attr = std::fs::metadata("null").unwrap(); assert_eq!(attr.len(), header.filesize.into()); assert!(attr.file_type().is_char_device()); assert_eq!(attr.modified().unwrap(), from_mtime(header.mtime)); assert_eq!(attr.permissions(), PermissionsExt::from_mode(header.mode)); assert_eq!(attr.uid(), header.uid); assert_eq!(attr.gid(), header.gid); assert_eq!(major(attr.rdev()), header.rmajor); assert_eq!(minor(attr.rdev()), header.rminor); assert_eq!( logger.get_logs(), "Creating character device './null' with mode 644\n" ); std::fs::remove_file("null").unwrap(); } #[test] fn test_write_directory_with_setuid() { let _lock = TEST_LOCK.lock().unwrap(); let _tempdir = TempDir::new_and_set_current_dir().unwrap(); let mut mtimes = BTreeMap::new(); let header = Header::new( 1, 0o43_777, getuid(), getgid(), 0, 1720081471, 0, 0, 0, "./directory_with_setuid", ); let mut logger = Logger::new_vec(Level::Debug); write_directory(&header, true, &mut logger, &mut mtimes).unwrap(); let attr = std::fs::metadata("directory_with_setuid").unwrap(); assert!(attr.is_dir()); assert_eq!(attr.permissions(), PermissionsExt::from_mode(header.mode)); assert_eq!(attr.uid(), header.uid); assert_eq!(attr.gid(), header.gid); assert_eq!( logger.get_logs(), format!( "Creating directory './directory_with_setuid' with mode 3777 and owner {}:{}\n", getuid(), getgid(), ), ); std::fs::remove_dir("directory_with_setuid").unwrap(); let mut expected_mtimes: BTreeMap = BTreeMap::new(); expected_mtimes.insert("./directory_with_setuid".into(), header.mtime.into()); assert_eq!(mtimes, expected_mtimes); } #[test] fn test_write_file_with_setuid() { let _lock = TEST_LOCK.lock().unwrap(); let _tempdir = TempDir::new_and_set_current_dir().unwrap(); let mut seen_files = SeenFiles::new(); let header = Header::new( 1, 0o104_755, getuid(), getgid(), 0, 1720081471, 9, 0, 0, "./file_with_setuid", ); let cpio = b"!/bin/sh\n\0\0\0"; let mut logger = Logger::new_vec(Level::Debug); write_file( &mut cpio.as_ref(), &header, true, &mut seen_files, &mut logger, ) .unwrap(); let attr = std::fs::metadata("file_with_setuid").unwrap(); assert_eq!(attr.len(), header.filesize.into()); assert!(attr.is_file()); assert_eq!(attr.modified().unwrap(), from_mtime(header.mtime)); assert_eq!(attr.permissions(), PermissionsExt::from_mode(header.mode)); assert_eq!(attr.uid(), header.uid); assert_eq!(attr.gid(), header.gid); assert_eq!( logger.get_logs(), format!( "Creating file './file_with_setuid' with permission 4755 and owner {}:{} and 9 bytes\n", getuid(), getgid(), ), ); std::fs::remove_file("file_with_setuid").unwrap(); } #[test] fn test_write_symbolic_link() { let _lock = TEST_LOCK.lock().unwrap(); let _tempdir = TempDir::new_and_set_current_dir().unwrap(); let header = Header::new( 1, 0o120_777, getuid(), getgid(), 0, 1721427072, 12, 0, 0, "./dead_symlink", ); let cpio = b"/nonexistent"; let mut logger = Logger::new_vec(Level::Warning); write_symbolic_link(&mut cpio.as_ref(), &header, true, &mut logger).unwrap(); let attr = std::fs::symlink_metadata("dead_symlink").unwrap(); assert_eq!(attr.len(), header.filesize.into()); assert!(attr.is_symlink()); assert_eq!(attr.modified().unwrap(), from_mtime(header.mtime)); assert_eq!(attr.permissions(), PermissionsExt::from_mode(header.mode)); assert_eq!(attr.uid(), header.uid); assert_eq!(attr.gid(), header.gid); assert_eq!(logger.get_logs(), ""); std::fs::remove_file("dead_symlink").unwrap(); } } threecpio-0.11.0/src/filetype.rs000064400000000000000000000011051046102023000146400ustar 00000000000000// Copyright (C) 2025, Benjamin Drung // SPDX-License-Identifier: ISC pub(crate) const MODE_PERMISSION_MASK: u32 = 0o007_777; pub(crate) const MODE_FILETYPE_MASK: u32 = 0o770_000; pub(crate) const FILETYPE_FIFO: u32 = 0o010_000; pub(crate) const FILETYPE_CHARACTER_DEVICE: u32 = 0o020_000; pub(crate) const FILETYPE_DIRECTORY: u32 = 0o040_000; pub(crate) const FILETYPE_BLOCK_DEVICE: u32 = 0o060_000; pub(crate) const FILETYPE_REGULAR_FILE: u32 = 0o100_000; pub(crate) const FILETYPE_SYMLINK: u32 = 0o120_000; pub(crate) const FILETYPE_SOCKET: u32 = 0o140_000; threecpio-0.11.0/src/header.rs000064400000000000000000000443301046102023000142560ustar 00000000000000// Copyright (C) 2024, Benjamin Drung // SPDX-License-Identifier: ISC use std::fs::Permissions; use std::io::{Error, ErrorKind, Read, Result, Write}; use std::num::NonZeroU32; use std::os::unix::fs::PermissionsExt; use crate::extract::SeenFiles; use crate::filetype::*; use crate::seek_forward::SeekForward; pub(crate) const CPIO_ALIGNMENT: u64 = 4; pub(crate) const CPIO_HEADER_LENGTH: u32 = 110; const CPIO_MAGIC_NUMBER: [u8; 6] = *b"070701"; const PATH_MAX: usize = 4096; pub(crate) const TRAILER_FILENAME: &str = "TRAILER!!!"; pub(crate) const TRAILER_SIZE: u64 = calculate_size(TRAILER_FILENAME, 0); #[derive(Debug, PartialEq)] pub(crate) struct Header { pub(crate) ino: u32, pub(crate) mode: u32, pub(crate) uid: u32, pub(crate) gid: u32, pub(crate) nlink: u32, pub(crate) mtime: u32, pub(crate) filesize: u32, major: u32, minor: u32, pub(crate) rmajor: u32, pub(crate) rminor: u32, pub(crate) filename: String, } impl Header { #![allow(clippy::too_many_arguments)] pub(crate) fn new( ino: u32, mode: u32, uid: u32, gid: u32, nlink: u32, mtime: u32, filesize: u32, rmajor: u32, rminor: u32, filename: S, ) -> Self where S: Into, { Self { ino, mode, uid, gid, nlink, mtime, filesize, major: 0, minor: 0, rmajor, rminor, filename: filename.into(), } } pub(crate) fn trailer() -> Self { Self { ino: 0, mode: 0, uid: 0, gid: 0, nlink: 1, mtime: 0, filesize: 0, major: 0, minor: 0, rmajor: 0, rminor: 0, filename: TRAILER_FILENAME.into(), } } // Return major and minor combined as u64 fn dev(&self) -> u64 { (u64::from(self.major) << 32) | u64::from(self.minor) } pub(crate) fn is_root_directory(&self) -> bool { self.filename == "." && self.mode & MODE_FILETYPE_MASK == FILETYPE_DIRECTORY } pub(crate) fn mode_perm(&self) -> u32 { self.mode & MODE_PERMISSION_MASK } // ls-style ASCII representation of the mode pub(crate) fn mode_string(&self) -> [u8; 10] { [ match self.mode & MODE_FILETYPE_MASK { FILETYPE_FIFO => b'p', FILETYPE_CHARACTER_DEVICE => b'c', FILETYPE_DIRECTORY => b'd', FILETYPE_BLOCK_DEVICE => b'b', FILETYPE_REGULAR_FILE => b'-', FILETYPE_SYMLINK => b'l', FILETYPE_SOCKET => b's', _ => b'?', }, if self.mode & 0o400 != 0 { b'r' } else { b'-' }, if self.mode & 0o200 != 0 { b'w' } else { b'-' }, match self.mode & 0o4100 { 0o4100 => b's', // set-uid and executable by owner 0o4000 => b'S', // set-uid but not executable by owner 0o0100 => b'x', _ => b'-', }, if self.mode & 0o040 != 0 { b'r' } else { b'-' }, if self.mode & 0o020 != 0 { b'w' } else { b'-' }, match self.mode & 0o2010 { 0o2010 => b's', // set-gid and executable by group 0o2000 => b'S', // set-gid but not executable by group 0o0010 => b'x', _ => b'-', }, if self.mode & 0o004 != 0 { b'r' } else { b'-' }, if self.mode & 0o002 != 0 { b'w' } else { b'-' }, match self.mode & 0o1001 { 0o1001 => b't', // sticky and executable by others 0o1000 => b'T', // sticky but not executable by others 0o0001 => b'x', _ => b'-', }, ] } fn padding_needed_for_file_content(&self) -> u64 { padding_needed_for(self.filesize.into(), CPIO_ALIGNMENT) } pub(crate) fn permission(&self) -> Permissions { PermissionsExt::from_mode(self.mode & MODE_PERMISSION_MASK) } fn ino_and_dev(&self) -> u128 { (u128::from(self.ino) << 64) | u128::from(self.dev()) } pub(crate) fn mark_seen(&self, seen_files: &mut SeenFiles) { seen_files.insert(self.ino_and_dev(), self.filename.clone()); } pub(crate) fn read(archive: &mut R) -> Result { let mut buffer = [0; CPIO_HEADER_LENGTH as usize]; archive.read_exact(&mut buffer)?; check_begins_with_cpio_magic_header(&buffer)?; let namesize = hex_str_to_u32(&buffer[94..102])?; let filename = read_filename(archive, namesize.into())?; Ok(Self { ino: hex_str_to_u32(&buffer[6..14])?, mode: hex_str_to_u32(&buffer[14..22])?, uid: hex_str_to_u32(&buffer[22..30])?, gid: hex_str_to_u32(&buffer[30..38])?, nlink: hex_str_to_u32(&buffer[38..46])?, mtime: hex_str_to_u32(&buffer[46..54])?, filesize: hex_str_to_u32(&buffer[54..62])?, major: hex_str_to_u32(&buffer[62..70])?, minor: hex_str_to_u32(&buffer[70..78])?, rmajor: hex_str_to_u32(&buffer[78..86])?, rminor: hex_str_to_u32(&buffer[86..94])?, filename, }) } pub(crate) fn read_symlink_target(&self, archive: &mut R) -> Result { let align = usize::try_from(self.padding_needed_for_file_content()).unwrap(); let filesize = self.filesize.try_into().unwrap(); let mut target_bytes = vec![0u8; filesize + align]; archive.read_exact(&mut target_bytes)?; target_bytes.truncate(filesize); // TODO: propper name reading handling let target = std::str::from_utf8(&target_bytes).unwrap(); Ok(target.into()) } pub(crate) fn skip_file_content(&self, archive: &mut R) -> Result<()> { skip_file_content(archive, self.filesize) } pub(crate) fn skip_file_content_padding(&self, archive: &mut R) -> Result<()> { let skip = self.padding_needed_for_file_content(); if skip == 0 { return Ok(()); }; archive.seek_forward(skip) } pub(crate) fn try_get_hard_link_target<'a>( &self, seen_files: &'a SeenFiles, ) -> Option<&'a String> { if self.nlink <= 1 { return None; } seen_files.get(&self.ino_and_dev()) } pub(crate) fn write(&self, file: &mut W) -> Result { self.write_with_alignment(file, None, 0) } pub(crate) fn write_with_alignment( &self, file: &mut W, alignment: Option, written: u64, ) -> Result { // The filename needs to be terminated with \0. let mut filename_len = self.filename.len().checked_add(1).unwrap(); if filename_len > PATH_MAX { return Err(Error::new( ErrorKind::InvalidData, format!("Path '{}' exceeds filename length limit", self.filename), )); } let offset = u64::from(CPIO_HEADER_LENGTH) + u64::try_from(filename_len).unwrap(); let mut padding_len; if alignment.is_some_and(|alignment| self.filesize >= alignment.into()) { padding_len = padding_needed_for(written + offset, alignment.unwrap().get().into()); let filename_plus_alignment = filename_len .checked_add(padding_len.try_into().unwrap()) .unwrap(); if filename_plus_alignment > PATH_MAX { // Required padding exceeds namesize maximum. Use normal padding. padding_len = padding_needed_for(offset, CPIO_ALIGNMENT); } else { filename_len = filename_plus_alignment; } } else { padding_len = padding_needed_for(offset, CPIO_ALIGNMENT); } let padding = vec![0u8; (padding_len + 1).try_into().unwrap()]; let filename_len: u32 = filename_len.try_into().unwrap(); write!( file, "{}{:08X}{:08X}{:08X}{:08X}{:08X}{:08X}{:08X}{:08X}{:08X}{:08X}{:08X}{:08X}00000000{}{}", std::str::from_utf8(&CPIO_MAGIC_NUMBER).unwrap(), self.ino, self.mode, self.uid, self.gid, self.nlink, self.mtime, self.filesize, self.major, self.minor, self.rmajor, self.rminor, filename_len, self.filename, std::str::from_utf8(&padding).unwrap(), )?; Ok(offset + padding_len) } pub(crate) fn write_file_data_padding(&self, file: &mut W) -> Result { let padding_len = self.padding_needed_for_file_content(); if padding_len == 0 { return Ok(0); } let padding = vec![0u8; padding_len.try_into().unwrap()]; file.write_all(&padding)?; Ok(padding_len) } } /// Calculate the size of the header and file data plus the 4-byte padding pub(crate) const fn calculate_size(filename: &str, filesize: u64) -> u64 { let filename_len = filename.len() as u64 + 1; let mut size = CPIO_HEADER_LENGTH as u64 + filename_len; size += padding_needed_for(size, CPIO_ALIGNMENT); size + filesize + padding_needed_for(filesize, CPIO_ALIGNMENT) } fn check_begins_with_cpio_magic_header(header: &[u8]) -> std::io::Result<()> { if header[0..6] != CPIO_MAGIC_NUMBER { return Err(Error::new( ErrorKind::InvalidData, format!( "Invalid CPIO magic number '{}'. Expected {}", &header[0..6].escape_ascii(), std::str::from_utf8(&CPIO_MAGIC_NUMBER).unwrap(), ), )); } Ok(()) } fn hex_str_to_u32(bytes: &[u8]) -> Result { let s = match std::str::from_utf8(bytes) { Err(_) => { return Err(Error::new( ErrorKind::InvalidData, format!("Invalid hexadecimal value '{}'", bytes.escape_ascii()), )) } Ok(value) => value, }; match u32::from_str_radix(s, 16) { Err(_) => Err(Error::new( ErrorKind::InvalidData, format!("Invalid hexadecimal value '{s}'"), )), Ok(value) => Ok(value), } } /// Returns the amount of padding needed after `offset` to ensure that the /// following address will be aligned to `alignment`. pub(crate) const fn padding_needed_for(offset: u64, alignment: u64) -> u64 { let misalignment = offset % alignment; if misalignment == 0 { return 0; } alignment - misalignment } fn read_filename(archive: &mut R, namesize: u64) -> Result { let header_align = padding_needed_for(u64::from(CPIO_HEADER_LENGTH) + namesize, CPIO_ALIGNMENT); let mut filename_bytes = vec![0u8; (namesize + header_align).try_into().unwrap()]; let filename_length: usize = (namesize - 1).try_into().unwrap(); archive.read_exact(&mut filename_bytes)?; if filename_bytes[filename_length] != 0 { return Err(Error::new( ErrorKind::InvalidData, format!( "Entry name '{:?}' is not NULL-terminated", &filename_bytes[0..filename_length] ), )); } filename_bytes.truncate(filename_length); // TODO: propper name reading handling let filename = std::str::from_utf8(&filename_bytes).unwrap(); Ok(filename.to_string()) } /// Read only the file name from the next cpio object. /// /// Read the next cpio object header, check the magic, skip the file data. /// Return the file name. pub(crate) fn read_file_name_and_size_from_next_cpio_object( archive: &mut R, ) -> Result<(String, u32)> { let mut header = [0; CPIO_HEADER_LENGTH as usize]; archive.read_exact(&mut header)?; check_begins_with_cpio_magic_header(&header)?; let filesize = hex_str_to_u32(&header[54..62])?; let namesize = hex_str_to_u32(&header[94..102])?; let filename = read_filename(archive, namesize.into())?; skip_file_content(archive, filesize)?; Ok((filename, filesize)) } fn skip_file_content(archive: &mut R, filesize: u32) -> Result<()> { if filesize == 0 { return Ok(()); }; let skip = u64::from(filesize) + padding_needed_for(filesize.into(), CPIO_ALIGNMENT); archive.seek_forward(skip) } #[cfg(test)] mod tests { use super::*; #[test] fn test_header_read() { // Wrapped before mtime and filename let archive = b"07070100000002000081B4000003E8000007D000000001\ 661BE5C600000008000000000000000000000000000000000000000A00000000\ path/file\0content\0"; let header = Header::read(&mut archive.as_ref()).unwrap(); assert_eq!( header, Header { ino: 2, mode: 0o100664, uid: 1000, gid: 2000, nlink: 1, mtime: 1713104326, filesize: 8, major: 0, minor: 0, rmajor: 0, rminor: 0, filename: "path/file".into() } ); // Test writing the header and get the original data back let mut output = Vec::new(); let mut size = header.write(&mut output).unwrap(); output.write_all(b"content\0").unwrap(); size += 8; assert_eq!( std::str::from_utf8(&output).unwrap(), std::str::from_utf8(archive).unwrap(), ); assert_eq!(size, archive.len() as u64); } #[test] fn test_header_read_invalid_magic_number() { let invalid_data = b"abc\tefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\ abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; let got = Header::read(&mut invalid_data.as_ref()).unwrap_err(); assert_eq!(got.kind(), ErrorKind::InvalidData); assert_eq!( got.to_string(), "Invalid CPIO magic number 'abc\\tef'. Expected 070701" ); } #[test] fn test_header_write() { let header = Header { ino: 42, mode: 0o43_777, uid: 1000, gid: 2001, nlink: 2, mtime: 1720081471, filesize: 0, major: 3, minor: 7, rmajor: 42, rminor: 153, filename: "./directory_with_setuid".into(), }; let mut output = Vec::new(); let size = header.write(&mut output).unwrap(); assert_eq!( std::str::from_utf8(&output).unwrap(), "0707010000002A000047FF000003E8000007D10000000266865C3F00000000\ 00000003000000070000002A000000990000001800000000\ ./directory_with_setuid\0\0\0", ); assert_eq!(size, 136); } #[test] fn test_header_write_filename_too_long() { let filename = format!("this/path/is/way/t{}/long", "o".repeat(5000)); let header = Header::new( 42, 0o43_777, 1000, 2000, 1, 1720081471, 0, 37, 153, &filename, ); let mut output = Vec::new(); let got = header.write(&mut output).unwrap_err(); assert_eq!(got.kind(), ErrorKind::InvalidData); assert_eq!( got.to_string(), format!("Path '{filename}' exceeds filename length limit") ); } #[test] fn test_header_write_with_alignment_exceeds_path_max() { let path = "usr/lib/modules/6.16.0-13-generic/modules.dep"; let header = Header::new(42, 0o100_644, 0xAA, 0xBB, 1, 0x689CD1CC, 917184, 0, 0, path); let mut output = Vec::new(); let size = header .write_with_alignment(&mut output, NonZeroU32::new(PATH_MAX as u32), 3956) .unwrap(); assert_eq!( std::str::from_utf8(&output).unwrap(), "0707010000002A000081A4000000AA000000BB00000001689CD1CC\ 000DFEC0000000000000000000000000000000000000002E00000000\ usr/lib/modules/6.16.0-13-generic/modules.dep\0", ); assert_eq!(size, 156); } #[test] fn test_header_write_with_alignment_near_path_max() { let header = Header::new( 42, 0o100_644, 0xAA, 0xBB, 1, 0x689CD1CC, 917184, 0, 0, "data", ); let mut output = Vec::new(); let size = header .write_with_alignment(&mut output, NonZeroU32::new(PATH_MAX as u32), 3988) .unwrap(); assert_eq!( std::str::from_utf8(&output).unwrap(), format!( "0707010000002A000081A4000000AA000000BB00000001689CD1CC\ 000DFEC00000000000000000000000000000000000000FFE00000000\ data\0\0{}", "\0".repeat(4088) ), ); assert_eq!(size, 4204); } #[test] fn test_hex_str_to_u32() { let value = hex_str_to_u32(b"000003E8").unwrap(); assert_eq!(value, 1000); } #[test] fn test_hex_str_to_u32_invalid_hex() { let got = hex_str_to_u32(b"something").unwrap_err(); assert_eq!(got.kind(), ErrorKind::InvalidData); assert_eq!(got.to_string(), "Invalid hexadecimal value 'something'"); } #[test] fn test_hex_str_to_u32_invalid_utf8() { let got = hex_str_to_u32(b"no\xc3\x28utf8").unwrap_err(); assert_eq!(got.kind(), ErrorKind::InvalidData); assert_eq!(got.to_string(), "Invalid hexadecimal value 'no\\xc3(utf8'"); } #[test] fn test_is_root_directory() { let header = Header::new(0, 0o040_755, 0, 0, 1, 1744150584, 0, 0, 0, "."); assert!(header.is_root_directory()); } #[test] fn test_is_root_directory_not_root_path() { let header = Header::new(0, 0o040_755, 0, 0, 1, 1744150584, 0, 0, 0, "path"); assert!(!header.is_root_directory()); } #[test] fn test_is_root_directory_is_file() { let header = Header::new(0, 0o100_644, 0, 0, 1, 1744150584, 0, 0, 0, "."); assert!(!header.is_root_directory()); } #[test] fn test_padding_needed_for() { assert_eq!(padding_needed_for(110, 4), 2); } #[test] fn test_padding_needed_for_is_aligned() { assert_eq!(padding_needed_for(32, 4), 0); } } threecpio-0.11.0/src/lib.rs000064400000000000000000000477411046102023000136050ustar 00000000000000// Copyright (C) 2024, Benjamin Drung // SPDX-License-Identifier: ISC use std::collections::HashMap; use std::fs::File; use std::io::{prelude::*, Result}; use std::num::NonZeroU32; use std::time::SystemTime; use glob::Pattern; use crate::compression::read_magic_header; use crate::filetype::*; use crate::header::{ read_file_name_and_size_from_next_cpio_object, Header, CPIO_ALIGNMENT, TRAILER_FILENAME, }; use crate::libc::strftime_local; use crate::logger::{Level, Logger}; use crate::manifest::Manifest; use crate::ranges::Ranges; use crate::seek_forward::SeekForward; #[macro_use] pub mod logger; mod compression; pub mod examine; mod extended_error; pub mod extract; mod filetype; mod header; mod libc; mod manifest; pub mod ranges; mod seek_forward; pub mod temp_dir; struct CpioFilenameReader<'a, R: Read + SeekForward> { archive: &'a mut R, } impl Iterator for CpioFilenameReader<'_, R> { type Item = Result; fn next(&mut self) -> Option { match read_file_name_and_size_from_next_cpio_object(self.archive) { Ok((filename, _)) => { if filename == TRAILER_FILENAME { None } else { Some(Ok(filename)) } } Err(x) => Some(Err(x)), } } } struct UserGroupCache { user_cache: HashMap>, group_cache: HashMap>, } impl UserGroupCache { fn new() -> Self { Self { user_cache: HashMap::new(), group_cache: HashMap::new(), } } /// Translate user ID (UID) to user name and cache result. fn get_user(&mut self, uid: u32) -> Result> { match self.user_cache.get(&uid) { Some(name) => Ok(name.clone()), None => { let name = libc::getpwuid_name(uid)?; self.user_cache.insert(uid, name.clone()); Ok(name) } } } /// Translate group ID (GID) to group name and cache result. fn get_group(&mut self, gid: u32) -> Result> { match self.group_cache.get(&gid) { Some(name) => Ok(name.clone()), None => { let name = libc::getgrgid_name(gid)?; self.group_cache.insert(gid, name.clone()); Ok(name) } } } } /// Format the time in a similar way to coreutils' ls command. fn format_time(timestamp: u32, now: i64) -> Result { // Logic from coreutils ls command: // Consider a time to be recent if it is within the past six months. // A Gregorian year has 365.2425 * 24 * 60 * 60 == 31556952 seconds // on the average. let recent = now - i64::from(timestamp) <= 15778476; if recent { strftime_local(b"%b %e %H:%M\0", timestamp) } else { strftime_local(b"%b %e %Y\0", timestamp) } } fn read_cpio_and_print_filenames( archive: &mut R, out: &mut W, patterns: &Vec, ) -> Result<()> { let cpio = CpioFilenameReader { archive }; for f in cpio { let filename = f?; if patterns.is_empty() || filename_matches(&filename, patterns) { writeln!(out, "{filename}")?; } } Ok(()) } fn read_cpio_and_print_long_format( archive: &mut R, out: &mut W, patterns: &Vec, now: i64, user_group_cache: &mut UserGroupCache, print_ino: bool, ) -> Result<()> { // Files can have the same mtime (especially when using SOURCE_DATE_EPOCH). // Cache the time string of the last mtime. let mut last_mtime = 0; let mut time_string: String = "".into(); loop { let header = match Header::read(archive) { Ok(header) => { if header.filename == "TRAILER!!!" { break; } else { header } } Err(e) => return Err(e), }; if !patterns.is_empty() && !filename_matches(&header.filename, patterns) { header.skip_file_content(archive)?; continue; } let user = match user_group_cache.get_user(header.uid)? { Some(name) => name, None => header.uid.to_string(), }; let group = match user_group_cache.get_group(header.gid)? { Some(name) => name, None => header.gid.to_string(), }; let mode_string = header.mode_string(); if header.mtime != last_mtime || time_string.is_empty() { last_mtime = header.mtime; time_string = format_time(header.mtime, now)?; }; if print_ino { write!(out, "{:>4} ", header.ino)?; } match header.mode & MODE_FILETYPE_MASK { FILETYPE_SYMLINK => { let target = header.read_symlink_target(archive)?; writeln!( out, "{} {:>3} {:<8} {:<8} {:>8} {} {} -> {}", std::str::from_utf8(&mode_string).unwrap(), header.nlink, user, group, header.filesize, time_string, header.filename, target )?; } FILETYPE_BLOCK_DEVICE | FILETYPE_CHARACTER_DEVICE => { header.skip_file_content(archive)?; writeln!( out, "{} {:>3} {:<8} {:<8} {:>3}, {:>3} {} {}", std::str::from_utf8(&mode_string).unwrap(), header.nlink, user, group, header.rmajor, header.rminor, time_string, header.filename )?; } _ => { header.skip_file_content(archive)?; writeln!( out, "{} {:>3} {:<8} {:<8} {:>8} {} {}", std::str::from_utf8(&mode_string).unwrap(), header.nlink, user, group, header.filesize, time_string, header.filename )?; } }; } Ok(()) } // Does the given file name matches one of the globbing patterns? fn filename_matches(filename: &str, patterns: &Vec) -> bool { for pattern in patterns { if pattern.matches(filename) { return true; } } false } fn seek_to_cpio_end(archive: &mut File) -> Result<()> { let cpio = CpioFilenameReader { archive }; for f in cpio { f?; } Ok(()) } /// Return the number of concatenated cpio archives. pub fn get_cpio_archive_count(archive: &mut File) -> Result { let mut count = 0; loop { let compression = match read_magic_header(archive)? { None => return Ok(count), Some(x) => x, }; count += 1; if compression.is_uncompressed() { seek_to_cpio_end(archive)?; } else { break; } } Ok(count) } // Parse SOURCE_DATE_EPOCH environment variable (if set and valid integer) fn get_source_date_epoch() -> Option { match std::env::var("SOURCE_DATE_EPOCH") { Ok(value) => match value.parse::() { Ok(source_date_epoch) => { if let Ok(x) = source_date_epoch.try_into() { Some(x) } else if source_date_epoch < 0 { Some(0) } else { Some(u32::MAX) } } Err(_) => None, }, Err(_) => None, } } /// Create a cpio archive and return the size in bytes of the uncompressed data. /// /// **Warning**: This function was designed for the `3cpio` command-line application. /// The API can change between releases and no stability promises are given. /// Please get in contact to support your use case and make the API for this function stable. pub fn create_cpio_archive( archive: Option, alignment: Option, logger: &mut Logger, ) -> Result { let source_date_epoch = get_source_date_epoch(); let stdin = std::io::stdin(); let buf_reader = std::io::BufReader::new(stdin); debug!(logger, "Parsing manifest from stdin...")?; let manifest = Manifest::from_input(buf_reader, logger)?; debug!(logger, "Writing cpio...")?; manifest.write_archive(archive, alignment, source_date_epoch, logger) } /// List the contents of the cpio archives. /// /// **Warning**: This function was designed for the `3cpio` command-line application. /// The API can change between releases and no stability promises are given. /// Please get in contact to support your use case and make the API for this function stable. pub fn list_cpio_content( mut archive: File, out: &mut W, parts: Option<&Ranges>, patterns: &Vec, log_level: Level, ) -> Result<()> { let mut user_group_cache = UserGroupCache::new(); let now = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .unwrap() .as_secs() .try_into() .unwrap(); let mut count = 0; loop { count += 1; let compression = match read_magic_header(&mut archive)? { None => return Ok(()), Some(x) => x, }; if parts.is_some_and(|f| !f.contains(&count)) { if compression.is_uncompressed() && parts.unwrap().has_more(&count) { seek_to_cpio_end(&mut archive)?; continue; } break; } if compression.is_uncompressed() { if log_level >= Level::Info { read_cpio_and_print_long_format( &mut archive, out, patterns, now, &mut user_group_cache, log_level >= Level::Debug, )?; } else { read_cpio_and_print_filenames(&mut archive, out, patterns)?; } } else { let mut decompressed = compression.decompress(archive)?; if log_level >= Level::Info { read_cpio_and_print_long_format( &mut decompressed, out, patterns, now, &mut user_group_cache, log_level >= Level::Debug, )?; } else { read_cpio_and_print_filenames(&mut decompressed, out, patterns)?; } break; } } Ok(()) } #[cfg(test)] mod tests { use std::env; use std::path::{Path, PathBuf}; use super::*; use crate::logger::Level; // Lock for tests that rely on / change the current directory pub(crate) static TEST_LOCK: std::sync::Mutex = std::sync::Mutex::new(0); pub(crate) fn tests_path>(path: P) -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests") .join(path) } extern "C" { fn tzset(); } impl UserGroupCache { fn insert_test_data(&mut self) { self.user_cache.insert(1000, Some("user".into())); self.group_cache.insert(123, Some("whoopsie".into())); self.group_cache.insert(2000, None); } } #[test] fn test_print_cpio_archive_count_compressed() { let mut archive = File::open(tests_path("zstd.cpio")).expect("test cpio should be present"); let count = get_cpio_archive_count(&mut archive).unwrap(); assert_eq!(count, 2); } #[test] fn test_get_cpio_archive_count_single() { let mut archive = File::open(tests_path("single.cpio")).expect("test cpio should be present"); let count = get_cpio_archive_count(&mut archive).unwrap(); assert_eq!(count, 1); } #[test] fn test_list_cpio_content_compressed_parts() { let archive = File::open(tests_path("gzip.cpio")).unwrap(); let mut output = Vec::new(); list_cpio_content( archive, &mut output, Some(&"2-".parse::().unwrap()), &Vec::new(), Level::Warning, ) .unwrap(); assert_eq!( String::from_utf8(output).unwrap(), ".\nusr\nusr/bin\nusr/bin/sh\n" ); } #[test] fn test_list_cpio_content_compressed_with_pattern() { let archive = File::open(tests_path("xz.cpio")).unwrap(); let patterns = vec![Pattern::new("p?th").unwrap()]; let mut output = Vec::new(); list_cpio_content(archive, &mut output, None, &patterns, Level::Warning).unwrap(); assert_eq!(String::from_utf8(output).unwrap(), "path\n"); } #[test] fn test_list_cpio_content_uncompressed_with_pattern() { let archive = File::open(tests_path("single.cpio")).unwrap(); let patterns = vec![Pattern::new("*/file").unwrap()]; let mut output = Vec::new(); list_cpio_content(archive, &mut output, None, &patterns, Level::Warning).unwrap(); assert_eq!(String::from_utf8(output).unwrap(), "path/file\n"); } #[test] fn test_read_cpio_and_print_long_format_character_device() { // Wrapped before mtime and filename let archive = b"07070100000003000021A4000000000000\ 00000000000167055BC800000000000000000000000000000005000000010000\ 000C00000000dev/console\0\0\0\ 0707010000000000000000000000000000000000000001\ 0000000000000000000000000000000000000000000000000000000B00000000\ TRAILER!!!\0\0\0\0"; let mut output = Vec::new(); let mut user_group_cache = UserGroupCache::new(); env::set_var("TZ", "UTC"); unsafe { tzset() }; read_cpio_and_print_long_format( &mut archive.as_ref(), &mut output, &Vec::new(), 1728486311, &mut user_group_cache, false, ) .unwrap(); assert_eq!( String::from_utf8(output).unwrap(), "crw-r--r-- 1 root root 5, 1 Oct 8 16:20 dev/console\n" ); } #[test] fn test_read_cpio_and_print_long_format_directory() { // Wrapped before mtime and filename let archive = b"07070100000001000047FF000000000000007B00000002\ 66A6E40400000000000000000000000000000000000000000000000B00000000\ /var/crash\0\0\0\0\ 0707010000000000000000000000000000000000000001\ 0000000000000000000000000000000000000000000000000000000B00000000\ TRAILER!!!\0\0\0\0"; let mut output = Vec::new(); let mut user_group_cache = UserGroupCache::new(); user_group_cache.insert_test_data(); env::set_var("TZ", "UTC"); unsafe { tzset() }; read_cpio_and_print_long_format( &mut archive.as_ref(), &mut output, &Vec::new(), 1722389471, &mut user_group_cache, false, ) .unwrap(); assert_eq!( String::from_utf8(output).unwrap(), "drwxrwsrwt 2 root whoopsie 0 Jul 29 00:36 /var/crash\n" ); } #[test] fn test_read_cpio_and_print_long_format_file() { // Wrapped before mtime and filename let archive = b"070701000036E4000081A4000003E8000007D000000001\ 66A3285300000041000000000000002400000000000000000000000D00000000\ conf/modules\0\0\ linear\nmultipath\nraid0\nraid1\nraid456\nraid5\nraid6\nraid10\nefivarfs\0\0\0\0\ 0707010000000000000000000000000000000000000001\ 0000000000000000000000000000000000000000000000000000000B00000000\ TRAILER!!!\0\0\0\0"; let mut output = Vec::new(); let mut user_group_cache = UserGroupCache::new(); user_group_cache.insert_test_data(); env::set_var("TZ", "UTC"); unsafe { tzset() }; read_cpio_and_print_long_format( &mut archive.as_ref(), &mut output, &Vec::new(), 1722645915, &mut user_group_cache, false, ) .unwrap(); assert_eq!( String::from_utf8(output).unwrap(), "-rw-r--r-- 1 user 2000 65 Jul 26 04:38 conf/modules\n" ); } #[test] fn test_read_cpio_and_print_long_format_pattern() { // Wrapped before mtime and filename let archive = b"070701000036E4000081A4000003E8000007D000000001\ 66A3285300000041000000000000002400000000000000000000000D00000000\ conf/modules\0\0\ linear\nmultipath\nraid0\nraid1\nraid456\nraid5\nraid6\nraid10\nefivarfs\0\0\0\0\ 0707010000000D0000A1FF000000000000000000000001\ 6237389400000007000000000000000000000000000000000000000400000000\ bin\0\0\0usr/bin\0\ 0707010000000000000000000000000000000000000001\ 0000000000000000000000000000000000000000000000000000000B00000000\ TRAILER!!!\0\0\0\0"; let mut output = Vec::new(); let mut user_group_cache = UserGroupCache::new(); user_group_cache.insert_test_data(); env::set_var("TZ", "UTC"); unsafe { tzset() }; read_cpio_and_print_long_format( &mut archive.as_ref(), &mut output, &vec![Pattern::new("bin").unwrap()], 1722645915, &mut user_group_cache, false, ) .unwrap(); assert_eq!( String::from_utf8(output).unwrap(), "lrwxrwxrwx 1 root root 7 Mar 20 2022 bin -> usr/bin\n" ); } #[test] fn test_read_cpio_and_print_long_format_symlink() { // Wrapped before mtime and filename let archive = b"0707010000000D0000A1FF000000000000000000000001\ 6237389400000007000000000000000000000000000000000000000400000000\ bin\0\0\0usr/bin\0\ 0707010000000000000000000000000000000000000001\ 0000000000000000000000000000000000000000000000000000000B00000000\ TRAILER!!!\0\0\0\0"; let mut output = Vec::new(); let mut user_group_cache = UserGroupCache::new(); user_group_cache.insert_test_data(); read_cpio_and_print_long_format( &mut archive.as_ref(), &mut output, &Vec::new(), 1722645915, &mut user_group_cache, false, ) .unwrap(); assert_eq!( String::from_utf8(output).unwrap(), "lrwxrwxrwx 1 root root 7 Mar 20 2022 bin -> usr/bin\n" ); } #[test] fn test_read_cpio_and_print_long_format_print_ino() { // Wrapped after mtime let archive = b"07070100000000000041ED00000000000000000000000265307180\ 00000000000000000000000000000000000000000000000200000000.\0\ 07070100000001000041ED00000000000000000000000265307180\ 00000000000000000000000000000000000000000000000700000000kernel\0\0\0\0\ 070701000000000000000000000000000000000000000100000000\ 00000000000000000000000000000000000000000000000B00000000TRAILER!!!\0\0\0\0"; let mut output = Vec::new(); let mut user_group_cache = UserGroupCache::new(); user_group_cache.insert_test_data(); env::set_var("TZ", "UTC"); unsafe { tzset() }; read_cpio_and_print_long_format( &mut archive.as_ref(), &mut output, &Vec::new(), 1722645915, &mut user_group_cache, true, ) .unwrap(); assert_eq!( String::from_utf8(output).unwrap(), concat!( " 0 drwxr-xr-x 2 root root 0 Oct 19 2023 .\n", " 1 drwxr-xr-x 2 root root 0 Oct 19 2023 kernel\n" ) ); } } threecpio-0.11.0/src/libc.rs000064400000000000000000000137661046102023000137500ustar 00000000000000use std::ffi::{CStr, CString}; use std::io::{Error, Result}; /// Get password file entry and return user name. /// /// This function wraps the standard C library function getpwuid(). /// The getpwuid() function returns a pointer to a structure containing the /// broken-out fields of the record in the password database (e.g., the local /// password file /etc/passwd, NIS, and LDAP) that matches the user ID uid. pub(crate) fn getpwuid_name(uid: u32) -> Result> { let mut pwd = std::mem::MaybeUninit::::uninit(); let mut buf = [0u8; 2048]; let mut result = std::ptr::null_mut::(); let rc = unsafe { libc::getpwuid_r( uid, pwd.as_mut_ptr(), buf.as_mut_ptr() as *mut libc::c_char, buf.len(), &mut result, ) }; if rc != 0 { return Err(Error::last_os_error()); } if result.is_null() { return Ok(None); } let name = unsafe { core::ffi::CStr::from_ptr((*result).pw_name) }; Ok(Some(name.to_string_lossy().to_string())) } /// Get group file entry and return group name. /// /// This function wraps the standard C library function getgrgid(). /// The getgrgid() function returns a pointer to a structure containing the /// broken-out fields of the record in the group database (e.g., the local /// group file /etc/group, NIS, and LDAP) that matches the group ID gid. pub(crate) fn getgrgid_name(gid: u32) -> Result> { let mut group = std::mem::MaybeUninit::::uninit(); let mut buf = [0u8; 2048]; let mut result = std::ptr::null_mut::(); let rc = unsafe { libc::getgrgid_r( gid, group.as_mut_ptr(), buf.as_mut_ptr() as *mut libc::c_char, buf.len(), &mut result, ) }; if rc != 0 { return Err(Error::last_os_error()); } if result.is_null() { return Ok(None); } let name = unsafe { core::ffi::CStr::from_ptr((*result).gr_name) }; Ok(Some(name.to_string_lossy().to_string())) } pub(crate) fn major(dev: u64) -> u32 { libc::major(dev) } pub(crate) fn minor(dev: u64) -> u32 { libc::minor(dev) } pub(crate) fn mknod(pathname: &str, mode: libc::mode_t, major: u32, minor: u32) -> Result<()> { let p = CString::new(pathname)?; let rc = unsafe { libc::mknod(p.as_ptr(), mode, libc::makedev(major, minor)) }; if rc != 0 { return Err(Error::last_os_error()); }; Ok(()) } pub(crate) fn set_modified(path: &str, mtime: i64) -> Result<()> { let p = CString::new(path)?; let mut modified: libc::timespec = unsafe { std::mem::zeroed() }; modified.tv_sec = mtime; // times contains the access time followed by modfied time let times = [modified, modified]; let rc = unsafe { libc::utimensat( libc::AT_FDCWD, p.as_ptr(), times.as_ptr(), libc::AT_SYMLINK_NOFOLLOW, ) }; if rc != 0 { return Err(Error::last_os_error()); }; Ok(()) } // TODO: Use c"…" string literal for `format` once stable fn strftime(format: &[u8], tm: *mut libc::tm) -> Result { let mut s = [0u8; 19]; let length = unsafe { libc::strftime( s.as_mut_ptr() as *mut libc::c_char, s.len(), CStr::from_bytes_with_nul_unchecked(format).as_ptr(), tm, ) }; if length == 0 { return Err(Error::other("strftime returned 0")); } Ok(String::from_utf8_lossy(&s[..length]).to_string()) } pub(crate) fn strftime_local(format: &[u8], timestamp: u32) -> Result { let mut tm = std::mem::MaybeUninit::::uninit(); let result = unsafe { libc::localtime_r(×tamp.into(), tm.as_mut_ptr()) }; if result.is_null() { return Err(Error::last_os_error()); }; strftime(format, result) } #[cfg(test)] pub(crate) mod tests { use super::*; use crate::temp_dir::TempDir; use std::time::{Duration, SystemTime}; extern "C" { fn tzset(); } #[test] fn test_getpwuid_name_root() { let got = getpwuid_name(0).unwrap(); assert_eq!(got, Some("root".to_string())); } #[test] fn test_getpwuid_name_non_existing() { // Assume that this UID is not in /etc/passwd (nobody is 65534) let got = getpwuid_name(65520).unwrap(); assert_eq!(got, None); } #[test] fn test_getgrgid_name_root() { let got = getgrgid_name(0).unwrap(); assert_eq!(got, Some("root".to_string())); } #[test] fn test_getgrgid_name_non_existing() { // Assume that this GID is not in /etc/passwd (nogroup is 65534) let got = getgrgid_name(65520).unwrap(); assert_eq!(got, None); } #[test] // Create a temporary directory and set the mtime 10 seconds earlier // than the current mtime of the directory. fn test_set_modified() { let dir = TempDir::new().unwrap(); let modified = dir.path.metadata().unwrap().modified().unwrap(); let duration = modified.duration_since(SystemTime::UNIX_EPOCH).unwrap(); let new_modified = SystemTime::UNIX_EPOCH .checked_add(Duration::new(duration.as_secs() - 10, 0)) .unwrap(); let mtime = new_modified.duration_since(SystemTime::UNIX_EPOCH).unwrap(); let p = dir.path.clone().into_os_string().into_string().unwrap(); set_modified(&p, mtime.as_secs().try_into().unwrap()).unwrap(); assert_eq!( dir.path.metadata().unwrap().modified().unwrap(), new_modified ); } #[test] fn test_strftime_local_year() { let time = strftime_local(b"%b %e %Y\0", 2278410030).unwrap(); assert_eq!(time, "Mar 14 2042"); } #[test] fn test_strftime_local_hour() { std::env::set_var("TZ", "UTC"); unsafe { tzset() }; let time = strftime_local(b"%b %e %H:%M\0", 1720735264).unwrap(); assert_eq!(time, "Jul 11 22:01"); } } threecpio-0.11.0/src/logger.rs000064400000000000000000000035511046102023000143050ustar 00000000000000// Copyright (C) 2025, Benjamin Drung // SPDX-License-Identifier: ISC use std::io::{Stderr, Write}; /// An enum representing the available verbosity levels of the logger. #[derive(Clone, Debug, PartialEq, PartialOrd)] pub enum Level { /// The warning level. Designates hazardous situations. Warning = 5, /// The info level. Designates useful information. Info = 7, /// The debug level. Designates lower priority information and for debugging. Debug = 8, } macro_rules! debug { ($dst:ident, $($arg:tt)*) => { if $dst.is_enabled_for_debug() { writeln!($dst.out, $($arg)*) } else { Ok(()) } }; } macro_rules! info { ($dst:ident, $($arg:tt)*) => { if $dst.is_enabled_for_info() { writeln!($dst.out, $($arg)*) } else { Ok(()) } }; } /// Simple logging implementation that logs to a writer and supports log levels. /// /// In contrast to the common `log` crate, the `Logger` needs to be specified /// as parameter in the logging macros. #[derive(Debug)] pub struct Logger { level: Level, pub(crate) out: W, } impl Logger { pub(crate) fn is_enabled_for_debug(&self) -> bool { self.level >= Level::Debug } pub(crate) fn is_enabled_for_info(&self) -> bool { self.level >= Level::Info } } impl Logger { /// Create a new `Logger` that logs to standard error (stderr). pub fn new_stderr(level: Level) -> Self { Self { level, out: std::io::stderr(), } } } #[cfg(test)] impl Logger> { pub(crate) fn new_vec(level: Level) -> Self { Self { level, out: Vec::new(), } } pub(crate) fn get_logs(&self) -> &str { core::str::from_utf8(&self.out).unwrap() } } threecpio-0.11.0/src/main.rs000064400000000000000000000305741046102023000137570ustar 00000000000000// Copyright (C) 2024, Benjamin Drung // SPDX-License-Identifier: ISC use std::env::set_current_dir; use std::fs::{create_dir, read_dir, File}; use std::io::{ErrorKind, Write}; use std::num::NonZeroU32; use std::path::Path; use std::process::ExitCode; use glob::Pattern; use lexopt::prelude::*; use threecpio::examine::examine_cpio_content; use threecpio::extract::{extract_cpio_archive, ExtractOptions}; use threecpio::logger::{Level, Logger}; use threecpio::ranges::Ranges; use threecpio::{create_cpio_archive, get_cpio_archive_count, list_cpio_content}; #[derive(Debug)] struct Args { count: bool, create: bool, data_alignment: Option, directory: String, examine: bool, extract: bool, force: bool, list: bool, log_level: Level, archive: Option, make_directories: bool, parts: Option, patterns: Vec, preserve_permissions: bool, raw: bool, subdir: Option, to_stdout: bool, } impl Args { fn extract_options(&self) -> ExtractOptions { ExtractOptions::new( self.make_directories, self.parts.clone(), self.patterns.clone(), self.preserve_permissions, self.subdir.clone(), ) } } fn print_help() { let executable = std::env::args().next().unwrap(); println!( "Usage: {executable} --count ARCHIVE {executable} {{-c|--create}} [-v|--debug] [-C DIR] [--data-align BYTES] [ARCHIVE] < manifest {executable} {{-e|--examine}} [--raw] ARCHIVE {executable} {{-t|--list}} [-v|--debug] [-P LIST] ARCHIVE [pattern...] {executable} {{-x|--extract}} [-v|--debug] [-C DIR] [--make-directories] [-P LIST] [-p] [-s NAME] [--to-stdout] [--force] ARCHIVE [pattern...] Optional arguments: --count Print the number of concatenated cpio archives. -c, --create Create a new cpio archive from the manifest on stdin. -e, --examine List the offsets of the cpio archives and their compression. -t, --list List the contents of the cpio archives. -x, --extract Extract cpio archives. -C, --directory=DIR Change directory before performing any operation. --data-align=BYTES Pad the cpio metadata to align the file data on BYTEs. --make-directories Create leading directories where needed. -P, --parts=LIST Only operate on the cpio archives that matches LIST. -p, --preserve-permissions Set permissions of extracted files to those recorded in the archive (default for superuser). --raw Use a machine-readable output format. -s, --subdir Extract the cpio archives into separate directories (using the given name plus an incrementing number) --to-stdout Extract files to standard output -v, --verbose Verbose output --debug Debug output --force Force overwriting existing files -h, --help print help message -V, --version print version number and exit", ); } fn print_version() { let name = std::option_env!("CARGO_BIN_NAME").unwrap(); let version = std::option_env!("CARGO_PKG_VERSION").unwrap(); println!("{name} {version}"); } fn parse_args() -> Result { let mut count = 0; let mut create = 0; let mut data_alignment = None; let mut examine = 0; let mut extract = 0; let mut force = false; let mut parts = None; let mut preserve_permissions = is_root(); let mut list = 0; let mut log_level = Level::Warning; let mut directory = ".".into(); let mut archive = None; let mut make_directories = false; let mut patterns = Vec::new(); let mut raw = false; let mut subdir: Option = None; let mut to_stdout = false; let mut arguments = Vec::new(); let mut parser = lexopt::Parser::from_env(); while let Some(arg) = parser.next()? { match arg { Long("count") => { count = 1; } Short('c') | Long("create") => { create = 1; } Short('C') | Long("directory") => { directory = parser.value()?.string()?; } Long("data-align") => { let value = parser.value()?; data_alignment = if let Ok(int_value) = value.parse::() { if int_value.get() % 4 != 0 { return Err("--data-align must be a multiple of 4 bytes".into()); } Some(int_value) } else { return Err("--data-align must be a positive number".into()); }; } Long("debug") => { log_level = Level::Debug; } Short('e') | Long("examine") => { examine = 1; } Long("force") => { force = true; } Short('h') | Long("help") => { print_help(); std::process::exit(0); } Long("make-directories") => { make_directories = true; } Short('P') | Long("parts") => { parts = Some(parser.value()?.parse()?); } Short('p') | Long("preserve-permissions") => { preserve_permissions = true; } Long("raw") => { raw = true; } Short('s') | Long("subdir") => { subdir = Some(parser.value()?.string()?); } Short('t') | Long("list") => { list = 1; } Long("to-stdout") => { to_stdout = true; } Short('v') | Long("verbose") => { if log_level <= Level::Info { log_level = Level::Info; } } Short('V') | Long("version") => { print_version(); std::process::exit(0); } Short('x') | Long("extract") => { extract = 1; } Value(val) if archive.is_none() => { archive = Some(val.string()?); } Value(val) => arguments.push(val.string()?), _ => return Err(arg.unexpected()), } } if count + create + examine + extract + list != 1 { return Err( "Either --count, --create, --examine, --extract, or --list must be specified!".into(), ); } if extract + list == 1 { for argument in arguments { let pattern = Pattern::new(&argument) .map_err(|e| format!("invalid pattern '{argument}': {e}"))?; patterns.push(pattern); } } else if !arguments.is_empty() { let first = &arguments[0]; return Err(Value(first.into()).unexpected()); } if let Some(ref s) = subdir { if s.contains('/') { return Err(format!("Subdir '{s}' must not contain slashes!").into()); } } if create != 1 && archive.is_none() { return Err("missing argument ARCHIVE".into()); } Ok(Args { count: count == 1, create: create == 1, data_alignment, directory, examine: examine == 1, extract: extract == 1, force, list: list == 1, log_level, archive, make_directories, parts, patterns, preserve_permissions, raw, subdir, to_stdout, }) } fn is_empty_directory>(path: P) -> std::io::Result { Ok(read_dir(path)?.next().is_none()) } fn is_root() -> bool { let uid = unsafe { libc::getuid() }; uid == 0 } fn create_and_set_current_dir(path: &str, force: bool) -> Result<(), String> { if let Err(e) = set_current_dir(path) { if e.kind() != ErrorKind::NotFound { return Err(format!("Failed to change directory to '{path}': {e}")); } if let Err(e) = create_dir(path) { return Err(format!("Failed to create directory '{path}': {e}")); } if let Err(e) = set_current_dir(path) { return Err(format!("Failed to change directory to '{path}': {e}")); } } if !force { match is_empty_directory(".") { Err(e) => { return Err(format!( "Failed to check content of directory '{path}': {e}" )); } Ok(false) => { return Err(format!( "Target directory '{path}' is not empty. \ Use --force to overwrite existing files!", )); } Ok(true) => {} } } Ok(()) } /// Print the number of concatenated cpio archives. fn print_cpio_archive_count(mut archive: File, out: &mut W) -> std::io::Result<()> { let count = get_cpio_archive_count(&mut archive)?; writeln!(out, "{count}")?; Ok(()) } fn main() -> ExitCode { let executable = std::env::args().next().unwrap(); let args = match parse_args() { Ok(a) => a, Err(e) => { eprintln!("{executable}: Error: {e}"); return ExitCode::from(2); } }; let mut logger = Logger::new_stderr(args.log_level.clone()); if args.create { let mut archive = None; if let Some(path) = args.archive.as_ref() { archive = match File::create(path) { Ok(f) => Some(f), Err(e) => { eprintln!("{executable}: Error: Failed to create '{path}': {e}"); return ExitCode::FAILURE; } }; if args.log_level >= Level::Debug { eprintln!("{executable}: Opened '{path}' for writing."); } } if let Err(e) = set_current_dir(&args.directory) { eprintln!( "{executable}: Error: Failed to change directory to '{}': {e}", args.directory, ); return ExitCode::FAILURE; } let result = create_cpio_archive(archive, args.data_alignment, &mut logger); if let Err(error) = result { match error.kind() { ErrorKind::BrokenPipe => {} _ => { eprintln!( "{executable}: Error: Failed to create '{}': {error}", args.archive.unwrap_or("cpio on stdout".into()), ); return ExitCode::FAILURE; } } } return ExitCode::SUCCESS; }; let archive = match File::open(args.archive.as_ref().unwrap()) { Ok(f) => f, Err(e) => { eprintln!( "{executable}: Error: Failed to open '{}': {e}", args.archive.unwrap(), ); return ExitCode::FAILURE; } }; if args.extract && !args.to_stdout { if let Err(e) = create_and_set_current_dir(&args.directory, args.force) { eprintln!("{executable}: Error: {e}"); return ExitCode::FAILURE; } } let mut stdout = std::io::stdout(); let (operation, result) = if args.count { ( "count number of cpio archives", print_cpio_archive_count(archive, &mut stdout), ) } else if args.examine { ( "examine content", examine_cpio_content(archive, &mut stdout, args.raw), ) } else if args.extract { ( "extract content", extract_cpio_archive( archive, args.to_stdout.then_some(&mut stdout), &args.extract_options(), &mut logger, ), ) } else if args.list { ( "list content", list_cpio_content( archive, &mut stdout, args.parts.as_ref(), &args.patterns, args.log_level, ), ) } else { unreachable!("no operation specified"); }; if let Err(e) = result { match e.kind() { ErrorKind::BrokenPipe => {} _ => { eprintln!( "{executable}: Error: Failed to {operation} of '{}': {e}", args.archive.unwrap(), ); return ExitCode::FAILURE; } } } ExitCode::SUCCESS } threecpio-0.11.0/src/manifest.rs000064400000000000000000001735051046102023000146430ustar 00000000000000// Copyright (C) 2025, Benjamin Drung // SPDX-License-Identifier: ISC use std::collections::HashMap; use std::fs::{symlink_metadata, Metadata}; use std::io::{BufRead, BufWriter, Error, ErrorKind, Result, Write}; use std::num::NonZeroU32; use std::os::unix::fs::{MetadataExt, PermissionsExt}; use crate::compression::Compression; use crate::extended_error::ExtendedError; use crate::filetype::*; use crate::header::{calculate_size, padding_needed_for, Header, TRAILER_SIZE}; use crate::libc::{major, minor}; use crate::logger::Logger; use crate::CPIO_ALIGNMENT; #[derive(Debug, PartialEq)] struct Hardlink { location: String, filesize: u32, references: u32, } fn get_hardlink_key(stat: &Metadata) -> u128 { (u128::from(stat.ino()) << 64) | u128::from(stat.dev()) } impl Hardlink { fn new>(location: S, filesize: u32) -> Self { Self { location: location.into(), filesize, references: 1, } } #[cfg(test)] fn with_references>(location: S, filesize: u32, references: u32) -> Self { Self { location: location.into(), filesize, references, } } } #[derive(Debug, PartialEq)] enum Filetype { Hardlink { key: u128, index: u32 }, EmptyFile, Directory, BlockDevice { major: u32, minor: u32 }, CharacterDevice { major: u32, minor: u32 }, Fifo, Socket, Symlink { target: String }, } #[derive(Debug, PartialEq)] struct File { filetype: Filetype, name: String, mode: u16, uid: u32, gid: u32, mtime: u32, } #[derive(Debug, PartialEq)] pub(crate) struct Archive { compression: Compression, files: Vec, hardlinks: HashMap, } #[derive(Debug, PartialEq)] pub(crate) struct Manifest { archives: Vec, umask: u32, } struct LazyMetadata<'a> { location: Option<&'a str>, metadata: Option, } impl<'a> LazyMetadata<'a> { fn new(location: Option<&'a str>) -> Self { LazyMetadata { location, metadata: None, } } fn get_metadata(&mut self, name: &str) -> Result<&Metadata> { if self.metadata.is_none() { let stat = match self.location { None => { return Err(Error::new( ErrorKind::InvalidInput, format!("Neither {name} nor location specified."), )) } Some(path) => symlink_metadata(path).map_err(|e| e.add_prefix(path))?, }; self.metadata = Some(stat); } Ok(self.metadata.as_ref().unwrap()) } fn parse_u32( &mut self, entry: Option<&str>, name: &str, f: impl Fn(&Metadata) -> Result, ) -> Result { match entry { Some("-") | Some("") | None => Ok(f(self.get_metadata(name)?)?), Some(x) => match x.parse() { Ok(y) => Ok(y), Err(e) => Err(Error::new( ErrorKind::InvalidInput, format!("invalid {name}: {e}"), )), }, } } fn parse_octal( &mut self, entry: Option<&str>, name: &str, f: impl Fn(&Metadata) -> u16, ) -> Result { match entry { Some("-") | Some("") | None => Ok(f(self.get_metadata(name)?)), Some(x) => match u16::from_str_radix(x, 8) { Ok(y) => Ok(y), Err(e) => Err(Error::new( ErrorKind::InvalidInput, format!("invalid {name}: {e}"), )), }, } } fn parse_filetype(&mut self, entry: Option<&str>, name: &str) -> Result { let filetype = match entry { Some("file") => FILETYPE_REGULAR_FILE, Some("dir") => FILETYPE_DIRECTORY, Some("block") => FILETYPE_BLOCK_DEVICE, Some("char") => FILETYPE_CHARACTER_DEVICE, Some("link") => FILETYPE_SYMLINK, Some("fifo") => FILETYPE_FIFO, Some("sock") => FILETYPE_SOCKET, Some("-") | Some("") | None => self.get_metadata(name)?.mode() & MODE_FILETYPE_MASK, Some(x) => { return Err(Error::new( ErrorKind::InvalidInput, format!("Unknown filetype '{x}'"), )) } }; Ok(filetype) } } fn pathbuf_to_string(path: std::path::PathBuf) -> Result { path.into_os_string().into_string().map_err(|e| { Error::new( ErrorKind::InvalidInput, format!("failed to convert path {e:#?} to string"), ) }) } fn parse_symlink(entry: Option<&str>, location: Option<&str>) -> Result { match entry { Some("-") | Some("") | None => match location { None => Err(Error::new( ErrorKind::InvalidInput, "Neither symlink nor location specified.", )), Some(path) => Ok(pathbuf_to_string(std::fs::read_link(path)?)?), }, Some(x) => Ok(x.into()), } } fn replace_empty(entry: Option<&str>) -> Option<&str> { match entry { Some("-") | Some("") | None => None, Some(x) => Some(x), } } fn sanitize_path(path: &str) -> &str { match path.strip_prefix("./") { Some(p) => { if p.is_empty() { "." } else { p } } None => match path.strip_prefix("/") { Some(p) => { if p.is_empty() { "." } else { p } } None => path, }, } } // Return the permission bits from Metadata.mode fn get_permission(mode: u32) -> u16 { (mode & MODE_PERMISSION_MASK) as u16 } // Return the rdev major from Metadata fn get_rmajor(metadata: &Metadata) -> Result { Ok(major(metadata.rdev())) } // Return the rdev major from Metadata fn get_rminor(metadata: &Metadata) -> Result { Ok(minor(metadata.rdev())) } fn get_mtime(metadata: &Metadata) -> Result { metadata.mtime().try_into().map_err(|_| { Error::new( ErrorKind::InvalidData, format!( "mtime {} outside of supported range from 0 to 4,294,967,295.", metadata.mtime() ), ) }) } // Determine umask for creating the cpio file based on the given file mode. // Since the "group" mode of the file can differ from the cpio writer group, // use the umask from "other" for "group". fn determine_umask(mode: u32) -> u32 { let other_umask = !mode & 0o7; (other_umask << 3) | other_umask } impl File { fn new>( filetype: Filetype, name: S, mode: u16, uid: u32, gid: u32, mtime: u32, ) -> Self { Self { filetype, name: name.into(), mode, uid, gid, mtime, } } /* Description from the 3cpio man page: The manifest is a text format that is parsed line by line. If the line starts with _#cpio_ it is interpreted as section marker to start a new cpio. A compression may be specified by adding a colon followed by the compression format and an optional compression level. Example for a Zstandard-compressed cpio with compression level 9: ---- #cpio: zstd -9 ---- All lines starting with _#_ excluding _#cpio_ (see above) will be treated as comments and will be ignored. Each element in the line is separated by a tab and is expected to be one of the following file types: ---- file dir block char link fifo sock ---- fifo is also known as named pipe (see fifo(7)). In case an element is empty or equal to - it is treated as not specified and it is derived from the input file. :: Path of the input file. It can be left unspecified in case all other needed fields are specified (and the file is otherwise empty). *Limitation*: The path must not start with #, be equal to -, or contain tabs. :: Path of the file inside the cpio. If the name is left unspecified it will be derived from . *Limitation*: The path must not be equal to - or contain tabs. :: File mode specified in octal. :: User ID (owner) of the file specified in decimal. :: Group ID of the file specified in decimal. :: Modification time of the file specified as seconds since the Epoch (1970-01-01 00:00 UTC). The specified time might be clamped by the time set in the SOURCE_DATE_EPOCH environment variable. :: Size of the input file in bytes. 3cpio will fail in case the input file is smaller than the provided file size. :: Major block/character device number in decimal. :: Minor block/character device number in decimal. :: Target of the symbolic link. *Limitation*: The target path must not be equal to - or contain tabs. *Limitations*: Files cannot start with # (will be treated as comment), be equal to - (will be treated as not specified), or contain tabs (will be split by tabs). These limitations of the manifest file are not expected to cause problems in practice. */ fn from_line>( line: S, hardlinks: &mut HashMap, ) -> Result<(Self, u32)> { let mut umask = 0; let mut iter = line.as_ref().split('\t'); let location = replace_empty(iter.next()); let name = match replace_empty(iter.next()) { Some(name) => name, None => match location { Some(path) => sanitize_path(path), None => { return Err(Error::new( ErrorKind::InvalidInput, "Neither location nor name were specified.", )) } }, }; let mut lazy_metadata = LazyMetadata::new(location); let filetype_value = lazy_metadata.parse_filetype(iter.next(), "filetype")?; let mode = lazy_metadata.parse_octal(iter.next(), "mode", |m| get_permission(m.mode()))?; let uid = lazy_metadata.parse_u32(iter.next(), "uid", |m| Ok(m.uid()))?; let gid = lazy_metadata.parse_u32(iter.next(), "gid", |m| Ok(m.gid()))?; let mtime = lazy_metadata.parse_u32(iter.next(), "mtime", get_mtime)?; let filetype = match filetype_value { FILETYPE_REGULAR_FILE => { let filesize = lazy_metadata.parse_u32(iter.next(), "filesize", |m| { m.size().try_into().map_err(|_| { Error::new( ErrorKind::InvalidData, format!( "File '{}' exceeds file size limit of 4 GiB.", location.unwrap() ), ) }) })?; if filesize == 0 { Filetype::EmptyFile } else { let stat = lazy_metadata.get_metadata("filetype")?; umask = determine_umask(stat.mode()); let key = get_hardlink_key(stat); let index = match hardlinks.get_mut(&key) { Some(hardlink) => { hardlink.references += 1; hardlink.references } None => { // Defer writing the hardlink hardlinks.insert(key, Hardlink::new(location.unwrap(), filesize)); 1 } }; Filetype::Hardlink { key, index } } } FILETYPE_DIRECTORY => Filetype::Directory, FILETYPE_BLOCK_DEVICE => Filetype::BlockDevice { major: lazy_metadata.parse_u32(iter.next(), "major", get_rmajor)?, minor: lazy_metadata.parse_u32(iter.next(), "minor", get_rminor)?, }, FILETYPE_CHARACTER_DEVICE => Filetype::CharacterDevice { major: lazy_metadata.parse_u32(iter.next(), "major", get_rmajor)?, minor: lazy_metadata.parse_u32(iter.next(), "minor", get_rminor)?, }, FILETYPE_SYMLINK => Filetype::Symlink { target: parse_symlink(iter.next(), location)?, }, FILETYPE_FIFO => Filetype::Fifo, FILETYPE_SOCKET => Filetype::Socket, unknown => { return Err(Error::new( ErrorKind::InvalidInput, format!("Unknown filetype '{unknown}'"), )) } }; Ok((Self::new(filetype, name, mode, uid, gid, mtime), umask)) } fn generate_header( &self, next_free_ino: u32, hardlinks: &HashMap, hardlinks2ino: &mut HashMap, ) -> (Header, u32) { let mut nlink = 1; let mut filesize = 0; let mut rmajor = 0; let mut rminor = 0; let mut ino = next_free_ino; let mut next_ino = next_free_ino + 1; let filetype; match &self.filetype { Filetype::EmptyFile => filetype = FILETYPE_REGULAR_FILE, Filetype::Hardlink { key, index } => { filetype = FILETYPE_REGULAR_FILE; if let Some(existing_ino) = hardlinks2ino.get(key) { ino = *existing_ino; next_ino = next_free_ino; } else { hardlinks2ino.insert(*key, ino); } let hardlink = hardlinks.get(key).unwrap(); nlink = hardlink.references; // last reference will write the hardlink filesize = if *index == nlink { hardlink.filesize } else { 0 }; } Filetype::Directory => { filetype = FILETYPE_DIRECTORY; nlink = 2; } Filetype::BlockDevice { major, minor } => { filetype = FILETYPE_BLOCK_DEVICE; rmajor = *major; rminor = *minor; } Filetype::CharacterDevice { major, minor } => { filetype = FILETYPE_CHARACTER_DEVICE; rmajor = *major; rminor = *minor; } Filetype::Symlink { target } => { filetype = FILETYPE_SYMLINK; filesize = target.len().try_into().unwrap(); } Filetype::Fifo => filetype = FILETYPE_FIFO, Filetype::Socket => filetype = FILETYPE_SOCKET, } ( Header::new( ino, filetype | u32::from(self.mode), self.uid, self.gid, nlink, self.mtime, filesize, rmajor, rminor, self.name.clone(), ), next_ino, ) } } impl Archive { fn new() -> Self { Self { compression: Compression::Uncompressed, files: Vec::new(), hardlinks: HashMap::new(), } } #[cfg(test)] fn with_files(files: Vec) -> Self { Self { compression: Compression::Uncompressed, files, hardlinks: HashMap::new(), } } #[cfg(test)] fn with_files_and_hardlinks(files: Vec, hardlinks: HashMap) -> Self { Self { compression: Compression::Uncompressed, files, hardlinks, } } #[cfg(test)] fn with_files_compressed(files: Vec, compression: Compression) -> Self { Self { compression, files, hardlinks: HashMap::new(), } } fn add_line>(&mut self, line: S) -> Result { let (file, umask) = File::from_line(line, &mut self.hardlinks)?; self.files.push(file); Ok(umask) } fn is_empty(&self) -> bool { self.files.is_empty() } fn set_compression(&mut self, compression: Compression) { self.compression = compression; } /// Calculate the size of the cpio archive (when using the standard 4-byte padding) fn size(&self) -> u64 { let mut size = 0; for file in &self.files { let filesize = match &file.filetype { // Filesize of hardlinks are calculated later Filetype::Hardlink { key: _, index: _ } => 0, Filetype::Symlink { target } => u32::try_from(target.len()).unwrap(), Filetype::EmptyFile | Filetype::Directory | Filetype::BlockDevice { major: _, minor: _ } | Filetype::CharacterDevice { major: _, minor: _ } | Filetype::Fifo | Filetype::Socket => 0, }; size += calculate_size(&file.name, filesize.into()); } for hardlink in self.hardlinks.values() { debug_assert!(hardlink.references > 0); let filesize = hardlink.filesize.into(); size += filesize + padding_needed_for(filesize, CPIO_ALIGNMENT); } size + TRAILER_SIZE } fn write( &self, output_file: &mut W, alignment: Option, source_date_epoch: Option, mut size: u64, logger: &mut Logger, ) -> Result { let mut next_ino = 0; let mut hardlink_ino = HashMap::new(); let mut header; for file in &self.files { info!(logger, "{}", file.name)?; (header, next_ino) = file.generate_header(next_ino, &self.hardlinks, &mut hardlink_ino); if let Some(epoch) = source_date_epoch { if header.mtime > epoch { header.mtime = epoch; } } debug!(logger, "{header:?}")?; size += header.write_with_alignment(output_file, alignment, size)?; match &file.filetype { Filetype::Hardlink { key, index: _ } => { if header.filesize > 0 { let hardlink = self.hardlinks.get(key).unwrap(); size += copy_file(&hardlink.location, hardlink.filesize, output_file)?; size += header.write_file_data_padding(output_file)?; } } Filetype::Symlink { target } => { output_file.write_all(target.as_bytes())?; size += u64::try_from(target.len()).unwrap(); size += header.write_file_data_padding(output_file)?; } Filetype::EmptyFile | Filetype::Directory | Filetype::BlockDevice { major: _, minor: _ } | Filetype::CharacterDevice { major: _, minor: _ } | Filetype::Fifo | Filetype::Socket => {} } } size += Header::trailer().write(output_file)?; Ok(size) } } impl Manifest { fn new(archives: Vec, umask: u32) -> Self { Self { archives, umask } } pub(crate) fn from_input( reader: R, logger: &mut Logger, ) -> Result { let mut archives = vec![Archive::new()]; let mut current_archive = archives.last_mut().unwrap(); let mut umask = 0; for (line_number, line) in reader.lines().enumerate() { let line = line.map_err(|e| e.add_line(line_number + 1))?; let line = line.trim(); if line.starts_with("#") || line.is_empty() { if line.starts_with("#cpio") { debug!(logger, "Parsing line {}: {line}", line_number + 1)?; if !current_archive.is_empty() { archives.push(Archive::new()); current_archive = archives.last_mut().unwrap(); }; match line.strip_prefix("#cpio:") { Some(compression_str) => { let compression = Compression::from_command_line(compression_str) .map_err(|e| e.add_line(line_number + 1))?; current_archive.set_compression(compression); } None => { if line != "#cpio" { return Err(Error::new( ErrorKind::InvalidInput, format!( "line {}: Unknown cpio archive directive: {line}", line_number + 1, ), )); } } } } continue; } debug!(logger, "Parsing line {}: {line}", line_number + 1)?; let file_mask = current_archive .add_line(line) .map_err(|e| e.add_line(line_number + 1))?; umask |= file_mask; } Ok(Self::new(archives, umask)) } fn apply_umask(&self, file: &std::fs::File) -> Result<()> { let mode = file.metadata()?.mode(); let new_mode = mode & !self.umask; if mode != new_mode { file.set_permissions(PermissionsExt::from_mode(new_mode))?; } Ok(()) } // Return the size in bytes of the uncompressed data. pub(crate) fn write_archive( self, mut file: Option, alignment: Option, source_date_epoch: Option, logger: &mut Logger, ) -> Result { let mut size = 0; if let Some(file) = file.as_ref() { self.apply_umask(file)?; } for archive in self.archives { if archive.compression.is_uncompressed() { if let Some(file) = file.as_mut() { let mut writer = BufWriter::new(file); size = archive.write(&mut writer, alignment, source_date_epoch, size, logger)?; writer.flush()?; } else { let mut stdout = std::io::stdout().lock(); size = archive.write(&mut stdout, alignment, source_date_epoch, size, logger)?; stdout.flush()?; } } else { let mut compressor = archive .compression .compress(file, source_date_epoch, || archive.size())?; let mut writer = BufWriter::new(compressor.stdin.as_ref().unwrap()); size = archive.write(&mut writer, None, source_date_epoch, size, logger)?; writer.flush()?; drop(writer); let exit_status = compressor.wait()?; if !exit_status.success() { return Err(Error::other(format!( "{} failed: {exit_status}", archive.compression.command() ))); } // TODO: Check that the compressed cpio is the last break; } } Ok(size) } } fn copy_file(path: &str, filesize: u32, writer: &mut W) -> Result { let file = std::fs::File::open(path).map_err(|e| e.add_prefix(path))?; let mut reader = std::io::BufReader::new(file); let copied_bytes = std::io::copy(&mut reader, writer)?; if copied_bytes != filesize.into() { return Err(Error::new( ErrorKind::UnexpectedEof, format!("Copied {copied_bytes} bytes from {path} but expected {filesize} bytes."), )); } Ok(copied_bytes) } #[cfg(test)] mod tests { use std::fs::{canonicalize, hard_link}; use std::io::Read; use std::path::Path; use super::*; use crate::logger::Level; use crate::temp_dir::TempDir; use crate::tests::TEST_LOCK; fn create_text_file_in_tmpdir>( tempdir: &TempDir, filename: P, content: &[u8], ) -> String { let path = tempdir.path.join(filename); let mut text_file = std::fs::File::create(&path).unwrap(); text_file.write_all(content).unwrap(); path.into_os_string().into_string().unwrap() } pub(crate) fn make_temp_dir_with_hardlinks() -> Result { let temp_dir = TempDir::new()?; let path = temp_dir.path.join("a"); let mut file = std::fs::File::create(&path)?; file.set_permissions(PermissionsExt::from_mode(0o755))?; file.write_all(b"content")?; hard_link(&path, temp_dir.path.join("b"))?; hard_link(&path, temp_dir.path.join("c"))?; Ok(temp_dir) } #[test] fn test_determine_umask_all_read() { assert_eq!(determine_umask(0o755), 0o022); } #[test] fn test_determine_umask_only_root() { assert_eq!(determine_umask(0o640), 0o077); } #[test] fn test_sanitize_path_absolute_path() { assert_eq!(sanitize_path("/path/to/file"), "path/to/file"); } #[test] fn test_sanitize_path_dot() { assert_eq!(sanitize_path("."), "."); } #[test] fn test_sanitize_path_dot_slash() { assert_eq!(sanitize_path("./"), "."); } #[test] fn test_sanitize_path_dot_slash_path() { assert_eq!(sanitize_path("./path/to/file"), "path/to/file"); } #[test] fn test_sanitize_path_relative_path() { assert_eq!(sanitize_path("path/to/file"), "path/to/file"); } #[test] fn test_sanitize_path_root() { assert_eq!(sanitize_path("/"), "."); } #[test] fn test_file_from_line_full_regular_file() { let line = "/usr/bin/gzip\tusr/bin/gzip\tfile\t755\t0\t0\t1739259005\t35288"; let stat = symlink_metadata("/usr/bin/gzip").unwrap(); let key = get_hardlink_key(&stat); let mut hardlinks = HashMap::new(); let (file, umask) = File::from_line(line, &mut hardlinks).unwrap(); assert_eq!( file, File::new( Filetype::Hardlink { key, index: 1 }, "usr/bin/gzip", 0o755, 0, 0, 1739259005 ) ); assert_eq!(umask, 0o022); assert_eq!( hardlinks, HashMap::from([(key, Hardlink::new("/usr/bin/gzip", 35288))]) ); } #[test] fn test_file_from_line_full_directory() { let line = "/usr\tusr\tdir\t755\t0\t0\t1681992796"; let mut hardlinks = HashMap::new(); let (file, umask) = File::from_line(line, &mut hardlinks).unwrap(); assert_eq!( file, File::new(Filetype::Directory, "usr", 0o755, 0, 0, 1681992796) ); assert_eq!(umask, 0); assert!(hardlinks.is_empty()); } #[test] fn test_file_from_line_full_block_device() { let line = "/dev/nvme0n1p2\tdev/nvme0n1p2\tblock\t660\t0\t0\t1745246683\t259\t2"; let mut hardlinks = HashMap::new(); let (file, umask) = File::from_line(line, &mut hardlinks).unwrap(); assert_eq!( file, File::new( Filetype::BlockDevice { major: 259, minor: 2 }, "dev/nvme0n1p2", 0o660, 0, 0, 1745246683 ) ); assert_eq!(umask, 0); assert!(hardlinks.is_empty()); } #[test] fn test_file_from_line_full_character_device() { let line = "/dev/console\tdev/console\tchar\t600\t0\t5\t1745246724\t5\t1"; let mut hardlinks = HashMap::new(); let (file, umask) = File::from_line(line, &mut hardlinks).unwrap(); assert_eq!( file, File::new( Filetype::CharacterDevice { major: 5, minor: 1 }, "dev/console", 0o600, 0, 5, 1745246724 ) ); assert_eq!(umask, 0); assert!(hardlinks.is_empty()); } #[test] fn test_file_from_line_full_symlink() { let line = "/bin\tbin\tlink\t777\t0\t0\t1647786132\tusr/bin"; let mut hardlinks = HashMap::new(); let (file, umask) = File::from_line(line, &mut hardlinks).unwrap(); assert_eq!( file, File::new( Filetype::Symlink { target: "usr/bin".into() }, "bin", 0o777, 0, 0, 1647786132 ) ); assert_eq!(umask, 0); assert!(hardlinks.is_empty()); } #[test] fn test_file_from_line_full_fifo() { let line = "/run/initctl\trun/initctl\tfifo\t0600\t0\t0\t1746789067"; let mut hardlinks = HashMap::new(); let (file, umask) = File::from_line(line, &mut hardlinks).unwrap(); assert_eq!( file, File::new(Filetype::Fifo, "run/initctl", 0o600, 0, 0, 1746789067) ); assert_eq!(umask, 0); assert!(hardlinks.is_empty()); } #[test] fn test_file_from_line_full_socket() { let line = "/run/systemd/notify\trun/systemd/notify\tsock\t777\t0\t0\t1746789058"; let mut hardlinks = HashMap::new(); let (file, umask) = File::from_line(line, &mut hardlinks).unwrap(); assert_eq!( file, File::new( Filetype::Socket, "run/systemd/notify", 0o777, 0, 0, 1746789058, ) ); assert_eq!(umask, 0); assert!(hardlinks.is_empty()); } #[test] fn test_file_from_line_empty_file() { let line = "\tetc/fstab.empty\tfile\t644\t0\t0\t1744705149\t0"; let mut hardlinks = HashMap::new(); let (file, umask) = File::from_line(line, &mut hardlinks).unwrap(); assert_eq!( file, File::new( Filetype::EmptyFile, "etc/fstab.empty", 0o644, 0, 0, 1744705149 ) ); assert_eq!(umask, 0); assert!(hardlinks.is_empty()); } #[test] fn test_file_from_line_location_regular_file() { let line = "/usr/bin/gzip"; let stat = symlink_metadata("/usr/bin/gzip").unwrap(); let key = get_hardlink_key(&stat); let mtime = stat.mtime().try_into().unwrap(); let size = stat.size().try_into().unwrap(); let mut hardlinks = HashMap::new(); let (file, umask) = File::from_line(line, &mut hardlinks).unwrap(); assert_eq!( file, File::new( Filetype::Hardlink { key, index: 1 }, "usr/bin/gzip", 0o755, 0, 0, mtime, ) ); assert_eq!(umask, 0o022); assert_eq!( hardlinks, HashMap::from([(key, Hardlink::new("/usr/bin/gzip", size))]) ); } #[test] fn test_file_from_line_location_duplicate_file() { let line = "/usr/bin/gzip\tgzip\t\t\t\t\t1745485084"; let stat = symlink_metadata("/usr/bin/gzip").unwrap(); let key = get_hardlink_key(&stat); let size = stat.size().try_into().unwrap(); let mut hardlinks = HashMap::new(); let (file, umask) = File::from_line(line, &mut hardlinks).unwrap(); assert_eq!( file, File::new( Filetype::Hardlink { key, index: 1 }, "gzip", 0o755, 0, 0, 1745485084, ) ); assert_eq!(umask, 0o022); assert_eq!( hardlinks, HashMap::from([(key, Hardlink::new("/usr/bin/gzip", size))]) ); let (file, umask) = File::from_line(line, &mut hardlinks).unwrap(); assert_eq!( file, File::new( Filetype::Hardlink { key, index: 2 }, "gzip", 0o755, 0, 0, 1745485084, ) ); assert_eq!(umask, 0o022); assert_eq!( hardlinks, HashMap::from([(key, Hardlink::with_references("/usr/bin/gzip", size, 2))]) ); } #[test] fn test_file_from_line_location_hardlink() { let temp_dir = make_temp_dir_with_hardlinks().unwrap(); let path = temp_dir.path.join("a").to_str().unwrap().to_owned(); let line = format!("{path}\ta\t\t644\t1\t2"); let stat = symlink_metadata(&path).unwrap(); let mtime = stat.mtime().try_into().unwrap(); let key = get_hardlink_key(&stat); let mut hardlinks = HashMap::new(); let (file, umask) = File::from_line(line, &mut hardlinks).unwrap(); assert_eq!( file, File::new( Filetype::Hardlink { key, index: 1 }, "a", 0o644, 1, 2, mtime, ) ); assert_eq!(umask, 0o022); assert_eq!(hardlinks, HashMap::from([(key, Hardlink::new(&path, 7))])); let line = format!( "{}/b\tb\t\t640\t3\t4\t1751413453", temp_dir.path.to_str().unwrap() ); let (file, umask) = File::from_line(line, &mut hardlinks).unwrap(); assert_eq!( file, File::new( Filetype::Hardlink { key, index: 2 }, "b", 0o640, 3, 4, 1751413453, ) ); assert_eq!(umask, 0o022); assert_eq!( hardlinks, HashMap::from([(key, Hardlink::with_references(&path, 7, 2))]) ); } #[test] fn test_file_from_line_location_relative_directory() { let _lock = TEST_LOCK.lock().unwrap(); let line = "./tests\t\t\t510\t7\t42"; let stat = symlink_metadata("tests").unwrap(); let mtime = stat.mtime().try_into().unwrap(); let mut hardlinks = HashMap::new(); let (file, umask) = File::from_line(line, &mut hardlinks).unwrap(); assert_eq!( file, File::new(Filetype::Directory, "tests", 0o510, 7, 42, mtime) ); assert_eq!(umask, 0); assert!(hardlinks.is_empty()); } #[test] fn test_file_from_line_location_directory() { let line = "/usr"; let stat = symlink_metadata("/usr").unwrap(); let mtime = stat.mtime().try_into().unwrap(); let mut hardlinks = HashMap::new(); let (file, umask) = File::from_line(line, &mut hardlinks).unwrap(); assert_eq!( file, File::new(Filetype::Directory, "usr", 0o755, 0, 0, mtime) ); assert_eq!(umask, 0); assert!(hardlinks.is_empty()); } #[test] fn test_file_from_line_location_block_device() { let line = "/dev/loop0"; let stat = match symlink_metadata("/dev/loop0") { Ok(s) => s, // This test expects a block device like /dev/loop0 being present. Err(_) => return, }; let mtime = stat.mtime().try_into().unwrap(); let mut hardlinks = HashMap::new(); let (file, umask) = File::from_line(line, &mut hardlinks).unwrap(); assert_eq!( file, File::new( Filetype::BlockDevice { major: major(stat.rdev()), minor: minor(stat.rdev()), }, "dev/loop0", 0o660, 0, 6, mtime, ) ); assert_eq!(umask, 0); assert!(hardlinks.is_empty()); } #[test] fn test_file_from_line_location_character_device() { let path = canonicalize("/dev/console").unwrap(); let line = path.clone().into_os_string().into_string().unwrap(); let stat = path.symlink_metadata().unwrap(); let rdev = stat.rdev(); let mode = (stat.mode() & MODE_PERMISSION_MASK).try_into().unwrap(); let mtime = stat.mtime().try_into().unwrap(); let mut hardlinks = HashMap::new(); let (file, umask) = File::from_line(&line, &mut hardlinks).unwrap(); assert_eq!( file, File::new( Filetype::CharacterDevice { major: major(rdev), minor: minor(rdev) }, line.strip_prefix("/").unwrap(), mode, stat.uid(), stat.gid(), mtime, ) ); assert_eq!(umask, 0); assert!(hardlinks.is_empty()); } #[test] fn test_file_from_line_location_symlink() { let line = "/bin"; let stat = symlink_metadata("/bin").unwrap(); let mtime = stat.mtime().try_into().unwrap(); let mut hardlinks = HashMap::new(); let (file, umask) = File::from_line(line, &mut hardlinks).unwrap(); assert_eq!( file, File::new( Filetype::Symlink { target: "usr/bin".into() }, "bin", 0o777, 0, 0, mtime, ) ); assert_eq!(umask, 0); assert!(hardlinks.is_empty()); } #[test] fn test_file_from_line_location_fifo() { let line = "/run/initctl"; let stat = match symlink_metadata("/run/initctl") { Ok(s) => s, // This test expects a fifo like /run/initctl being present. Err(_) => return, }; let mtime = stat.mtime().try_into().unwrap(); let mut hardlinks = HashMap::new(); let (file, umask) = File::from_line(line, &mut hardlinks).unwrap(); assert_eq!( file, File::new(Filetype::Fifo, "run/initctl", 0o600, 0, 0, mtime) ); assert_eq!(umask, 0); assert!(hardlinks.is_empty()); } #[test] fn test_file_from_line_location_socket() { let line = "/run/systemd/notify"; let stat = match symlink_metadata("/run/systemd/notify") { Ok(s) => s, // This test expects a socket like /run/systemd/notify being present. Err(_) => return, }; let mtime = stat.mtime().try_into().unwrap(); let mut hardlinks = HashMap::new(); let (file, umask) = File::from_line(line, &mut hardlinks).unwrap(); assert_eq!( file, File::new(Filetype::Socket, "run/systemd/notify", 0o777, 0, 0, mtime) ); assert_eq!(umask, 0); assert!(hardlinks.is_empty()); } #[test] fn test_file_from_line_empty_fields() { let line = "/run\t\t\t\t\t\t"; let stat = symlink_metadata("/run").unwrap(); let mtime = stat.mtime().try_into().unwrap(); let mut hardlinks = HashMap::new(); let (file, umask) = File::from_line(line, &mut hardlinks).unwrap(); assert_eq!( file, File::new(Filetype::Directory, "run", 0o755, 0, 0, mtime) ); assert_eq!(umask, 0); assert!(hardlinks.is_empty()); } #[test] fn test_file_from_line_fields_with_dash() { let line = "/etc\t-\t-\t-\t-\t-\t"; let stat = symlink_metadata("/etc").unwrap(); let mtime = stat.mtime().try_into().unwrap(); let mut hardlinks = HashMap::new(); let (file, umask) = File::from_line(line, &mut hardlinks).unwrap(); assert_eq!( file, File::new(Filetype::Directory, "etc", 0o755, 0, 0, mtime) ); assert_eq!(umask, 0); assert!(hardlinks.is_empty()); } #[test] fn test_manifest_from_input() { let input = b"\ # This is a comment\n\n\ /bin\tbin\tdir\t755\t0\t0\t1681992796\n\ /usr/bin/gzip\tbin/gzip\tfile\t755\t0\t0\t1739259005\t35288\n"; let mut logger = Logger::new_vec(Level::Debug); let manifest = Manifest::from_input(input.as_ref(), &mut logger).unwrap(); let stat = symlink_metadata("/usr/bin/gzip").unwrap(); let key = get_hardlink_key(&stat); let expected_archive = Archive::with_files_and_hardlinks( vec![ File::new(Filetype::Directory, "bin", 0o755, 0, 0, 1681992796), File::new( Filetype::Hardlink { key, index: 1 }, "bin/gzip", 0o755, 0, 0, 1739259005, ), ], HashMap::from([(key, Hardlink::new("/usr/bin/gzip", 35288))]), ); assert_eq!(manifest, Manifest::new(vec![expected_archive], 0o022)); assert_eq!( logger.get_logs(), "Parsing line 3: /bin\tbin\tdir\t755\t0\t0\t1681992796\n\ Parsing line 4: /usr/bin/gzip\tbin/gzip\tfile\t755\t0\t0\t1739259005\t35288\n", ); } #[test] fn test_manifest_from_input_compressed() { let input = b"\ #cpio: zstd -1\n\ /bin\tbin\tdir\t755\t0\t0\t1681992796\n"; let mut logger = Logger::new_vec(Level::Info); let manifest = Manifest::from_input(input.as_ref(), &mut logger).unwrap(); let expected_archive = Archive::with_files_compressed( vec![File::new( Filetype::Directory, "bin", 0o755, 0, 0, 1681992796, )], Compression::Zstd { level: Some(1) }, ); assert_eq!(manifest, Manifest::new(vec![expected_archive], 0)); assert_eq!(logger.get_logs(), ""); } #[test] fn test_manifest_from_input_multiple_uncompressed() { let input = b"\ # This is a comment\n\n\ #cpio\n\ /bin\tbin\tdir\t755\t0\t0\t1681992796\n\ #cpio\n\ /\t.\tdir\t755\t0\t0\t1732230747\n"; let mut logger = Logger::new_vec(Level::Debug); let manifest = Manifest::from_input(input.as_ref(), &mut logger).unwrap(); let expected_manifest = Manifest::new( vec![ Archive::with_files(vec![File::new( Filetype::Directory, "bin", 0o755, 0, 0, 1681992796, )]), Archive::with_files(vec![File::new( Filetype::Directory, ".", 0o755, 0, 0, 1732230747, )]), ], 0, ); assert_eq!(manifest, expected_manifest); assert_eq!( logger.get_logs(), "Parsing line 3: #cpio\n\ Parsing line 4: /bin\tbin\tdir\t755\t0\t0\t1681992796\n\ Parsing line 5: #cpio\n\ Parsing line 6: /\t.\tdir\t755\t0\t0\t1732230747\n", ); } #[test] fn test_manifest_from_input_file_not_found() { let input = b"/nonexistent\n"; let mut logger = Logger::new_vec(Level::Info); let got = Manifest::from_input(input.as_ref(), &mut logger).unwrap_err(); assert_eq!(got.kind(), ErrorKind::NotFound); assert_eq!( got.to_string(), "line 1: /nonexistent: No such file or directory (os error 2)" ); assert_eq!(logger.get_logs(), ""); } #[test] fn test_manifest_from_input_invalid_cpio_directive() { let input = b" #cpio \n #cpio: zstd \n #cpio something -42 "; let mut logger = Logger::new_vec(Level::Warning); let got = Manifest::from_input(input.as_ref(), &mut logger).unwrap_err(); assert_eq!(got.kind(), ErrorKind::InvalidInput); assert_eq!( got.to_string(), "line 3: Unknown cpio archive directive: #cpio something -42" ); assert_eq!(logger.get_logs(), ""); } #[test] fn test_manifest_from_input_unknown_compressor() { let input = b"#cpio: brotli\n"; let mut logger = Logger::new_vec(Level::Info); let got = Manifest::from_input(input.as_ref(), &mut logger).unwrap_err(); assert_eq!(got.kind(), ErrorKind::InvalidData); assert_eq!( got.to_string(), "line 1: Unknown compression format: brotli" ); assert_eq!(logger.get_logs(), ""); } #[test] fn test_manifest_write_archive_empty_bzip2() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path.join("initrd.img"); let input = b"#cpio: bzip2 -3\n"; let mut logger = Logger::new_vec(Level::Info); let manifest = Manifest::from_input(input.as_ref(), &mut logger).unwrap(); let file = std::fs::File::create(&path).unwrap(); let size = manifest .write_archive(Some(file), None, Some(1754439117), &mut logger) .unwrap(); assert_eq!(size, 124); let mut written_file = std::fs::File::open(&path).unwrap(); let mut output = Vec::new(); let read = written_file.read_to_end(&mut output).unwrap(); assert_eq!( output, b"BZh31AY&SY\x12<\x9e\xb3\0\0\ \x0c^\0D\0(\0h\x802$\x14\0 \x001\ L\0\0\xd3(\x0d\x0fH\x88\x17A\xa8\x8eh!$\ \xe5l\xc6e\xf5\xba\xaf\x8b\xb9\"\x9c(H\x09\x1eOY\x80" ); assert_eq!(read, 66); assert_eq!(logger.get_logs(), ""); } #[test] fn test_manifest_write_archive_empty_gzip() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path.join("initrd.img"); let input = b"#cpio: gzip -7\n"; let mut logger = Logger::new_vec(Level::Warning); let manifest = Manifest::from_input(input.as_ref(), &mut logger).unwrap(); let file = std::fs::File::create(&path).unwrap(); let size = manifest .write_archive(Some(file), None, Some(1754439117), &mut logger) .unwrap(); assert_eq!(size, 124); let mut written_file = std::fs::File::open(&path).unwrap(); let mut output = Vec::new(); let read = written_file.read_to_end(&mut output).unwrap(); assert_eq!( output, b"\x1f\x8b\x08\0\0\0\0\0\0\x03307070\ 4 \x0e\x10\xab\x0e\x1d8\xc1\x18!A\x8e\x9e>\xae\ A\x8a\x8a\x8a\x0c@\0\0N\xe5\x097|\0\0\0" ); assert_eq!(read, 48); assert_eq!(logger.get_logs(), ""); } #[test] fn test_manifest_write_archive_empty_lz4() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path.join("initrd.img"); let input = b"#cpio: lz4 -4\n"; let mut logger = Logger::new_vec(Level::Warning); let manifest = Manifest::from_input(input.as_ref(), &mut logger).unwrap(); let file = std::fs::File::create(&path).unwrap(); let size = manifest .write_archive(Some(file), None, Some(1754439117), &mut logger) .unwrap(); assert_eq!(size, 124); let mut written_file = std::fs::File::open(&path).unwrap(); let mut output = Vec::new(); let read = written_file.read_to_end(&mut output).unwrap(); assert_eq!( output, b"\x02!L\x18$\0\0\0\x7f0707010\ \x01\0\x13/10\x01\0#\x14B\x09\0\xe0TR\ AILER!!!\0\0\0\0" ); assert_eq!(read, 44); assert_eq!(logger.get_logs(), ""); } #[test] fn test_manifest_write_archive_empty_lzma() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path.join("initrd.img"); let input = b"#cpio: lzma -1\n"; let mut logger = Logger::new_vec(Level::Warning); let manifest = Manifest::from_input(input.as_ref(), &mut logger).unwrap(); let file = std::fs::File::create(&path).unwrap(); let size = manifest .write_archive(Some(file), None, Some(1754439117), &mut logger) .unwrap(); assert_eq!(size, 124); let mut written_file = std::fs::File::open(&path).unwrap(); let mut output = Vec::new(); let read = written_file.read_to_end(&mut output).unwrap(); assert_eq!( output, b"]\0\0\x10\0\xff\xff\xff\xff\xff\xff\xff\xff\0\x18\x0d\ \xdd\x04b3\x02;A\xe5P\x06\xe8\xc4\xa0\xd8\x89Z\ pL\xa1]\xb0mv\xe7&\xc4o\xff\xfe$\x90\0" ); assert_eq!(read, 48); assert_eq!(logger.get_logs(), ""); } #[test] fn test_manifest_write_archive_empty_lzop() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path.join("initrd.img"); let input = b"#cpio: lzop -9\n"; let mut logger = Logger::new_vec(Level::Warning); let manifest = Manifest::from_input(input.as_ref(), &mut logger).unwrap(); let file = std::fs::File::create(&path).unwrap(); let got = manifest.write_archive(Some(file), None, Some(1754439117), &mut logger); if got .as_ref() .is_err_and(|e| e.to_string() == "Program 'lzop' not found in PATH.") { return; } let size = got.unwrap(); assert_eq!(size, 124); let mut written_file = std::fs::File::open(&path).unwrap(); let mut output = Vec::new(); let read = written_file.read_to_end(&mut output).unwrap(); // The lzop magic is 9 bytes long. Then follows: 3x 16-bit version fields, // 2x 8-bit method and level, 2x 32-bit flags and mode, and 64-bit mtime. // Then follows the filename (8-bit size) and then the 32-bit CRC32 checksum. output.splice(9..15, b"versio".to_owned()); output.splice(25..33, b"mtime-42".to_owned()); output.splice(34..38, b"CRCS".to_owned()); assert_eq!( output, b"\x89LZO\0\x0d\x0a\x1a\x0aversio\x03\ \x09\x03\0\0\x0d\0\0\0\0mtime-4\ 2\0CRCS\0\0\0|\0\0\0%\xbc\x7f\ \x179\x1307F\x0010 \x05\x02\x0010 \ \x15\x01\0B\xe0\x01\x08TRAILER!!\ !\0@\0\x11\0\0\0\0\0\0" ); assert_eq!(read, 91); assert_eq!(logger.get_logs(), ""); } #[test] fn test_manifest_write_archive_empty_xz() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path.join("initrd.img"); let input = b"#cpio: xz -6\n"; let mut logger = Logger::new_vec(Level::Warning); let manifest = Manifest::from_input(input.as_ref(), &mut logger).unwrap(); let file = std::fs::File::create(&path).unwrap(); let size = manifest .write_archive(Some(file), None, Some(1754439117), &mut logger) .unwrap(); assert_eq!(size, 124); let mut written_file = std::fs::File::open(&path).unwrap(); let mut output = Vec::new(); let read = written_file.read_to_end(&mut output).unwrap(); assert_eq!( output, b"\xfd7zXZ\0\0\x01i\"\xde6\x02\0!\x01\ \x16\0\0\0t/\xe5\xa3\xe0\0{\0\x1c]\0\x18\ \x0d\xdd\x04c\x9d\x8a@Z1\xe4\xcb{\x1c\xc7\xc9\xc0\ \xef\x917N\x01]\xbd\xd5q\xc8\0\0N\xe5\x097\ \0\x014|\xcb{\x1f\xc2\x90B\x99\x0d\x01\0\0\0\0\x01YZ" ); assert_eq!(read, 84); assert_eq!(logger.get_logs(), ""); } #[test] fn test_manifest_write_archive_empty_zstd() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path.join("initrd.img"); let input = b"#cpio: zstd -2\n"; let mut logger = Logger::new_vec(Level::Warning); let manifest = Manifest::from_input(input.as_ref(), &mut logger).unwrap(); let file = std::fs::File::create(&path).unwrap(); let size = manifest .write_archive(Some(file), None, Some(1754439117), &mut logger) .unwrap(); assert_eq!(size, 124); let mut written_file = std::fs::File::open(&path).unwrap(); let mut output = Vec::new(); let read = written_file.read_to_end(&mut output).unwrap(); assert_eq!( output, b"(\xb5/\xfd$|\x15\x01\0\xc8070701\ 010B0TRAILER!!!\0\ \0\0\0\x03\x10\0\x19\xde\x89?F\x95\xfb\x16m" ); assert_eq!(read, 47); assert_eq!(logger.get_logs(), ""); } #[test] fn test_manifest_write_fail_compression() { let temp_dir = TempDir::new().unwrap(); let root_dir = File::new(Filetype::Directory, ".", 0o755, 0x333, 0x42, 0x6841897B); let archive = Archive::with_files_compressed(vec![root_dir], Compression::Failing); let manifest = Manifest::new(vec![archive], 0o022); let file = std::fs::File::create(temp_dir.path.join("initrd.img")).unwrap(); let mut logger = Logger::new_vec(Level::Debug); let got = manifest .write_archive(Some(file), None, None, &mut logger) .unwrap_err(); assert!( matches!(got.kind(), ErrorKind::Other if got.to_string() == "false failed: exit status: 1") || matches!(got.kind(), ErrorKind::BrokenPipe) ); assert_eq!( logger.get_logs(), ".\n\ Header { ino: 0, mode: 16877, uid: 819, gid: 66, nlink: 2, mtime: 1749125499, \ filesize: 0, major: 0, minor: 0, rmajor: 0, rminor: 0, filename: \".\" }\n", ); } #[test] fn test_archive_write() { let archive = Archive::with_files(vec![ File::new(Filetype::Directory, ".", 0o755, 0x333, 0x42, 0x6841897B), File::new( Filetype::BlockDevice { major: 0x6425, minor: 0x1437, }, "loop0", 0o660, 0x334, 0x43, 0x6862B88B, ), File::new( Filetype::CharacterDevice { major: 0x2E0E, minor: 0x8C75, }, "console", 0o600, 0x335, 0x44, 0x6862B8B4, ), File::new( Filetype::Symlink { target: "usr/sbin".into(), }, "sbin", 0o777, 0x336, 0x45, 0x62373894, ), File::new(Filetype::Fifo, "initctl", 0o600, 0x337, 0x46, 0x6862B88A), File::new(Filetype::Socket, "notify", 0o777, 0x338, 0x47, 0x681DE2C2), File::new(Filetype::EmptyFile, "fstab", 0o644, 0x339, 0x48, 0x6E44C280), ]); let mut output = Vec::new(); let mut logger = Logger::new_vec(Level::Info); let size = archive .write(&mut output, None, Some(0x6B49D200), 0, &mut logger) .unwrap(); assert_eq!( std::str::from_utf8(&output).unwrap(), "07070100000000000041ED0000033300000042000000026841897B\ 00000000000000000000000000000000000000000000000200000000\ .\0\ 07070100000001000061B00000033400000043000000016862B88B\ 00000000000000000000000000006425000014370000000600000000\ loop0\0\ 07070100000002000021800000033500000044000000016862B8B4\ 00000000000000000000000000002E0E00008C750000000800000000\ console\0\0\0\ 070701000000030000A1FF00000336000000450000000162373894\ 00000008000000000000000000000000000000000000000500000000\ sbin\0\0usr/sbin\ 07070100000004000011800000033700000046000000016862B88A\ 00000000000000000000000000000000000000000000000800000000\ initctl\0\0\0\ 070701000000050000C1FF000003380000004700000001681DE2C2\ 00000000000000000000000000000000000000000000000700000000\ notify\0\0\0\0\ 07070100000006000081A40000033900000048000000016B49D200\ 00000000000000000000000000000000000000000000000600000000\ fstab\0\ 070701000000000000000000000000000000000000000100000000\ 00000000000000000000000000000000000000000000000B00000000\ TRAILER!!!\0\0\0\0", ); assert_eq!(size, 952); assert_eq!(archive.size(), 952); assert_eq!( logger.get_logs(), ".\nloop0\nconsole\nsbin\ninitctl\nnotify\nfstab\n" ); } #[test] fn test_archive_write_aligned() { let tempdir = TempDir::new().unwrap(); let example = create_text_file_in_tmpdir( &tempdir, "example.txt", b"This is just an example text file!\n", ); let small = create_text_file_in_tmpdir(&tempdir, "small.txt", b"shorter than alignment\n"); let mut hardlinks = HashMap::new(); hardlinks.insert(42, Hardlink::with_references(example, 35, 1)); hardlinks.insert(99, Hardlink::with_references(small, 23, 1)); let archive = Archive::with_files_and_hardlinks( vec![ File::new(Filetype::Directory, ".", 0o755, 0x333, 0x42, 0x6841897B), File::new( Filetype::Hardlink { key: 42, index: 1 }, "example", 0o644, 0x339, 0x48, 0x6E44C280, ), File::new( Filetype::Hardlink { key: 99, index: 1 }, "small.txt", 0o644, 0x339, 0x48, 0x6E44C280, ), ], hardlinks, ); let mut output = Vec::new(); let mut logger = Logger::new_vec(Level::Info); let size = archive .write( &mut output, NonZeroU32::new(32), Some(0x6B49D200), 0, &mut logger, ) .unwrap(); assert_eq!( std::str::from_utf8(&output).unwrap(), "07070100000000000041ED0000033300000042000000026841897B\ 00000000000000000000000000000000000000000000000200000000\ .\0\ 07070100000001000081A40000033900000048000000016B49D200\ 00000023000000000000000000000000000000000000002200000000\ example\0\0\0\ \0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\ This is just an example text file!\n\0\ 07070100000002000081A40000033900000048000000016B49D200\ 00000017000000000000000000000000000000000000000A00000000\ small.txt\0\ shorter than alignment\n\0\ 070701000000000000000000000000000000000000000100000000\ 00000000000000000000000000000000000000000000000B00000000\ TRAILER!!!\0\0\0\0", ); assert_eq!(size, 560); assert_eq!(archive.size(), 536); assert_eq!(logger.get_logs(), ".\nexample\nsmall.txt\n"); } #[test] fn test_archive_write_hardlinks() { let temp_dir = make_temp_dir_with_hardlinks().unwrap(); let path = temp_dir.path.join("a").to_str().unwrap().to_owned(); // This archive data is the output of test_file_from_line_location_hardlink. let archive = Archive::with_files_and_hardlinks( vec![ File::new( Filetype::Hardlink { key: 8921120, index: 1, }, "a", 0o644, 1, 2, 0x6861C7C5, ), File::new( Filetype::Hardlink { key: 8921120, index: 2, }, "b", 0o640, 3, 4, 0x686472CD, ), ], HashMap::from([(8921120, Hardlink::with_references(&path, 7, 2))]), ); let mut output = Vec::new(); let mut logger = Logger::new_vec(Level::Info); let size = archive .write(&mut output, None, None, 0, &mut logger) .unwrap(); assert_eq!( std::str::from_utf8(&output).unwrap(), "07070100000000000081A40000000100000002000000026861C7C5\ 00000000000000000000000000000000000000000000000200000000\ a\0\ 07070100000000000081A0000000030000000400000002686472CD\ 00000007000000000000000000000000000000000000000200000000\ b\0content\0\ 070701000000000000000000000000000000000000000100000000\ 00000000000000000000000000000000000000000000000B00000000\ TRAILER!!!\0\0\0\0", ); assert_eq!(size, 356); assert_eq!(archive.size(), 356); assert_eq!(logger.get_logs(), "a\nb\n"); } } threecpio-0.11.0/src/ranges.rs000064400000000000000000000134131046102023000143030ustar 00000000000000// Copyright (C) 2025, Benjamin Drung // SPDX-License-Identifier: ISC use std::num::ParseIntError; use std::ops::{RangeFrom, RangeInclusive, RangeTo}; use std::str::FromStr; #[derive(Clone, Debug, PartialEq)] struct Range { start: Option, end: Option, } impl Range { fn new(start: Option, end: Option) -> Self { Self { start, end } } fn contains(&self, item: &i32) -> bool { if let Some(start) = self.start { if *item < start { return false; } } if let Some(end) = self.end { if *item > end { return false; } } true } fn has_more(&self, item: &i32) -> bool { self.end.is_none_or(|end| end > *item) } } impl From> for Range { fn from(item: RangeInclusive) -> Self { Self { start: Some(*item.start()), end: Some(*item.end()), } } } impl From> for Range { fn from(item: RangeFrom) -> Self { Self { start: Some(item.start), end: None, } } } impl From> for Range { fn from(item: RangeTo) -> Self { Self { start: None, end: Some(item.end), } } } /// An array of ranges. /// /// Each range can either be /// * bounded inclusively below and above (`start-end`), /// * bounded inclusively below (`start-`), or /// * bounded exclusively above (`-end`). #[derive(Clone, Debug, PartialEq)] pub struct Ranges(Vec); impl Ranges { #[cfg(test)] fn new(ranges: Vec) -> Self { Self(ranges) } /// Returns `true` if `item` is contained in at least of of the ranges. /// /// # Examples /// /// ``` /// use threecpio::ranges::Ranges; /// /// assert!(!"3-4".parse::().unwrap().contains(&2)); /// assert!( "3-4".parse::().unwrap().contains(&3)); /// assert!( "3-4".parse::().unwrap().contains(&4)); /// assert!(!"3-4".parse::().unwrap().contains(&5)); /// ``` pub fn contains(&self, item: &i32) -> bool { for range in &self.0 { if range.contains(item) { return true; } } false } /// Returns `true` if `Ranges` contain items higher than `item`. /// /// # Examples /// /// ``` /// use threecpio::ranges::Ranges; /// /// assert!( "2-4".parse::().unwrap().has_more(&3)); /// assert!(!"2-4".parse::().unwrap().has_more(&4)); /// ``` /// /// Ranges bounded inclusively below will cause `has_more` to always /// return `true`: /// /// ``` /// use threecpio::ranges::Ranges; /// /// assert!("3-".parse::().unwrap().has_more(&9000)); /// ``` pub fn has_more(&self, item: &i32) -> bool { for range in &self.0 { if range.has_more(item) { return true; } } false } } impl FromStr for Ranges { type Err = ParseIntError; /// Parses a string `s` to return `Ranges`. /// /// `s` is made up of one range, or many ranges separated by commas. /// Each range can either be /// * one single item (`item`), /// * bounded inclusively below and above (`start-end`), /// * bounded inclusively below (`start-`), or /// * bounded exclusively above (`-end`). /// /// # Examples /// /// ``` /// use threecpio::ranges::Ranges; /// /// assert!("1-3,5,7-".parse::().is_ok()); /// ``` fn from_str(s: &str) -> Result { let mut ranges = Vec::new(); for range_str in s.split(",") { if let Some((start, end)) = range_str.split_once("-") { let start = if start.is_empty() { None } else { Some(start.parse()?) }; let end = if end.is_empty() { None } else { Some(end.parse()?) }; ranges.push(Range::new(start, end)); } else { let start = range_str.parse()?; ranges.push(Range::new(Some(start), Some(start))); } } Ok(Self(ranges)) } } #[cfg(test)] pub(crate) mod tests { use super::*; #[test] fn test_parse_ranges_error_single() { for s in ["str", "1-str", "str-5"] { let got = s.parse::().unwrap_err(); assert_eq!(got.to_string(), "invalid digit found in string"); } } #[test] fn test_parse_ranges_single() { assert_eq!("3".parse::(), Ok(Ranges::new(vec![(3..=3).into()]))) } #[test] fn test_parse_ranges_range() { assert_eq!( "2-4".parse::(), Ok(Ranges::new(vec![(2..=4).into()])) ) } #[test] fn test_parse_ranges_multiple() { assert_eq!( "1,3-5".parse::(), Ok(Ranges::new(vec![(1..=1).into(), (3..=5).into()])) ) } #[test] fn test_parse_ranges_open_end() { assert_eq!("2-".parse::(), Ok(Ranges::new(vec![(2..).into()]))) } #[test] fn test_parse_ranges_open_start() { assert_eq!("-4".parse::(), Ok(Ranges::new(vec![(..4).into()]))) } #[test] fn test_ranges_contains() { let ranges = "1-3,5".parse::().unwrap(); assert!(ranges.contains(&2)); assert!(!ranges.contains(&4)); } #[test] fn test_ranges_has_more() { let ranges = "4-5,7,-2".parse::().unwrap(); assert!(ranges.has_more(&6)); assert!(!ranges.has_more(&7)); } } threecpio-0.11.0/src/seek_forward.rs000064400000000000000000000032331046102023000154760ustar 00000000000000// Copyright (C) 2024, Benjamin Drung // SPDX-License-Identifier: ISC use std::fs::File; use std::io::{Error, ErrorKind, Read, Result, Seek, SeekFrom}; use std::process::ChildStdout; const PIPE_SIZE: usize = 65536; pub(crate) trait SeekForward { /// Seek forward to an offset, in bytes, in a stream. /// /// A seek beyond the end of a stream is allowed, but behavior is defined /// by the implementation. /// /// # Errors /// /// Seeking can fail, for example because it might involve flushing a buffer. fn seek_forward(&mut self, offset: u64) -> Result<()>; } impl SeekForward for File { fn seek_forward(&mut self, offset: u64) -> Result<()> { self.seek(SeekFrom::Current(offset.try_into().unwrap()))?; Ok(()) } } impl SeekForward for ChildStdout { fn seek_forward(&mut self, offset: u64) -> Result<()> { let mut seek_reader = self.take(offset); let mut remaining: usize = offset.try_into().unwrap(); let mut buffer = [0; PIPE_SIZE]; while remaining > 0 { let read = seek_reader.read(&mut buffer)?; remaining -= read; } Ok(()) } } impl SeekForward for &[u8] { fn seek_forward(&mut self, offset: u64) -> Result<()> { let mut seek_reader = std::io::Read::take(self, offset); let mut buffer = Vec::new(); let read = seek_reader.read_to_end(&mut buffer)?; if read < offset.try_into().unwrap() { return Err(Error::new( ErrorKind::UnexpectedEof, format!("read only {read} bytes, but {offset} wanted"), )); } Ok(()) } } threecpio-0.11.0/src/temp_dir.rs000064400000000000000000000072431046102023000146330ustar 00000000000000// Copyright (C) 2025, Benjamin Drung // SPDX-License-Identifier: ISC use std::env::{self, set_current_dir}; use std::fs::File; use std::io::{Read, Result, Write}; use std::path::{Path, PathBuf}; #[cfg(test)] use std::env::current_dir; pub struct TempDir { /// Path of the temporary directory. pub path: PathBuf, cwd: Option, } impl TempDir { /// Create a file in the temporary directory and return full path. pub fn create>(&self, filename: P, content: &[u8]) -> Result { let path = self.path.join(filename); let mut file = File::create(&path)?; file.write_all(content)?; Ok(path.into_os_string().into_string().unwrap()) } /// Creates a new temporary directory. /// /// This temporary directory and all the files it contains will be removed /// on drop. /// /// The temporary directory name is constructed by using `CARGO_PKG_NAME` /// followed by a dot and random alphanumeric characters. /// /// # Examples /// /// ``` /// use threecpio::temp_dir::TempDir; /// /// let tempdir = TempDir::new().unwrap(); /// println!("Temporary directory: {}", tempdir.path.display()); /// ``` pub fn new() -> Result { let path = create_tempdir()?; Ok(Self { path, cwd: None }) } /// Creates a new temporary directory and changes the current working /// directory to this directory. /// /// This temporary directory and all the files it contains will be removed /// on drop. The current working directory will be set back on drop as well. /// /// The temporary directory name is constructed by using `CARGO_PKG_NAME` /// followed by a dot and random alphanumeric characters. #[cfg(test)] pub(crate) fn new_and_set_current_dir() -> Result { let path = create_tempdir()?; let cwd = current_dir()?; set_current_dir(&path)?; Ok(Self { path, cwd: Some(cwd), }) } } impl Drop for TempDir { /// Removes the temporary directory and all the files it contains. fn drop(&mut self) { if let Some(cwd) = self.cwd.as_ref() { let _ = set_current_dir(cwd); } let _ = std::fs::remove_dir_all(&self.path); } } /// Similar to base64 encoding (but without last two elements) fn base62_encode(byte: u8) -> char { let lowerbits: u8 = byte % 62; let char = match lowerbits { 0..=25 => lowerbits + 65, 26..=51 => lowerbits - 26 + 97, 52..=61 => lowerbits - 52 + 48, _ => unreachable!(), }; char.into() } fn create_tempdir() -> Result { let mut random = [0u8; 10]; File::open("/dev/urandom")?.read_exact(&mut random)?; let name = std::option_env!("CARGO_PKG_NAME").unwrap(); let dir_builder = std::fs::DirBuilder::new(); let mut path = env::temp_dir(); path.push(format!("{name}.{}", to_alphanumerics(&random))); dir_builder.create(&path)?; Ok(path) } /// Encode given data in alphanumeric characters. /// /// For simplicity throw away some bits of the given data. fn to_alphanumerics(data: &[u8]) -> String { let mut encoded = String::new(); for byte in data { encoded.push(base62_encode(*byte)); } encoded } #[cfg(test)] mod tests { use super::*; #[test] fn test_base62_encode() { assert_eq!(base62_encode(0), 'A'); assert_eq!(base62_encode(27), 'b'); assert_eq!(base62_encode(55), '3'); assert_eq!(base62_encode(118), '4'); } #[test] fn test_to_alphanumerics() { assert_eq!(to_alphanumerics(b"\x2c\x37\xeb\x18"), "s3xY"); } } threecpio-0.11.0/tests/bigdata.cpio000064400000000000000000000113361046102023000153020ustar 0000000000000007070100000000000041FD000000000000000000000002661BE5C600000000000000000000000000000000000000000000000200000000.07070100000001000041FD000000000000000000000002661BE5C600000000000000000000000000000000000000000000000500000000path07070100000002000081B4000000000000000000000001661BE5C600000008000000000000000000000000000000000000000A00000000path/filecontent 07070100000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000B00000000TRAILER!!!07070100000000000041FD000000000000000000000002661BE5C600000000000000000000000000000000000000000000000200000000.07070100000001000041FD000000000000000000000002661BE5C600000000000000000000000000000000000000000000000400000000usr07070100000002000041FD000000000000000000000002661BE5C600000000000000000000000000000000000000000000000800000000usr/bin07070100000003000081B4000000000000000000000001661BE5C600000038000000000000000000000000000000000000000B00000000usr/bin/shThis is a fake busybox binary to simulate a POSIX shell 07070100000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000B00000000TRAILER!!!(/`T070701041FD02661BE5C602.81B40161C06A6zeros t)5WXu\aA&fD D D D D D 5070701010BTRAILER!!!,8qXkthreecpio-0.11.0/tests/bzip2.cpio000064400000000000000000000013311046102023000147270ustar 0000000000000007070100000000000041FD000000000000000000000002661BE5C600000000000000000000000000000000000000000000000200000000.07070100000001000041FD000000000000000000000002661BE5C600000000000000000000000000000000000000000000000500000000path07070100000002000081B4000000000000000000000001661BE5C600000008000000000000000000000000000000000000000A00000000path/filecontent 07070100000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000B00000000TRAILER!!!BZh91AY&SYRPh ?$@3o`0CM45@Hi1i&M /^^%LLck_c+$$p6mk5S`9Fo [ ؗ_S8_Lc>z!`,_2ܑN$(@@@threecpio-0.11.0/tests/cli.rs000064400000000000000000000436031046102023000141520ustar 00000000000000// Copyright (C) 2024, Benjamin Drung // SPDX-License-Identifier: ISC use std::env; use std::error::Error; use std::fs::{symlink_metadata, File}; use std::io::{ErrorKind, Read, Write}; use std::os::unix::fs::MetadataExt; use std::process::{Command, Output, Stdio}; use std::time::SystemTime; use threecpio::temp_dir::TempDir; // Derive target directory (e.g. `target/debug`) from current executable fn get_target_dir() -> std::path::PathBuf { let mut path = env::current_exe().expect("env::current_exe not set"); path.pop(); if path.ends_with("deps") { path.pop(); } path } fn get_command() -> Command { let mut program = get_target_dir(); program.push("3cpio"); Command::new(program) } fn program_not_available(program: &str) -> bool { let mut cmd = Command::new(program); cmd.arg("--help"); cmd.output().is_err_and(|e| e.kind() == ErrorKind::NotFound) } trait ExitCodeAssertion { fn assert_failure(self, expected_code: i32) -> Self; fn assert_success(self) -> Self; } impl ExitCodeAssertion for Output { fn assert_failure(self, expected_code: i32) -> Self { assert_eq!(self.status.code().expect("exit code"), expected_code); self } fn assert_success(self) -> Self { assert!(self.status.success()); self } } trait OutputAssertion { fn assert_stderr(self, expected: S) -> Self; fn assert_stdout(self, expected: S) -> Self; } impl OutputAssertion for Output where String: PartialEq, S: std::fmt::Debug, { fn assert_stderr(self, expected: S) -> Self { let stderr = String::from_utf8(self.stderr.clone()).expect("stderr"); assert_eq!(stderr, expected); self } fn assert_stdout(self, expected: S) -> Self { let stdout = String::from_utf8(self.stdout.clone()).expect("stdout"); assert_eq!(stdout, expected); self } } trait OutputContainsAssertion { fn assert_stderr_contains(self, expected: &str) -> Self; fn assert_stdout_contains(self, expected: &str) -> Self; } impl OutputContainsAssertion for Output { fn assert_stderr_contains(self, expected: &str) -> Self { let stderr = String::from_utf8(self.stderr.clone()).expect("stderr"); assert!( stderr.contains(expected), "'{expected}' not found in '{stderr}'", ); self } fn assert_stdout_contains(self, expected: &str) -> Self { let stdout = String::from_utf8(self.stdout.clone()).expect("stdout"); assert!( stdout.contains(expected), "'{expected}' not found in '{stdout}'", ); self } } #[test] fn test_create_compressed_cpio_file() -> Result<(), Box> { let temp_dir = TempDir::new()?; let path = temp_dir.path.join("empty.cpio"); let path = path.into_os_string().into_string().unwrap(); let mut cmd = get_command(); cmd.args(["--create", &path]) .env("SOURCE_DATE_EPOCH", "1754509394") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); let process = cmd.spawn()?; let mut stdin = process.stdin.as_ref().unwrap(); stdin.write_all(b"#cpio: lz4 -0\n")?; process .wait_with_output()? .assert_stdout("") .assert_stderr("Compression level 0 lower than minimum, raising to 1.\n"); let mut cpio = Vec::new(); let mut cpio_file = File::open(&path)?; cpio_file.read_to_end(&mut cpio)?; assert_eq!( cpio, b"\x02!L\x18%\0\0\0\x7f0707010\ \x01\0\x13\x0f(\0\x15\x0c\x02\0\x14B\x11\0\xe0T\ RAILER!!!\0\0\0\0", ); Ok(()) } #[test] fn test_create_compressed_cpio_on_stdout() -> Result<(), Box> { let mut cmd = get_command(); cmd.arg("--create") .env("SOURCE_DATE_EPOCH", "1754504178") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); let process = cmd.spawn()?; let mut stdin = process.stdin.as_ref().unwrap(); stdin.write_all(b"#cpio: zstd -42\n/usr\t\t\t\t\t\t1681992796\n")?; let output = process .wait_with_output()? .assert_stderr("Compression level 42 higher than maximum, reducing to 19.\n"); assert_eq!( output.stdout, b"(\xb5/\xfd$\xf0\x0d\x02\0\x02\xc3\x0a\x11\x90M\x07\ \xa0\xff\x18S\x04G\xf3[\xc9\xb1\xef\x8eT\x06m\x0b\ \0h\x8a-\xd3\xdc\xe7l\xfb`\\\x8c\x06\x0a)\x04\ \x09'\x95\xe2\xbc\\\x0e\x08 \xc0s\x07\x19\xde\x89v\ \xe16%\xc3\x9b\x88\xd2F1\x02\xd2\\\x1b:" ); Ok(()) } #[test] fn test_create_cpio_on_stdout() -> Result<(), Box> { let mut cmd = get_command(); cmd.arg("--create"); let process = cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).spawn()?; let mut stdin = process.stdin.as_ref().unwrap(); stdin.write_all(b"/usr\t\t\t\t\t\t1681992796\n")?; let output = process.wait_with_output()?; assert_eq!( std::str::from_utf8(&output.stdout).unwrap(), "07070100000000000041ED00000000000000000000000264412C5C\ 00000000000000000000000000000000000000000000000400000000\ usr\0\0\0\ 070701000000000000000000000000000000000000000100000000\ 00000000000000000000000000000000000000000000000B00000000\ TRAILER!!!\0\0\0\0", ); Ok(()) } #[test] fn test_create_cpio_file() -> Result<(), Box> { let temp_dir = TempDir::new()?; let mut path = temp_dir.path.clone(); let now = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .unwrap(); path.push(format!("3cpio-{now:?}.cpio")); let path = path.into_os_string().into_string().unwrap(); let mut cmd = get_command(); cmd.args(["--create", &path]); let process = cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).spawn()?; let mut stdin = process.stdin.as_ref().unwrap(); stdin.write_all(b"/usr\t\t\t\t\t\t1681992796\n")?; process.wait_with_output()?.assert_stdout(""); let mut cpio = Vec::new(); let mut cpio_file = File::open(&path)?; cpio_file.read_to_end(&mut cpio)?; assert_eq!( std::str::from_utf8(&cpio).unwrap(), "07070100000000000041ED00000000000000000000000264412C5C\ 00000000000000000000000000000000000000000000000400000000\ usr\0\0\0\ 070701000000000000000000000000000000000000000100000000\ 00000000000000000000000000000000000000000000000B00000000\ TRAILER!!!\0\0\0\0", ); Ok(()) } #[test] fn test_create_data_align() -> Result<(), Box> { let temp_dir = TempDir::new()?; let path = temp_dir.create("example.txt", b"This is just an example text file!\n")?; let mut cmd = get_command(); cmd.args(["--create", "--data-align", "16"]); let process = cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).spawn()?; let mut stdin = process.stdin.as_ref().unwrap(); let manifest = format!("/usr\t\t\t\t\t\t1681992796\n{path}\tusr/file\t\t644\t3\t7\t1755046204\n"); stdin.write_all(manifest.as_bytes())?; let output = process.wait_with_output()?; assert_eq!( std::str::from_utf8(&output.stdout).unwrap(), "07070100000000000041ED00000000000000000000000264412C5C\ 00000000000000000000000000000000000000000000000400000000\ usr\0\0\0\ 07070100000001000081A4000000030000000700000001689BE13C\ 00000023000000000000000000000000000000000000000E00000000\ usr/file\0\0\ \0\0\0\0\ This is just an example text file!\n\0\ 070701000000000000000000000000000000000000000100000000\ 00000000000000000000000000000000000000000000000B00000000\ TRAILER!!!\0\0\0\0", ); Ok(()) } #[test] fn test_create_data_align_negative_number() -> Result<(), Box> { let mut cmd = get_command(); cmd.args(["--create", "--data-align", "-42", "/tmp/initrd"]); cmd.output()? .assert_failure(2) .assert_stderr_contains("Error: --data-align must be a positive number") .assert_stdout(""); Ok(()) } #[test] fn test_create_data_align_not_a_multiple_of_four() -> Result<(), Box> { let mut cmd = get_command(); cmd.args(["--create", "--data-align", "7", "/tmp/initrd"]); cmd.output()? .assert_failure(2) .assert_stderr_contains("Error: --data-align must be a multiple of 4 bytes") .assert_stdout(""); Ok(()) } #[test] fn test_create_data_align_zero() -> Result<(), Box> { let mut cmd = get_command(); cmd.args(["--create", "--data-align", "0", "/tmp/initrd"]); cmd.output()? .assert_failure(2) .assert_stderr_contains("Error: --data-align must be a positive number") .assert_stdout(""); Ok(()) } #[test] fn test_create_uncompressed_plus_zstd_on_stdout() -> Result<(), Box> { let mut cmd = get_command(); cmd.arg("--create"); let process = cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).spawn()?; let mut stdin = process.stdin.as_ref().unwrap(); stdin.write_all(b"#cpio\n/usr\t\t\t\t\t\t1681992796\n#cpio: zstd -2\n")?; let output = process.wait_with_output()?; assert_eq!( output.stdout, b"07070100000000000041ED00000000000000000000000264412C5C\ 00000000000000000000000000000000000000000000000400000000\ usr\0\0\0\ 070701000000000000000000000000000000000000000100000000\ 00000000000000000000000000000000000000000000000B00000000\ TRAILER!!!\0\0\0\0\ (\xb5/\xfd$|\x15\x01\0\xc8070701\ 010B0TRAILER!!!\0\ \0\0\0\x03\x10\0\x19\xde\x89?F\x95\xfb\x16m", ); Ok(()) } #[test] fn test_count_cpio_archives() -> Result<(), Box> { let mut cmd = get_command(); cmd.arg("--count").arg("tests/zstd.cpio"); cmd.output()?.assert_success().assert_stdout("2\n"); Ok(()) } #[test] fn test_count_unexpected_argument() -> Result<(), Box> { let mut cmd = get_command(); cmd.arg("--count").arg("tests/single.cpio").arg("foobar"); cmd.output()? .assert_failure(2) .assert_stderr_contains("Error: unexpected argument \"foobar\"") .assert_stdout(""); Ok(()) } #[test] fn test_examine_compressed_cpio_raw() -> Result<(), Box> { for compression in ["bzip2", "gzip", "lz4", "lzop", "xz", "zstd"] { if program_not_available(compression) { continue; } let path = format!("tests/{compression}.cpio"); let mut cmd = get_command(); cmd.arg("-e").arg(&path).arg("--raw"); let size = symlink_metadata(&path)?.size(); cmd.output()? .assert_stderr("") .assert_success() .assert_stdout(format!( "0\t512\t512\tcpio\t8\n512\t{size}\t{}\t{compression}\t56\n", size - 512 )); } Ok(()) } #[test] fn test_extract_parts_to_stdout() -> Result<(), Box> { let mut cmd = get_command(); cmd.arg("-x") .arg("-P") .arg("2-") .arg("--to-stdout") .arg("tests/zstd.cpio"); cmd.output()? .assert_stderr("") .assert_success() .assert_stdout("This is a fake busybox binary to simulate a POSIX shell\n"); Ok(()) } #[test] fn test_extract_make_directories_with_pattern() -> Result<(), Box> { let tempdir = TempDir::new()?; let mut cmd = get_command(); cmd.arg("-x") .arg("-C") .arg(&tempdir.path) .arg("--make-directories") .arg("-v") .arg("tests/zstd.cpio") .arg("path/file"); cmd.output()? .assert_stderr("path/file\n") .assert_success() .assert_stdout(""); assert!(tempdir.path.join("path/file").exists()); Ok(()) } #[test] fn test_examine_single_cpio_raw() -> Result<(), Box> { let mut cmd = get_command(); cmd.arg("-e").arg("--raw").arg("tests/single.cpio"); cmd.output()? .assert_stderr("") .assert_success() .assert_stdout("0\t512\t512\tcpio\t8\n"); Ok(()) } #[test] fn test_extract_to_stdout() -> Result<(), Box> { let mut cmd = get_command(); cmd.arg("-x") .arg("--to-stdout") .arg("tests/gzip.cpio") .arg("path/f?le"); cmd.output()? .assert_stderr("") .assert_success() .assert_stdout("content\n"); Ok(()) } #[test] fn test_extract_with_subdir() -> Result<(), Box> { let tempdir = TempDir::new()?; let mut cmd = get_command(); cmd.arg("-x") .arg("-C") .arg(&tempdir.path) .arg("-s") .arg("subdir") .arg("-v") .arg("tests/lz4.cpio"); println!("tempdir = {:?}", tempdir.path); cmd.output()? .assert_stderr(".\npath\npath/file\n.\nusr\nusr/bin\nusr/bin/sh\n") .assert_success() .assert_stdout(""); assert!(tempdir.path.join("subdir1/path/file").exists()); assert!(tempdir.path.join("subdir2/usr/bin/sh").exists()); Ok(()) } #[test] fn test_help() -> Result<(), Box> { let mut cmd = get_command(); cmd.arg("--help"); cmd.output()? .assert_stderr("") .assert_success() .assert_stdout_contains("Extract the cpio archives into separate directories"); Ok(()) } #[test] fn test_archive_doesnt_exist() -> Result<(), Box> { let mut cmd = get_command(); cmd.arg("-t").arg("test/file/does/not/exist"); cmd.output()? .assert_failure(1) .assert_stderr_contains("No such file or directory") .assert_stdout(""); Ok(()) } #[test] fn test_invalid_pattern() -> Result<(), Box> { let mut cmd = get_command(); cmd.arg("-t").arg("tests/single.cpio").arg("[abc.txt"); cmd.output()? .assert_failure(2) .assert_stderr_contains("Error: invalid pattern '[abc.txt'") .assert_stdout(""); Ok(()) } #[test] fn test_list_content_compressed_cpio() -> Result<(), Box> { for compression in ["bzip2", "gzip", "lz4", "lzma", "lzop", "xz", "zstd"] { if program_not_available(compression) { continue; } let mut cmd = get_command(); cmd.arg("-t").arg(format!("tests/{compression}.cpio")); cmd.output()? .assert_stderr("") .assert_success() .assert_stdout(".\npath\npath/file\n.\nusr\nusr/bin\nusr/bin/sh\n"); } Ok(()) } #[test] fn test_list_content_compressed_cpio_verbose() -> Result<(), Box> { for compression in ["bzip2", "gzip", "lz4", "lzma", "lzop", "xz", "zstd"] { if program_not_available(compression) { continue; } let mut cmd = get_command(); cmd.arg("-tv").arg(format!("tests/{compression}.cpio")); cmd.env("TZ", "UTC"); cmd.output()? .assert_stderr("") .assert_success() .assert_stdout( "drwxrwxr-x 2 root root 0 Apr 14 2024 .\n\ drwxrwxr-x 2 root root 0 Apr 14 2024 path\n\ -rw-rw-r-- 1 root root 8 Apr 14 2024 path/file\n\ drwxrwxr-x 2 root root 0 Apr 14 2024 .\n\ drwxrwxr-x 2 root root 0 Apr 14 2024 usr\n\ drwxrwxr-x 2 root root 0 Apr 14 2024 usr/bin\n\ -rw-rw-r-- 1 root root 56 Apr 14 2024 usr/bin/sh\n", ); } Ok(()) } #[test] fn test_list_content_parts_compressed_cpio() -> Result<(), Box> { let mut cmd = get_command(); cmd.arg("-t").arg("--parts=1").arg("tests/xz.cpio"); cmd.output()? .assert_stderr("") .assert_success() .assert_stdout(".\npath\npath/file\n"); Ok(()) } #[test] fn test_list_content_single_cpio() -> Result<(), Box> { let mut cmd = get_command(); cmd.arg("-t").arg("tests/single.cpio"); cmd.output()? .assert_stderr("") .assert_success() .assert_stdout(".\npath\npath/file\n"); Ok(()) } #[test] fn test_list_content_single_cpio_verbose() -> Result<(), Box> { let mut cmd = get_command(); cmd.arg("-tv").arg("tests/single.cpio"); cmd.env("TZ", "UTC"); cmd.output()? .assert_stderr("") .assert_success() .assert_stdout( "drwxrwxr-x 2 root root 0 Apr 14 2024 .\n\ drwxrwxr-x 2 root root 0 Apr 14 2024 path\n\ -rw-rw-r-- 1 root root 8 Apr 14 2024 path/file\n", ); Ok(()) } #[test] fn test_list_content_single_cpio_with_pattern() -> Result<(), Box> { let mut cmd = get_command(); cmd.arg("-t").arg("tests/single.cpio").arg("p?th"); cmd.output()? .assert_stderr("") .assert_success() .assert_stdout("path\n"); Ok(()) } #[test] fn test_missing_archive_argument() -> Result<(), Box> { let mut cmd = get_command(); cmd.arg("-t"); cmd.output()? .assert_failure(2) .assert_stderr_contains("missing argument ARCHIVE") .assert_stdout(""); Ok(()) } #[test] fn test_print_version() -> Result<(), Box> { let mut cmd = get_command(); cmd.arg("--version"); let stdout = cmd.output()?.assert_stderr("").assert_success().stdout; let stdout = String::from_utf8(stdout).expect("stdout"); let words: Vec<&str> = stdout.split_whitespace().collect(); assert_eq!(words.len(), 2, "not two words: '{stdout}'"); assert_eq!(words[0], "3cpio"); let version = words[1]; // Simple implementation for regular expression match: [0-9.]+ let mut matches = String::from(version); matches.retain(|c| c.is_ascii_digit() || c == '.'); assert_eq!(matches, version); Ok(()) } #[test] fn test_unexpected_option() -> Result<(), Box> { let mut cmd = get_command(); cmd.arg("--foobar"); cmd.output()? .assert_failure(2) .assert_stderr_contains("Error: invalid option '--foobar'") .assert_stdout(""); Ok(()) } threecpio-0.11.0/tests/generate000075500000000000000000000036571046102023000145620ustar 00000000000000#!/bin/sh set -eu # Copyright (C) 2024, Benjamin Drung # SPDX-License-Identifier: ISC # Generate the test cpio files export SOURCE_DATE_EPOCH=1713104326 # Generate the test data generate_cpio() { input_dir="$1" find "$input_dir" -depth -exec touch --no-dereference --date="@${SOURCE_DATE_EPOCH}" {} \; { cd "$input_dir"; find .; } | LC_ALL=C sort \ | cpio --owner "+0:+0" --reproducible --quiet -o -H newc -D "$input_dir" } cd "$(dirname "$0")" input="$(mktemp -d "${TMPDIR-/tmp}/3cpio_XXXXXX")" trap 'rm -rf "${input}"' 0 1 2 3 6 mkdir -p "$input/single/path" echo "content" > "$input/single/path/file" generate_cpio "${input}/single" > single.cpio mkdir -p "$input/shell/usr/bin/" echo "This is a fake busybox binary to simulate a POSIX shell" > "$input/shell/usr/bin/sh" generate_cpio "${input}/shell" > "$input/shell.cpio" touch --date="@${SOURCE_DATE_EPOCH}" "$input/shell.cpio" mkdir -p "$input/bigdata" dd bs=1000 count=102500 if=/dev/zero of="$input/bigdata/zeros" generate_cpio "${input}/bigdata" > "$input/bigdata.cpio" touch --date="@${SOURCE_DATE_EPOCH}" "$input/bigdata.cpio" cp single.cpio bzip2.cpio bzip2 -9 < "$input/shell.cpio" >> bzip2.cpio cp single.cpio gzip.cpio gzip -n -9 < "$input/shell.cpio" >> gzip.cpio cp single.cpio lz4.cpio lz4 -l -9 < "$input/shell.cpio" >> lz4.cpio cp single.cpio lzma.cpio lzma -9 < "$input/shell.cpio" >> lzma.cpio cp single.cpio lzop.cpio lzop -9 -c "$input/shell.cpio" >> lzop.cpio cp single.cpio xz.cpio xz --check=crc32 --threads=1 -9 < "$input/shell.cpio" >> xz.cpio cp single.cpio zstd.cpio zstd -q -9 < "$input/shell.cpio" >> zstd.cpio cat single.cpio "$input/shell.cpio" > bigdata.cpio zstd -q -9 < "$input/bigdata.cpio" >> bigdata.cpio mkdir "$input/path-traversal" ln -s /tmp "$input/path-traversal/tmp" echo "TEST Traversal" > "$input/path-traversal/tmpYtrav.txt" generate_cpio "$input/path-traversal" | sed "s@tmpY@tmp/@g" > path-traversal.cpio threecpio-0.11.0/tests/gzip.cpio000064400000000000000000000012721046102023000146560ustar 0000000000000007070100000000000041FD000000000000000000000002661BE5C600000000000000000000000000000000000000000000000200000000.07070100000001000041FD000000000000000000000002661BE5C600000000000000000000000000000000000000000000000500000000path07070100000002000081B4000000000000000000000001661BE5C600000008000000000000000000000000000000000000000A00000000path/filecontent 07070100000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000B00000000TRAILER!!!3070704@&n.؁ic0@ϐFE vN $;25+2t2^c "uBW8h5CHFf%*%f*$W&W($U*+g$U{F(gp`<Ѐ<wsHk"(^threecpio-0.11.0/tests/lz4.cpio000064400000000000000000000013221046102023000144120ustar 0000000000000007070100000000000041FD000000000000000000000002661BE5C600000000000000000000000000000000000000000000000200000000.07070100000001000041FD000000000000000000000002661BE5C600000000000000000000000000000000000000000000000500000000path07070100000002000081B4000000000000000000000001661BE5C600000008000000000000000000000000000000000000000A00000000path/filecontent 07070100000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000B00000000TRAILER!!!!Lw0707010_41FD02661BE5C602 ).p1pD4 Zusrt2tD8tL/binx3%?1B4@1x/38xBx0/shThis is a fake busybox binary to simulate a POSIX shell |/10#TRAILER!!!`Pthreecpio-0.11.0/tests/lzma.cpio000064400000000000000000000012561046102023000146520ustar 0000000000000007070100000000000041FD000000000000000000000002661BE5C600000000000000000000000000000000000000000000000200000000.07070100000001000041FD000000000000000000000002661BE5C600000000000000000000000000000000000000000000000500000000path07070100000002000081B4000000000000000000000001661BE5C600000008000000000000000000000000000000000000000A00000000path/filecontent 07070100000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000B00000000TRAILER!!!] c:ݢ `?4zs0 YZthreecpio-0.11.0/tests/zstd.cpio000064400000000000000000000012601046102023000146660ustar 0000000000000007070100000000000041FD000000000000000000000002661BE5C600000000000000000000000000000000000000000000000200000000.07070100000001000041FD000000000000000000000002661BE5C600000000000000000000000000000000000000000000000500000000path07070100000002000081B4000000000000000000000001661BE5C600000008000000000000000000000000000000000000000A00000000path/filecontent 07070100000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000B00000000TRAILER!!!(/``(>"Kk}dPlHr0nj3hvL`GS|(G}&UɏHVOI_+uvw#%ޠylWzX;`[{+t @dtia8\kxcVdh as$6