diff --git a/tests/adversarial/archive_fuzz_stress.rs b/tests/adversarial/archive_fuzz_stress.rs index 57e4600c..59fdd733 100644 --- a/tests/adversarial/archive_fuzz_stress.rs +++ b/tests/adversarial/archive_fuzz_stress.rs @@ -306,3 +306,81 @@ fn test_cli_end_to_end_fuzz_corpus_execution() { assert!(t_ok, "lez -T -l --inspect-archives failed: {t_err}"); assert!(t_out.contains("valid.tar")); } + +#[test] +fn test_concatenated_and_trailing_garbage_tar_archives() { + let fixture = ArchiveFuzzDir::new("concat_tar"); + + // Build first tar with file1 + let mut buf1 = Vec::new(); + { + let mut builder = tar::Builder::new(&mut buf1); + let mut h = tar::Header::new_gnu(); + h.set_size(5); + h.set_mode(0o644); + h.set_cksum(); + builder + .append_data(&mut h, "part1.txt", &b"first"[..]) + .unwrap(); + builder.into_inner().unwrap(); + } + + // Build second tar with file2 + let mut buf2 = Vec::new(); + { + let mut builder = tar::Builder::new(&mut buf2); + let mut h = tar::Header::new_gnu(); + h.set_size(6); + h.set_mode(0o644); + h.set_cksum(); + builder + .append_data(&mut h, "part2.txt", &b"second"[..]) + .unwrap(); + builder.into_inner().unwrap(); + } + + // Concatenate both buffers plus trailing random garbage + let mut concat = buf1; + concat.extend_from_slice(&buf2); + concat.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11, 0x22, 0x33]); + + let p = fixture.create_raw_file("concatenated.tar", &concat); + let entries = archives::read_entries(&p).expect("read_entries must not panic"); + assert!( + !entries.is_empty(), + "Must read at least entries from the first tar block" + ); + assert!(entries.iter().any(|e| e.path == "part1.txt")); +} + +#[test] +fn test_truncated_tar_blocks_in_middle_of_file() { + let fixture = ArchiveFuzzDir::new("mid_trunc"); + + let mut buf = Vec::new(); + { + let mut builder = tar::Builder::new(&mut buf); + for i in 0..5 { + let mut h = tar::Header::new_gnu(); + h.set_size(100); + h.set_mode(0o644); + h.set_cksum(); + builder + .append_data(&mut h, format!("entry_{i}.dat"), &vec![b'X'; 100][..]) + .unwrap(); + } + builder.into_inner().unwrap(); + } + + // Truncate halfway through the buffer (cutting an entry body or header in half) + let half_len = buf.len() / 2; + let truncated_buf = &buf[..half_len]; + + let p = fixture.create_raw_file("half_truncated.tar", truncated_buf); + let entries = archives::read_entries(&p).expect("read_entries on truncated archive"); + // Should return whatever entries were completely parsed before truncation + assert!( + !entries.is_empty(), + "Should return partially parsed valid entries" + ); +} diff --git a/tests/adversarial/io_error_isolation.rs b/tests/adversarial/io_error_isolation.rs new file mode 100644 index 00000000..2b740622 --- /dev/null +++ b/tests/adversarial/io_error_isolation.rs @@ -0,0 +1,248 @@ +// SPDX-FileCopyrightText: 2026 fxrdhan +// SPDX-License-Identifier: EUPL-1.2 + +//! Adversarial test suite for filesystem I/O error isolation, partial failure +//! resilience, and graceful error handling across different views: +//! - Unreadable directories (`EACCES` / `000` permissions) +//! - Unreadable individual files in large directories +//! - Tree traversal (`-T`) resilience when subtree branches are unreadable +//! - Long view (`-l`) metadata degradation when `stat` on an entry fails +//! - JSON view (`--json`) syntax validity when entries encounter errors +//! - LOC engine (`--code`) resilience when some files cannot be opened +//! - Clean exit code propagation (`1` or `13`) with partial stdout output + +use std::fs::{self, File as StdFile}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +struct IoErrorFixture { + path: PathBuf, +} + +impl IoErrorFixture { + fn new(prefix: &str) -> Self { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "lez_ioerr_{prefix}_{}_{}", + std::process::id(), + nanos + )); + let _ = fs::remove_dir_all(&path); + fs::create_dir_all(&path).expect("Failed to create temp io error test directory"); + Self { path } + } + + fn create_file(&self, rel: &str, content: &[u8]) -> PathBuf { + let p = self.path.join(rel); + if let Some(parent) = p.parent() { + fs::create_dir_all(parent).unwrap(); + } + let mut f = StdFile::create(&p).unwrap(); + f.write_all(content).unwrap(); + p + } + + fn create_dir(&self, rel: &str) -> PathBuf { + let p = self.path.join(rel); + fs::create_dir_all(&p).unwrap(); + p + } + + #[cfg(unix)] + fn make_unreadable(&self, rel: &str) { + let p = self.path.join(rel); + let mut perms = fs::metadata(&p).unwrap().permissions(); + perms.set_mode(0o000); + fs::set_permissions(&p, perms).unwrap(); + } + + #[cfg(unix)] + fn restore_permissions(&self, rel: &str) { + let p = self.path.join(rel); + if let Ok(metadata) = fs::metadata(&p) { + let mut perms = metadata.permissions(); + perms.set_mode(0o755); + let _ = fs::set_permissions(&p, perms); + } + } +} + +impl Drop for IoErrorFixture { + fn drop(&mut self) { + #[cfg(unix)] + { + // Restore permissions on all children before recursive delete + if let Ok(entries) = fs::read_dir(&self.path) { + for entry in entries.flatten() { + if let Ok(metadata) = entry.metadata() { + let mut perms = metadata.permissions(); + perms.set_mode(0o755); + let _ = fs::set_permissions(entry.path(), perms); + } + } + } + } + let _ = fs::remove_dir_all(&self.path); + } +} + +fn bin_path() -> &'static str { + env!("CARGO_BIN_EXE_lez") +} + +fn run_lez(dir: &Path, args: &[&str]) -> (i32, String, String) { + let output = Command::new(bin_path()) + .current_dir(dir) + .args(args) + .env("NO_COLOR", "1") + .env("LEZ_COLORS", "reset") + .output() + .expect("Failed to execute lez binary"); + + let code = output.status.code().unwrap_or(-1); + ( + code, + String::from_utf8_lossy(&output.stdout).to_string(), + String::from_utf8_lossy(&output.stderr).to_string(), + ) +} + +#[test] +#[cfg(unix)] +fn test_unreadable_directory_in_grid_view_isolated_failure() { + let fixture = IoErrorFixture::new("unreadable_dir_grid"); + + // Populate accessible files and an unreadable subdirectory + fixture.create_file("alpha.txt", b"readable content 1"); + fixture.create_file("beta.txt", b"readable content 2"); + fixture.create_dir("locked_folder"); + fixture.create_file("locked_folder/secret.dat", b"secret"); + fixture.make_unreadable("locked_folder"); + + // Default grid listing of parent directory should list all items + let (code, stdout, _stderr) = run_lez(&fixture.path, &["--color=never"]); + + assert_eq!(code, 0, "Listing parent directory should succeed"); + assert!(stdout.contains("alpha.txt"), "Should list alpha.txt"); + assert!(stdout.contains("beta.txt"), "Should list beta.txt"); + assert!( + stdout.contains("locked_folder"), + "Should list locked_folder entry name" + ); + + // Direct listing of the locked folder itself must fail gracefully with error + let (locked_code, _locked_stdout, locked_stderr) = + run_lez(&fixture.path, &["locked_folder", "--color=never"]); + + assert!( + locked_code != 0, + "Accessing locked folder directly must return non-zero exit code" + ); + assert!( + !locked_stderr.is_empty(), + "Must emit error message to stderr" + ); + + fixture.restore_permissions("locked_folder"); +} + +#[test] +#[cfg(unix)] +fn test_unreadable_subdirectory_in_tree_view_continues_sibling_traversal() { + let fixture = IoErrorFixture::new("unreadable_dir_tree"); + + fixture.create_dir("accessible_1"); + fixture.create_file("accessible_1/file1.txt", b"content 1"); + + fixture.create_dir("locked_branch"); + fixture.create_file("locked_branch/hidden.txt", b"hidden"); + + fixture.create_dir("accessible_2"); + fixture.create_file("accessible_2/file2.txt", b"content 2"); + + fixture.make_unreadable("locked_branch"); + + let (_code, stdout, _stderr) = run_lez(&fixture.path, &["-T", "--color=never"]); + + // Stdout must STILL contain the valid branches and locked entry + assert!( + stdout.contains("accessible_1"), + "Stdout should contain accessible_1" + ); + assert!( + stdout.contains("file1.txt"), + "Stdout should contain file1.txt under accessible_1" + ); + assert!( + stdout.contains("accessible_2"), + "Stdout should contain accessible_2" + ); + assert!( + stdout.contains("file2.txt"), + "Stdout should contain file2.txt under accessible_2" + ); + assert!( + stdout.contains("locked_branch"), + "Stdout should contain locked_branch leaf" + ); + + fixture.restore_permissions("locked_branch"); +} + +#[test] +#[cfg(unix)] +fn test_unreadable_file_in_long_and_json_view() { + let fixture = IoErrorFixture::new("unreadable_file_views"); + + fixture.create_file("normal.txt", b"normal data"); + fixture.create_file("unreadable.bin", b"cannot read content"); + fixture.make_unreadable("unreadable.bin"); + + // 1. Long view + let (code_l, stdout_l, _stderr_l) = run_lez(&fixture.path, &["-l", "--color=never"]); + assert_eq!(code_l, 0, "Long view of directory metadata should succeed"); + assert!(stdout_l.contains("normal.txt")); + assert!(stdout_l.contains("unreadable.bin")); + + // 2. JSON view + let (code_j, stdout_j, _stderr_j) = run_lez(&fixture.path, &["--json", "-l", "--color=never"]); + assert_eq!(code_j, 0, "JSON view should succeed"); + let parsed: Result = serde_json::from_str(&stdout_j); + assert!( + parsed.is_ok(), + "JSON output must be strictly valid JSON: {stdout_j}" + ); + + fixture.restore_permissions("unreadable.bin"); +} + +#[test] +#[cfg(unix)] +fn test_unreadable_files_in_loc_engine_view() { + let fixture = IoErrorFixture::new("unreadable_loc"); + + fixture.create_file( + "valid.rs", + b"fn main() {\n println!(\"Hello World\");\n}\n", + ); + fixture.create_file("unreadable.rs", b"fn secret() {\n // secret code\n}\n"); + fixture.make_unreadable("unreadable.rs"); + + let (_code, stdout, _stderr) = run_lez(&fixture.path, &["--code", "--color=never"]); + + // Valid file LOC should still be counted and displayed + assert!( + stdout.contains("Rust") || stdout.contains("valid.rs") || stdout.contains("Total"), + "LOC engine must count readable files: {stdout}" + ); + + fixture.restore_permissions("unreadable.rs"); +} diff --git a/tests/adversarial/signal_cleanup.rs b/tests/adversarial/signal_cleanup.rs new file mode 100644 index 00000000..f6d44d1c --- /dev/null +++ b/tests/adversarial/signal_cleanup.rs @@ -0,0 +1,163 @@ +// SPDX-FileCopyrightText: 2026 fxrdhan +// SPDX-License-Identifier: EUPL-1.2 + +//! Adversarial test suite for process signal handling, graceful termination, +//! and resource cleanup: +//! - SIGINT (Ctrl+C) delivery during active directory traversal +//! - SIGTERM (terminate) process teardown +//! - Process group cleanup and immediate termination without hanging +//! - Terminal state and cursor preservation invariants + +#![cfg(unix)] +#![allow(clippy::zombie_processes)] + +use std::fs::{self, File as StdFile}; +use std::io::Write; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +struct SignalTestDir { + path: PathBuf, +} + +impl SignalTestDir { + fn new(prefix: &str) -> Self { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = + std::env::temp_dir().join(format!("lez_sig_{prefix}_{}_{}", std::process::id(), nanos)); + let _ = fs::remove_dir_all(&path); + fs::create_dir_all(&path).expect("Failed to create temp signal test directory"); + Self { path } + } + + fn populate_deep_tree(&self, depth: usize, breadth: usize) { + fn recurse(dir: &std::path::Path, current_depth: usize, max_depth: usize, breadth: usize) { + if current_depth >= max_depth { + return; + } + for b in 0..breadth { + let sub = dir.join(format!("dir_{current_depth}_{b}")); + let _ = fs::create_dir_all(&sub); + for f in 0..5 { + let file_p = sub.join(format!("file_{f}.txt")); + let mut file = StdFile::create(file_p).unwrap(); + let _ = file.write_all(b"sample text payload for signal testing\n"); + } + recurse(&sub, current_depth + 1, max_depth, breadth); + } + } + + recurse(&self.path, 0, depth, breadth); + } +} + +impl Drop for SignalTestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +fn bin_path() -> &'static str { + env!("CARGO_BIN_EXE_lez") +} + +#[test] +fn test_sigint_interruption_during_tree_traversal() { + let fixture = SignalTestDir::new("sigint_tree"); + // Generate a deep tree to give lez work to do + fixture.populate_deep_tree(4, 5); + + let mut child = Command::new(bin_path()) + .current_dir(&fixture.path) + .args(["-T", "-l", "--total-size", "--color=never"]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("Failed to spawn lez child"); + + let pid = child.id() as i32; + + // Allow process to start running + std::thread::sleep(Duration::from_millis(15)); + + // Send SIGINT (Ctrl+C signal) via libc::kill + unsafe { + libc::kill(pid, libc::SIGINT); + } + + let start = Instant::now(); + let mut exit_status = None; + + // Ensure child exits within a strict 3-second window + while start.elapsed() < Duration::from_secs(3) { + if let Ok(Some(status)) = child.try_wait() { + exit_status = Some(status); + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + + let status = match exit_status { + Some(s) => s, + None => { + let _ = child.kill(); + child.wait().expect("Failed to wait on killed child") + } + }; + + assert!( + status.code().is_some() || status.to_string().contains("signal"), + "Process must terminate cleanly on SIGINT" + ); +} + +#[test] +fn test_sigterm_graceful_process_teardown() { + let fixture = SignalTestDir::new("sigterm_scan"); + fixture.populate_deep_tree(4, 4); + + let mut child = Command::new(bin_path()) + .current_dir(&fixture.path) + .args(["-R", "--color=never"]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("Failed to spawn lez child"); + + let pid = child.id() as i32; + + std::thread::sleep(Duration::from_millis(15)); + + // Send SIGTERM + unsafe { + libc::kill(pid, libc::SIGTERM); + } + + let start = Instant::now(); + let mut exit_status = None; + + while start.elapsed() < Duration::from_secs(3) { + if let Ok(Some(status)) = child.try_wait() { + exit_status = Some(status); + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + + let status = match exit_status { + Some(s) => s, + None => { + let _ = child.kill(); + child.wait().expect("Failed to wait on killed child") + } + }; + + assert!( + status.code().is_some() || status.to_string().contains("signal"), + "Process must terminate cleanly on SIGTERM" + ); +} diff --git a/tests/adversarial_tests.rs b/tests/adversarial_tests.rs index de113c53..c0b0d3bd 100644 --- a/tests/adversarial_tests.rs +++ b/tests/adversarial_tests.rs @@ -25,6 +25,8 @@ mod fd_exhaustion; mod filesystem_types_stress; #[path = "adversarial/grid_width_and_odin.rs"] mod grid_width_and_odin; +#[path = "adversarial/io_error_isolation.rs"] +mod io_error_isolation; #[path = "adversarial/janet_loc_basics.rs"] mod janet_loc_basics; #[path = "adversarial/janet_loc_stress.rs"] @@ -41,6 +43,8 @@ mod nested_git_and_time_env; mod property_fuzz_engine; #[path = "adversarial/raw_bytes_paths.rs"] mod raw_bytes_paths; +#[path = "adversarial/signal_cleanup.rs"] +mod signal_cleanup; #[path = "adversarial/since_duration_stress.rs"] mod since_duration_stress; #[path = "adversarial/smart_group_basics.rs"] diff --git a/tests/loc_engine/syntax_edge_cases.rs b/tests/loc_engine/syntax_edge_cases.rs index b31713c7..8de84a05 100644 --- a/tests/loc_engine/syntax_edge_cases.rs +++ b/tests/loc_engine/syntax_edge_cases.rs @@ -142,3 +142,91 @@ end Main; ); assert!(ada_counts.comments >= 1); } + +#[test] +fn test_javascript_typescript_template_literals_and_comments() { + let js = loc::language_for("app.ts", Some("ts")).expect("TypeScript language"); + let js_source = r##" +// Top-level line comment +import { useState } from 'react'; + +export function Component() { + /* Multi-line + block comment */ + const template = `Hello ${/* nested comment in template */ (() => "world")()}`; + const escaped = `\`not a comment\` // inside template`; + return
{template}
; +} +"##; + let counts = LocCounts::from_source(js_source, js); + assert_eq!( + counts.code + counts.comments + counts.blanks, + counts.lines, + "Mathematical invariant code + comments + blanks == lines must hold" + ); + assert!(counts.comments >= 3, "Must count comment lines"); + assert!(counts.code >= 4, "Must count code lines"); +} + +#[test] +fn test_ruby_and_perl_heredocs_and_comments() { + let rb = loc::language_for("script.rb", Some("rb")).expect("Ruby language"); + let rb_source = r##" +# Ruby header comment +def render_doc + heredoc = <<~HEREDOC + # This is text inside heredoc, not a Ruby comment + echo "hello" + HEREDOC + puts heredoc # trailing comment +end +"##; + let rb_counts = LocCounts::from_source(rb_source, rb); + assert_eq!( + rb_counts.code + rb_counts.comments + rb_counts.blanks, + rb_counts.lines + ); + assert!(rb_counts.comments >= 2); + assert!(rb_counts.code >= 5); + + let pl = loc::language_for("script.pl", Some("pl")).expect("Perl language"); + let pl_source = r##" +#!/usr/bin/perl +# Perl comment +my $text = <<'END'; +# Not a comment +END +print $text; +"##; + let pl_counts = LocCounts::from_source(pl_source, pl); + assert_eq!( + pl_counts.code + pl_counts.comments + pl_counts.blanks, + pl_counts.lines + ); + assert!(pl_counts.comments >= 2); +} + +#[test] +fn test_html_xml_markdown_comment_structures() { + let html = loc::language_for("index.html", Some("html")).expect("HTML language"); + let html_source = r##" + + + + + Test <!-- not a comment --> Page + + +

Hello

+ + +"##; + let html_counts = LocCounts::from_source(html_source, html); + assert_eq!( + html_counts.code + html_counts.comments + html_counts.blanks, + html_counts.lines + ); + assert!(html_counts.comments >= 2); + assert!(html_counts.code >= 8); +}