mirror of
https://github.com/astral-sh/ruff.git
synced 2025-12-11 12:31:00 +00:00
Add a idempotent fuzz_target for ruff_python_formatter (#9448)
Co-authored-by: Addison Crump <addison.crump@cispa.de> Co-authored-by: Addison Crump <me@addisoncrump.info>
This commit is contained in:
parent
4a3bb67b5f
commit
14d3fe6bfa
6 changed files with 170 additions and 0 deletions
|
|
@ -18,9 +18,11 @@ cargo-fuzz = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
ruff_linter = { path = "../crates/ruff_linter" }
|
ruff_linter = { path = "../crates/ruff_linter" }
|
||||||
|
ruff_python_ast = { path = "../crates/ruff_python_ast" }
|
||||||
ruff_python_codegen = { path = "../crates/ruff_python_codegen" }
|
ruff_python_codegen = { path = "../crates/ruff_python_codegen" }
|
||||||
ruff_python_parser = { path = "../crates/ruff_python_parser" }
|
ruff_python_parser = { path = "../crates/ruff_python_parser" }
|
||||||
ruff_source_file = { path = "../crates/ruff_source_file" }
|
ruff_source_file = { path = "../crates/ruff_source_file" }
|
||||||
|
ruff_python_formatter = { path = "../crates/ruff_python_formatter"}
|
||||||
|
|
||||||
arbitrary = { version = "1.3.0", features = ["derive"] }
|
arbitrary = { version = "1.3.0", features = ["derive"] }
|
||||||
libfuzzer-sys = { git = "https://github.com/rust-fuzz/libfuzzer", default-features = false }
|
libfuzzer-sys = { git = "https://github.com/rust-fuzz/libfuzzer", default-features = false }
|
||||||
|
|
@ -38,10 +40,18 @@ path = "fuzz_targets/ruff_parse_simple.rs"
|
||||||
name = "ruff_fix_validity"
|
name = "ruff_fix_validity"
|
||||||
path = "fuzz_targets/ruff_fix_validity.rs"
|
path = "fuzz_targets/ruff_fix_validity.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "ruff_formatter_validity"
|
||||||
|
path = "fuzz_targets/ruff_formatter_validity.rs"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "ruff_parse_idempotency"
|
name = "ruff_parse_idempotency"
|
||||||
path = "fuzz_targets/ruff_parse_idempotency.rs"
|
path = "fuzz_targets/ruff_parse_idempotency.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "ruff_formatter_idempotency"
|
||||||
|
path = "fuzz_targets/ruff_formatter_idempotency.rs"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
debug = true
|
debug = true
|
||||||
|
|
|
||||||
|
|
@ -101,3 +101,16 @@ This fuzz harness checks that fixes applied by Ruff do not introduce new errors
|
||||||
[`ruff_linter::test::test_snippet`](../crates/ruff_linter/src/test.rs) testing utility.
|
[`ruff_linter::test::test_snippet`](../crates/ruff_linter/src/test.rs) testing utility.
|
||||||
It currently is only configured to use default settings, but may be extended in future versions to
|
It currently is only configured to use default settings, but may be extended in future versions to
|
||||||
test non-default linter settings.
|
test non-default linter settings.
|
||||||
|
|
||||||
|
### `ruff_formatter_idempotency`
|
||||||
|
|
||||||
|
This fuzz harness ensures that the formatter is [idempotent](https://en.wikipedia.org/wiki/Idempotence)
|
||||||
|
which detects possible unsteady states of Ruff's formatter.
|
||||||
|
|
||||||
|
### `ruff_formatter_validity`
|
||||||
|
|
||||||
|
This fuzz harness checks that Ruff's formatter does not introduce new linter errors/warnings by
|
||||||
|
linting once, counting the number of each error type, then formatting, then linting again and
|
||||||
|
ensuring that the number of each error type does not increase across formats. This has the
|
||||||
|
beneficial side effect of discovering cases where the linter does not discover a lint error when
|
||||||
|
it should have due to a formatting inconsistency.
|
||||||
|
|
|
||||||
1
fuzz/corpus/ruff_formatter_idempotency
Symbolic link
1
fuzz/corpus/ruff_formatter_idempotency
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
ruff_fix_validity
|
||||||
1
fuzz/corpus/ruff_formatter_validity
Symbolic link
1
fuzz/corpus/ruff_formatter_validity
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
ruff_fix_validity
|
||||||
47
fuzz/fuzz_targets/ruff_formatter_idempotency.rs
Normal file
47
fuzz/fuzz_targets/ruff_formatter_idempotency.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
//! Fuzzer harness which double formats the input and access the idempotency or unsteady state of the
|
||||||
|
//! ruff's formatter.
|
||||||
|
|
||||||
|
#![no_main]
|
||||||
|
|
||||||
|
use libfuzzer_sys::{fuzz_target, Corpus};
|
||||||
|
use similar::TextDiff;
|
||||||
|
|
||||||
|
use ruff_python_formatter::{format_module_source, PyFormatOptions};
|
||||||
|
|
||||||
|
fn do_fuzz(case: &[u8]) -> Corpus {
|
||||||
|
// Throw away inputs which aren't utf-8
|
||||||
|
let Ok(code) = std::str::from_utf8(case) else {
|
||||||
|
return Corpus::Reject;
|
||||||
|
};
|
||||||
|
|
||||||
|
let options = PyFormatOptions::default();
|
||||||
|
// format the code once
|
||||||
|
if let Ok(formatted) = format_module_source(code, options.clone()) {
|
||||||
|
let formatted = formatted.as_code();
|
||||||
|
|
||||||
|
// reformat the code second time
|
||||||
|
if let Ok(reformatted) = format_module_source(formatted, options.clone()) {
|
||||||
|
let reformatted = reformatted.as_code();
|
||||||
|
|
||||||
|
if formatted != reformatted {
|
||||||
|
let diff = TextDiff::from_lines(formatted, reformatted)
|
||||||
|
.unified_diff()
|
||||||
|
.header("Formatted Once", "Formatted Twice")
|
||||||
|
.to_string();
|
||||||
|
panic!(
|
||||||
|
"\nReformatting the code a second time resulted in formatting changes.\nInput: {:?}\ndiff:\n{}",
|
||||||
|
code, diff
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
panic!(
|
||||||
|
"Unable to format the code second time:\nInput:{:?}\nformatted:\n{:?}",
|
||||||
|
code, formatted
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Corpus::Keep
|
||||||
|
}
|
||||||
|
|
||||||
|
fuzz_target!(|case: &[u8]| -> Corpus { do_fuzz(case) });
|
||||||
98
fuzz/fuzz_targets/ruff_formatter_validity.rs
Normal file
98
fuzz/fuzz_targets/ruff_formatter_validity.rs
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
//! Fuzzer harness which actively tries to find testcases that cause Ruff to introduce errors into
|
||||||
|
//! the resulting file.
|
||||||
|
|
||||||
|
#![no_main]
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
use libfuzzer_sys::{fuzz_target, Corpus};
|
||||||
|
use ruff_linter::linter::ParseSource;
|
||||||
|
use ruff_linter::settings::flags::Noqa;
|
||||||
|
use ruff_linter::settings::LinterSettings;
|
||||||
|
use ruff_linter::source_kind::SourceKind;
|
||||||
|
use ruff_python_ast::PySourceType;
|
||||||
|
use ruff_python_formatter::{format_module_source, PyFormatOptions};
|
||||||
|
use similar::TextDiff;
|
||||||
|
|
||||||
|
static SETTINGS: OnceLock<LinterSettings> = OnceLock::new();
|
||||||
|
|
||||||
|
fn do_fuzz(case: &[u8]) -> Corpus {
|
||||||
|
// throw away inputs which aren't utf-8
|
||||||
|
let Ok(code) = std::str::from_utf8(case) else {
|
||||||
|
return Corpus::Reject;
|
||||||
|
};
|
||||||
|
|
||||||
|
// the settings are immutable to test_snippet, so we avoid re-initialising here
|
||||||
|
let linter_settings = SETTINGS.get_or_init(LinterSettings::default);
|
||||||
|
let format_options = PyFormatOptions::default();
|
||||||
|
|
||||||
|
let linter_results = ruff_linter::linter::lint_only(
|
||||||
|
"fuzzed-source.py".as_ref(),
|
||||||
|
None,
|
||||||
|
&linter_settings,
|
||||||
|
Noqa::Enabled,
|
||||||
|
&SourceKind::Python(code.to_string()),
|
||||||
|
PySourceType::Python,
|
||||||
|
ParseSource::None,
|
||||||
|
);
|
||||||
|
|
||||||
|
if linter_results.error.is_some() {
|
||||||
|
return Corpus::Keep; // keep, but don't continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut warnings = HashMap::new();
|
||||||
|
|
||||||
|
for msg in linter_results.data.0 {
|
||||||
|
let count: &mut usize = warnings.entry(msg.kind.name).or_default();
|
||||||
|
*count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// format the code once
|
||||||
|
if let Ok(formatted) = format_module_source(code, format_options.clone()) {
|
||||||
|
let formatted = formatted.as_code().to_string();
|
||||||
|
|
||||||
|
let linter_results = ruff_linter::linter::lint_only(
|
||||||
|
"fuzzed-source.py".as_ref(),
|
||||||
|
None,
|
||||||
|
&linter_settings,
|
||||||
|
Noqa::Enabled,
|
||||||
|
&SourceKind::Python(formatted.clone()),
|
||||||
|
PySourceType::Python,
|
||||||
|
ParseSource::None,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
linter_results.error.is_none(),
|
||||||
|
"formatter introduced a parse error"
|
||||||
|
);
|
||||||
|
|
||||||
|
for msg in linter_results.data.0 {
|
||||||
|
if let Some(count) = warnings.get_mut(&msg.kind.name) {
|
||||||
|
if let Some(decremented) = count.checked_sub(1) {
|
||||||
|
*count = decremented;
|
||||||
|
} else {
|
||||||
|
panic!(
|
||||||
|
"formatter introduced additional linter warning: {msg:?}\ndiff: {}",
|
||||||
|
TextDiff::from_lines(code, &formatted)
|
||||||
|
.unified_diff()
|
||||||
|
.header("Unformatted", "Formatted")
|
||||||
|
.to_string()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
panic!(
|
||||||
|
"formatter introduced new linter warning that was not previously present: {msg:?}\ndiff: {}",
|
||||||
|
TextDiff::from_lines(code, &formatted)
|
||||||
|
.unified_diff()
|
||||||
|
.header("Unformatted", "Formatted")
|
||||||
|
.to_string()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Corpus::Keep
|
||||||
|
}
|
||||||
|
|
||||||
|
fuzz_target!(|case: &[u8]| -> Corpus { do_fuzz(case) });
|
||||||
Loading…
Add table
Add a link
Reference in a new issue