mirror of
https://github.com/uutils/sed.git
synced 2026-06-10 16:14:15 -07:00
add a sed fuzzer (#72)
* add a sed fuzzer * gh/action: add a fuzzing task * Update fuzz/fuzz_targets/fuzz_common/mod.rs Co-authored-by: Daniel Hofstetter <daniel.hofstetter@42dh.com> * Update fuzz/Cargo.toml Co-authored-by: Daniel Hofstetter <daniel.hofstetter@42dh.com> * Update fuzz/fuzz_targets/fuzz_sed.rs Co-authored-by: Daniel Hofstetter <daniel.hofstetter@42dh.com> * Update fuzz/fuzz_targets/fuzz_sed.rs Co-authored-by: Daniel Hofstetter <daniel.hofstetter@42dh.com> * Update fuzz/fuzz_targets/fuzz_common/pretty_print.rs Co-authored-by: Daniel Hofstetter <daniel.hofstetter@42dh.com> --------- Co-authored-by: Daniel Hofstetter <daniel.hofstetter@42dh.com>
This commit is contained in:
co-authored by
Daniel Hofstetter
parent
1b5e124a0f
commit
a4606a50c2
@@ -0,0 +1,264 @@
|
||||
name: Fuzzing
|
||||
|
||||
# spell-checker:ignore fuzzer dtolnay Swatinem
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
permissions:
|
||||
contents: read # to fetch code (actions/checkout)
|
||||
|
||||
# End the current execution if there is a new changeset in the PR.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
fuzz-build:
|
||||
name: Build the fuzzers
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@nightly
|
||||
- name: Install `cargo-fuzz`
|
||||
run: cargo install cargo-fuzz
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: "cargo-fuzz-cache-key"
|
||||
cache-directories: "fuzz/target"
|
||||
- name: Run `cargo-fuzz build`
|
||||
run: cargo +nightly fuzz build
|
||||
|
||||
fuzz-run:
|
||||
needs: fuzz-build
|
||||
name: Fuzz
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
env:
|
||||
RUN_FOR: 60
|
||||
strategy:
|
||||
matrix:
|
||||
test-target:
|
||||
- { name: fuzz_sed, should_pass: false }
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@nightly
|
||||
- name: Install `cargo-fuzz`
|
||||
run: cargo install cargo-fuzz
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: "cargo-fuzz-cache-key"
|
||||
cache-directories: "fuzz/target"
|
||||
- name: Restore Cached Corpus
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
key: corpus-cache-${{ matrix.test-target.name }}
|
||||
path: |
|
||||
fuzz/corpus/${{ matrix.test-target.name }}
|
||||
- name: Run ${{ matrix.test-target.name }} for XX seconds
|
||||
id: run_fuzzer
|
||||
shell: bash
|
||||
continue-on-error: ${{ !matrix.test-target.should_pass }}
|
||||
run: |
|
||||
mkdir -p fuzz/stats
|
||||
STATS_FILE="fuzz/stats/${{ matrix.test-target.name }}.txt"
|
||||
cargo +nightly fuzz run ${{ matrix.test-target.name }} -- -max_total_time=${{ env.RUN_FOR }} -timeout=${{ env.RUN_FOR }} -detect_leaks=0 -print_final_stats=1 2>&1 | tee "$STATS_FILE"
|
||||
|
||||
# Extract key stats from the output
|
||||
if grep -q "stat::number_of_executed_units" "$STATS_FILE"; then
|
||||
RUNS=$(grep "stat::number_of_executed_units" "$STATS_FILE" | awk '{print $2}')
|
||||
echo "runs=$RUNS" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "runs=unknown" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
if grep -q "stat::average_exec_per_sec" "$STATS_FILE"; then
|
||||
EXEC_RATE=$(grep "stat::average_exec_per_sec" "$STATS_FILE" | awk '{print $2}')
|
||||
echo "exec_rate=$EXEC_RATE" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "exec_rate=unknown" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
if grep -q "stat::new_units_added" "$STATS_FILE"; then
|
||||
NEW_UNITS=$(grep "stat::new_units_added" "$STATS_FILE" | awk '{print $2}')
|
||||
echo "new_units=$NEW_UNITS" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "new_units=unknown" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Save should_pass value to file for summary job to use
|
||||
echo "${{ matrix.test-target.should_pass }}" > "fuzz/stats/${{ matrix.test-target.name }}.should_pass"
|
||||
|
||||
# Print stats to job output for immediate visibility
|
||||
echo "----------------------------------------"
|
||||
echo "FUZZING STATISTICS FOR ${{ matrix.test-target.name }}"
|
||||
echo "----------------------------------------"
|
||||
echo "Runs: $(grep -q "stat::number_of_executed_units" "$STATS_FILE" && grep "stat::number_of_executed_units" "$STATS_FILE" | awk '{print $2}' || echo "unknown")"
|
||||
echo "Execution Rate: $(grep -q "stat::average_exec_per_sec" "$STATS_FILE" && grep "stat::average_exec_per_sec" "$STATS_FILE" | awk '{print $2}' || echo "unknown") execs/sec"
|
||||
echo "New Units: $(grep -q "stat::new_units_added" "$STATS_FILE" && grep "stat::new_units_added" "$STATS_FILE" | awk '{print $2}' || echo "unknown")"
|
||||
echo "Expected: ${{ matrix.test-target.should_pass }}"
|
||||
if grep -q "SUMMARY: " "$STATS_FILE"; then
|
||||
echo "Status: $(grep "SUMMARY: " "$STATS_FILE" | head -1)"
|
||||
else
|
||||
echo "Status: Completed"
|
||||
fi
|
||||
echo "----------------------------------------"
|
||||
|
||||
# Add summary to GitHub step summary
|
||||
echo "### Fuzzing Results for ${{ matrix.test-target.name }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Metric | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if grep -q "stat::number_of_executed_units" "$STATS_FILE"; then
|
||||
echo "| Runs | $(grep "stat::number_of_executed_units" "$STATS_FILE" | awk '{print $2}') |" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
if grep -q "stat::average_exec_per_sec" "$STATS_FILE"; then
|
||||
echo "| Execution Rate | $(grep "stat::average_exec_per_sec" "$STATS_FILE" | awk '{print $2}') execs/sec |" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
if grep -q "stat::new_units_added" "$STATS_FILE"; then
|
||||
echo "| New Units | $(grep "stat::new_units_added" "$STATS_FILE" | awk '{print $2}') |" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
echo "| Should pass | ${{ matrix.test-target.should_pass }} |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if grep -q "SUMMARY: " "$STATS_FILE"; then
|
||||
echo "| Status | $(grep "SUMMARY: " "$STATS_FILE" | head -1) |" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "| Status | Completed |" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
- name: Save Corpus Cache
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
key: corpus-cache-${{ matrix.test-target.name }}
|
||||
path: |
|
||||
fuzz/corpus/${{ matrix.test-target.name }}
|
||||
- name: Upload Stats
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: fuzz-stats-${{ matrix.test-target.name }}
|
||||
path: |
|
||||
fuzz/stats/${{ matrix.test-target.name }}.txt
|
||||
fuzz/stats/${{ matrix.test-target.name }}.should_pass
|
||||
retention-days: 5
|
||||
fuzz-summary:
|
||||
needs: fuzz-run
|
||||
name: Fuzzing Summary
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Download all stats
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: fuzz/stats-artifacts
|
||||
pattern: fuzz-stats-*
|
||||
merge-multiple: true
|
||||
- name: Prepare stats directory
|
||||
run: |
|
||||
mkdir -p fuzz/stats
|
||||
# Debug: List content of stats-artifacts directory
|
||||
echo "Contents of stats-artifacts directory:"
|
||||
find fuzz/stats-artifacts -type f | sort
|
||||
|
||||
# Extract files from the artifact directories - handle nested directories
|
||||
find fuzz/stats-artifacts -type f -name "*.txt" -exec cp {} fuzz/stats/ \;
|
||||
find fuzz/stats-artifacts -type f -name "*.should_pass" -exec cp {} fuzz/stats/ \;
|
||||
|
||||
# Debug information
|
||||
echo "Contents of stats directory after extraction:"
|
||||
ls -la fuzz/stats/
|
||||
echo "Contents of should_pass files (if any):"
|
||||
cat fuzz/stats/*.should_pass 2>/dev/null || echo "No should_pass files found"
|
||||
- name: Generate Summary
|
||||
run: |
|
||||
echo "# Fuzzing Summary" > fuzzing_summary.md
|
||||
echo "" >> fuzzing_summary.md
|
||||
echo "| Target | Runs | Exec/sec | New Units | Should pass | Status |" >> fuzzing_summary.md
|
||||
echo "|--------|------|----------|-----------|-------------|--------|" >> fuzzing_summary.md
|
||||
|
||||
TOTAL_RUNS=0
|
||||
TOTAL_NEW_UNITS=0
|
||||
|
||||
for stat_file in fuzz/stats/*.txt; do
|
||||
TARGET=$(basename "$stat_file" .txt)
|
||||
SHOULD_PASS_FILE="${stat_file%.*}.should_pass"
|
||||
|
||||
# Get expected status
|
||||
if [ -f "$SHOULD_PASS_FILE" ]; then
|
||||
EXPECTED=$(cat "$SHOULD_PASS_FILE")
|
||||
else
|
||||
EXPECTED="unknown"
|
||||
fi
|
||||
|
||||
# Extract runs
|
||||
if grep -q "stat::number_of_executed_units" "$stat_file"; then
|
||||
RUNS=$(grep "stat::number_of_executed_units" "$stat_file" | awk '{print $2}')
|
||||
TOTAL_RUNS=$((TOTAL_RUNS + RUNS))
|
||||
else
|
||||
RUNS="unknown"
|
||||
fi
|
||||
|
||||
# Extract execution rate
|
||||
if grep -q "stat::average_exec_per_sec" "$stat_file"; then
|
||||
EXEC_RATE=$(grep "stat::average_exec_per_sec" "$stat_file" | awk '{print $2}')
|
||||
else
|
||||
EXEC_RATE="unknown"
|
||||
fi
|
||||
|
||||
# Extract new units added
|
||||
if grep -q "stat::new_units_added" "$stat_file"; then
|
||||
NEW_UNITS=$(grep "stat::new_units_added" "$stat_file" | awk '{print $2}')
|
||||
if [[ "$NEW_UNITS" =~ ^[0-9]+$ ]]; then
|
||||
TOTAL_NEW_UNITS=$((TOTAL_NEW_UNITS + NEW_UNITS))
|
||||
fi
|
||||
else
|
||||
NEW_UNITS="unknown"
|
||||
fi
|
||||
|
||||
# Extract status
|
||||
if grep -q "SUMMARY: " "$stat_file"; then
|
||||
STATUS=$(grep "SUMMARY: " "$stat_file" | head -1)
|
||||
else
|
||||
STATUS="Completed"
|
||||
fi
|
||||
|
||||
echo "| $TARGET | $RUNS | $EXEC_RATE | $NEW_UNITS | $EXPECTED | $STATUS |" >> fuzzing_summary.md
|
||||
done
|
||||
|
||||
echo "" >> fuzzing_summary.md
|
||||
echo "## Overall Statistics" >> fuzzing_summary.md
|
||||
echo "" >> fuzzing_summary.md
|
||||
echo "- **Total runs:** $TOTAL_RUNS" >> fuzzing_summary.md
|
||||
echo "- **Total new units discovered:** $TOTAL_NEW_UNITS" >> fuzzing_summary.md
|
||||
echo "- **Average execution rate:** $(grep -h "stat::average_exec_per_sec" fuzz/stats/*.txt | awk '{sum += $2; count++} END {if (count > 0) print sum/count " execs/sec"; else print "unknown"}')" >> fuzzing_summary.md
|
||||
|
||||
# Add count by expected status
|
||||
echo "- **Tests expected to pass:** $(find fuzz/stats -name "*.should_pass" -exec cat {} \; | grep -c "true")" >> fuzzing_summary.md
|
||||
echo "- **Tests expected to fail:** $(find fuzz/stats -name "*.should_pass" -exec cat {} \; | grep -c "false")" >> fuzzing_summary.md
|
||||
|
||||
# Write to GitHub step summary
|
||||
cat fuzzing_summary.md >> $GITHUB_STEP_SUMMARY
|
||||
- name: Show Summary
|
||||
run: |
|
||||
cat fuzzing_summary.md
|
||||
- name: Upload Summary
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: fuzzing-summary
|
||||
path: fuzzing_summary.md
|
||||
retention-days: 5
|
||||
Generated
+1377
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
[package]
|
||||
name = "uucore-fuzz"
|
||||
version = "0.0.0"
|
||||
publish = false
|
||||
edition = "2024"
|
||||
|
||||
[package.metadata]
|
||||
cargo-fuzz = true
|
||||
|
||||
[dependencies]
|
||||
console = "0.15.0"
|
||||
libfuzzer-sys = "0.4.7"
|
||||
libc = "0.2.153"
|
||||
tempfile = "3.15.0"
|
||||
rand = { version = "0.9.0", features = ["small_rng"] }
|
||||
similar = "2.5.0"
|
||||
uucore = { version = "0.1.0", features = ["libc"] }
|
||||
|
||||
uu_sed = { path = "../src/uu/sed/" }
|
||||
|
||||
|
||||
# Prevent this from interfering with workspaces
|
||||
[workspace]
|
||||
members = ["."]
|
||||
|
||||
[[bin]]
|
||||
name = "fuzz_sed"
|
||||
path = "fuzz_targets/fuzz_sed.rs"
|
||||
test = false
|
||||
doc = false
|
||||
@@ -0,0 +1,436 @@
|
||||
// This file is part of the uutils sed package.
|
||||
//
|
||||
// For the full copyright and license information, please view the LICENSE
|
||||
// file that was distributed with this source code.
|
||||
|
||||
use console::Style;
|
||||
use libc::STDIN_FILENO;
|
||||
use libc::{STDERR_FILENO, STDOUT_FILENO, close, dup, dup2, pipe};
|
||||
use pretty_print::{
|
||||
print_diff, print_end_with_status, print_or_empty, print_section, print_with_style,
|
||||
};
|
||||
use rand::Rng;
|
||||
use rand::prelude::IndexedRandom;
|
||||
use std::env::temp_dir;
|
||||
use std::ffi::OsString;
|
||||
use std::fs::File;
|
||||
use std::io::{Seek, SeekFrom, Write};
|
||||
use std::os::fd::{AsRawFd, RawFd};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::{Once, atomic::AtomicBool};
|
||||
use std::{io, thread};
|
||||
|
||||
pub mod pretty_print;
|
||||
|
||||
/// Represents the result of running a command, including its standard output,
|
||||
/// standard error, and exit code.
|
||||
pub struct CommandResult {
|
||||
/// The standard output (stdout) of the command as a string.
|
||||
pub stdout: String,
|
||||
|
||||
/// The standard error (stderr) of the command as a string.
|
||||
pub stderr: String,
|
||||
|
||||
/// The exit code of the command.
|
||||
pub exit_code: i32,
|
||||
}
|
||||
|
||||
static CHECK_GNU: Once = Once::new();
|
||||
static IS_GNU: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub fn is_gnu_cmd(cmd_path: &str) -> Result<(), std::io::Error> {
|
||||
CHECK_GNU.call_once(|| {
|
||||
let version_output = Command::new(cmd_path).arg("--version").output().unwrap();
|
||||
|
||||
println!("version_output {version_output:#?}");
|
||||
|
||||
let version_str = String::from_utf8_lossy(&version_output.stdout).to_string();
|
||||
if version_str.contains("GNU coreutils") {
|
||||
IS_GNU.store(true, Ordering::Relaxed);
|
||||
}
|
||||
});
|
||||
|
||||
if IS_GNU.load(Ordering::Relaxed) {
|
||||
Ok(())
|
||||
} else {
|
||||
panic!("Not the GNU implementation");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_and_run_uumain<F>(
|
||||
args: &[OsString],
|
||||
uumain_function: F,
|
||||
pipe_input: Option<&str>,
|
||||
) -> CommandResult
|
||||
where
|
||||
F: FnOnce(std::vec::IntoIter<OsString>) -> i32 + Send + 'static,
|
||||
{
|
||||
// Duplicate the stdout and stderr file descriptors
|
||||
let original_stdout_fd = unsafe { dup(STDOUT_FILENO) };
|
||||
let original_stderr_fd = unsafe { dup(STDERR_FILENO) };
|
||||
if original_stdout_fd == -1 || original_stderr_fd == -1 {
|
||||
return CommandResult {
|
||||
stdout: String::new(),
|
||||
stderr: "Failed to duplicate STDOUT_FILENO or STDERR_FILENO".to_string(),
|
||||
exit_code: -1,
|
||||
};
|
||||
}
|
||||
|
||||
println!("Running test {:?}", &args[0..]);
|
||||
let mut pipe_stdout_fds = [-1; 2];
|
||||
let mut pipe_stderr_fds = [-1; 2];
|
||||
|
||||
// Create pipes for stdout and stderr
|
||||
if unsafe { pipe(pipe_stdout_fds.as_mut_ptr()) } == -1
|
||||
|| unsafe { pipe(pipe_stderr_fds.as_mut_ptr()) } == -1
|
||||
{
|
||||
return CommandResult {
|
||||
stdout: String::new(),
|
||||
stderr: "Failed to create pipes".to_string(),
|
||||
exit_code: -1,
|
||||
};
|
||||
}
|
||||
|
||||
// Redirect stdout and stderr to their respective pipes
|
||||
if unsafe { dup2(pipe_stdout_fds[1], STDOUT_FILENO) } == -1
|
||||
|| unsafe { dup2(pipe_stderr_fds[1], STDERR_FILENO) } == -1
|
||||
{
|
||||
unsafe {
|
||||
close(pipe_stdout_fds[0]);
|
||||
close(pipe_stdout_fds[1]);
|
||||
close(pipe_stderr_fds[0]);
|
||||
close(pipe_stderr_fds[1]);
|
||||
}
|
||||
return CommandResult {
|
||||
stdout: String::new(),
|
||||
stderr: "Failed to redirect STDOUT_FILENO or STDERR_FILENO".to_string(),
|
||||
exit_code: -1,
|
||||
};
|
||||
}
|
||||
|
||||
let original_stdin_fd = if let Some(input_str) = pipe_input {
|
||||
// we have pipe input
|
||||
let mut input_file = tempfile::tempfile().unwrap();
|
||||
write!(input_file, "{input_str}").unwrap();
|
||||
input_file.seek(SeekFrom::Start(0)).unwrap();
|
||||
|
||||
// Redirect stdin to read from the in-memory file
|
||||
let original_stdin_fd = unsafe { dup(STDIN_FILENO) };
|
||||
if original_stdin_fd == -1 || unsafe { dup2(input_file.as_raw_fd(), STDIN_FILENO) } == -1 {
|
||||
return CommandResult {
|
||||
stdout: String::new(),
|
||||
stderr: "Failed to set up stdin redirection".to_string(),
|
||||
exit_code: -1,
|
||||
};
|
||||
}
|
||||
Some(original_stdin_fd)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let (uumain_exit_status, captured_stdout, captured_stderr) = thread::scope(|s| {
|
||||
let out = s.spawn(|| read_from_fd(pipe_stdout_fds[0]));
|
||||
let err = s.spawn(|| read_from_fd(pipe_stderr_fds[0]));
|
||||
#[allow(clippy::unnecessary_to_owned)]
|
||||
// TODO: clippy wants us to use args.iter().cloned() ?
|
||||
let status = uumain_function(args.to_owned().into_iter());
|
||||
// Reset the exit code global variable in case we run another test after this one
|
||||
// See https://github.com/uutils/coreutils/issues/5777
|
||||
uucore::error::set_exit_code(0);
|
||||
io::stdout().flush().unwrap();
|
||||
io::stderr().flush().unwrap();
|
||||
unsafe {
|
||||
close(pipe_stdout_fds[1]);
|
||||
close(pipe_stderr_fds[1]);
|
||||
close(STDOUT_FILENO);
|
||||
close(STDERR_FILENO);
|
||||
}
|
||||
(status, out.join().unwrap(), err.join().unwrap())
|
||||
});
|
||||
|
||||
// Restore the original stdout and stderr
|
||||
if unsafe { dup2(original_stdout_fd, STDOUT_FILENO) } == -1
|
||||
|| unsafe { dup2(original_stderr_fd, STDERR_FILENO) } == -1
|
||||
{
|
||||
return CommandResult {
|
||||
stdout: String::new(),
|
||||
stderr: "Failed to restore the original STDOUT_FILENO or STDERR_FILENO".to_string(),
|
||||
exit_code: -1,
|
||||
};
|
||||
}
|
||||
unsafe {
|
||||
close(original_stdout_fd);
|
||||
close(original_stderr_fd);
|
||||
}
|
||||
|
||||
// Restore the original stdin if it was modified
|
||||
if let Some(fd) = original_stdin_fd {
|
||||
if unsafe { dup2(fd, STDIN_FILENO) } == -1 {
|
||||
return CommandResult {
|
||||
stdout: String::new(),
|
||||
stderr: "Failed to restore the original STDIN".to_string(),
|
||||
exit_code: -1,
|
||||
};
|
||||
}
|
||||
unsafe { close(fd) };
|
||||
}
|
||||
|
||||
CommandResult {
|
||||
stdout: captured_stdout,
|
||||
stderr: captured_stderr
|
||||
.split_once(':')
|
||||
.map_or("", |x| x.1)
|
||||
.trim()
|
||||
.to_string(),
|
||||
exit_code: uumain_exit_status,
|
||||
}
|
||||
}
|
||||
|
||||
fn read_from_fd(fd: RawFd) -> String {
|
||||
let mut captured_output = Vec::new();
|
||||
let mut read_buffer = [0; 1024];
|
||||
loop {
|
||||
let bytes_read = unsafe {
|
||||
libc::read(
|
||||
fd,
|
||||
read_buffer.as_mut_ptr().cast::<libc::c_void>(),
|
||||
read_buffer.len(),
|
||||
)
|
||||
};
|
||||
|
||||
if bytes_read == -1 {
|
||||
eprintln!("Failed to read from the pipe");
|
||||
break;
|
||||
}
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
}
|
||||
captured_output.extend_from_slice(&read_buffer[..bytes_read as usize]);
|
||||
}
|
||||
|
||||
unsafe { libc::close(fd) };
|
||||
|
||||
String::from_utf8_lossy(&captured_output).into_owned()
|
||||
}
|
||||
|
||||
pub fn run_gnu_cmd(
|
||||
cmd_path: &str,
|
||||
args: &[OsString],
|
||||
check_gnu: bool,
|
||||
pipe_input: Option<&str>,
|
||||
) -> Result<CommandResult, CommandResult> {
|
||||
if check_gnu {
|
||||
match is_gnu_cmd(cmd_path) {
|
||||
Ok(()) => {} // if the check passes, do nothing
|
||||
Err(e) => {
|
||||
// Convert the io::Error into the function's error type
|
||||
return Err(CommandResult {
|
||||
stdout: String::new(),
|
||||
stderr: e.to_string(),
|
||||
exit_code: -1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut command = Command::new(cmd_path);
|
||||
for arg in args {
|
||||
command.arg(arg);
|
||||
}
|
||||
|
||||
// See https://github.com/uutils/coreutils/issues/6794
|
||||
// uutils' coreutils is not locale-aware, and aims to mirror/be compatible with GNU Core Utilities's LC_ALL=C behavior
|
||||
command.env("LC_ALL", "C");
|
||||
|
||||
let output = if let Some(input_str) = pipe_input {
|
||||
// We have an pipe input
|
||||
command
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
let mut child = command.spawn().expect("Failed to execute command");
|
||||
let child_stdin = child.stdin.as_mut().unwrap();
|
||||
child_stdin
|
||||
.write_all(input_str.as_bytes())
|
||||
.expect("Failed to write to stdin");
|
||||
|
||||
match child.wait_with_output() {
|
||||
Ok(output) => output,
|
||||
Err(e) => {
|
||||
return Err(CommandResult {
|
||||
stdout: String::new(),
|
||||
stderr: e.to_string(),
|
||||
exit_code: -1,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Just run with args
|
||||
match command.output() {
|
||||
Ok(output) => output,
|
||||
Err(e) => {
|
||||
return Err(CommandResult {
|
||||
stdout: String::new(),
|
||||
stderr: e.to_string(),
|
||||
exit_code: -1,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
let exit_code = output.status.code().unwrap_or(-1);
|
||||
// Here we get stdout and stderr as Strings
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
let stderr = stderr
|
||||
.split_once(':')
|
||||
.map_or("", |x| x.1)
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
if output.status.success() || !check_gnu {
|
||||
Ok(CommandResult {
|
||||
stdout,
|
||||
stderr,
|
||||
exit_code,
|
||||
})
|
||||
} else {
|
||||
Err(CommandResult {
|
||||
stdout,
|
||||
stderr,
|
||||
exit_code,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare results from two different implementations of a command.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `test_type` - The command.
|
||||
/// * `input` - The input provided to the command.
|
||||
/// * `rust_result` - The result of running the command with the Rust implementation.
|
||||
/// * `gnu_result` - The result of running the command with the GNU implementation.
|
||||
/// * `fail_on_stderr_diff` - Whether to fail the test if there is a difference in stderr output.
|
||||
pub fn compare_result(
|
||||
test_type: &str,
|
||||
input: &str,
|
||||
pipe_input: Option<&str>,
|
||||
rust_result: &CommandResult,
|
||||
gnu_result: &CommandResult,
|
||||
fail_on_stderr_diff: bool,
|
||||
) {
|
||||
print_section(format!("Compare result for: {test_type} {input}"));
|
||||
|
||||
if let Some(pipe) = pipe_input {
|
||||
println!("Pipe: {pipe}");
|
||||
}
|
||||
|
||||
let mut discrepancies = Vec::new();
|
||||
let mut should_panic = false;
|
||||
|
||||
if rust_result.stdout.trim() != gnu_result.stdout.trim() {
|
||||
discrepancies.push("stdout differs");
|
||||
println!("Rust stdout:");
|
||||
print_or_empty(rust_result.stdout.as_str());
|
||||
println!("GNU stdout:");
|
||||
print_or_empty(gnu_result.stdout.as_ref());
|
||||
print_diff(&rust_result.stdout, &gnu_result.stdout);
|
||||
should_panic = true;
|
||||
}
|
||||
|
||||
if rust_result.stderr.trim() != gnu_result.stderr.trim() {
|
||||
discrepancies.push("stderr differs");
|
||||
println!("Rust stderr:");
|
||||
print_or_empty(rust_result.stderr.as_str());
|
||||
println!("GNU stderr:");
|
||||
print_or_empty(gnu_result.stderr.as_str());
|
||||
print_diff(&rust_result.stderr, &gnu_result.stderr);
|
||||
if fail_on_stderr_diff {
|
||||
should_panic = true;
|
||||
}
|
||||
}
|
||||
|
||||
if rust_result.exit_code != gnu_result.exit_code {
|
||||
discrepancies.push("exit code differs");
|
||||
println!(
|
||||
"Different exit code: (Rust: {}, GNU: {})",
|
||||
rust_result.exit_code, gnu_result.exit_code
|
||||
);
|
||||
should_panic = true;
|
||||
}
|
||||
|
||||
if discrepancies.is_empty() {
|
||||
print_end_with_status("Same behavior", true);
|
||||
} else {
|
||||
print_with_style(
|
||||
format!("Discrepancies detected: {}", discrepancies.join(", ")),
|
||||
Style::new().red(),
|
||||
);
|
||||
if should_panic {
|
||||
print_end_with_status(
|
||||
format!("Test failed and will panic for: {test_type} {input}"),
|
||||
false,
|
||||
);
|
||||
panic!("Test failed for: {test_type} {input}");
|
||||
} else {
|
||||
print_end_with_status(
|
||||
format!("Test completed with discrepancies for: {test_type} {input}"),
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
pub fn generate_random_string(max_length: usize) -> String {
|
||||
let mut rng = rand::rng();
|
||||
let valid_utf8: Vec<char> = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
.chars()
|
||||
.collect();
|
||||
let invalid_utf8 = [0xC3, 0x28]; // Invalid UTF-8 sequence
|
||||
let mut result = String::new();
|
||||
|
||||
for _ in 0..rng.random_range(0..=max_length) {
|
||||
if rng.random_bool(0.9) {
|
||||
let ch = valid_utf8.choose(&mut rng).unwrap();
|
||||
result.push(*ch);
|
||||
} else {
|
||||
let ch = invalid_utf8.choose(&mut rng).unwrap();
|
||||
if let Some(c) = char::from_u32(*ch as u32) {
|
||||
result.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn generate_random_file() -> Result<String, std::io::Error> {
|
||||
let mut rng = rand::rng();
|
||||
let file_name: String = (0..10)
|
||||
.map(|_| rng.random_range(b'a'..=b'z') as char)
|
||||
.collect();
|
||||
let mut file_path = temp_dir();
|
||||
file_path.push(file_name);
|
||||
|
||||
let mut file = File::create(&file_path)?;
|
||||
|
||||
let content_length = rng.random_range(10..1000);
|
||||
let content: String = (0..content_length)
|
||||
.map(|_| rng.random_range(b' '..=b'~') as char)
|
||||
.collect();
|
||||
|
||||
file.write_all(content.as_bytes())?;
|
||||
|
||||
Ok(file_path.to_str().unwrap().to_string())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn replace_fuzz_binary_name(cmd: &str, result: &mut CommandResult) {
|
||||
let fuzz_bin_name = format!("fuzz/target/x86_64-unknown-linux-gnu/release/fuzz_{cmd}");
|
||||
|
||||
result.stdout = result.stdout.replace(&fuzz_bin_name, cmd);
|
||||
result.stderr = result.stderr.replace(&fuzz_bin_name, cmd);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// This file is part of the uutils sed package.
|
||||
//
|
||||
// For the full copyright and license information, please view the LICENSE
|
||||
// file that was distributed with this source code.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use console::{Style, style};
|
||||
use similar::TextDiff;
|
||||
|
||||
pub fn print_section<S: fmt::Display>(s: S) {
|
||||
println!("{}", style(format!("=== {s}")).bold());
|
||||
}
|
||||
|
||||
pub fn print_subsection<S: fmt::Display>(s: S) {
|
||||
println!("{}", style(format!("--- {s}")).bright());
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn print_test_begin<S: fmt::Display>(msg: S) {
|
||||
println!(
|
||||
"{} {} {}",
|
||||
style("===").bold(), // Kind of gray
|
||||
style("TEST").black().on_yellow().bold(),
|
||||
style(msg).bold()
|
||||
);
|
||||
}
|
||||
|
||||
pub fn print_end_with_status<S: fmt::Display>(msg: S, ok: bool) {
|
||||
let ok = if ok {
|
||||
style(" OK ").black().on_green().bold()
|
||||
} else {
|
||||
style(" KO ").black().on_red().bold()
|
||||
};
|
||||
|
||||
println!(
|
||||
"{} {ok} {}",
|
||||
style("===").bold(), // Kind of gray
|
||||
style(msg).bold()
|
||||
);
|
||||
}
|
||||
|
||||
pub fn print_or_empty(s: &str) {
|
||||
let to_print = if s.is_empty() { "(empty)" } else { s };
|
||||
|
||||
println!("{}", style(to_print).dim());
|
||||
}
|
||||
|
||||
pub fn print_with_style<S: fmt::Display>(msg: S, style: Style) {
|
||||
println!("{}", style.apply_to(msg));
|
||||
}
|
||||
|
||||
pub fn print_diff(got: &str, expected: &str) {
|
||||
let diff = TextDiff::from_lines(got, expected);
|
||||
|
||||
print_subsection("START diff");
|
||||
|
||||
for change in diff.iter_all_changes() {
|
||||
let (sign, style) = match change.tag() {
|
||||
similar::ChangeTag::Equal => (" ", Style::new().dim()),
|
||||
similar::ChangeTag::Delete => ("-", Style::new().red()),
|
||||
similar::ChangeTag::Insert => ("+", Style::new().green()),
|
||||
};
|
||||
print!("{}{}", style.apply_to(sign).bold(), style.apply_to(change));
|
||||
}
|
||||
|
||||
print_subsection("END diff");
|
||||
println!();
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
// This file is part of the uutils sed package.
|
||||
//
|
||||
// For the full copyright and license information, please view the LICENSE
|
||||
// file that was distributed with this source code.
|
||||
#![no_main]
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use rand::prelude::*;
|
||||
use std::ffi::OsString;
|
||||
use uu_sed::uumain;
|
||||
mod fuzz_common;
|
||||
use crate::fuzz_common::{
|
||||
CommandResult, compare_result, generate_and_run_uumain, generate_random_string, run_gnu_cmd,
|
||||
};
|
||||
use rand::rng;
|
||||
|
||||
static CMD_PATH: &str = "sed";
|
||||
|
||||
fn generate_sed_args() -> Vec<String> {
|
||||
let mut rng = rng();
|
||||
let mut args = Vec::new();
|
||||
|
||||
let opts = ["-n", "-E", "-i", "--posix"];
|
||||
for opt in &opts {
|
||||
if rng.random_bool(0.2) {
|
||||
args.push((*opt).to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Choose sed script type: either inline (-e) or script commands
|
||||
let use_inline_script = rng.random_bool(0.9); // Mostly use inline scripts
|
||||
|
||||
if use_inline_script {
|
||||
args.push("-e".to_string());
|
||||
args.push(generate_sed_script(&mut rng));
|
||||
} else {
|
||||
// For a script file approach, we would need to create a temporary file
|
||||
// but that's complex for fuzzing, so we'll stick with inline scripts
|
||||
args.push("-e".to_string());
|
||||
args.push(generate_sed_script(&mut rng));
|
||||
}
|
||||
|
||||
args
|
||||
}
|
||||
|
||||
fn generate_sed_script(rng: &mut ThreadRng) -> String {
|
||||
// Generate a random sed script
|
||||
// Most common sed operations: substitute, delete, print, append, insert
|
||||
let operations = ["s", "d", "p", "a\\", "i\\", "c\\", "=", "q", "l"];
|
||||
let operation = operations.choose(rng).unwrap();
|
||||
|
||||
match *operation {
|
||||
"s" => {
|
||||
// Substitution: s/pattern/replacement/flags
|
||||
let pattern = generate_pattern(rng);
|
||||
let replacement = generate_replacement(rng);
|
||||
let flags = if rng.random_bool(0.3) {
|
||||
let flag_options = ["g", "i", "p"];
|
||||
(*flag_options.choose(rng).unwrap()).to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
format!("s/{pattern}/{replacement}/{flags}")
|
||||
}
|
||||
"d" => {
|
||||
// Delete: [addr]d
|
||||
if rng.random_bool(0.3) {
|
||||
format!("{}d", generate_address(rng))
|
||||
} else {
|
||||
"d".to_string()
|
||||
}
|
||||
}
|
||||
"p" => {
|
||||
// Print: [addr]p
|
||||
if rng.random_bool(0.3) {
|
||||
format!("{}p", generate_address(rng))
|
||||
} else {
|
||||
"p".to_string()
|
||||
}
|
||||
}
|
||||
"a\\" => {
|
||||
// Append: [addr]a\text
|
||||
let text = generate_random_string(rng.random_range(1..10));
|
||||
if rng.random_bool(0.3) {
|
||||
format!("{}a\\{}", generate_address(rng), text)
|
||||
} else {
|
||||
format!("a\\{text}")
|
||||
}
|
||||
}
|
||||
"i\\" => {
|
||||
// Insert: [addr]i\text
|
||||
let text = generate_random_string(rng.random_range(1..10));
|
||||
if rng.random_bool(0.3) {
|
||||
format!("{}i\\{}", generate_address(rng), text)
|
||||
} else {
|
||||
format!("i\\{text}")
|
||||
}
|
||||
}
|
||||
"c\\" => {
|
||||
// Change: [addr]c\text
|
||||
let text = generate_random_string(rng.random_range(1..10));
|
||||
if rng.random_bool(0.3) {
|
||||
format!("{}c\\{}", generate_address(rng), text)
|
||||
} else {
|
||||
format!("c\\{text}")
|
||||
}
|
||||
}
|
||||
"=" => {
|
||||
// Print line number: [addr]=
|
||||
if rng.random_bool(0.3) {
|
||||
format!("{}=", generate_address(rng))
|
||||
} else {
|
||||
"=".to_string()
|
||||
}
|
||||
}
|
||||
"q" => {
|
||||
// Quit: [addr]q
|
||||
if rng.random_bool(0.3) {
|
||||
format!("{}q", generate_address(rng))
|
||||
} else {
|
||||
"q".to_string()
|
||||
}
|
||||
}
|
||||
"l" => {
|
||||
// List non-printable characters: [addr]l
|
||||
if rng.random_bool(0.3) {
|
||||
format!("{}l", generate_address(rng))
|
||||
} else {
|
||||
"l".to_string()
|
||||
}
|
||||
}
|
||||
_ => "s/./X/".to_string(), // Fallback
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_address(rng: &mut ThreadRng) -> String {
|
||||
// Generate 0, 1, or 2 addresses for sed commands
|
||||
let addr_count_options = [0, 1, 2];
|
||||
let addr_count = *addr_count_options.choose(rng).unwrap();
|
||||
|
||||
match addr_count {
|
||||
0 => {
|
||||
// No address - command applies to all lines
|
||||
String::new()
|
||||
}
|
||||
1 => {
|
||||
// Single address: line number or regex
|
||||
let addr_types = ["line_num", "regex"];
|
||||
let addr_type = addr_types.choose(rng).unwrap();
|
||||
|
||||
match *addr_type {
|
||||
"line_num" => {
|
||||
// Line number
|
||||
rng.random_range(1..100).to_string()
|
||||
}
|
||||
"regex" => {
|
||||
// Regex pattern
|
||||
format!("/{}/", generate_pattern(rng))
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
// Two addresses: range
|
||||
if rng.random_bool(0.5) {
|
||||
// Number range
|
||||
let start = rng.random_range(1..50);
|
||||
let end = rng.random_range(start..100);
|
||||
format!("{start},{end}")
|
||||
} else {
|
||||
// Mixed range: can be number,number or regex,regex or number,regex or regex,number
|
||||
let start = if rng.random_bool(0.5) {
|
||||
rng.random_range(1..50).to_string()
|
||||
} else {
|
||||
format!("/{}/", generate_pattern(rng))
|
||||
};
|
||||
|
||||
let end = if rng.random_bool(0.5) {
|
||||
rng.random_range(1..100).to_string()
|
||||
} else {
|
||||
format!("/{}/", generate_pattern(rng))
|
||||
};
|
||||
|
||||
format!("{start},{end}")
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_pattern(rng: &mut ThreadRng) -> String {
|
||||
// Generate a simple regex pattern
|
||||
// Keeping it simple to avoid invalid regex issues
|
||||
let simple_chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
let pattern_length = rng.random_range(1..5);
|
||||
let pattern: String = (0..pattern_length)
|
||||
.map(|_| {
|
||||
let idx = rng.random_range(0..simple_chars.len());
|
||||
simple_chars.chars().nth(idx).unwrap()
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sometimes add regex metacharacters
|
||||
if rng.random_bool(0.3) {
|
||||
let meta_chars = [".", "*", "+", "?", "^", "$", "[a-z]", "\\w", "\\d"];
|
||||
let meta = meta_chars.choose(rng).unwrap();
|
||||
if rng.random_bool(0.5) {
|
||||
format!("{pattern}{meta}")
|
||||
} else {
|
||||
format!("{meta}{pattern}")
|
||||
}
|
||||
} else {
|
||||
pattern
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_replacement(rng: &mut ThreadRng) -> String {
|
||||
// Generate a replacement string
|
||||
// Can be simple text, with or without backreferences
|
||||
let replacement_length = rng.random_range(0..10);
|
||||
let replacement: String = (0..replacement_length)
|
||||
.map(|_| rng.random_range(b'a'..=b'z') as char)
|
||||
.collect();
|
||||
|
||||
// Sometimes add backreferences
|
||||
if rng.random_bool(0.2) {
|
||||
let backrefs = ["\\0", "\\1", "\\2", "&"];
|
||||
let backref = backrefs.choose(rng).unwrap();
|
||||
if rng.random_bool(0.5) {
|
||||
format!("{replacement}{backref}")
|
||||
} else {
|
||||
format!("{backref}{replacement}")
|
||||
}
|
||||
} else {
|
||||
replacement
|
||||
}
|
||||
}
|
||||
|
||||
fuzz_target!(|_data: &[u8]| {
|
||||
let sed_args = generate_sed_args();
|
||||
let mut args = vec![OsString::from("sed")];
|
||||
args.extend(sed_args.iter().map(OsString::from));
|
||||
|
||||
// Generate random input text
|
||||
let input_text = generate_random_string(200);
|
||||
|
||||
// Run uutils implementation
|
||||
let rust_result = generate_and_run_uumain(&args, uumain, Some(&input_text));
|
||||
|
||||
// Run GNU implementation
|
||||
let gnu_result = match run_gnu_cmd(CMD_PATH, &args[1..], false, Some(&input_text)) {
|
||||
Ok(result) => result,
|
||||
Err(error_result) => {
|
||||
eprintln!("Failed to run GNU command:");
|
||||
eprintln!("Stderr: {}", error_result.stderr);
|
||||
eprintln!("Exit Code: {}", error_result.exit_code);
|
||||
CommandResult {
|
||||
stdout: String::new(),
|
||||
stderr: error_result.stderr,
|
||||
exit_code: error_result.exit_code,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Compare results
|
||||
compare_result(
|
||||
"sed",
|
||||
&format!("{:?}", &args[1..]),
|
||||
Some(&input_text),
|
||||
&rust_result,
|
||||
&gnu_result,
|
||||
false,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user