use std::fmt::Write as _;
use std::io::{BufRead, BufReader, Write};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, RecvTimeoutError, channel};
use std::time::{Duration, Instant};
const BINARY: &str = env!("CARGO_BIN_EXE_i18n-le");
const SECRET: &str = "zzq-frase-secreta-zzq";
const CASE_LIMIT: Duration = Duration::from_secs(20);
const LIBRARIES: [&str; 4] = ["i18next", "next-intl", "vscode-l10n", "flutter-arb"];
static COUNTER: AtomicUsize = AtomicUsize::new(0);
fn seconds() -> Duration {
Duration::from_secs(
std::env::var("I18N_LE_FUZZ_SECONDS")
.ok()
.and_then(|raw| raw.parse().ok())
.unwrap_or(1),
)
}
fn seed() -> u64 {
std::env::var("I18N_LE_FUZZ_SEED")
.ok()
.and_then(|raw| raw.parse().ok())
.unwrap_or(0x118e_1e20_2608_12ff)
}
struct Seeded(u64);
impl Seeded {
fn next(&mut self) -> u64 {
let mut state = self.0;
state ^= state >> 12;
state ^= state << 25;
state ^= state >> 27;
self.0 = state;
state.wrapping_mul(0x2545_f491_4f6c_dd1d)
}
fn below(&mut self, limit: usize) -> usize {
let limit = u64::try_from(limit).unwrap_or(1).max(1);
usize::try_from(self.next() % limit).unwrap_or(0)
}
fn pick<'a, T>(&mut self, from: &'a [T]) -> &'a T {
&from[self.below(from.len())]
}
}
fn fragment(seeded: &mut Seeded) -> String {
let names = ["name", "count", "user.first-name", "n0", "_x", "a-b"];
let name = *seeded.pick(&names);
match seeded.below(16) {
0 => format!("{{{{{name}}}}}"),
1 => format!("{{{{ {name} }}}}"),
2 => format!("{{{name}}}"),
3 => format!("{{ {name} }}"),
4 => format!("{{{}}}", seeded.below(9)),
5 => format!("{{{name}, plural, one {{# cosa}} other {{# cosas}}}}"),
6 => format!("{{{name}, plural, one {{# cosa"),
7 => "{".repeat(1 + seeded.below(80)),
8 => "}".repeat(1 + seeded.below(80)),
9 => "{{".repeat(1 + seeded.below(40)),
10 => format!("$t({name})"),
11 => format!("$t(a.b.{{{{{name}}}}})"),
12 => format!("${{{name}}}"),
13 => format!("{{ ${name} }}"),
14 => (*seeded.pick(&["100%%", "90%-os", "%90", "%s", "%1$s", "%d", "up to 20%!"]))
.to_string(),
_ => {
(*seeded.pick(&["日本語", "Привет", "🎯", "café", "\u{200b}", "a\u{0301}"])).to_string()
}
}
}
fn message(seeded: &mut Seeded) -> String {
let mut text = String::new();
for _ in 0..=seeded.below(6) {
text.push_str(&fragment(seeded));
text.push(' ');
}
text
}
fn catalogue(seeded: &mut Seeded, keys: usize) -> String {
let mut document = String::from("{");
let _ = write!(document, "\"secreto\":\"{SECRET}\"");
for index in 0..keys {
let key = match seeded.below(6) {
0 => format!("item_{}", seeded.pick(&["one", "other", "few", "many"])),
1 => format!("@{index}"),
2 => "@@locale".to_string(),
3 => format!("a.b.c{index}"),
4 => format!("Save the file {index}"),
_ => format!("key{index}"),
};
let value = message(seeded).replace('"', "'").replace('\\', "/");
let _ = write!(document, ",{}:{}", json_string(&key), json_string(&value));
}
document.push('}');
document
}
fn json_string(text: &str) -> String {
serde_json::to_string(text).expect("a string serializes")
}
struct Server {
child: Child,
stdin: ChildStdin,
lines: Receiver<String>,
}
impl Server {
fn start() -> Self {
let mut child = Command::new(BINARY)
.arg("mcp")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("the server starts");
let stdin = child.stdin.take().expect("stdin");
let stdout = child.stdout.take().expect("stdout");
let (sender, lines) = channel();
std::thread::spawn(move || {
for line in BufReader::new(stdout).lines().map_while(Result::ok) {
if sender.send(line).is_err() {
return;
}
}
});
Self {
child,
stdin,
lines,
}
}
fn call(&mut self, case: &str, request: &serde_json::Value) -> serde_json::Value {
writeln!(self.stdin, "{request}").unwrap_or_else(|error| {
panic!("{case}: the server stopped reading ({error})");
});
self.stdin
.flush()
.unwrap_or_else(|error| panic!("{case}: {error}"));
match self.lines.recv_timeout(CASE_LIMIT) {
Ok(line) => serde_json::from_str(&line)
.unwrap_or_else(|error| panic!("{case}: the reply is not JSON ({error}): {line}")),
Err(RecvTimeoutError::Timeout) => {
let _ = self.child.kill();
panic!("{case}: no reply within {CASE_LIMIT:?}");
}
Err(RecvTimeoutError::Disconnected) => {
panic!("{case}: the server died — a panic, not an answer");
}
}
}
}
impl Drop for Server {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
fn check(seeded: &mut Seeded, server: &mut Server, case: &str) {
let library = *seeded.pick(&LIBRARIES);
let keys = 1 + seeded.below(12);
let english = catalogue(seeded, keys);
let keys = 1 + seeded.below(12);
let spanish = catalogue(seeded, keys);
let request = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "check_catalogues",
"arguments": {
"library": library,
"files": [
{ "path": "en.json", "content": english },
{ "path": "es.json", "content": spanish },
],
},
},
});
let reply = server.call(case, &request);
assert!(
reply.get("error").is_none(),
"{case}: {library} — a generated catalogue became a protocol error: {reply}"
);
let rendered = serde_json::to_string(&reply).expect("the reply serializes");
assert!(
!rendered.contains(SECRET),
"{case}: {library} — a translated value reached the answer: {rendered}"
);
}
#[test]
fn the_lexer_survives_what_grows_out_of_its_own_rules() {
let seed = seed();
let budget = seconds();
eprintln!("fuzz: seed {seed:#x}, budget {budget:?}");
let mut seeded = Seeded(seed);
let mut server = Server::start();
let deadline = Instant::now() + budget;
let mut cases = 0u64;
while Instant::now() < deadline {
cases += 1;
check(&mut seeded, &mut server, &format!("case {cases}"));
}
assert!(cases > 0, "the budget ran out before a single case");
eprintln!("fuzz: {cases} generated catalogue pairs, seed {seed:#x}");
}
#[test]
fn pathological_runs_terminate() {
let mut server = Server::start();
let runs = [
"{".repeat(20_000),
"}".repeat(20_000),
"{{".repeat(10_000),
"}}".repeat(10_000),
"$t(".repeat(10_000),
"%".repeat(20_000),
format!("{}{}", "{a, plural, one {".repeat(2_000), "}".repeat(2_000)),
format!("{{{}}}", "a".repeat(20_000)),
format!("{{{{{}", "名".repeat(10_000)),
];
for (index, run) in runs.iter().enumerate() {
for library in LIBRARIES {
let case = format!("run {index} under {library}");
let started = Instant::now();
let request = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "check_catalogues",
"arguments": {
"library": library,
"files": [
{ "path": "en.json", "content": serde_json::json!({ "a": run, "s": SECRET }).to_string() },
{ "path": "es.json", "content": serde_json::json!({ "a": run, "s": SECRET }).to_string() },
],
},
},
});
let reply = server.call(&case, &request);
assert!(reply.get("error").is_none(), "{case}: {reply}");
assert!(
!serde_json::to_string(&reply)
.expect("serializes")
.contains(SECRET),
"{case}: a value reached the answer"
);
eprintln!("fuzz: {case} in {:?}", started.elapsed());
}
}
}
#[test]
fn identification_never_picks_between_two_answers() {
let seed = seed();
eprintln!("fuzz: identification seed {seed:#x}");
let mut seeded = Seeded(seed ^ 0x5bd1_e995);
for case in 0..48 {
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
let root =
std::env::temp_dir().join(format!("i18n-le-fuzz-{}-{unique}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("locales")).expect("a temporary directory");
let declared: Vec<&str> = ["i18next", "next-intl", "@vscode/l10n"]
.into_iter()
.filter(|_| seeded.below(2) == 0)
.collect();
let dependencies: Vec<String> = declared
.iter()
.map(|name| format!("\"{name}\":\"^26.0.0\""))
.collect();
std::fs::write(
root.join("package.json"),
format!("{{\"dependencies\":{{{}}}}}", dependencies.join(",")),
)
.expect("a manifest");
std::fs::write(
root.join("app.ts"),
format!(
"{}\n{}\n",
if seeded.below(2) == 0 {
"useTranslation()"
} else {
""
},
if seeded.below(2) == 0 {
"useTranslations()"
} else {
""
}
),
)
.expect("a source file");
for locale in ["en", "es"] {
let keys = 1 + seeded.below(8);
std::fs::write(
root.join("locales").join(format!("{locale}.json")),
catalogue(&mut seeded, keys),
)
.expect("a catalogue");
}
let output = Command::new(BINARY)
.arg(root.join("locales"))
.stdin(Stdio::null())
.output()
.expect("the binary runs");
let code = output
.status
.code()
.unwrap_or_else(|| panic!("case {case}: died on a signal"));
assert!(
(0..=2).contains(&code),
"case {case}: exit {code} is outside 0/1/2"
);
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
assert!(
!stdout.contains(SECRET) && !stderr.contains(SECRET),
"case {case}: a translated value reached a stream"
);
if code == 2 {
assert!(stdout.is_empty(), "case {case}: a refusal wrote a report");
assert!(!stderr.is_empty(), "case {case}: a silent refusal");
} else {
let report: serde_json::Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|error| panic!("case {case}: {error}: {stdout}"));
let library = report["system"]["library"]
.as_str()
.unwrap_or_else(|| panic!("case {case}: an answer with no library"));
assert!(
LIBRARIES.contains(&library),
"case {case}: {library} is not a library this reads"
);
}
let _ = std::fs::remove_dir_all(&root);
}
}
#[test]
fn the_generator_produces_documents_the_reader_accepts() {
let mut seeded = Seeded(seed());
let mut parsed = 0;
for _ in 0..200 {
let keys = 1 + seeded.below(12);
let document = catalogue(&mut seeded, keys);
if serde_json::from_str::<serde_json::Value>(&document).is_ok() {
parsed += 1;
}
}
assert_eq!(parsed, 200, "the generator writes documents nothing reads");
}