From d11a872002284f1050e1fdb2c34f27be2447136a Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Sun, 7 Dec 2025 19:29:00 +0100 Subject: [PATCH 1/6] Remove v1 sink.rs which isn't used or compiled anymore --- src/sink.rs | 114 ---------------------------------------------------- 1 file changed, 114 deletions(-) delete mode 100644 src/sink.rs diff --git a/src/sink.rs b/src/sink.rs deleted file mode 100644 index 07c96b8..0000000 --- a/src/sink.rs +++ /dev/null @@ -1,114 +0,0 @@ -use std::ops::Range; - -/// Trait for processing the edit-scripts computed with [`diff`](crate::diff) -pub trait Sink: Sized { - type Out; - - /// This method is called whenever a diff [`algorithm`](crate::Algorithm) - /// finds a change between the two processed input files. - /// A change is a continuous subsequence of [tokens](crate::intern::Token) `before` that needs - /// to be replaced by a different continuous subsequence of tokens `after` to construct the second file from the first. - /// - /// These token subsequences are passed to this function in **strictly monotonically increasing order**. - /// That means that for two subsequent calls `process_change(before1, after1)` and `process_change(before2, after2)` - /// the following always holds: - /// - /// ``` no_compile - /// assert!(before1.end < before2.start); - /// assert!(after1.end < after2.start); - /// ``` - /// - /// # Parameters - /// - **`before`** - the **position** of the removed token subsequence in the original file. - /// - **`after`** - the **position** of the inserted token subsequence in the destination file. - /// - /// # Notes - //// - /// A `Sink` has no function to indicate that a section of a file remains unchanged. - /// However due to the monotonically increasing calls, implementations can easily determine - /// which subsequences remain unchanged by saving `before.end`/`after.end`. - /// The range between `before.start`/`after.end` and the previous `before.end`/`after.end` - /// is always unchanged. - fn process_change(&mut self, before: Range, after: Range); - - /// This function is called after all calls to `process_change` are complete - /// to obtain the final diff result - fn finish(self) -> Self::Out; - - /// Utility method that constructs a [`Counter`] that tracks the total number - /// of inserted and removed tokens in the changes passed to [`process_change`](crate::Sink::process_change). - fn with_counter(self) -> Counter { - Counter::new(self) - } -} - -impl, Range)> Sink for T { - type Out = (); - - fn process_change(&mut self, before: Range, after: Range) { - self(before, after) - } - - fn finish(self) -> Self::Out {} -} - -impl Sink for () { - type Out = (); - fn process_change(&mut self, _before: Range, _after: Range) {} - fn finish(self) -> Self::Out {} -} - -/// A [`Sink`] which wraps a different sink -/// and counts the number of `removed` and `inserted` [tokens](crate::intern::Token). -pub struct Counter { - /// Total number of recorded inserted [`tokens`](crate::intern::Token). - /// Computed by summing the lengths of the `after` subsequences passed to [`process_change`](crate::Sink::process_change). - pub removals: u32, - /// Total number of recorded inserted [`tokens`](crate::intern::Token). - /// Computed by summing the lengths of the `after` subsequences passed to [`process_change`](crate::Sink::process_change). - pub insertions: u32, - /// The [`Sink`] for which the counter records [`tokens`](crate::intern::Token). - /// All calls to [`process_change`](crate::Sink::process_change) are forwarded to the `sink` by the counter. - /// After [`finish`](crate::Sink::finish) is called, this field contains the output returned by the [`finish`](crate::Sink::finish) - /// method of the wrapped [`Sink`]. - pub wrapped: T, -} - -impl Counter { - pub fn new(sink: S) -> Self { - Self { - insertions: 0, - removals: 0, - wrapped: sink, - } - } -} - -impl Sink for Counter { - type Out = Counter; - fn process_change(&mut self, before: Range, after: Range) { - self.removals += before.end - before.start; - self.insertions += after.end - after.start; - self.wrapped.process_change(before, after) - } - - fn finish(self) -> Self::Out { - Counter { - removals: self.removals, - insertions: self.insertions, - wrapped: self.wrapped.finish(), - } - } -} - -impl Counter { - pub fn total(&self) -> usize { - self.insertions as usize + self.removals as usize - } -} - -impl Default for Counter<()> { - fn default() -> Self { - Counter::new(()) - } -} From 332c9fcb04c157a0d3c226abc265927a41a107dd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 7 Dec 2025 15:49:43 +0000 Subject: [PATCH 2/6] Add comprehensive fuzz testing with cargo-fuzz for all major algorithms Co-authored-by: Byron <63622+Byron@users.noreply.github.com> --- .github/workflows/ci.yml | 24 ++++ README.md | 3 + fuzz/.gitignore | 4 + fuzz/Cargo.lock | 135 ++++++++++++++++++++ fuzz/Cargo.toml | 43 +++++++ fuzz/README.md | 73 +++++++++++ fuzz/fuzz_targets/comprehensive_diff.rs | 63 +++++++++ fuzz/fuzz_targets/diff_compute_with.rs | 55 ++++++++ fuzz/fuzz_targets/postprocess_heuristics.rs | 59 +++++++++ fuzz/fuzz_targets/unified_diff_printer.rs | 75 +++++++++++ 10 files changed, 534 insertions(+) create mode 100644 fuzz/.gitignore create mode 100644 fuzz/Cargo.lock create mode 100644 fuzz/Cargo.toml create mode 100644 fuzz/README.md create mode 100644 fuzz/fuzz_targets/comprehensive_diff.rs create mode 100644 fuzz/fuzz_targets/diff_compute_with.rs create mode 100644 fuzz/fuzz_targets/postprocess_heuristics.rs create mode 100644 fuzz/fuzz_targets/unified_diff_printer.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 687e770..c65b967 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,3 +88,27 @@ jobs: run: cargo doc --no-deps --workspace --document-private-items env: RUSTDOCFLAGS: -D warnings + + fuzz: + name: Fuzz Tests + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout sources + uses: actions/checkout@v4 + + - name: Install nightly toolchain + uses: dtolnay/rust-toolchain@nightly + + - uses: Swatinem/rust-cache@v2 + + - name: Install cargo-fuzz + run: cargo install cargo-fuzz + + - name: Run fuzz tests + run: | + # Run each fuzz target for 45 seconds (total ~3 minutes for 4 targets) + for target in comprehensive_diff diff_compute_with postprocess_heuristics unified_diff_printer; do + cargo fuzz run $target -- -max_total_time=45 -runs=0 + done diff --git a/README.md b/README.md index fc09118..b1e722e 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,9 @@ The sourcecode of the helix editor. +## Testing + +`imara-diff` includes comprehensive fuzz testing using [cargo-fuzz](https://rust-fuzz.github.io/book/cargo-fuzz.html) to ensure robustness against arbitrary inputs. Fuzz tests cover all major algorithms (Myers, Histogram, MyersMinimal), postprocessing with different heuristics, and unified diff printing. See the [fuzz/README.md](fuzz/README.md) for more details on running fuzz tests locally. ## Stability Policy 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..a70c7e3 --- /dev/null +++ b/fuzz/Cargo.lock @@ -0,0 +1,135 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "cc" +version = "1.2.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "find-msvc-tools" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + +[[package]] +name = "imara-diff" +version = "0.2.0" +dependencies = [ + "hashbrown", + "memchr", +] + +[[package]] +name = "imara-diff-fuzz" +version = "0.0.0" +dependencies = [ + "imara-diff", + "libfuzzer-sys", +] + +[[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.178" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" + +[[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 = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[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 = "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..785cf3e --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "imara-diff-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" + +[dependencies.imara-diff] +path = ".." +features = ["unified_diff"] + +[[bin]] +name = "comprehensive_diff" +path = "fuzz_targets/comprehensive_diff.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "diff_compute_with" +path = "fuzz_targets/diff_compute_with.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "postprocess_heuristics" +path = "fuzz_targets/postprocess_heuristics.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "unified_diff_printer" +path = "fuzz_targets/unified_diff_printer.rs" +test = false +doc = false +bench = false diff --git a/fuzz/README.md b/fuzz/README.md new file mode 100644 index 0000000..8c7b76c --- /dev/null +++ b/fuzz/README.md @@ -0,0 +1,73 @@ +# Fuzz Testing + +This directory contains fuzz tests for imara-diff using [cargo-fuzz](https://rust-fuzz.github.io/book/cargo-fuzz.html). + +## Fuzz Targets + +The following fuzz targets are available: + +### 1. `comprehensive_diff` +Tests all three diff algorithms (Myers, Histogram, MyersMinimal) with: +- Computing diffs on arbitrary string inputs +- Postprocessing with no heuristic and line heuristic +- Unified diff printing +- Basic queries (count_additions, count_removals, is_added, is_removed) +- Hunks iteration + +### 2. `diff_compute_with` +Tests the lower-level `compute_with` API that works directly with Token sequences: +- Creating arbitrary token sequences +- Computing diffs with all algorithms +- Querying individual token states +- Iterating through hunks + +### 3. `postprocess_heuristics` +Tests postprocessing with different heuristics: +- No heuristic +- Line heuristic (default indent-based) +- Custom indent heuristic with different tab sizes +- Validates hunk ranges are valid after postprocessing + +### 4. `unified_diff_printer` +Tests unified diff printing with: +- Different context lengths (0-10) +- Various input combinations +- Validates output format (lines start with ' ', '+', '-', or '@') + +## Running Fuzz Tests + +### Prerequisites +- Nightly Rust toolchain: `rustup install nightly` +- cargo-fuzz: `cargo install cargo-fuzz` + +### Running a specific target +```bash +# Run for a specific time (e.g., 60 seconds) +cargo +nightly fuzz run comprehensive_diff -- -max_total_time=60 + +# Run with a specific number of runs +cargo +nightly fuzz run comprehensive_diff -- -runs=1000000 +``` + +### Running all targets +```bash +for target in comprehensive_diff diff_compute_with postprocess_heuristics unified_diff_printer; do + cargo +nightly fuzz run $target -- -max_total_time=60 +done +``` + +### Analyzing coverage +```bash +cargo +nightly fuzz coverage comprehensive_diff +``` + +## CI Integration + +Fuzz tests are automatically run in CI for 3 minutes total (45 seconds per target × 4 targets) to ensure no regressions in robustness. + +## Adding New Fuzz Targets + +1. Create a new file in `fuzz_targets/` directory +2. Add a new `[[bin]]` entry in `Cargo.toml` +3. Update the CI workflow to run the new target +4. Update this README with a description of the new target diff --git a/fuzz/fuzz_targets/comprehensive_diff.rs b/fuzz/fuzz_targets/comprehensive_diff.rs new file mode 100644 index 0000000..2fcee18 --- /dev/null +++ b/fuzz/fuzz_targets/comprehensive_diff.rs @@ -0,0 +1,63 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use imara_diff::{Algorithm, Diff, InternedInput}; + +fuzz_target!(|data: &[u8]| { + // Split the input data into two parts for before and after strings + // We use a simple strategy: if data is empty, use empty strings + // otherwise find a split point to create before/after + + if data.is_empty() { + return; + } + + // Use the first byte as a split point indicator (modulo the length) + let split_point = if data.len() > 1 { + (data[0] as usize % data.len()).min(data.len() - 1) + } else { + 0 + }; + + let before_bytes = &data[..split_point]; + let after_bytes = &data[split_point..]; + + // Convert to strings, replacing invalid UTF-8 with replacement character + let before = String::from_utf8_lossy(before_bytes); + let after = String::from_utf8_lossy(after_bytes); + + // Create interned input + let input = InternedInput::new(before.as_ref(), after.as_ref()); + + // Test all three diff algorithms + for algorithm in [Algorithm::Histogram, Algorithm::Myers, Algorithm::MyersMinimal] { + // Compute diff + let mut diff = Diff::compute(algorithm, &input); + + // Test basic queries + let _ = diff.count_additions(); + let _ = diff.count_removals(); + + // Test hunks iteration + for hunk in diff.hunks() { + let _ = hunk.is_pure_insertion(); + let _ = hunk.is_pure_removal(); + let _ = hunk.invert(); + } + + // Test postprocessing with no heuristic + diff.postprocess_no_heuristic(&input); + + // Test postprocessing with line heuristic + diff.postprocess_lines(&input); + + // Test unified diff printing + { + use imara_diff::{BasicLineDiffPrinter, UnifiedDiffConfig}; + let printer = BasicLineDiffPrinter(&input.interner); + let config = UnifiedDiffConfig::default(); + let unified = diff.unified_diff(&printer, config, &input); + let _ = unified.to_string(); + } + } +}); diff --git a/fuzz/fuzz_targets/diff_compute_with.rs b/fuzz/fuzz_targets/diff_compute_with.rs new file mode 100644 index 0000000..4d499f4 --- /dev/null +++ b/fuzz/fuzz_targets/diff_compute_with.rs @@ -0,0 +1,55 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use imara_diff::{Algorithm, Diff, Token}; + +fuzz_target!(|data: &[u8]| { + // Test the lower-level compute_with API that works directly with tokens + if data.len() < 4 { + return; + } + + // Use first two bytes to determine before/after lengths + let before_len = (data[0] as usize % 100).min(data.len() / 2); + let after_len = (data[1] as usize % 100).min(data.len() / 2); + + // Create token sequences from remaining bytes + let mut before_tokens = Vec::new(); + let mut after_tokens = Vec::new(); + + for i in 0..before_len { + if i + 2 < data.len() { + before_tokens.push(Token::from(data[i + 2] as u32 % 256)); + } + } + + for i in 0..after_len { + if i + 2 + before_len < data.len() { + after_tokens.push(Token::from(data[i + 2 + before_len] as u32 % 256)); + } + } + + // Test all algorithms with compute_with + for algorithm in [Algorithm::Histogram, Algorithm::Myers, Algorithm::MyersMinimal] { + let mut diff = Diff::default(); + diff.compute_with(algorithm, &before_tokens, &after_tokens, 256); + + // Test basic queries + let _ = diff.count_additions(); + let _ = diff.count_removals(); + + // Test is_removed and is_added for valid indices + for i in 0..before_tokens.len() as u32 { + let _ = diff.is_removed(i); + } + for i in 0..after_tokens.len() as u32 { + let _ = diff.is_added(i); + } + + // Test hunks + for hunk in diff.hunks() { + let _ = hunk.is_pure_insertion(); + let _ = hunk.is_pure_removal(); + } + } +}); diff --git a/fuzz/fuzz_targets/postprocess_heuristics.rs b/fuzz/fuzz_targets/postprocess_heuristics.rs new file mode 100644 index 0000000..9f2175d --- /dev/null +++ b/fuzz/fuzz_targets/postprocess_heuristics.rs @@ -0,0 +1,59 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use imara_diff::{Algorithm, Diff, InternedInput, IndentHeuristic, IndentLevel}; + +fuzz_target!(|data: &[u8]| { + // Test postprocessing with various heuristics + if data.is_empty() { + return; + } + + // Split input into before and after + let split_point = if data.len() > 1 { + (data[0] as usize % data.len()).min(data.len() - 1) + } else { + 0 + }; + + let before_bytes = &data[..split_point]; + let after_bytes = &data[split_point..]; + + let before = String::from_utf8_lossy(before_bytes); + let after = String::from_utf8_lossy(after_bytes); + + let input = InternedInput::new(before.as_ref(), after.as_ref()); + + // Test with different algorithms + for algorithm in [Algorithm::Histogram, Algorithm::Myers] { + let mut diff = Diff::compute(algorithm, &input); + + // Test postprocess with no heuristic + diff.postprocess_no_heuristic(&input); + let _ = diff.count_additions(); + let _ = diff.count_removals(); + + // Test postprocess with line heuristic + let mut diff2 = Diff::compute(algorithm, &input); + diff2.postprocess_lines(&input); + let _ = diff2.count_additions(); + let _ = diff2.count_removals(); + + // Test postprocess with custom indent heuristic + let mut diff3 = Diff::compute(algorithm, &input); + diff3.postprocess_with_heuristic( + &input, + IndentHeuristic::new(|token| { + IndentLevel::for_ascii_line(input.interner[token].as_bytes().iter().copied(), 4) + }), + ); + let _ = diff3.count_additions(); + let _ = diff3.count_removals(); + + // Verify hunks are valid after postprocessing + for hunk in diff.hunks() { + assert!(hunk.before.start <= hunk.before.end); + assert!(hunk.after.start <= hunk.after.end); + } + } +}); diff --git a/fuzz/fuzz_targets/unified_diff_printer.rs b/fuzz/fuzz_targets/unified_diff_printer.rs new file mode 100644 index 0000000..d0e3f5b --- /dev/null +++ b/fuzz/fuzz_targets/unified_diff_printer.rs @@ -0,0 +1,75 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use imara_diff::{Algorithm, Diff, InternedInput, BasicLineDiffPrinter, UnifiedDiffConfig}; + +/// Valid prefixes for unified diff output lines +const VALID_DIFF_LINE_PREFIXES: [char; 4] = [' ', '+', '-', '@']; + +fuzz_target!(|data: &[u8]| { + // Test unified diff printing extensively + { + if data.is_empty() { + return; + } + + // Split input into before, after, and context_len + let split1 = if data.len() > 2 { + (data[0] as usize % data.len()).min(data.len() - 1) + } else { + 0 + }; + + let split2 = if data.len() > split1 + 1 { + split1 + ((data[split1] as usize % (data.len() - split1)).max(1)) + } else { + data.len() + }; + + let before_bytes = &data[..split1]; + let after_bytes = &data[split1..split2]; + + // Use remaining byte for context_len (0-10) + let context_len = if split2 < data.len() { + data[split2] as u32 % 11 + } else { + 3 + }; + + let before = String::from_utf8_lossy(before_bytes); + let after = String::from_utf8_lossy(after_bytes); + + let input = InternedInput::new(before.as_ref(), after.as_ref()); + + // Test with different algorithms + for algorithm in [Algorithm::Histogram, Algorithm::Myers, Algorithm::MyersMinimal] { + let mut diff = Diff::compute(algorithm, &input); + + // Postprocess before printing + diff.postprocess_lines(&input); + + // Create printer and config + let printer = BasicLineDiffPrinter(&input.interner); + let mut config = UnifiedDiffConfig::default(); + config.context_len(context_len); + + // Generate unified diff - should not panic + let unified = diff.unified_diff(&printer, config, &input); + let output = unified.to_string(); + + // Basic sanity checks on output + // It should be valid UTF-8 (already guaranteed by to_string) + // Lines should start with valid diff prefixes + for line in output.lines() { + if !line.is_empty() { + let first_char = line.chars().next().unwrap(); + // Should be a valid diff line prefix + assert!( + VALID_DIFF_LINE_PREFIXES.contains(&first_char), + "Invalid diff line prefix: '{}' in line: '{}'", first_char, line + ); + } + } + } + } +}); From ef21d963cda82fd8d255ec8b1f7a161d485d44b3 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Sun, 7 Dec 2025 19:25:23 +0100 Subject: [PATCH 3/6] refactor --- .github/workflows/ci.yml | 4 +- fuzz/Cargo.lock | 49 ++++++++ fuzz/Cargo.toml | 2 +- fuzz/README.md | 34 +----- fuzz/fuzz_targets/comprehensive_diff.rs | 71 +++++------ fuzz/fuzz_targets/diff_compute_with.rs | 35 ++++-- fuzz/fuzz_targets/postprocess_heuristics.rs | 63 +++++----- fuzz/fuzz_targets/unified_diff_printer.rs | 123 ++++++++++---------- 8 files changed, 203 insertions(+), 178 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c65b967..e96f667 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,6 +109,6 @@ jobs: - name: Run fuzz tests run: | # Run each fuzz target for 45 seconds (total ~3 minutes for 4 targets) - for target in comprehensive_diff diff_compute_with postprocess_heuristics unified_diff_printer; do - cargo fuzz run $target -- -max_total_time=45 -runs=0 + for target in unified_diff_printer postprocess_heuristics comprehensive_diff diff_compute_with; do + cargo fuzz run $target --release -- -max_total_time=45 done diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index a70c7e3..7265389 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -7,6 +7,9 @@ name = "arbitrary" version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] [[package]] name = "cc" @@ -26,6 +29,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[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 = "find-msvc-tools" version = "0.1.5" @@ -107,6 +121,24 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + [[package]] name = "r-efi" version = "5.3.0" @@ -119,6 +151,23 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "syn" +version = "2.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + [[package]] name = "wasip2" version = "1.0.1+wasi-0.2.4" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 785cf3e..c742dcb 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -8,7 +8,7 @@ edition = "2021" cargo-fuzz = true [dependencies] -libfuzzer-sys = "0.4" +libfuzzer-sys = { version = "0.4", features = ["arbitrary-derive"] } [dependencies.imara-diff] path = ".." diff --git a/fuzz/README.md b/fuzz/README.md index 8c7b76c..dae0670 100644 --- a/fuzz/README.md +++ b/fuzz/README.md @@ -2,38 +2,6 @@ This directory contains fuzz tests for imara-diff using [cargo-fuzz](https://rust-fuzz.github.io/book/cargo-fuzz.html). -## Fuzz Targets - -The following fuzz targets are available: - -### 1. `comprehensive_diff` -Tests all three diff algorithms (Myers, Histogram, MyersMinimal) with: -- Computing diffs on arbitrary string inputs -- Postprocessing with no heuristic and line heuristic -- Unified diff printing -- Basic queries (count_additions, count_removals, is_added, is_removed) -- Hunks iteration - -### 2. `diff_compute_with` -Tests the lower-level `compute_with` API that works directly with Token sequences: -- Creating arbitrary token sequences -- Computing diffs with all algorithms -- Querying individual token states -- Iterating through hunks - -### 3. `postprocess_heuristics` -Tests postprocessing with different heuristics: -- No heuristic -- Line heuristic (default indent-based) -- Custom indent heuristic with different tab sizes -- Validates hunk ranges are valid after postprocessing - -### 4. `unified_diff_printer` -Tests unified diff printing with: -- Different context lengths (0-10) -- Various input combinations -- Validates output format (lines start with ' ', '+', '-', or '@') - ## Running Fuzz Tests ### Prerequisites @@ -52,7 +20,7 @@ cargo +nightly fuzz run comprehensive_diff -- -runs=1000000 ### Running all targets ```bash for target in comprehensive_diff diff_compute_with postprocess_heuristics unified_diff_printer; do - cargo +nightly fuzz run $target -- -max_total_time=60 + cargo +nightly fuzz run --release $target -- -max_total_time=60 done ``` diff --git a/fuzz/fuzz_targets/comprehensive_diff.rs b/fuzz/fuzz_targets/comprehensive_diff.rs index 2fcee18..eb1c488 100644 --- a/fuzz/fuzz_targets/comprehensive_diff.rs +++ b/fuzz/fuzz_targets/comprehensive_diff.rs @@ -1,63 +1,54 @@ #![no_main] -use libfuzzer_sys::fuzz_target; use imara_diff::{Algorithm, Diff, InternedInput}; +use libfuzzer_sys::fuzz_target; -fuzz_target!(|data: &[u8]| { - // Split the input data into two parts for before and after strings - // We use a simple strategy: if data is empty, use empty strings - // otherwise find a split point to create before/after - - if data.is_empty() { - return; - } - - // Use the first byte as a split point indicator (modulo the length) - let split_point = if data.len() > 1 { - (data[0] as usize % data.len()).min(data.len() - 1) - } else { - 0 - }; - - let before_bytes = &data[..split_point]; - let after_bytes = &data[split_point..]; - - // Convert to strings, replacing invalid UTF-8 with replacement character - let before = String::from_utf8_lossy(before_bytes); - let after = String::from_utf8_lossy(after_bytes); - +use libfuzzer_sys::arbitrary; + +#[derive(arbitrary::Arbitrary, Debug)] +struct Input<'a> { + before: &'a [u8], + after: &'a [u8], +} + +/// Tests all three diff algorithms (Myers, Histogram, MyersMinimal) with: +/// - Computing diffs on arbitrary string inputs +/// - Postprocessing with no heuristic and line heuristic +/// - Unified diff printing +/// - Basic queries (count_additions, count_removals, is_added, is_removed) +/// - Hunks iteration +fn do_fuzz(Input { before, after }: Input<'_>) { // Create interned input - let input = InternedInput::new(before.as_ref(), after.as_ref()); - + let input = InternedInput::new(before, after); + // Test all three diff algorithms - for algorithm in [Algorithm::Histogram, Algorithm::Myers, Algorithm::MyersMinimal] { + for algorithm in [ + Algorithm::Histogram, + Algorithm::Myers, + Algorithm::MyersMinimal, + ] { // Compute diff let mut diff = Diff::compute(algorithm, &input); - + // Test basic queries let _ = diff.count_additions(); let _ = diff.count_removals(); - + // Test hunks iteration for hunk in diff.hunks() { let _ = hunk.is_pure_insertion(); let _ = hunk.is_pure_removal(); let _ = hunk.invert(); } - + // Test postprocessing with no heuristic diff.postprocess_no_heuristic(&input); - + // Test postprocessing with line heuristic diff.postprocess_lines(&input); - - // Test unified diff printing - { - use imara_diff::{BasicLineDiffPrinter, UnifiedDiffConfig}; - let printer = BasicLineDiffPrinter(&input.interner); - let config = UnifiedDiffConfig::default(); - let unified = diff.unified_diff(&printer, config, &input); - let _ = unified.to_string(); - } } +} + +fuzz_target!(|input: Input<'_>| { + do_fuzz(input); }); diff --git a/fuzz/fuzz_targets/diff_compute_with.rs b/fuzz/fuzz_targets/diff_compute_with.rs index 4d499f4..c81300b 100644 --- a/fuzz/fuzz_targets/diff_compute_with.rs +++ b/fuzz/fuzz_targets/diff_compute_with.rs @@ -1,43 +1,52 @@ #![no_main] -use libfuzzer_sys::fuzz_target; use imara_diff::{Algorithm, Diff, Token}; +use libfuzzer_sys::fuzz_target; -fuzz_target!(|data: &[u8]| { +/// Tests the lower-level `compute_with` API that works directly with Token sequences: +/// - Creating arbitrary token sequences +/// - Computing diffs with all algorithms +/// - Querying individual token states +/// - Iterating through hunks +fn do_fuzz(data: &[u8]) { // Test the lower-level compute_with API that works directly with tokens if data.len() < 4 { return; } - + // Use first two bytes to determine before/after lengths let before_len = (data[0] as usize % 100).min(data.len() / 2); let after_len = (data[1] as usize % 100).min(data.len() / 2); - + // Create token sequences from remaining bytes let mut before_tokens = Vec::new(); let mut after_tokens = Vec::new(); - + for i in 0..before_len { if i + 2 < data.len() { before_tokens.push(Token::from(data[i + 2] as u32 % 256)); } } - + for i in 0..after_len { if i + 2 + before_len < data.len() { after_tokens.push(Token::from(data[i + 2 + before_len] as u32 % 256)); } } - + // Test all algorithms with compute_with - for algorithm in [Algorithm::Histogram, Algorithm::Myers, Algorithm::MyersMinimal] { + for algorithm in [ + Algorithm::Histogram, + Algorithm::Myers, + Algorithm::MyersMinimal, + ] { let mut diff = Diff::default(); diff.compute_with(algorithm, &before_tokens, &after_tokens, 256); - + // Test basic queries let _ = diff.count_additions(); let _ = diff.count_removals(); - + // Test is_removed and is_added for valid indices for i in 0..before_tokens.len() as u32 { let _ = diff.is_removed(i); @@ -45,11 +54,15 @@ fuzz_target!(|data: &[u8]| { for i in 0..after_tokens.len() as u32 { let _ = diff.is_added(i); } - + // Test hunks for hunk in diff.hunks() { let _ = hunk.is_pure_insertion(); let _ = hunk.is_pure_removal(); } } +} + +fuzz_target!(|data: &[u8]| { + do_fuzz(data); }); diff --git a/fuzz/fuzz_targets/postprocess_heuristics.rs b/fuzz/fuzz_targets/postprocess_heuristics.rs index 9f2175d..5e8e01b 100644 --- a/fuzz/fuzz_targets/postprocess_heuristics.rs +++ b/fuzz/fuzz_targets/postprocess_heuristics.rs @@ -1,59 +1,68 @@ #![no_main] +use imara_diff::{Algorithm, Diff, IndentHeuristic, IndentLevel, InternedInput}; use libfuzzer_sys::fuzz_target; -use imara_diff::{Algorithm, Diff, InternedInput, IndentHeuristic, IndentLevel}; -fuzz_target!(|data: &[u8]| { - // Test postprocessing with various heuristics - if data.is_empty() { - return; - } - - // Split input into before and after - let split_point = if data.len() > 1 { - (data[0] as usize % data.len()).min(data.len() - 1) - } else { - 0 - }; - - let before_bytes = &data[..split_point]; - let after_bytes = &data[split_point..]; - - let before = String::from_utf8_lossy(before_bytes); - let after = String::from_utf8_lossy(after_bytes); - - let input = InternedInput::new(before.as_ref(), after.as_ref()); - +use libfuzzer_sys::arbitrary; + +#[derive(arbitrary::Arbitrary, Debug)] +struct Input<'a> { + before: &'a str, + after: &'a str, + ident_level: u8, +} + +/// Tests postprocessing with different heuristics: +/// - No heuristic +/// - Line heuristic (default indent-based) +/// - Custom indent heuristic with different tab sizes +/// - Validates hunk ranges are valid after postprocessing +fn do_fuzz( + Input { + before, + after, + ident_level, + }: Input<'_>, +) { + let input = InternedInput::new(before, after); + // Test with different algorithms for algorithm in [Algorithm::Histogram, Algorithm::Myers] { let mut diff = Diff::compute(algorithm, &input); - + // Test postprocess with no heuristic diff.postprocess_no_heuristic(&input); let _ = diff.count_additions(); let _ = diff.count_removals(); - + // Test postprocess with line heuristic let mut diff2 = Diff::compute(algorithm, &input); diff2.postprocess_lines(&input); let _ = diff2.count_additions(); let _ = diff2.count_removals(); - + // Test postprocess with custom indent heuristic let mut diff3 = Diff::compute(algorithm, &input); diff3.postprocess_with_heuristic( &input, IndentHeuristic::new(|token| { - IndentLevel::for_ascii_line(input.interner[token].as_bytes().iter().copied(), 4) + IndentLevel::for_ascii_line( + input.interner[token].as_bytes().iter().copied(), + ident_level, + ) }), ); let _ = diff3.count_additions(); let _ = diff3.count_removals(); - + // Verify hunks are valid after postprocessing for hunk in diff.hunks() { assert!(hunk.before.start <= hunk.before.end); assert!(hunk.after.start <= hunk.after.end); } } +} + +fuzz_target!(|input: Input<'_>| { + do_fuzz(input); }); diff --git a/fuzz/fuzz_targets/unified_diff_printer.rs b/fuzz/fuzz_targets/unified_diff_printer.rs index d0e3f5b..1ab3a31 100644 --- a/fuzz/fuzz_targets/unified_diff_printer.rs +++ b/fuzz/fuzz_targets/unified_diff_printer.rs @@ -1,75 +1,70 @@ #![no_main] +use imara_diff::{Algorithm, BasicLineDiffPrinter, Diff, InternedInput, UnifiedDiffConfig}; +use libfuzzer_sys::arbitrary; use libfuzzer_sys::fuzz_target; -use imara_diff::{Algorithm, Diff, InternedInput, BasicLineDiffPrinter, UnifiedDiffConfig}; /// Valid prefixes for unified diff output lines const VALID_DIFF_LINE_PREFIXES: [char; 4] = [' ', '+', '-', '@']; -fuzz_target!(|data: &[u8]| { - // Test unified diff printing extensively - { - if data.is_empty() { - return; - } - - // Split input into before, after, and context_len - let split1 = if data.len() > 2 { - (data[0] as usize % data.len()).min(data.len() - 1) - } else { - 0 - }; - - let split2 = if data.len() > split1 + 1 { - split1 + ((data[split1] as usize % (data.len() - split1)).max(1)) - } else { - data.len() - }; - - let before_bytes = &data[..split1]; - let after_bytes = &data[split1..split2]; - - // Use remaining byte for context_len (0-10) - let context_len = if split2 < data.len() { - data[split2] as u32 % 11 - } else { - 3 - }; - - let before = String::from_utf8_lossy(before_bytes); - let after = String::from_utf8_lossy(after_bytes); - - let input = InternedInput::new(before.as_ref(), after.as_ref()); - - // Test with different algorithms - for algorithm in [Algorithm::Histogram, Algorithm::Myers, Algorithm::MyersMinimal] { - let mut diff = Diff::compute(algorithm, &input); - - // Postprocess before printing - diff.postprocess_lines(&input); - - // Create printer and config - let printer = BasicLineDiffPrinter(&input.interner); - let mut config = UnifiedDiffConfig::default(); - config.context_len(context_len); - - // Generate unified diff - should not panic - let unified = diff.unified_diff(&printer, config, &input); - let output = unified.to_string(); - - // Basic sanity checks on output - // It should be valid UTF-8 (already guaranteed by to_string) - // Lines should start with valid diff prefixes - for line in output.lines() { - if !line.is_empty() { - let first_char = line.chars().next().unwrap(); - // Should be a valid diff line prefix - assert!( - VALID_DIFF_LINE_PREFIXES.contains(&first_char), - "Invalid diff line prefix: '{}' in line: '{}'", first_char, line - ); - } +#[derive(arbitrary::Arbitrary, Debug)] +struct Input<'a> { + before: &'a str, + after: &'a str, + context_len: u32, +} + +/// Tests unified diff printing with: +/// - Different context lengths (0-10) +/// - Various input combinations +/// - Validates output format (lines start with ' ', '+', '-', or '@') +fn do_fuzz( + Input { + before, + after, + context_len, + }: Input<'_>, +) { + let input = InternedInput::new(before, after); + + // Test with different algorithms + for algorithm in [ + Algorithm::Histogram, + Algorithm::Myers, + Algorithm::MyersMinimal, + ] { + let mut diff = Diff::compute(algorithm, &input); + + // Postprocess before printing + diff.postprocess_lines(&input); + + // Create printer and config + let printer = BasicLineDiffPrinter(&input.interner); + let mut config = UnifiedDiffConfig::default(); + config.context_len(context_len); + + // Generate unified diff + let unified = diff.unified_diff(&printer, config, &input); + let output = unified.to_string(); + + // Basic sanity checks on output + // It should be valid UTF-8 (already guaranteed by to_string) + // Lines should start with valid diff prefixes + for line in output.lines() { + if !line.is_empty() { + let first_char = line.chars().next().unwrap(); + // Should be a valid diff line prefix + assert!( + VALID_DIFF_LINE_PREFIXES.contains(&first_char), + "Invalid diff line prefix: '{}' in line: '{}'", + first_char, + line + ); } } } +} + +fuzz_target!(|input: Input<'_>| { + do_fuzz(input); }); From d5ce397b0e6513007e4c14b3fa4d842ab2091d54 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Sun, 7 Dec 2025 20:12:11 +0100 Subject: [PATCH 4/6] Assure `tab_width` for `IdentLevel` isn't ever 0. That would panic, so we set it to at least 1. --- src/slider_heuristic.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/slider_heuristic.rs b/src/slider_heuristic.rs index a54e8c1..f3f8591 100644 --- a/src/slider_heuristic.rs +++ b/src/slider_heuristic.rs @@ -119,13 +119,14 @@ impl IndentLevel { /// # Parameters /// /// * `src` - An iterator over the bytes of the line - /// * `tab_width` - The number of spaces that a tab character represents + /// * `tab_width` - The number of spaces that a tab character represents (min is 1) /// /// # Returns /// /// The computed indentation level, or `BLANK` if the line contains only whitespace pub fn for_ascii_line(src: impl IntoIterator, tab_width: u8) -> IndentLevel { let mut indent_level = IndentLevel(0); + let tab_width = tab_width.max(1); for c in src { match c { b' ' => indent_level.0 += 1, From ca8d48c44d101d04ba25fe230174c0bc4a68b859 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Tue, 30 Dec 2025 10:11:59 +0100 Subject: [PATCH 5/6] Internally sanitize UnifiedDiffPrinter context len to avoid panic It's a bit unclear why this really happens, but this is a first fix to allow using fuzz tests. --- src/unified_diff.rs | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/src/unified_diff.rs b/src/unified_diff.rs index c975e63..725331a 100644 --- a/src/unified_diff.rs +++ b/src/unified_diff.rs @@ -222,22 +222,17 @@ pub struct UnifiedDiff<'a, P: UnifiedDiffPrinter> { impl Display for UnifiedDiff<'_, P> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let first_hunk = self.diff.hunks().next().unwrap_or_default(); - let mut pos = first_hunk - .before - .start - .saturating_sub(self.config.context_len); + let context_len = self.config.context_len.min(1024 * 1024); + let mut pos = first_hunk.before.start.saturating_sub(context_len); let mut before_context_start = pos; - let mut after_context_start = first_hunk - .after - .start - .saturating_sub(self.config.context_len); + let mut after_context_start = first_hunk.after.start.saturating_sub(context_len); let mut before_context_len = 0; let mut after_context_len = 0; let mut buffer = String::new(); for hunk in self.diff.hunks() { - if hunk.before.start - pos > 2 * self.config.context_len { + if hunk.before.start - pos > 2 * context_len { if !buffer.is_empty() { - let end = (pos + self.config.context_len).min(self.before.len() as u32); + let end = (pos + context_len).min(self.before.len() as u32); self.printer.display_header( &mut *f, before_context_start, @@ -251,9 +246,9 @@ impl Display for UnifiedDiff<'_, P> { } buffer.clear(); } - pos = hunk.before.start - self.config.context_len; + pos = hunk.before.start - context_len; before_context_start = pos; - after_context_start = hunk.after.start - self.config.context_len; + after_context_start = hunk.after.start - context_len; before_context_len = 0; after_context_len = 0; } @@ -271,7 +266,7 @@ impl Display for UnifiedDiff<'_, P> { pos = hunk.before.end; } if !buffer.is_empty() { - let end = (pos + self.config.context_len).min(self.before.len() as u32); + let end = (pos + context_len).min(self.before.len() as u32); self.printer.display_header( &mut *f, before_context_start, From 59c0f85d6f8e2fc4add3988f07ab44d17c996877 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Tue, 30 Dec 2025 10:13:13 +0100 Subject: [PATCH 6/6] Also fuzz word diffs --- fuzz/fuzz_targets/comprehensive_diff.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/fuzz/fuzz_targets/comprehensive_diff.rs b/fuzz/fuzz_targets/comprehensive_diff.rs index eb1c488..26642dc 100644 --- a/fuzz/fuzz_targets/comprehensive_diff.rs +++ b/fuzz/fuzz_targets/comprehensive_diff.rs @@ -8,7 +8,9 @@ use libfuzzer_sys::arbitrary; #[derive(arbitrary::Arbitrary, Debug)] struct Input<'a> { before: &'a [u8], + before_str: &'a str, after: &'a [u8], + after_str: &'a str, } /// Tests all three diff algorithms (Myers, Histogram, MyersMinimal) with: @@ -17,7 +19,14 @@ struct Input<'a> { /// - Unified diff printing /// - Basic queries (count_additions, count_removals, is_added, is_removed) /// - Hunks iteration -fn do_fuzz(Input { before, after }: Input<'_>) { +fn do_fuzz( + Input { + before, + before_str, + after, + after_str, + }: Input<'_>, +) { // Create interned input let input = InternedInput::new(before, after); @@ -47,6 +56,15 @@ fn do_fuzz(Input { before, after }: Input<'_>) { // Test postprocessing with line heuristic diff.postprocess_lines(&input); } + + let input = InternedInput::new(before_str, after_str); + let mut word_input = InternedInput::default(); + let mut word_diff = Diff::default(); + + let diff = Diff::compute(Algorithm::Myers, &input); + for hunk in diff.hunks() { + hunk.latin_word_diff(&input, &mut word_input, &mut word_diff); + } } fuzz_target!(|input: Input<'_>| {