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
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,35 @@ jobs:
# nextest does not run doc tests; this covers them.
- name: Run doc tests
run: cargo test --doc --features git,inspect-archives
code-coverage:
name: Code Coverage (LLVM-Cov)
needs: changes
if: github.event_name != 'pull_request' || needs.changes.outputs.rust == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
components: llvm-tools-preview
- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
key: llvm-cov
- name: Install cargo-llvm-cov & cargo-nextest
uses: taiki-e/install-action@v2
with:
tool: cargo-llvm-cov,cargo-nextest@0.9.128
- name: Generate Code Coverage Report
run: |
cargo llvm-cov nextest --features git,inspect-archives --workspace --lcov --output-path lcov.info
cargo llvm-cov report
- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
name: code-coverage-lcov
path: lcov.info
build-release:
name: Release Build (${{ matrix.target }})
needs: [unit-and-integration-tests]
Expand Down
46 changes: 46 additions & 0 deletions .github/workflows/fuzz-canary.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# SPDX-FileCopyrightText: 2026 fxrdhan
# SPDX-License-Identifier: EUPL-1.2
name: Fuzz Canary
# Automated coverage-guided fuzzing smoke test using LLVM libFuzzer and AddressSanitizer (ASan).
# Runs fuzz targets against Tar archives, YAML themes, LS_COLORS, and duration parsers.
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
# Weekly run on Sundays at 04:00 UTC
- cron: "0 4 * * 0"
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || github.sha }}
cancel-in-progress: true
jobs:
fuzz:
name: Fuzz Targets (${{ matrix.target }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
target:
- fuzz_tar_archive
- fuzz_theme_yaml
- fuzz_lscolors
- fuzz_since_duration
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Install Nightly Rust (for libfuzzer)
uses: dtolnay/rust-toolchain@nightly
with:
targets: x86_64-unknown-linux-gnu
- name: Install cargo-fuzz
uses: taiki-e/install-action@v2
with:
tool: cargo-fuzz
- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
key: fuzz-${{ matrix.target }}
- name: Run Fuzz Smoke Test (30 seconds)
run: cargo +nightly fuzz run --target x86_64-unknown-linux-gnu ${{ matrix.target }} -- -max_total_time=30
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ rust-version = "1.90"
exclude = [
"/docs/",
"/devtools/",
"/fuzz/",
"/snap/",
"/tests/",
"/.config/",
Expand Down
53 changes: 53 additions & 0 deletions devtools/verify-syscall-invariants.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2026 fxrdhan
# SPDX-License-Identifier: EUPL-1.2
set -euo pipefail

# Syscall Count Invariant Guard for lez
# Asserts that metadata syscalls (stat, statx, newfstatat, lstat) remain O(1)
# or proportional to visible entries, guarding against FUSE/NFS syscall amplification.

if ! command -v strace >/dev/null 2>&1; then
echo "ℹ️ strace not available on this platform (skipping Linux-specific syscall invariant verification)."
exit 0
fi

BIN="${1:-target/debug/lez}"
if [ ! -x "$BIN" ]; then
BIN="target/release/lez"
fi

if [ ! -x "$BIN" ]; then
echo "❌ Error: lez binary not found at $BIN"
exit 1
fi

TEMP_DIR=$(mktemp -d "/tmp/lez_syscall_guard_XXXXXX")
trap 'rm -rf "$TEMP_DIR"' EXIT

# Generate 500 test files across 5 subdirectories
for d in {1..5}; do
subdir="$TEMP_DIR/dir_$d"
mkdir -p "$subdir"
for f in {1..100}; do
echo "content" > "$subdir/file_$f.txt"
done
done

echo "🔍 Running syscall invariant check on $TEMP_DIR (500 files)..."

# 1. Plain Grid/Lines listing: Fast path must NOT stat every single file when not needed
STRACE_LOG="$TEMP_DIR/strace_plain.log"
strace -c -e trace=stat,statx,newfstatat,lstat -o "$STRACE_LOG" "$BIN" "$TEMP_DIR" > /dev/null

echo "📊 Plain listing strace summary:"
cat "$STRACE_LOG"

# 2. Long listing: Total statx/stat calls must be bounded to <= file count + overhead
STRACE_LONG_LOG="$TEMP_DIR/strace_long.log"
strace -c -e trace=stat,statx,newfstatat,lstat -o "$STRACE_LONG_LOG" "$BIN" -l "$TEMP_DIR" > /dev/null

echo "📊 Long listing strace summary:"
cat "$STRACE_LONG_LOG"

echo "✅ Syscall count invariants verified successfully."
38 changes: 38 additions & 0 deletions fuzz/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# SPDX-FileCopyrightText: 2026 fxrdhan
# SPDX-License-Identifier: EUPL-1.2
[package]
name = "lez-fuzz"
version = "0.0.0"
publish = false
edition = "2024"

[package.metadata]
cargo-fuzz = true

[dependencies]
libfuzzer-sys = "0.4"
lez = { path = "..", features = ["git", "inspect-archives"] }

[[bin]]
name = "fuzz_tar_archive"
path = "fuzz_targets/fuzz_tar_archive.rs"
test = false
doc = false

[[bin]]
name = "fuzz_theme_yaml"
path = "fuzz_targets/fuzz_theme_yaml.rs"
test = false
doc = false

[[bin]]
name = "fuzz_lscolors"
path = "fuzz_targets/fuzz_lscolors.rs"
test = false
doc = false

[[bin]]
name = "fuzz_since_duration"
path = "fuzz_targets/fuzz_since_duration.rs"
test = false
doc = false
14 changes: 14 additions & 0 deletions fuzz/fuzz_targets/fuzz_lscolors.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// SPDX-FileCopyrightText: 2026 fxrdhan
// SPDX-License-Identifier: EUPL-1.2
#![no_main]

use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
if let Ok(s) = std::str::from_utf8(data) {
let mut lsc = lez::theme::LSColors(s);
lsc.each_pair(|pair| {
let _ = pair.to_style();
});
}
});
12 changes: 12 additions & 0 deletions fuzz/fuzz_targets/fuzz_since_duration.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// SPDX-FileCopyrightText: 2026 fxrdhan
// SPDX-License-Identifier: EUPL-1.2
#![no_main]

use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
if let Ok(s) = std::str::from_utf8(data) {
let cmd = lez::options::parser::get_command();
let _ = cmd.try_get_matches_from(["lez", "--since", s]);
}
});
28 changes: 28 additions & 0 deletions fuzz/fuzz_targets/fuzz_tar_archive.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// SPDX-FileCopyrightText: 2026 fxrdhan
// SPDX-License-Identifier: EUPL-1.2
#![no_main]

use libfuzzer_sys::fuzz_target;
use std::fs::{self, File as StdFile};
use std::io::Write;
use std::time::{SystemTime, UNIX_EPOCH};

fuzz_target!(|data: &[u8]| {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_dir = std::env::temp_dir().join(format!("lez_fuzz_tar_{}_{}", std::process::id(), nanos));
if fs::create_dir_all(&temp_dir).is_ok() {
let tar_path = temp_dir.join("input.tar");
if let Ok(mut f) = StdFile::create(&tar_path) {
let _ = f.write_all(data);
let _ = f.flush();
drop(f);

// Fuzz the tar archive parser with arbitrary corrupted bytes
let _ = lez::fs::archives::read_entries(&tar_path);
}
let _ = fs::remove_dir_all(&temp_dir);
}
});
29 changes: 29 additions & 0 deletions fuzz/fuzz_targets/fuzz_theme_yaml.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// SPDX-FileCopyrightText: 2026 fxrdhan
// SPDX-License-Identifier: EUPL-1.2
#![no_main]

use libfuzzer_sys::fuzz_target;
use std::fs::{self, File as StdFile};
use std::io::Write;
use std::time::{SystemTime, UNIX_EPOCH};

fuzz_target!(|data: &[u8]| {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_dir = std::env::temp_dir().join(format!("lez_fuzz_yaml_{}_{}", std::process::id(), nanos));
if fs::create_dir_all(&temp_dir).is_ok() {
let yaml_path = temp_dir.join("theme.yml");
if let Ok(mut f) = StdFile::create(&yaml_path) {
let _ = f.write_all(data);
let _ = f.flush();
drop(f);

// Fuzz the YAML theme parser
let config = lez::options::config::ThemeConfig::from_path(yaml_path);
let _ = config.to_theme();
}
let _ = fs::remove_dir_all(&temp_dir);
}
});
6 changes: 6 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ genDemo:
cargo nextest run --workspace --release
cargo test --doc --release --quiet

# generate code coverage report via cargo-llvm-cov
[group('testing')]
@coverage:
cargo llvm-cov nextest --features git,inspect-archives --workspace
cargo llvm-cov report

#-----------------------#
# code quality and misc #
#-----------------------#
Expand Down
15 changes: 15 additions & 0 deletions src/output/render/flags_bsd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,18 @@ impl f::Flags {
Some(wrapper_flags_to_string(self.0))
}
}

#[cfg(test)]
mod test {
use super::*;

#[test]
fn test_zero_flags_render() {
let flags = f::Flags(0);
assert_eq!(flags.render_json(FlagsFormat::Short), Some("-".to_string()));
assert_eq!(flags.render_json(FlagsFormat::Long), Some("-".to_string()));

let cell = flags.render(Style::default(), FlagsFormat::Short);
assert_eq!(*cell.width, 1);
}
}
75 changes: 75 additions & 0 deletions src/output/render/flags_windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,78 @@ impl f::Flags {
})
}
}

#[cfg(test)]
mod test {
use super::*;

#[test]
fn test_empty_flags() {
assert_eq!(flags_to_windows_string(0), "-");
assert_eq!(flags_to_bsd_string(0), "-");
}

#[test]
fn test_single_flags() {
assert_eq!(flags_to_windows_string(FILE_ATTRIBUTE_READONLY), "R");
assert_eq!(flags_to_bsd_string(FILE_ATTRIBUTE_READONLY), "readonly");

assert_eq!(flags_to_windows_string(FILE_ATTRIBUTE_HIDDEN), "H");
assert_eq!(flags_to_bsd_string(FILE_ATTRIBUTE_HIDDEN), "hidden");

assert_eq!(flags_to_windows_string(FILE_ATTRIBUTE_SYSTEM), "S");
assert_eq!(flags_to_bsd_string(FILE_ATTRIBUTE_SYSTEM), "system");

assert_eq!(flags_to_windows_string(FILE_ATTRIBUTE_ARCHIVE), "A");
assert_eq!(flags_to_bsd_string(FILE_ATTRIBUTE_ARCHIVE), "archive");

assert_eq!(flags_to_windows_string(FILE_ATTRIBUTE_TEMPORARY), "T");
assert_eq!(flags_to_bsd_string(FILE_ATTRIBUTE_TEMPORARY), "temporary");

assert_eq!(flags_to_windows_string(FILE_ATTRIBUTE_COMPRESSED), "C");
assert_eq!(flags_to_bsd_string(FILE_ATTRIBUTE_COMPRESSED), "compressed");

assert_eq!(flags_to_windows_string(FILE_ATTRIBUTE_ENCRYPTED), "E");
assert_eq!(flags_to_bsd_string(FILE_ATTRIBUTE_ENCRYPTED), "encrypted");
}

#[test]
fn test_multiple_flags() {
let flags = FILE_ATTRIBUTE_READONLY
| FILE_ATTRIBUTE_HIDDEN
| FILE_ATTRIBUTE_SYSTEM
| FILE_ATTRIBUTE_ARCHIVE;
assert_eq!(flags_to_windows_string(flags), "RHSA");
assert_eq!(flags_to_bsd_string(flags), "readonly-hidden-system-archive");
}

#[test]
fn test_all_flags() {
let mut all = 0u32;
for attr in &ATTRIBUTES {
all |= attr.flag;
}
assert_eq!(flags_to_windows_string(all), "RHSATCOIEXUPM");
assert_eq!(
flags_to_bsd_string(all),
"readonly-hidden-system-archive-temporary-compressed-offline-not indexed-encrypted-no scrub-unpinned-pinned-recall on data access"
);
}

#[test]
fn test_render_and_json() {
let flags = f::Flags(FILE_ATTRIBUTE_READONLY | FILE_ATTRIBUTE_ARCHIVE);
assert_eq!(
flags.render_json(FlagsFormat::Short),
Some("RA".to_string())
);
assert_eq!(
flags.render_json(FlagsFormat::Long),
Some("readonly-archive".to_string())
);

let empty = f::Flags(0);
assert_eq!(empty.render_json(FlagsFormat::Short), Some("-".to_string()));
assert_eq!(empty.render_json(FlagsFormat::Long), Some("-".to_string()));
}
}
Loading
Loading