Skip to content
Merged
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
120 changes: 120 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
44 changes: 41 additions & 3 deletions src/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
21 changes: 20 additions & 1 deletion src/reader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -91,3 +91,22 @@ pub(crate) fn guid(data: &[u8], off: usize) -> Option<String> {
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");
}
}
Loading