diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43b4494..5ad59b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,6 +98,32 @@ 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 + # 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) + 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/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. --- 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(); + } +});