diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..43b4494 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,120 @@ +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 (100% production lines) + runs-on: ubuntu-latest + 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 + - 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) + 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 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"); + } +}