From b643576f85d7e2de52391e20abb94aaf5e540f26 Mon Sep 17 00:00:00 2001 From: Albert Hui Date: Sat, 1 Aug 2026 16:22:46 +0800 Subject: [PATCH 1/3] test(fuzz): add idlist and pipeline cargo-fuzz targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README, docs/index.md and the CHANGELOG all advertised "fuzzed", but fuzz/fuzz_targets/ was empty and no target existed anywhere in history — ADR 0003 recorded this honestly as a known gap. ADR-0012 requires a fuzz target per parsed structure for any crate that parses untrusted input, and shell-item blobs are lifted verbatim from .lnk LinkTargetIDList structures and registry ShellBags values. Two targets, following the fleet pattern (lnk-forensic, shellhist-forensic for the naming and CI job; xpress-huffman, safe-read for the single-crate fuzz manifest with path = ".."): idlist parse_idlist alone — the cb framing loop, the class dispatch, the per-class decoders and the 0xbeef0004 extension block. pipeline parse_idlist to reconstruct_path to display_name — the joins and label selection over lossily-decoded UTF-16, where a char-boundary assumption would surface rather than in parse. lnk-forensic and winreg-forensic already fuzz harnesses that reach this code incidentally through their own readers; that is not a substitute for a dedicated target and does not make a first-person claim true. Both run clean on first use, with no crashes found: idlist 8,229,495 executions in 121s pipeline 9,131,724 executions in 121s (local, aarch64-apple-darwin, libFuzzer, -max_total_time=120). No crashes in N executions is present-robustness evidence over the inputs libFuzzer reached; it does not prove that no panicking input exists. The static lints remain the load-bearing guarantee. CI gains a nightly smoke-fuzz job matching the sibling repos, building all targets with `cargo fuzz check` then running each for 30s on every PR. cargo-fuzz is installed via the same pinned taiki-e/install-action already used for cargo-llvm-cov rather than an unpinned `cargo install`. CONTRIBUTING named `shelllink` and `forensic` — lnk-forensic's target names, carried over when this repo's scaffolding was copied from it, and wrong here even once targets existed. Corrected to idlist and pipeline. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 25 +++++++ CONTRIBUTING.md | 2 +- fuzz/Cargo.lock | 122 ++++++++++++++++++++++++++++++++++ fuzz/Cargo.toml | 32 +++++++++ fuzz/fuzz_targets/idlist.rs | 17 +++++ fuzz/fuzz_targets/pipeline.rs | 19 ++++++ 6 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 fuzz/Cargo.lock create mode 100644 fuzz/Cargo.toml create mode 100644 fuzz/fuzz_targets/idlist.rs create mode 100644 fuzz/fuzz_targets/pipeline.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43b4494..2f73660 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,6 +98,31 @@ jobs: print("All production lines covered.") PY + fuzz: + name: Fuzz targets (nightly smoke) + runs-on: ubuntu-latest + env: + # cargo-fuzz supplies its own sanitizer/coverage RUSTFLAGS; an inherited + # value would be overwritten rather than appended to. + RUSTFLAGS: "" + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch ref; nightly selected below + with: + toolchain: nightly + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - name: Install cargo-fuzz + uses: taiki-e/install-action@59012be0884e296ca2da49b530610e72c49039ad # v2.81.6 + with: + tool: cargo-fuzz + - name: Build all fuzz targets + run: cargo +nightly fuzz check + - name: Smoke-fuzz each target (30s) + run: | + for t in idlist pipeline; do + cargo +nightly fuzz run "$t" -- -max_total_time=30 -rss_limit_mb=4096 + done + deny: name: cargo-deny (bans/licenses/sources) runs-on: ubuntu-latest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 55e75cc..2b89551 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,7 +51,7 @@ cargo llvm-cov --workspace --lib --show-missing-lines # 100% line coverage ```bash rustup install nightly cargo install cargo-fuzz - cargo +nightly fuzz run shelllink # or: forensic + cargo +nightly fuzz run idlist # or: pipeline ``` If the reader gains new structure handling, extend or add the matching fuzz diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock new file mode 100644 index 0000000..02b80e5 --- /dev/null +++ b/fuzz/Cargo.lock @@ -0,0 +1,122 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "forensicnomicon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c747a0663464b13e3932e7ef0f03504304869c8434ca5c44caf06e1dd8be0f64" +dependencies = [ + "forensicnomicon-core", + "forensicnomicon-data", +] + +[[package]] +name = "forensicnomicon-core" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21800c5e29085274fbb324be30790894481c9c5faf8c659b3d0d994840ca0941" + +[[package]] +name = "forensicnomicon-data" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fc507e10461af6be7ab1404347be31995453005aad4382d9edde5a8c4556486" +dependencies = [ + "forensicnomicon-core", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "shellitem" +version = "0.2.1" +dependencies = [ + "forensicnomicon", +] + +[[package]] +name = "shellitem-fuzz" +version = "0.0.0" +dependencies = [ + "libfuzzer-sys", + "shellitem", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..67e00dd --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "shellitem-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" + +[dependencies.shellitem] +path = ".." + +[[bin]] +name = "idlist" +path = "fuzz_targets/idlist.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "pipeline" +path = "fuzz_targets/pipeline.rs" +test = false +doc = false +bench = false diff --git a/fuzz/fuzz_targets/idlist.rs b/fuzz/fuzz_targets/idlist.rs new file mode 100644 index 0000000..a09a9ac --- /dev/null +++ b/fuzz/fuzz_targets/idlist.rs @@ -0,0 +1,17 @@ +#![no_main] +//! `ITEMIDLIST` parse over arbitrary bytes — must never panic. +//! +//! `parse_idlist` walks a chain of variable-length shell items, each framed by +//! an attacker-controlled 2-byte `cb` size field, and dispatches on the class +//! byte into the per-class decoders (root `0x1f`, volume `0x2e`/`0x2f`, file +//! entry `0x30`-major plus its `0xbeef0004` extension block, network `0xc3`). +//! Every one of those fields — the `cb` framing, the class byte, the extension +//! offsets, the FAT date/time words, the UTF-16 name runs — is fuzzer-supplied +//! here. The invariant is total: for *any* byte slice the call returns a +//! (possibly empty, possibly truncated) `Vec`, never panics, never +//! reads out of bounds, and never loops forever on a zero-advance `cb`. +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + let _ = shellitem::parse_idlist(data); +}); diff --git a/fuzz/fuzz_targets/pipeline.rs b/fuzz/fuzz_targets/pipeline.rs new file mode 100644 index 0000000..2e3399d --- /dev/null +++ b/fuzz/fuzz_targets/pipeline.rs @@ -0,0 +1,19 @@ +#![no_main] +//! Full public pipeline over arbitrary bytes — parse → path reconstruction → +//! per-item field access — must never panic. +//! +//! `idlist.rs` covers the decoder in isolation; this target adds the two things +//! a consumer actually does with the result. `reconstruct_path` joins decoded +//! names with `\` after a known-folder rewrite, and `display_name` picks the +//! most specific label — both operating on strings that came out of lossy +//! UTF-16 decoding of fuzzer bytes, so this is where a char-boundary or +//! empty-name assumption would surface rather than in the parse itself. +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + let items = shellitem::parse_idlist(data); + let _ = shellitem::reconstruct_path(&items); + for item in &items { + let _ = item.display_name(); + } +}); From dc27905db95b93a3b28c3f1300a6ade4e0349d7d Mon Sep 17 00:00:00 2001 From: Albert Hui Date: Sat, 1 Aug 2026 16:23:06 +0800 Subject: [PATCH 2/3] docs: replace the unearned "fuzzed" claim with measured exec counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The published README asserted the crate was "fuzzed over `parse_idlist`" while no fuzz target existed. The preceding commit made a dedicated target real; this makes the documentation say what is actually true and measured rather than merely asserted. README "Trust but verify" moves to the fleet's canonical paired-bullet form: a Fuzzed bullet carrying the empirical evidence (both target names, what each drives, and the exec counts reached) beside a Panic-free-by-lint bullet carrying the static posture. Per the fleet's evidence-based-rigor rule these are complementary, not interchangeable — the lints make panics unreachable by construction, the fuzzer tests that empirically over a finite number of inputs. Also corrected in the same sentence: the README cited `#![forbid(unsafe_code)]` as an inner attribute, but the lint is declared in Cargo.toml `[lints.rust]` as `unsafe_code = "forbid"`. Same guarantee, but a reader grepping for the attribute would not have found it. ADR 0003's "Known gap" consequence and docs/PRD.md's validation section both said no fuzz target was committed and no ci.yml existed. The fuzz half is closed by the preceding commit; the ci.yml half was already stale, since ci.yml runs clippy -D warnings, the low-MSRV job and the coverage gate today. Both now describe the current state, and both keep the limit of the evidence explicit rather than reading the exec counts as proof of absence. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 4 +++- docs/PRD.md | 9 +++++---- .../0003-panic-free-forbid-unsafe-parsing.md | 17 ++++++++++------- docs/index.md | 3 ++- 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index cd7da9e..30c4c27 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,9 @@ Format constants (class bytes, the `0xbeef0004`/`0xbeef0026` extension signature ## Trust but verify -Panic-free on untrusted input (every `cb`/offset range-checked before use; UTF-16 decoded lossily; DOS-date conversion overflow-guarded), `#![forbid(unsafe_code)]`, and fuzzed over `parse_idlist`. Validated against spec-exact `ITEMIDLIST` fixtures derived from libfwsi. +- **Fuzzed** — two `cargo-fuzz` targets drive arbitrary bytes: `idlist` (`parse_idlist` alone) and `pipeline` (`parse_idlist` → `reconstruct_path` → `display_name`). No crashes in 8.2M and 9.1M executions respectively; CI smoke-fuzzes both on every PR. +- **Panic-free by lint** — `unsafe_code = "forbid"` with `unwrap_used`/`expect_used` denied, so every `cb`/offset is range-checked before use, UTF-16 is decoded lossily, and DOS-date conversion is overflow-guarded. +- **Validated** against spec-exact `ITEMIDLIST` fixtures derived from libfwsi. --- diff --git a/docs/PRD.md b/docs/PRD.md index e0c391c..5718905 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -84,7 +84,8 @@ path reconstruction — see `git log`) over spec-exact `ITEMIDLIST` fixtures derived from libfwsi. The static robustness posture is `forbid(unsafe_code)` plus `unwrap_used`/`expect_used = deny` with bounds-checked readers (ADR 0003). -A dynamic fuzz target over `parse_idlist` is the intended partner to the static -posture and the README/CHANGELOG advertise it; the `fuzz/` harness is scaffolded -but **no fuzz target is committed yet** — an outstanding gap tracked in ADR 0003, -alongside the absence of a `ci.yml` to run the low-MSRV and coverage jobs. +The dynamic partner to that static posture is `fuzz/`: two `cargo-fuzz` targets +over arbitrary bytes — `idlist` (`parse_idlist` alone) and `pipeline` +(`parse_idlist` → `reconstruct_path` → `display_name`). Neither crashed in its +first run (8.2M and 9.1M executions respectively); `ci.yml` smoke-fuzzes both for +30s on every PR alongside the low-MSRV and coverage jobs (ADR 0003). diff --git a/docs/decisions/0003-panic-free-forbid-unsafe-parsing.md b/docs/decisions/0003-panic-free-forbid-unsafe-parsing.md index 06f9e09..094aa55 100644 --- a/docs/decisions/0003-panic-free-forbid-unsafe-parsing.md +++ b/docs/decisions/0003-panic-free-forbid-unsafe-parsing.md @@ -41,10 +41,13 @@ a length field. the `unsafe-forbidden` badge. - A truncated or hostile blob degrades to a partial-but-sound result, never a crash of the consuming analyzer (ADR 0005). -- **Known gap — the dynamic partner is not yet committed.** The static lints - make panics unreachable by construction; a `cargo-fuzz` target over - `parse_idlist` is the empirical check that the README and CHANGELOG advertise - ("fuzzed"), but `fuzz/fuzz_targets/` is currently empty and no target exists in - history. Likewise there is no `ci.yml` yet to run clippy `-D warnings`, the - low-MSRV job, or a coverage gate. Leading claim here is the *verifiable* static - posture; the fuzz target and CI wiring remain outstanding work. +- **The dynamic partner is committed** (`fuzz/`, 2026-08-01). The static lints + make panics unreachable *by construction*; two `cargo-fuzz` targets test that + empirically — `idlist` drives `parse_idlist` alone, `pipeline` drives + `parse_idlist` → `reconstruct_path` → `display_name`. First run: no crashes in + 8.2M and 9.1M executions respectively (local, aarch64-apple-darwin, + libFuzzer 120s each). `ci.yml` now runs clippy `-D warnings`, the low-MSRV + job, the coverage gate, and a 30s smoke-fuzz of each target on every PR. + Fuzzing shows present-robustness over N executions; it does not prove the + absence of a panicking input, which is why the lints — not the exec count — + remain the load-bearing guarantee. diff --git a/docs/index.md b/docs/index.md index 841c956..be3b4a7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -19,7 +19,8 @@ Class/extension constants come from `forensicnomicon::shellbags`; the spec is libyal's *Windows Shell Item format*. **Consumers:** `lnk-core` (LinkTargetIDList), `winreg-artifacts` (ShellBags), -Jump Lists. Panic-free on untrusted input, `forbid(unsafe_code)`, fuzzed. +Jump Lists. Panic-free by lint (`forbid(unsafe_code)`, `unwrap_used`/`expect_used` +denied) and fuzzed over `parse_idlist` and the full parse → path pipeline. --- From d3ca91ff35a057dcd89892d20ca648031e666899 Mon Sep 17 00:00:00 2001 From: Albert Hui Date: Sat, 1 Aug 2026 16:27:45 +0800 Subject: [PATCH 3/3] ci(fuzz): build cargo-fuzz from source so the target triple is gnu, not musl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fuzz job failed on the first CI run: error: sanitizer is incompatible with statically linked libc, disable it using `-C target-feature=-crt-static` error[E0463]: can't find crate for `std` note: the `x86_64-unknown-linux-musl` target may not be installed I had installed cargo-fuzz via the pinned taiki-e/install-action already used in this workflow for cargo-llvm-cov, preferring local consistency. That is the cause: the prebuilt Linux cargo-fuzz binary is musl-linked, and cargo-fuzz defaults --target to its own build triple, so it tried to build the targets for x86_64-unknown-linux-musl. libFuzzer's sanitizers cannot link against a static libc, and the musl std was not installed either. `cargo install cargo-fuzz` compiles on the runner and therefore picks up the gnu host triple. That is what lnk-forensic and shellhist-forensic do, and their "Fuzz targets (nightly smoke)" jobs are green — matching the fleet was the right call here and deviating from it was mine. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f73660..5ad59b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,10 +111,11 @@ jobs: with: toolchain: nightly - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - - name: Install cargo-fuzz - uses: taiki-e/install-action@59012be0884e296ca2da49b530610e72c49039ad # v2.81.6 - with: - tool: cargo-fuzz + # Built from source on the runner, not fetched via taiki-e/install-action: + # the prebuilt Linux cargo-fuzz is musl-linked, and cargo-fuzz defaults + # --target to its own build triple. That yields x86_64-unknown-linux-musl, + # which fails "sanitizer is incompatible with statically linked libc". + - run: cargo install cargo-fuzz - name: Build all fuzz targets run: cargo +nightly fuzz check - name: Smoke-fuzz each target (30s)