From 513220b5b95566bca479eb5d375e4d0265918d64 Mon Sep 17 00:00:00 2001 From: Albert Hui Date: Sun, 12 Jul 2026 13:37:02 +0800 Subject: [PATCH 1/3] feat(fuzz): add cargo-fuzz decompress target + weekly campaign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a libFuzzer target feeding arbitrary bytes to the public `decompress` entry point (the MS-XCA §2.2.4 decompressor consumed by prefetch-forensic and memf-*), asserting the invariant that hostile input yields Ok or a typed Err, never a panic/abort/OOM. The output-size hint is capped at 1 MiB so the caller- controlled Vec::with_capacity is never the thing under test. fuzz.yml runs a bounded campaign weekly + on demand; ci.yml's fuzz-check job compiles it on every push. A local ~18k-exec smoke run (seeded with the real prefetch vectors) produced no crash — the bounds-checked decoder is panic-free. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/fuzz.yml | 48 +++++++++++++++++++++++++++++++++ .gitignore | 18 ++++++++++++- docs/validation.md | 20 ++++++++++++++ fuzz/Cargo.toml | 26 ++++++++++++++++++ fuzz/fuzz_targets/decompress.rs | 24 +++++++++++++++++ 5 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/fuzz.yml create mode 100644 fuzz/Cargo.toml create mode 100644 fuzz/fuzz_targets/decompress.rs diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 0000000..63fb658 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,48 @@ +name: Fuzz campaign + +# ci.yml only *compiles* the fuzz target (`cargo fuzz check`); this workflow +# actually RUNS it on a schedule for a bounded time and uploads the evolved +# corpus and any crash artifact for inspection. +on: + schedule: + - cron: "0 3 * * 1" # weekly, Monday 03:00 UTC + workflow_dispatch: + inputs: + minutes: + description: Minutes to fuzz the target + default: "10" + +permissions: + contents: read + +jobs: + fuzz: + name: Fuzz ${{ matrix.target }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + target: [decompress] + env: + # cargo-fuzz builds with the host nightly; its own deps' warnings must not + # fail the run (mirrors ci.yml's fuzz-check job). + RUSTFLAGS: "" + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: dtolnay/rust-toolchain@nightly + - uses: Swatinem/rust-cache@9bdad043e88c75890e36ad3bbc8d27f0090dd609 # v2.7.8 + - run: cargo install cargo-fuzz + - name: Fuzz ${{ matrix.target }} + working-directory: fuzz + run: | + minutes="${{ github.event.inputs.minutes || '10' }}" + cargo +nightly fuzz run "${{ matrix.target }}" -- -max_total_time="$((minutes * 60))" + - name: Upload corpus and any crash artifacts + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: fuzz-${{ matrix.target }} + path: | + fuzz/corpus/${{ matrix.target }} + fuzz/artifacts/${{ matrix.target }} + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 3164fcb..71c21b4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,19 @@ /target +# Library crate: the lockfile is not committed (downstreams resolve their own). Cargo.lock -/site + +# mkdocs build output (generated by `mkdocs build`; the Pages workflow builds it in CI) +/site/ + +# cargo-fuzz build output and evolved corpora/crash artifacts (standalone workspace) +/fuzz/target +/fuzz/corpus +/fuzz/artifacts +/fuzz/Cargo.lock + +# OS / editor cruft +.DS_Store +Thumbs.db +.idea/ +.vscode/ +*.swp diff --git a/docs/validation.md b/docs/validation.md index f0e6071..7e033fd 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -62,4 +62,24 @@ print("dissect == expected:", out == exp, PY ``` +## Robustness (fuzzing) + +The decompressor parses fully attacker-controlled bytes, so correctness on +well-formed input is only half the story — it must never panic, abort, or +over-allocate on hostile input. A `cargo-fuzz` / libFuzzer target +(`fuzz/fuzz_targets/decompress.rs`) feeds arbitrary bytes to `decompress` with a +capped output-size hint and asserts the invariant *"every input yields `Ok` or a +typed `Err`, never a panic."* Every length, offset, and Huffman-table field read +from the stream is bounds-checked; back-references before the output start and +truncated tables return `BadMatchOffset` / `TruncatedTable`. + +`ci.yml` compiles the target on every push (`cargo +nightly fuzz check`); +`fuzz.yml` runs a bounded campaign weekly and on demand, uploading the evolved +corpus and any crash artifact. A local smoke run of ~18k executions (seeded with +the real vectors above) produced no crash. + +This static/dynamic pairing is backed by the lint posture: +`#![forbid(unsafe_code)]` plus `clippy::unwrap_used` / `expect_used` denied in +production code, so no unchecked panic path can be introduced. + [MS-XCA]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-xca/ diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..9e7f16e --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "xpress-huffman-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +# Standalone workspace: cargo-fuzz builds this crate in isolation, not as a +# member of the main crate it fuzzes. +[workspace] + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +arbitrary = { version = "1", features = ["derive"] } + +[dependencies.xpress-huffman] +path = ".." + +[[bin]] +name = "decompress" +path = "fuzz_targets/decompress.rs" +test = false +doc = false +bench = false diff --git a/fuzz/fuzz_targets/decompress.rs b/fuzz/fuzz_targets/decompress.rs new file mode 100644 index 0000000..22a1f67 --- /dev/null +++ b/fuzz/fuzz_targets/decompress.rs @@ -0,0 +1,24 @@ +//! Fuzz the Xpress-Huffman decompressor on arbitrary bytes. +//! +//! `decompress` walks per-64-KiB-block Huffman code-length tables, builds a +//! canonical decode tree, and replays LZ77 literals / back-references from a +//! bit stream — every field of which is attacker-controlled here. On any input +//! it must return `Ok` or a typed `Err` (`TruncatedTable` / `BadMatchOffset`), +//! never panic, abort, or over-allocate. +//! +//! `decompressed_size` is a caller-supplied capacity hint (`Vec::with_capacity`), +//! so a hostile value is an allocation bomb the *caller* controls, not a decoder +//! bug. The harness therefore derives it from the input and caps it at 1 MiB — +//! large enough to drive the multi-block loop (16 × 64 KiB) and the +//! stop-at-requested-size / overshoot paths, small enough that libFuzzer's RSS +//! limit is never the thing under test. +#![no_main] +use libfuzzer_sys::fuzz_target; + +const SIZE_CAP: usize = 1 << 20; + +fuzz_target!(|input: (u32, &[u8])| { + let (requested, data) = input; + let decompressed_size = requested as usize % (SIZE_CAP + 1); + let _ = xpress_huffman::decompress(data, decompressed_size); +}); From c271b52cbdef273648784285a5733c72116b9048 Mon Sep 17 00:00:00 2001 From: Albert Hui Date: Sun, 12 Jul 2026 13:37:22 +0800 Subject: [PATCH 2/3] chore: bring CI + config up to the pre-publish gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructures ci.yml into separate jobs (fmt · clippy -D warnings · test on the ubuntu/macos/windows matrix · low-MSRV 1.85 build · cargo-deny · gitleaks · 100%-function coverage · nightly fuzz-check · docs), matching the fleet gate. Adds renovate.json (rangeStrategy bump + lockFileMaintenance + Actions digest pinning) and .pre-commit-config.yaml (fmt/clippy/gitleaks parity with CI). Cleans stale copy-paste from the vmdk crate: clippy.toml doc-idents, .gitleaks.toml allowlist, deny.toml (adds [graph] + LLVM-exception, drops the vmdk-specific bits). .gitignore gains OS/editor cruft + fuzz build output. rustfmt.toml drops the unstable imports_granularity line (ignored on the pinned stable, only emitted a warning). Bumps 0.1.0 -> 0.1.1 (gate + fuzz hardening). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 113 ++++++++++++++++++++++++++++++--------- .gitleaks.toml | 13 ++--- .pre-commit-config.yaml | 29 ++++++++++ Cargo.toml | 2 +- clippy.toml | 4 +- deny.toml | 9 ++++ renovate.json | 18 +++++++ rustfmt.toml | 1 - 8 files changed, 151 insertions(+), 38 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 renovate.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca001cc..8fc40c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,24 +12,58 @@ permissions: env: CARGO_TERM_COLOR: always CARGO_INCREMENTAL: "0" - RUSTFLAGS: -Dwarnings + RUSTFLAGS: -D warnings jobs: - test: - name: Test & lint + fmt: + name: Format runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + - uses: dtolnay/rust-toolchain@stable with: - components: rustfmt, clippy + components: rustfmt - run: cargo fmt --check - - run: cargo clippy --all-targets --all-features - - run: cargo clippy --all-targets # default (no_std) feature set + + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@9bdad043e88c75890e36ad3bbc8d27f0090dd609 # v2.7.8 + - run: cargo clippy --all-targets --all-features -- -D warnings + - run: cargo clippy --all-targets -- -D warnings # default (no_std) feature set + + test: + name: Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@9bdad043e88c75890e36ad3bbc8d27f0090dd609 # v2.7.8 - run: cargo test --all-features + - run: cargo test # default (no_std) feature set - name: no_std build run: cargo build + msrv: + name: MSRV (1.85) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: dtolnay/rust-toolchain@1.85.0 + - uses: Swatinem/rust-cache@9bdad043e88c75890e36ad3bbc8d27f0090dd609 # v2.7.8 + # This published library promises a LOW MSRV (a crates.io compatibility + # signal), distinct from the dev-toolchain pin in rust-toolchain.toml. + - run: cargo build + - run: cargo build --all-features + deny: name: cargo-deny (bans/licenses/sources) runs-on: ubuntu-latest @@ -53,26 +87,57 @@ jobs: command: check advisories coverage: - name: Coverage (100% lib) + name: Coverage (100% functions) runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + - uses: dtolnay/rust-toolchain@stable with: components: llvm-tools-preview - - uses: taiki-e/install-action@cargo-llvm-cov - - name: Gate on 100% line coverage (excluding // cov:unreachable) + - uses: Swatinem/rust-cache@9bdad043e88c75890e36ad3bbc8d27f0090dd609 # v2.7.8 + - uses: taiki-e/install-action@v2 # renovate: pin digest + with: + tool: cargo-llvm-cov + # Every function must be covered. Line % reads below 100 on the single + # `// cov:unreachable` defensive arm (a valid Huffman tree always reaches a + # leaf) — the function gate is the meaningful invariant. + - run: cargo llvm-cov --all-features --fail-under-functions 100 --show-missing-lines + + fuzz-check: + name: Fuzz targets compile (nightly) + runs-on: ubuntu-latest + # cargo-fuzz builds with the host nightly; warnings in its own deps must not + # fail the install, so this job does not deny warnings. + env: + RUSTFLAGS: "" + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: dtolnay/rust-toolchain@nightly + - uses: Swatinem/rust-cache@9bdad043e88c75890e36ad3bbc8d27f0090dd609 # v2.7.8 + - run: cargo install cargo-fuzz + - run: cargo +nightly fuzz check + working-directory: fuzz + + secrets: + name: Secret Scan (gitleaks) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + - name: Install gitleaks run: | - cargo llvm-cov --all-features --lib --lcov --output-path lcov.info - # Fail on any DA:,0 whose source line lacks a `// cov:unreachable` - # marker (the fleet defence-in-depth exemption). - fail=0 - while IFS= read -r entry; do - line="${entry#DA:}"; line="${line%,0}" - src=$(sed -n "${line}p" src/lib.rs) - case "$src" in - *cov:unreachable*) ;; - *) echo "Uncovered (no cov:unreachable): src/lib.rs:${line}: ${src}"; fail=1 ;; - esac - done < <(grep -E '^DA:[0-9]+,0$' lcov.info || true) - exit $fail + VERSION=$(curl -s https://api.github.com/repos/gitleaks/gitleaks/releases/latest | jq -r '.tag_name[1:]') + curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/gitleaks_${VERSION}_linux_x64.tar.gz" \ + | tar xz -C /tmp gitleaks + - name: Run gitleaks + run: /tmp/gitleaks detect --source . + + docs: + name: Docs + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@9bdad043e88c75890e36ad3bbc8d27f0090dd609 # v2.7.8 + - run: RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features diff --git a/.gitleaks.toml b/.gitleaks.toml index b1fb328..bbd6e41 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -1,12 +1,5 @@ -# gitleaks configuration — extends the default ruleset and allowlists known -# false positives. The only finding is a CI cache key (`corpus-vmdk-v1`) that -# gitleaks' generic-api-key rule flags on entropy alone; cache keys are not -# secrets. Scoped to the cache-key pattern so real secrets still surface. +# gitleaks configuration — extends the default ruleset. No project-specific +# allowlist entries: this crate ships only code, docs, and public test vectors, +# so real secrets surface against the upstream rules. [extend] useDefault = true - -[allowlist] -description = "CI cache keys are not secrets" -regexes = [ - '''corpus-vmdk-v[0-9]+''', -] diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..a66124e --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,29 @@ +# Pre-commit ⇄ CI parity: the same fmt / clippy / secret-scan checks CI enforces +# run here locally, so a green commit is a green pipeline. +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-merge-conflict + + - repo: https://github.com/doublify/pre-commit-rust + rev: v1.0 + hooks: + - id: fmt + - id: clippy + args: ["--all-targets", "--all-features", "--", "-D", "warnings"] + + # Secret scan — mirrors the CI `gitleaks detect --source .` job. A local hook + # runs the identical command against the installed gitleaks binary rather than + # pinning an external rev, keeping the two in lock-step. + - repo: local + hooks: + - id: gitleaks + name: gitleaks (secret scan) + entry: gitleaks detect --source . --no-banner + language: system + pass_filenames: false diff --git a/Cargo.toml b/Cargo.toml index bdb17ed..0af8115 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "xpress-huffman" -version = "0.1.0" +version = "0.1.1" edition = "2021" rust-version = "1.85" description = "Pure-Rust, panic-free decompressor for Microsoft Xpress-Huffman ([MS-XCA] §2.2.4, LZXPRESS_HUFFMAN) — the codec behind Win10+ prefetch (MAM), hiberfil.sys, SMB3 and registry-hive compression. Cross-platform, no Windows API." diff --git a/clippy.toml b/clippy.toml index 6f36e3f..6a8e190 100644 --- a/clippy.toml +++ b/clippy.toml @@ -1,9 +1,9 @@ # Allow `unwrap()`/`expect()` inside test functions and `#[cfg(test)]` modules. -# Production code keeps `clippy::unwrap_used = deny` (see workspace Cargo.toml); +# Production code keeps `clippy::unwrap_used = deny` (see Cargo.toml `[lints]`); # tests legitimately unwrap to fail loudly, so this is the upstream-recommended # exception rather than scattering `#[allow]` across every test module. allow-unwrap-in-tests = true allow-expect-in-tests = true # Product/format names that look like code identifiers to doc_markdown. -doc-valid-idents = ["VMware", "VMDK", "VMFS", "qemu-img", "libvmdk", "seSparse", "COWD", "streamOptimized", "..", "SHA-256", "MD5", "ext4", "NTFS"] +doc-valid-idents = ["LZXPRESS_HUFFMAN", "COMPRESSION_FORMAT_XPRESS_HUFF", "RtlDecompressBufferEx", "SMB3", "SCCA", "KiB", "MiB", "SHA-256", "MD5", "LZ77", "LZNT", "no_std", "dissect.util"] diff --git a/deny.toml b/deny.toml index 42b7589..6c699b5 100644 --- a/deny.toml +++ b/deny.toml @@ -1,18 +1,27 @@ +# cargo-deny configuration — supply-chain and licence gate. +[graph] +all-features = false + [advisories] version = 2 +yanked = "deny" ignore = [] [licenses] version = 2 +# This crate is Apache-2.0 and has zero runtime dependencies; permit only +# permissive, non-copyleft licences in the tree. allow = [ "MIT", "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", "BSD-2-Clause", "BSD-3-Clause", "ISC", "Unicode-3.0", "Zlib", ] +confidence-threshold = 0.9 [bans] multiple-versions = "deny" diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..be5bd0a --- /dev/null +++ b/renovate.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended"], + "rangeStrategy": "bump", + "lockFileMaintenance": { "enabled": true }, + "packageRules": [ + { + "matchManagers": ["cargo"], + "matchUpdateTypes": ["minor", "patch"], + "groupName": "cargo minor/patch" + }, + { + "matchManagers": ["github-actions"], + "groupName": "github-actions", + "pinDigests": true + } + ] +} diff --git a/rustfmt.toml b/rustfmt.toml index 87dc8cb..758d417 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1,2 +1 @@ max_width = 100 -imports_granularity = "Crate" From c99cfd6ff87fc55c329c1492d478b5b2a7c92120 Mon Sep 17 00:00:00 2001 From: Albert Hui Date: Sun, 12 Jul 2026 18:12:05 +0800 Subject: [PATCH 3/3] ci: bump cargo-deny-action v2.0.4 -> v2.0.20 (parse CVSS 4.0 advisories) v2.0.4's bundled cargo-deny fails to load the RustSec advisory DB when an advisory (RUSTSEC-2026-0124) carries a CVSS 4.0 vector. v2.0.20 parses it. --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8fc40c1..c1d860f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,7 +69,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: EmbarkStudios/cargo-deny-action@34899fc7ba81ca6268d5947a7a16b4649013fea1 # v2.0.4 + - uses: EmbarkStudios/cargo-deny-action@bb137d7af7e4fb67e5f82a49c4fce4fad40782fe # v2.0.20 with: command: check bans licenses sources @@ -82,7 +82,7 @@ jobs: continue-on-error: true steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: EmbarkStudios/cargo-deny-action@34899fc7ba81ca6268d5947a7a16b4649013fea1 # v2.0.4 + - uses: EmbarkStudios/cargo-deny-action@bb137d7af7e4fb67e5f82a49c4fce4fad40782fe # v2.0.20 with: command: check advisories