use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
const BINARY: &str = env!("CARGO_BIN_EXE_string-le");
static COUNTER: AtomicUsize = AtomicUsize::new(0);
const PER_CASE_LIMIT: Duration = Duration::from_secs(60);
fn skipped(case: &str, why: &str) {
eprintln!("SKIPPED {case}: {why}");
}
struct Tree {
root: PathBuf,
}
impl Tree {
fn new(name: &str) -> Self {
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
let root = std::env::temp_dir().join(format!(
"string-le-hazard-{name}-{}-{unique}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("a temporary directory");
Self {
root: std::fs::canonicalize(&root).expect("a canonical directory"),
}
}
fn path(&self) -> &Path {
&self.root
}
fn arg(&self) -> String {
self.root.to_string_lossy().into_owned()
}
fn write(&self, relative: &str, contents: &[u8]) -> PathBuf {
let target = self.root.join(relative);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).expect("a parent directory");
}
std::fs::write(&target, contents).expect("a file");
target
}
}
impl Drop for Tree {
fn drop(&mut self) {
#[cfg(unix)]
restore_permissions(&self.root);
let _ = std::fs::remove_dir_all(&self.root);
}
}
#[cfg(unix)]
fn restore_permissions(root: &Path) {
use std::os::unix::fs::PermissionsExt;
let Ok(entries) = std::fs::read_dir(root) else {
return;
};
for entry in entries.flatten() {
let _ = std::fs::set_permissions(entry.path(), std::fs::Permissions::from_mode(0o644));
}
}
struct Run {
code: i32,
stdout: String,
stderr: String,
}
fn survives(case: &str, args: &[&str]) -> Run {
let started = Instant::now();
let output = Command::new(BINARY)
.args(args)
.output()
.unwrap_or_else(|error| panic!("{case}: the binary did not run: {error}"));
let elapsed = started.elapsed();
let code = output.status.code().unwrap_or_else(|| {
panic!(
"{case}: the process died on a signal rather than exiting: {:?}",
output.status
)
});
assert!(
(0..=2).contains(&code),
"{case}: exit {code} is outside the documented 0, 1, 2"
);
assert!(
elapsed < PER_CASE_LIMIT,
"{case}: took {elapsed:?}, which is a hang rather than a scan"
);
Run {
code,
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
}
}
fn reports(run: &Run) -> Vec<serde_json::Value> {
run.stdout
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str(line).expect("stdout carries only JSON"))
.collect()
}
fn report_for(run: &Run, suffix: &str) -> Option<serde_json::Value> {
reports(run).into_iter().find(|report| {
report["file"]
.as_str()
.is_some_and(|file| file.ends_with(suffix))
})
}
#[test]
fn every_content_hazard_is_scanned_without_a_crash() {
let cases: Vec<(&str, Vec<u8>)> = vec![
("a utf-8 bom", b"\xef\xbb\xbf{\"a\":\"value\"}\n".to_vec()),
(
"crlf line endings",
b"{\r\n\"a\":\"value\"\r\n}\r\n".to_vec(),
),
("a lone carriage return", b"{\r\"a\":\"value\"\r}".to_vec()),
("no trailing newline", b"{\"a\":\"value\"}".to_vec()),
("an empty file", Vec::new()),
("whitespace only", b" \n\t \n".to_vec()),
("a nul byte mid-file", b"{\"a\":\"val\x00ue\"}".to_vec()),
("invalid utf-8", b"{\"a\":\"val\xff\xfeue\"}".to_vec()),
(
"utf-16le with a bom",
b"\xff\xfe{\x00\"\x00a\x00\"\x00:\x00\"\x00v\x00\"\x00}\x00".to_vec(),
),
(
"a four-byte emoji before the value",
"{\"\u{1f3af}\":\"value\"}\n".as_bytes().to_vec(),
),
("a one megabyte line", {
let mut bytes = b"{\"a\":\"".to_vec();
bytes.extend(std::iter::repeat_n(b'x', 1_000_000));
bytes.extend_from_slice(b"\"}");
bytes
}),
("one hundred thousand lines", {
let mut bytes = b"{\n".to_vec();
for index in 0..100_000 {
bytes.extend_from_slice(format!(" \"k{index}\": \"v{index}\",\n").as_bytes());
}
bytes.extend_from_slice(b" \"last\": \"end\"\n}\n");
bytes
}),
];
for (case, contents) in cases {
let tree = Tree::new("content");
tree.write("document.json", &contents);
survives(case, &[&tree.arg()]);
survives(case, &["--strict", &tree.arg()]);
survives(case, &["--values", &tree.arg()]);
}
}
#[test]
fn a_byte_order_mark_does_not_move_the_reported_column() {
let tree = Tree::new("bom-column");
tree.write("plain.json", b"{\"a\":\"value\"}\n");
tree.write("marked.json", b"\xef\xbb\xbf{\"a\":\"value\"}\n");
let run = survives("a bom does not move a column", &[&tree.arg()]);
let plain = report_for(&run, "plain.json").expect("the plain file was read");
let marked = report_for(&run, "marked.json").expect("the marked file was read");
assert_eq!(plain["strings"][0]["value"], "value");
assert_eq!(marked["strings"][0], plain["strings"][0]);
}
#[test]
fn a_value_holding_a_byte_order_mark_is_trimmed_the_way_the_extension_trims() {
let tree = Tree::new("bom-value");
tree.write(
"document.json",
"{\"a\":\"\u{feff}edges\u{feff}\",\"b\":\"in\u{feff}side\"}\n".as_bytes(),
);
let run = survives("a value holding a bom", &["--values", &tree.arg()]);
assert_eq!(
run.stdout.lines().collect::<Vec<_>>(),
["edges", "in\u{feff}side"]
);
}
#[test]
fn a_value_holding_a_lone_carriage_return_keeps_it_inside_and_trims_it_off() {
let tree = Tree::new("cr-value");
tree.write("document.json", b"{\"a\":\"one\rtwo\",\"b\":\"three\r\"}\n");
let run = survives("a value holding a lone cr", &[&tree.arg()]);
let report = report_for(&run, "document.json").expect("the file was read");
assert_eq!(report["strings"][0]["value"], "one\rtwo");
assert_eq!(report["strings"][1]["value"], "three");
}
#[test]
fn a_value_holding_a_nul_byte_makes_the_file_binary_rather_than_skipped() {
let tree = Tree::new("nul-value");
tree.write("binary.json", b"{\"a\":\"val\x00ue\"}");
tree.write("kept.json", b"{\"b\":\"kept\"}");
let run = survives("a value holding a nul", &[&tree.arg()]);
let named: Vec<String> = reports(&run)
.iter()
.filter_map(|report| report["file"].as_str().map(str::to_string))
.collect();
assert_eq!(named.len(), 1, "{}", run.stdout);
assert!(named[0].ends_with("kept.json"), "{}", run.stdout);
assert!(
run.stderr.contains("1 binary file skipped"),
"the count is the coverage line: {}",
run.stderr
);
assert_eq!(
survives("a binary file under strict", &["--strict", &tree.arg()]).code,
0,
"a binary file was never a text candidate"
);
}
#[test]
fn a_value_holding_invalid_utf8_is_a_named_skip_that_fails_strict() {
let tree = Tree::new("undecodable");
tree.write("notes.txt", b"const a = 'val\xff\xfeue';");
tree.write("kept.ts", b"const b = 'kept';");
let run = survives("a value holding invalid utf-8", &[&tree.arg()]);
let report = report_for(&run, "notes.txt").expect("the file is in the report");
assert_eq!(report["diagnostics"][0]["code"], "skipped");
assert_eq!(report["diagnostics"][0]["message"], "not UTF-8 text");
assert_eq!(run.code, 0, "a skip alone is not a failure");
assert_eq!(
survives(
"an undecodable file under strict",
&["--strict", &tree.arg()]
)
.code,
2
);
}
#[test]
fn exit_two_is_for_a_malformed_question_and_not_for_an_unreadable_file() {
let tree = Tree::new("exit-two");
tree.write("kept.json", b"{\"a\":\"value\"}");
tree.write("notes.txt", b"hi\xff\xfe");
assert_ne!(
survives("an unreadable file", &[&tree.arg()]).code,
2,
"an unreadable file is reported, not a refusal"
);
assert_eq!(
survives("an unknown flag", &["--nonsense", &tree.arg()]).code,
2
);
assert_eq!(
survives("a path that is not there", &["/no/such/place-xyz"]).code,
2
);
}
#[test]
fn every_filesystem_hazard_is_walked_without_a_crash() {
let tree = Tree::new("filesystem");
tree.write("real.json", b"{\"a\":\"value\"}");
std::fs::create_dir_all(tree.path().join("x.json")).expect("a directory");
tree.write("with spaces.json", b"{\"a\":\"spaces\"}");
tree.write("naïve-café.json", b"{\"a\":\"unicode\"}");
tree.write("🎯.json", b"{\"a\":\"emoji\"}");
link_to_a_file(&tree);
a_broken_link(&tree);
a_link_loop(&tree);
a_fifo(&tree);
a_long_path(&tree);
let run = survives("a tree of filesystem hazards", &[&tree.arg()]);
assert_eq!(run.code, 0, "{}", run.stderr);
let named: Vec<String> = reports(&run)
.iter()
.filter_map(|report| report["file"].as_str().map(str::to_string))
.collect();
assert!(
named.iter().any(|file| file.ends_with("real.json")),
"the ordinary file is still read: {named:?}"
);
assert!(
!named.iter().any(|file| file.ends_with("x.json/")),
"a directory is not a document: {named:?}"
);
survives(
"a tree of filesystem hazards, strictly",
&["--strict", &tree.arg()],
);
}
#[cfg(unix)]
fn link_to_a_file(tree: &Tree) {
let _ =
std::os::unix::fs::symlink(tree.path().join("real.json"), tree.path().join("link.json"));
}
#[cfg(windows)]
fn link_to_a_file(tree: &Tree) {
if std::os::windows::fs::symlink_file(
tree.path().join("real.json"),
tree.path().join("link.json"),
)
.is_err()
{
skipped(
"a symlink to a file",
"this Windows session may not create links",
);
}
}
#[cfg(unix)]
fn a_broken_link(tree: &Tree) {
let _ =
std::os::unix::fs::symlink(tree.path().join("gone.json"), tree.path().join("dead.json"));
}
#[cfg(windows)]
fn a_broken_link(tree: &Tree) {
if std::os::windows::fs::symlink_file(
tree.path().join("gone.json"),
tree.path().join("dead.json"),
)
.is_err()
{
skipped(
"a broken symlink",
"this Windows session may not create links",
);
}
}
#[cfg(unix)]
fn a_link_loop(tree: &Tree) {
let first = tree.path().join("loop-a");
let second = tree.path().join("loop-b");
let _ = std::os::unix::fs::symlink(&second, &first);
let _ = std::os::unix::fs::symlink(&first, &second);
}
#[cfg(windows)]
fn a_link_loop(tree: &Tree) {
let first = tree.path().join("loop-a");
let second = tree.path().join("loop-b");
if std::os::windows::fs::symlink_dir(&second, &first).is_err()
|| std::os::windows::fs::symlink_dir(&first, &second).is_err()
{
skipped(
"a symlink loop",
"this Windows session may not create links",
);
}
}
#[cfg(unix)]
fn a_fifo(tree: &Tree) {
let made = Command::new("mkfifo")
.arg(tree.path().join("pipe.json"))
.status();
if !made.is_ok_and(|status| status.success()) {
skipped("a FIFO", "mkfifo is not available here");
}
}
#[cfg(windows)]
fn a_fifo(_tree: &Tree) {
skipped("a FIFO", "Windows has no FIFO in the filesystem namespace");
}
fn a_long_path(tree: &Tree) {
let mut deep = tree.path().to_path_buf();
for _ in 0..12 {
deep = deep.join("directory-with-a-long-enough-name-to-pass");
}
if std::fs::create_dir_all(&deep).is_err() {
skipped(
"a path over 260 characters",
"this filesystem refused to create it",
);
return;
}
if std::fs::write(deep.join("deep.json"), b"{\"a\":\"deep\"}").is_err() {
skipped(
"a path over 260 characters",
"this filesystem refused to write it",
);
}
}
#[cfg(unix)]
#[test]
fn a_file_that_cannot_be_opened_is_reported_rather_than_dropped() {
use std::os::unix::fs::PermissionsExt;
let tree = Tree::new("permissions");
tree.write("kept.json", b"{\"a\":\"value\"}");
let locked = tree.write("locked.json", b"{\"b\":\"unreadable\"}");
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000))
.expect("the mode is set");
if std::fs::read(&locked).is_ok() {
skipped(
"a permission-denied file",
"this session reads regardless of the mode",
);
return;
}
let run = survives("a permission-denied file", &[&tree.arg()]);
let report = report_for(&run, "locked.json").expect("the file is named in the report");
assert_eq!(report["diagnostics"][0]["code"], "skipped");
assert_eq!(run.code, 0, "an unreadable file is not a refusal");
assert_eq!(
survives(
"a permission-denied file, strictly",
&["--strict", &tree.arg()]
)
.code,
2
);
}
#[cfg(not(unix))]
#[test]
fn a_file_that_cannot_be_opened_is_reported_rather_than_dropped() {
skipped(
"a permission-denied file",
"the unix mode has no equivalent this test can set here",
);
}