Skip to content
Draft
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
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
9 changes: 5 additions & 4 deletions docs/PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
17 changes: 10 additions & 7 deletions docs/decisions/0003-panic-free-forbid-unsafe-parsing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 2 additions & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
122 changes: 122 additions & 0 deletions fuzz/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 32 additions & 0 deletions fuzz/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions fuzz/fuzz_targets/idlist.rs
Original file line number Diff line number Diff line change
@@ -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<ShellItem>`, 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);
});
19 changes: 19 additions & 0 deletions fuzz/fuzz_targets/pipeline.rs
Original file line number Diff line number Diff line change
@@ -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();
}
});
Loading