Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 91 additions & 26 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,30 +12,64 @@ 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
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

Expand All @@ -48,31 +82,62 @@ 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

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:<line>,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
48 changes: 48 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
@@ -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
18 changes: 17 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -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
13 changes: 3 additions & 10 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
@@ -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]+''',
]
29 changes: 29 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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."
Expand Down
4 changes: 2 additions & 2 deletions clippy.toml
Original file line number Diff line number Diff line change
@@ -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"]
9 changes: 9 additions & 0 deletions deny.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
20 changes: 20 additions & 0 deletions docs/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
26 changes: 26 additions & 0 deletions fuzz/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions fuzz/fuzz_targets/decompress.rs
Original file line number Diff line number Diff line change
@@ -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);
});
Loading
Loading