diff --git a/Cargo.lock b/Cargo.lock index 2f69db5..2bd2de8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,15 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "bitflags" version = "2.9.4" @@ -29,6 +38,17 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "errno" version = "0.3.14" @@ -185,6 +205,7 @@ name = "testscript-rs" version = "0.1.0" dependencies = [ "anyhow", + "arbitrary", "regex", "tempfile", "thiserror", diff --git a/Cargo.toml b/Cargo.toml index 5a607e6..0dcb52c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,3 +15,6 @@ thiserror = "2.0" tempfile = "3.0" walkdir = "2.0" regex = "1.0" + +[dev-dependencies] +arbitrary = { version = "1.0", features = ["derive"] } diff --git a/FUZZ.md b/FUZZ.md new file mode 100644 index 0000000..ea173fd --- /dev/null +++ b/FUZZ.md @@ -0,0 +1,68 @@ +# Running Fuzz Tests + +This document describes how to run fuzz tests for testscript-rs to ensure parser resilience. + +## Quick Start + +```bash +# Install fuzzing tools +cargo install cargo-fuzz + +# Switch to nightly Rust (required for fuzzing) +rustup default nightly + +# Run parser fuzzing for 30 seconds +timeout 30 cargo fuzz run parser + +# Run all fuzz targets with time limit +for target in parser tokens env_substitution structured; do + echo "Fuzzing $target..." + timeout 60 cargo fuzz run "$target" -- -timeout=10 -runs=10000 +done +``` + +## What Gets Tested + +The fuzz tests target potential security and stability issues: + +### Security Concerns +- **Path traversal**: `../../etc/passwd` in filenames +- **Command injection**: Malicious command patterns +- **Memory safety**: No crashes, infinite loops, excessive memory usage +- **Input validation**: Proper error handling for malformed input + +### Parser Resilience +- **Never panics**: All inputs should return proper Result types +- **Deterministic**: Same input produces same output +- **UTF-8 handling**: Graceful degradation for invalid UTF-8 +- **Edge cases**: Empty files, malformed headers, unclosed quotes + +## Fuzz Targets + +| Target | Purpose | +|--------|---------| +| `parser` | Main parser with random input | +| `tokens` | Command token parsing edge cases | +| `env_substitution` | Environment variable handling | +| `structured` | Well-formed but boundary-case inputs | + +## Example Output + +```bash +$ cargo fuzz run parser -- -runs=1000 +INFO: Loaded 1 modules (1968 inline 8-bit counters) +INFO: -max_len is not provided; libFuzzer will not generate inputs larger than 4096 bytes +#1000 DONE cov: 249 ft: 413 corp: 23/70b lim: 4 exec/s: 0 rss: 44Mb +Done 1000 runs in 1 second(s) +``` + +No crashes = success! 🎉 + +## Switch Back to Stable + +```bash +# Return to stable Rust for normal development +rustup default stable +``` + +See `fuzz/README.md` for detailed documentation. \ No newline at end of file diff --git a/fuzz/.gitignore b/fuzz/.gitignore new file mode 100644 index 0000000..1a45eee --- /dev/null +++ b/fuzz/.gitignore @@ -0,0 +1,4 @@ +target +corpus +artifacts +coverage diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock new file mode 100644 index 0000000..9f2425a --- /dev/null +++ b/fuzz/Cargo.lock @@ -0,0 +1,349 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "bitflags" +version = "2.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" + +[[package]] +name = "cc" +version = "1.2.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1354349954c6fc9cb0deab020f27f783cf0b604e8bb754dc4658ecf0d29c35f" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ced73b1dacfc750a6db6c0a0c3a3853c8b41997e2e2c563dc90804ae6867959" + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.176" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "proc-macro2" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "regex" +version = "1.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "syn" +version = "2.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "testscript-rs" +version = "0.1.0" +dependencies = [ + "anyhow", + "regex", + "tempfile", + "thiserror", + "walkdir", +] + +[[package]] +name = "testscript-rs-fuzz" +version = "0.0.0" +dependencies = [ + "arbitrary", + "libfuzzer-sys", + "testscript-rs", +] + +[[package]] +name = "thiserror" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" + +[[package]] +name = "windows-sys" +version = "0.61.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f109e41dd4a3c848907eb83d5a42ea98b3769495597450cf6d153507b166f0f" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..f65d484 --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "testscript-rs-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +arbitrary = { version = "1.0", features = ["derive"] } + +[dependencies.testscript-rs] +path = ".." + +[[bin]] +name = "parser" +path = "fuzz_targets/parser.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "tokens" +path = "fuzz_targets/tokens.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "env_substitution" +path = "fuzz_targets/env_substitution.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "structured" +path = "fuzz_targets/structured.rs" +test = false +doc = false +bench = false diff --git a/fuzz/README.md b/fuzz/README.md new file mode 100644 index 0000000..c2bba4c --- /dev/null +++ b/fuzz/README.md @@ -0,0 +1,139 @@ +# Fuzz Testing for testscript-rs + +This directory contains fuzz tests for ensuring the parser in testscript-rs is resilient to malformed, malicious, or unexpected input. + +## Setup + +1. Install cargo-fuzz and switch to nightly Rust: +```bash +cargo install cargo-fuzz +rustup default nightly +``` + +2. Build all fuzz targets: +```bash +cargo fuzz build +``` + +## Fuzz Targets + +### `parser` +Tests the main `parser::parse()` function with completely random input. + +**Focus**: +- Parser never panics on any input +- Always returns proper Result types +- Deterministic parsing (same input produces same result) +- Basic sanity checks on parsed output + +```bash +cargo fuzz run parser +``` + +### `tokens` +Tests command token parsing through the public parser interface. + +**Focus**: +- Command line token parsing with various quoting +- Condition parsing (`[unix]`, `[!windows]`) +- Negation and background process indicators + +```bash +cargo fuzz run tokens +``` + +### `env_substitution` +Tests environment variable substitution functionality. + +**Focus**: +- Variable substitution (`$VAR`, `${VAR}`) +- Edge cases with special characters +- Idempotent behavior +- Escape sequences (`$$`) + +```bash +cargo fuzz run env_substitution +``` + +### `structured` +Uses `arbitrary` crate to generate structured (but potentially malformed) txtar content. + +**Focus**: +- Well-formed but edge-case txtar structures +- Large inputs with many commands/files +- Boundary conditions + +```bash +cargo fuzz run structured +``` + +## Security Testing + +The fuzz tests specifically look for: + +- **Path traversal attempts**: `../../etc/passwd` in filenames +- **Command injection**: Malicious command patterns +- **Memory safety**: No crashes, infinite loops, or excessive memory usage +- **Input validation**: Proper error handling for malformed input + +## Running Tests + +### Quick Test +```bash +# Run for 30 seconds with timeout protection +timeout 30 cargo fuzz run parser -- -timeout=5 +``` + +### Continuous Fuzzing +```bash +# Run until manually stopped +cargo fuzz run parser + +# Run with specific options +cargo fuzz run parser -- -runs=10000 -max_len=8192 +``` + +### All Targets +```bash +for target in parser tokens env_substitution structured; do + echo "Testing $target..." + timeout 60 cargo fuzz run "$target" -- -timeout=10 -runs=1000 +done +``` + +## Corpus + +The `corpus/` directory contains seed inputs that help guide fuzzing: + +- `corpus/parser/`: Basic txtar structures and edge cases +- `corpus/tokens/`: Command parsing scenarios +- `corpus/env_substitution/`: Variable substitution patterns + +## Artifacts + +If crashes are found, they will be stored in `artifacts/` directory. Each crash can be reproduced with: + +```bash +cargo fuzz run parser artifacts/parser/crash- +``` + +## Success Criteria + +- ✅ Parser never panics on any input +- ✅ All errors use proper Error types (no unwrap/panic) +- ✅ No infinite loops or excessive memory usage +- ✅ No path traversal vulnerabilities in file creation +- ✅ Deterministic parsing behavior +- ✅ Proper UTF-8 handling (graceful degradation) + +## Integration with CI + +For CI environments, run bounded fuzzing: + +```bash +# Quick smoke test (suitable for CI) +cargo fuzz run parser -- -runs=1000 -max_total_time=30 + +# More thorough (nightly CI) +cargo fuzz run parser -- -runs=100000 -max_total_time=300 +``` \ No newline at end of file diff --git a/fuzz/fuzz_targets/env_substitution.rs b/fuzz/fuzz_targets/env_substitution.rs new file mode 100644 index 0000000..05939f1 --- /dev/null +++ b/fuzz/fuzz_targets/env_substitution.rs @@ -0,0 +1,59 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use testscript_rs::TestEnvironment; + +fuzz_target!(|data: &[u8]| { + // Convert bytes to string, handling invalid UTF-8 gracefully + let input = String::from_utf8_lossy(data); + + // Create a test environment to test environment variable substitution + if let Ok(mut env) = TestEnvironment::new() { + // Add some test environment variables with potentially problematic values + let test_vars = vec![ + ("WORK", "/tmp/work"), + ("HOME", "/home/user"), + ("PATH", "/usr/bin:/bin"), + ("SPECIAL", "${}()[].*^$\\"), + ("EMPTY", ""), + ("DOLLAR", "$"), + ("NESTED", "${OTHER}"), + ("OTHER", "value"), + ]; + + for (key, value) in test_vars { + env.env_vars.insert(key.to_string(), value.to_string()); + } + + // Test environment variable substitution + // This should never panic, regardless of input + let result = env.substitute_env_vars(&input); + + // Basic sanity check - result should be a valid string + let _len = result.len(); + + // Test with some edge cases + let edge_cases = vec![ + format!("${{{}}}", input), // ${input} + format!("${}", input), // $input + format!("{}${{WORK}}", input), // input${WORK} + format!("${{WORK}}{}", input), // ${WORK}input + format!("$${}$$", input), // $$input$$ + ]; + + for edge_case in edge_cases { + let _result = env.substitute_env_vars(&edge_case); + // Should not panic + } + + // Test that substitution is idempotent for non-recursive cases + let once = env.substitute_env_vars(&input); + let twice = env.substitute_env_vars(&once); + + // For most inputs, applying substitution twice should yield the same result + // unless the first substitution introduced new variables to substitute + if !once.contains('$') { + assert_eq!(once, twice, "Substitution should be idempotent when no $ remains"); + } + } +}); \ No newline at end of file diff --git a/fuzz/fuzz_targets/parser.rs b/fuzz/fuzz_targets/parser.rs new file mode 100644 index 0000000..03f20f3 --- /dev/null +++ b/fuzz/fuzz_targets/parser.rs @@ -0,0 +1,48 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use testscript_rs::parser; + +fuzz_target!(|data: &[u8]| { + // Convert bytes to string, handling invalid UTF-8 gracefully + let input = String::from_utf8_lossy(data); + + // Fuzz the main parser function + // The parser should never panic and always return a proper Result + let result = parser::parse(&input); + + // We don't care if parsing succeeds or fails, just that it doesn't panic + // If it succeeds, verify the result is well-formed + if let Ok(ref script) = result { + // Basic sanity checks - these should never fail for valid parsed scripts + // This helps catch internal consistency bugs + for command in &script.commands { + // Command name should not be empty if command exists + assert!(!command.name.is_empty(), "Empty command name"); + // Line number should be positive + assert!(command.line_num > 0, "Invalid line number"); + } + + for file in &script.files { + // File name should not be empty if file exists + assert!(!file.name.is_empty(), "Empty file name"); + } + } + + // Test that the parser is deterministic - same input should produce same result + let result2 = parser::parse(&input); + match (result.is_ok(), result2.is_ok()) { + (true, true) => { + // Both succeeded - results should be identical + if let (Ok(ref script1), Ok(ref script2)) = (&result, &result2) { + assert_eq!(script1, script2, "Parser is not deterministic"); + } + } + (false, false) => { + // Both failed - this is fine, errors don't need to be identical + } + _ => { + panic!("Parser is not deterministic - one call succeeded, other failed"); + } + } +}); diff --git a/fuzz/fuzz_targets/structured.rs b/fuzz/fuzz_targets/structured.rs new file mode 100644 index 0000000..20c38be --- /dev/null +++ b/fuzz/fuzz_targets/structured.rs @@ -0,0 +1,108 @@ +#![no_main] + +use arbitrary::{Arbitrary, Unstructured}; +use libfuzzer_sys::fuzz_target; +use testscript_rs::parser; + +#[derive(Arbitrary, Debug)] +struct FuzzInput { + commands: Vec, + files: Vec, +} + +#[derive(Arbitrary, Debug)] +struct FuzzCommand { + condition: Option, + negated: bool, + background: bool, + name: String, + args: Vec, +} + +#[derive(Arbitrary, Debug)] +struct FuzzFile { + name: String, + contents: Vec, +} + +impl FuzzInput { + fn to_txtar(&self) -> String { + let mut result = String::new(); + + // Add commands + for cmd in &self.commands { + // Add condition prefix + if let Some(ref condition) = cmd.condition { + result.push_str(&format!("[{}] ", condition)); + } + + // Add negation + if cmd.negated { + result.push_str("! "); + } + + // Add command name and args + result.push_str(&cmd.name); + for arg in &cmd.args { + // Simple quoting - add quotes if arg contains spaces + if arg.contains(' ') || arg.contains('\t') { + result.push_str(&format!(" \"{}\"", arg.replace('"', "\\\""))); + } else { + result.push_str(&format!(" {}", arg)); + } + } + + // Add background indicator + if cmd.background { + result.push_str(" &"); + } + + result.push('\n'); + } + + // Add files + for file in &self.files { + result.push_str(&format!("-- {} --\n", file.name)); + // Convert bytes to string, replacing invalid UTF-8 + let content = String::from_utf8_lossy(&file.contents); + result.push_str(&content); + if !content.ends_with('\n') { + result.push('\n'); + } + } + + result + } +} + +fuzz_target!(|data: &[u8]| { + // Create structured input using arbitrary + let mut unstructured = Unstructured::new(data); + if let Ok(fuzz_input) = FuzzInput::arbitrary(&mut unstructured) { + // Convert to txtar format + let txtar_content = fuzz_input.to_txtar(); + + // Test the parser + let result = parser::parse(&txtar_content); + + // Verify parser doesn't panic and produces consistent results + if let Ok(script) = result { + // Validate the parsed structure + // Note: parsed commands may be fewer than input due to empty lines, comments, etc. + assert!(script.commands.len() <= fuzz_input.commands.len().max(1000), "Too many commands parsed"); + + for command in &script.commands { + assert!(!command.name.is_empty(), "Empty command name"); + assert!(command.line_num > 0, "Invalid line number"); + } + + for file in &script.files { + assert!(!file.name.is_empty(), "Empty file name"); + } + } + } + + // Also test with raw bytes as backup for edge cases + let raw_input = String::from_utf8_lossy(data); + let _result = parser::parse(&raw_input); +}); \ No newline at end of file diff --git a/fuzz/fuzz_targets/tokens.rs b/fuzz/fuzz_targets/tokens.rs new file mode 100644 index 0000000..184249a --- /dev/null +++ b/fuzz/fuzz_targets/tokens.rs @@ -0,0 +1,40 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + // Convert bytes to string, handling invalid UTF-8 gracefully + let input = String::from_utf8_lossy(data); + + // We need to test the token parsing function, but it's private + // So we test it indirectly through the public parser interface + // Create minimal txtar content with the fuzzed command line + let txtar_content = format!("{}\n", input); + + // Fuzz through the main parser - this will exercise parse_command_tokens + let result = testscript_rs::parser::parse(&txtar_content); + + // The parser should never panic + // If parsing succeeds, verify basic properties + if let Ok(script) = result { + for command in &script.commands { + // If we have a command, it should have at least a name + assert!(!command.name.is_empty(), "Command with empty name"); + // Arguments should be valid strings (no invalid UTF-8) + for arg in &command.args { + // Just accessing the string should not panic + let _len = arg.len(); + } + } + } + + // Test with various command prefixes to exercise condition parsing + if !input.trim().is_empty() && !input.starts_with('#') { + let prefixes = ["", "[unix] ", "[!windows] ", "! "]; + for prefix in &prefixes { + let prefixed_content = format!("{}{}\n", prefix, input); + let _result = testscript_rs::parser::parse(&prefixed_content); + // Should not panic regardless of result + } + } +}); \ No newline at end of file