From 342f2d01a064eab01a7c9d6859e85eafa400e8a8 Mon Sep 17 00:00:00 2001 From: Albert Hui Date: Mon, 27 Jul 2026 07:56:21 +0800 Subject: [PATCH 1/2] ci: add standard fleet CI (fmt/clippy/test/MSRV/coverage + rust-cache) Adds .github/workflows/ci.yml with fmt, clippy (-D warnings), a ubuntu/macos/windows test matrix, an MSRV build (1.81), an advisory coverage job, and cargo-deny (deny.toml present, no prior deny job). rust-cache after each toolchain step in compiling jobs; all actions SHA-pinned (version ref for the MSRV toolchain). vet.yml untouched. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 97 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..dc6234f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,97 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: "0" + +jobs: + fmt: + name: Rustfmt + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + components: rustfmt + - run: cargo fmt --all --check + + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + components: clippy + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - run: cargo clippy --workspace --all-targets --all-features -- -D warnings + + test: + name: Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - run: cargo test --workspace --all-features + + check-msrv: + name: Check MSRV (1.81) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: dtolnay/rust-toolchain@1.81.0 + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - run: cargo build --workspace --all-features --locked + + coverage: + name: Coverage (advisory) + runs-on: ubuntu-latest + # TODO: raise to the fleet 100% line-coverage gate once tests are added. + # Report-only until the test suite is expanded; does not gate merges. + continue-on-error: true + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + components: llvm-tools-preview + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@59012be0884e296ca2da49b530610e72c49039ad # v2.81.6 + with: + tool: cargo-llvm-cov + - run: cargo llvm-cov --workspace --all-features --summary-only + + deny: + name: cargo-deny (bans/licenses/sources) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1 + with: + command: check bans licenses sources + + advisories: + name: cargo-deny (advisories) + runs-on: ubuntu-latest + # Informational: a malformed entry in the upstream RustSec advisory-db breaks + # DB *loading* for the whole ecosystem and must not gate our build. + continue-on-error: true + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1 + with: + command: check advisories From 75930c855d4dc157f606c0bcf12cad26cba25e0f Mon Sep 17 00:00:00 2001 From: Albert Hui Date: Mon, 27 Jul 2026 08:51:39 +0800 Subject: [PATCH 2/2] test(coverage): reach 100% line coverage; promote coverage to hard gate Add real tests covering the two remaining uncovered production lines: - reader::utf16_z out-of-range offset (defensive empty-string contract); the line was reachable via direct call, so drop the cov:unreachable marker and test it instead. - parse_beef0004 with a long-name offset that decodes empty. The remaining gap was an LLVM drop-cleanup landing pad on the conditional String drop; consume `long` unconditionally via then_some (behavior-identical, idiomatic) so the spurious region disappears at the root. Flip the coverage CI job from advisory (continue-on-error) to the fleet hard gate: fail on any uncovered production line (DA:n,0), honoring cov:unreachable. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 33 +++++++++++++++++++++++++----- src/parse.rs | 44 +++++++++++++++++++++++++++++++++++++--- src/reader.rs | 21 ++++++++++++++++++- 3 files changed, 89 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc6234f..43b4494 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,11 +58,8 @@ jobs: - run: cargo build --workspace --all-features --locked coverage: - name: Coverage (advisory) + name: Coverage (100% production lines) runs-on: ubuntu-latest - # TODO: raise to the fleet 100% line-coverage gate once tests are added. - # Report-only until the test suite is expanded; does not gate merges. - continue-on-error: true steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable @@ -73,7 +70,33 @@ jobs: uses: taiki-e/install-action@59012be0884e296ca2da49b530610e72c49039ad # v2.81.6 with: tool: cargo-llvm-cov - - run: cargo llvm-cov --workspace --all-features --summary-only + - name: Fail on any uncovered production line (DA:n,0), honoring cov:unreachable + run: | + cargo llvm-cov --all-features --lcov --output-path cov.lcov + python3 - <<'PY' + import sys + def marked(path, n): + try: + return "cov:unreachable" in open(path).read().splitlines()[n - 1] + except Exception: + return False + cur, bad = None, [] + for line in open("cov.lcov").read().splitlines(): + if line.startswith("SF:"): + cur = line[3:] + elif line.startswith("DA:") and cur: + if "/tests/" in cur or "/fuzz/" in cur: + continue + n, hits = line[3:].split(",") + if hits == "0" and not marked(cur, int(n)): + bad.append(f"{cur}:{n}") + if bad: + print("Uncovered production lines — add a test or // cov:unreachable:") + for b in bad: + print(" " + b) + sys.exit(1) + print("All production lines covered.") + PY deny: name: cargo-deny (bans/licenses/sources) diff --git a/src/parse.rs b/src/parse.rs index e801218..cfb8ff0 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -166,9 +166,7 @@ fn parse_beef0004(item: &mut ShellItem, block_start: usize) { if long_name_offset != 0 { let abs = block_off.saturating_add(long_name_offset); let long = reader::utf16_z(&data, abs); - if !long.is_empty() { - item.long_name = Some(long); - } + item.long_name = (!long.is_empty()).then_some(long); } // Version >= 7 carries 2 unknown bytes then the 8-byte NTFS file reference @@ -703,6 +701,46 @@ mod file_entry_tests { assert!(items[0].modified.is_none()); } + #[test] + fn beef0004_long_name_offset_out_of_range_leaves_long_name_none() { + // A malformed/truncated block can declare a non-zero long-name offset + // that points past the buffer. utf16_z bounds-checks it to an empty + // string, so long_name must stay None — no panic, no bogus name. + let mut body = Vec::new(); + body.push(0x32); + body.push(0x00); + body.extend_from_slice(&100u32.to_le_bytes()); + body.extend_from_slice(&0u32.to_le_bytes()); + body.extend_from_slice(&0u16.to_le_bytes()); + body.extend_from_slice(b"TRUNC~1.TXT\0"); // 12 bytes -> 16-bit aligned + let mut block = Vec::new(); + block.extend_from_slice(&[0u8, 0u8]); // size + block.extend_from_slice(&8u16.to_le_bytes()); // version 8 + block.extend_from_slice(&0xBEEF_0004u32.to_le_bytes()); + block.extend_from_slice(&0u32.to_le_bytes()); // created + block.extend_from_slice(&0u32.to_le_bytes()); // accessed + let lno_pos = block.len(); + // Long-name offset points far past the end of the buffer; abs saturates + // and utf16_z returns "". + block.extend_from_slice(&0xFFFFu16.to_le_bytes()); + block.extend_from_slice(&[0u8, 0u8]); // unknown + block.extend_from_slice(&0u64.to_le_bytes()[..6]); // MFT entry + block.extend_from_slice(&0u16.to_le_bytes()); // MFT seq + block.extend_from_slice(&0u16.to_le_bytes()); // first ext offset + let bs = block.len() as u16; + block[0..2].copy_from_slice(&bs.to_le_bytes()); + // (lno_pos already holds 0xFFFF; leave it) + let _ = lno_pos; + body.extend_from_slice(&block); + let cb = (2 + body.len()) as u16; + let mut item = cb.to_le_bytes().to_vec(); + item.extend_from_slice(&body); + + let items = parse_idlist(&list_one(item)); + assert_eq!(items[0].name.as_deref(), Some("TRUNC~1.TXT")); + assert!(items[0].long_name.is_none()); + } + #[test] fn directory_class_0x31_is_file_entry() { let mut body = Vec::new(); diff --git a/src/reader.rs b/src/reader.rs index da29acb..c4c0345 100644 --- a/src/reader.rs +++ b/src/reader.rs @@ -63,7 +63,7 @@ pub(crate) fn ascii_z(data: &[u8], off: usize) -> String { #[must_use] pub(crate) fn utf16_z(data: &[u8], off: usize) -> String { let Some(slice) = data.get(off..) else { - return String::new(); // cov:unreachable: off is always derived from within-bounds field positions + return String::new(); }; let mut units = Vec::new(); let mut i = 0; @@ -91,3 +91,22 @@ pub(crate) fn guid(data: &[u8], off: usize) -> Option { g[3], g[2], g[1], g[0], g[5], g[4], g[7], g[6], g[8], g[9], g[10], g[11], g[12], g[13], g[14], g[15], )) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn utf16_z_offset_past_end_returns_empty() { + // `off` beyond the buffer must degrade to an empty string, never panic — + // the Paranoid Gatekeeper contract for attacker-controlled offsets. + assert_eq!(utf16_z(&[0x41, 0x00], 8), ""); + } + + #[test] + fn utf16_z_decodes_until_nul_and_ignores_trailing_bytes() { + // "Hi" then a UTF-16 NUL; trailing bytes after the NUL are ignored. + let data = [0x48, 0x00, 0x69, 0x00, 0x00, 0x00, 0x5A, 0x00]; + assert_eq!(utf16_z(&data, 0), "Hi"); + } +}