use std::time::{Duration, Instant};
use super::format::{FALLBACK_FORMAT, SUPPORTED_FORMATS, determine_file_type, resolve_format};
use super::{corpus, extract, heuristics};
const HEURISTICS: &str = include_str!("../../fixtures/heuristics.json");
const PER_INPUT: Duration = Duration::from_secs(2);
const DEFAULT_SECONDS: u64 = 2;
const MAX_DOCUMENT: usize = 16 * 1024;
fn budget() -> Duration {
let seconds = std::env::var("PATHS_LE_FUZZ_SECONDS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(DEFAULT_SECONDS);
Duration::from_secs(seconds)
}
fn seed() -> u64 {
std::env::var("PATHS_LE_FUZZ_SEED")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(0x5ea1_0000_0002_0000)
}
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
z ^ (z >> 31)
}
fn below(&mut self, bound: usize) -> usize {
(self.next() % bound as u64) as usize
}
fn pick<'a, T>(&mut self, items: &'a [T]) -> &'a T {
&items[self.below(items.len())]
}
}
const ALPHABET: [char; 40] = [
'/',
'\\',
'.',
':',
'-',
'_',
'~',
'#',
'?',
'*',
'|',
'<',
'>',
'"',
'\'',
'`',
' ',
'\t',
'\n',
'\r',
'a',
'z',
'A',
'Z',
'0',
'9',
'{',
'}',
'[',
']',
'(',
')',
',',
';',
'=',
'\u{feff}',
'\u{85}',
'\u{2028}',
'\u{1f3af}',
'\u{300}',
];
fn seeds() -> Vec<String> {
let mut pool: Vec<String> = Vec::new();
let corpus: serde_json::Value =
serde_json::from_str(HEURISTICS).expect("the corpus is valid JSON");
for key in ["isPathLike", "classifyPathType"] {
let Some(cases) = corpus[key].as_array() else {
continue;
};
pool.extend(
cases
.iter()
.filter_map(|case| case["input"].as_str())
.map(str::to_string),
);
}
pool.extend(
corpus::DOCUMENTS
.iter()
.map(|(_, content)| (*content).to_string()),
);
assert!(!pool.is_empty(), "the corpus seeded nothing");
pool
}
fn candidate(rng: &mut Rng, pool: &[String], limit: usize) -> String {
let mut value = match rng.below(8) {
0 => String::new(),
1..=2 => (0..rng.below(48)).map(|_| *rng.pick(&ALPHABET)).collect(),
_ => rng.pick(pool).clone(),
};
for _ in 0..=rng.below(4) {
value = mutate(rng, &value, limit);
}
value
}
fn mutate(rng: &mut Rng, value: &str, limit: usize) -> String {
if value.len() > limit {
return truncate_on_boundary(value, limit);
}
let character = *rng.pick(&ALPHABET);
match rng.below(6) {
0 => format!("{character}{value}"),
1 => format!("{value}{character}"),
2 => {
let at = floor_to_boundary(value, rng.below(value.len().max(1)));
format!("{}{character}{}", &value[..at], &value[at..])
}
3 => {
let at = floor_to_boundary(value, rng.below(value.len().max(1)));
truncate_on_boundary(&value[at..], limit)
}
4 => truncate_on_boundary(&value.repeat(2 + rng.below(6)), limit),
_ => truncate_on_boundary(
&format!("{}{value}", character.to_string().repeat(rng.below(64))),
limit,
),
}
}
fn floor_to_boundary(value: &str, mut at: usize) -> usize {
while at > 0 && !value.is_char_boundary(at) {
at -= 1;
}
at
}
fn truncate_on_boundary(value: &str, limit: usize) -> String {
if value.len() <= limit {
return value.to_string();
}
value[..floor_to_boundary(value, limit)].to_string()
}
fn readable(value: &str) -> String {
value.escape_debug().to_string()
}
fn hammer(target: &str, limit: usize, mut check: impl FnMut(&str)) {
let seed = seed();
let budget = budget();
let pool = seeds();
let mut rng = Rng(seed);
let started = Instant::now();
let mut iterations = 0u64;
while started.elapsed() < budget {
let input = candidate(&mut rng, &pool, limit);
let call = Instant::now();
check(&input);
let elapsed = call.elapsed();
assert!(
elapsed < PER_INPUT,
"{target} took {elapsed:?} on iteration {iterations} (seed {seed}): \"{}\"",
readable(&input)
);
iterations += 1;
}
eprintln!("fuzz {target}: {iterations} inputs in {budget:?} (seed {seed})");
assert!(iterations > 0, "{target} ran no inputs");
}
#[test]
fn is_path_like_answers_for_anything() {
hammer("is_path_like", 4096, |input| {
assert_eq!(
heuristics::is_path_like(input),
heuristics::is_path_like(input)
);
});
}
#[test]
fn classify_path_type_answers_for_anything() {
hammer("classify_path_type", 4096, |input| {
let kind = heuristics::classify_path_type(input);
assert_eq!(kind, heuristics::classify_path_type(input));
});
}
#[test]
fn a_resolved_format_is_always_one_the_engine_dispatches() {
hammer("resolve_format", 512, |input| {
for language in [
resolve_format(Some(input), None),
resolve_format(None, Some(input)),
resolve_format(Some(input), Some(input)),
] {
let typed = determine_file_type(language) != super::FileType::Unknown;
assert!(
typed || matches!(language, FALLBACK_FORMAT | "markdown" | "xml"),
"{} resolved to {language}, which the engine reads as neither a typed \
format nor a declared generic scan",
readable(input)
);
}
});
}
#[test]
fn extraction_never_panics_and_stays_inside_the_document() {
let mut languages: Vec<&str> = SUPPORTED_FORMATS.to_vec();
languages.extend(["jsonc", "scss", "less", "xml", "python", FALLBACK_FORMAT]);
let mut turn = 0usize;
hammer("extract", MAX_DOCUMENT, move |input| {
let language = languages[turn % languages.len()];
turn += 1;
let result = extract(input, language);
let lines = 1 + input.bytes().filter(|byte| *byte == b'\n').count();
for path in &result.paths {
assert!(
path.position.line >= 1 && path.position.column >= 1,
"{language} put a path at {}:{} in \"{}\"",
path.position.line,
path.position.column,
readable(input)
);
assert!(
language == "csv" || path.position.line <= lines,
"{language} put a path on line {} of a {lines}-line document: \"{}\"",
path.position.line,
readable(input)
);
}
});
}