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
74 changes: 33 additions & 41 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ name: CI

on:
push:
branches: [ main ]
branches: [main]
pull_request:
branches: [ main ]
branches: [main]

env:
CARGO_TERM_COLOR: always
Expand All @@ -20,42 +20,34 @@ jobs:
rust: [stable]

steps:
- uses: actions/checkout@v4

- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ matrix.rust }}
components: clippy, rustfmt

- name: Cache cargo registry
uses: actions/cache@v4
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}

- name: Cache cargo index
uses: actions/cache@v4
with:
path: ~/.cargo/git
key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}

- name: Cache cargo build
uses: actions/cache@v4
with:
path: target
key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}

- name: Run tests
run: cargo test --verbose

- name: Run clippy
run: cargo clippy --all-targets -- -D warnings

- name: Check formatting
run: cargo fmt -- --check

- name: Test sample-cli example
run: |
cd examples/sample-cli
cargo test --verbose
- uses: actions/checkout@v4

- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ matrix.rust }}
components: clippy, rustfmt

- name: Cache cargo registry
uses: actions/cache@v4
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}

- name: Cache cargo index
uses: actions/cache@v4
with:
path: ~/.cargo/git
key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}

- name: Cache cargo build
uses: actions/cache@v4
with:
path: target
key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}

- uses: pre-commit/action@v3.0.1
- run: cargo test --verbose
- run: |
cd examples/sample-cli
cargo test --verbose
5 changes: 3 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ repos:
name: Rust formatter
- id: clippy
name: Rust linter
args: ['--all-targets', '--', '-D', 'warnings']
args:
["--fix", "--allow-dirty", "--all-targets", "--", "-D", "warnings"]
- id: cargo-check
name: Rust compiler check
name: Rust compiler check
29 changes: 16 additions & 13 deletions fuzz/fuzz_targets/env_substitution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ 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
Expand All @@ -20,40 +20,43 @@ fuzz_target!(|data: &[u8]| {
("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!("${{{}}}", input), // ${input}
format!("${}", input), // $input
format!("{}${{WORK}}", input), // input${WORK}
format!("${{WORK}}{}", input), // ${WORK}input
format!("$${}$$", input), // $$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");
assert_eq!(
once, twice,
"Substitution should be idempotent when no $ remains"
);
}
}
});
});
8 changes: 4 additions & 4 deletions fuzz/fuzz_targets/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ 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 {
Expand All @@ -22,13 +22,13 @@ fuzz_target!(|data: &[u8]| {
// 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()) {
Expand Down
31 changes: 17 additions & 14 deletions fuzz/fuzz_targets/structured.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,19 +28,19 @@ struct FuzzFile {
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 {
Expand All @@ -51,15 +51,15 @@ impl FuzzInput {
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));
Expand All @@ -70,7 +70,7 @@ impl FuzzInput {
result.push('\n');
}
}

result
}
}
Expand All @@ -81,28 +81,31 @@ fuzz_target!(|data: &[u8]| {
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");

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);
});
});
10 changes: 5 additions & 5 deletions fuzz/fuzz_targets/tokens.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ 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 {
Expand All @@ -27,7 +27,7 @@ fuzz_target!(|data: &[u8]| {
}
}
}

// Test with various command prefixes to exercise condition parsing
if !input.trim().is_empty() && !input.starts_with('#') {
let prefixes = ["", "[unix] ", "[!windows] ", "! "];
Expand All @@ -37,4 +37,4 @@ fuzz_target!(|data: &[u8]| {
// Should not panic regardless of result
}
}
});
});