use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
const DEADLINE: Duration = Duration::from_secs(60);
const VALUE: &str = "2024-01-15";
const PNG: &[u8] = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\x01\0\0\0\x01\0";
fn skipped(case: &str, why: &str) {
eprintln!("hazards: skipped {case} — {why}");
}
struct Sandbox(PathBuf);
impl Sandbox {
fn new(name: &str) -> Self {
let path = Path::new(env!("CARGO_TARGET_TMPDIR"))
.join("hazards")
.join(name);
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).expect("the sandbox is created");
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
fn write(&self, name: &str, bytes: &[u8]) -> PathBuf {
let path = self.0.join(name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("the parent directory is created");
}
std::fs::write(&path, bytes).expect("the file is written");
path
}
}
impl Drop for Sandbox {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
struct Run {
stdout: String,
stderr: String,
code: Option<i32>,
}
impl Run {
fn report_for(&self, name: &str) -> Option<serde_json::Value> {
self.stdout
.lines()
.map(|line| {
serde_json::from_str::<serde_json::Value>(line)
.unwrap_or_else(|error| panic!("stdout line is not JSON ({error}): {line}"))
})
.find(|report| {
report["file"]
.as_str()
.is_some_and(|file| file.ends_with(name))
})
}
}
fn run(root: &Path, arguments: &[&str]) -> Run {
let mut child = Command::new(env!("CARGO_BIN_EXE_dates-le"))
.arg(root.as_os_str())
.arg("--no-ignore")
.args(arguments)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("the binary starts");
let mut out = child.stdout.take().expect("stdout is piped");
let mut err = child.stderr.take().expect("stderr is piped");
let stdout = std::thread::spawn(move || {
let mut buffer = Vec::new();
let _ = out.read_to_end(&mut buffer);
buffer
});
let stderr = std::thread::spawn(move || {
let mut buffer = Vec::new();
let _ = err.read_to_end(&mut buffer);
buffer
});
let started = Instant::now();
let status = loop {
match child.try_wait().expect("the child can be waited on") {
Some(status) => break status,
None if started.elapsed() > DEADLINE => {
let _ = child.kill();
let _ = child.wait();
panic!(
"the binary did not finish within {DEADLINE:?} on {}",
root.display()
);
}
None => std::thread::sleep(Duration::from_millis(10)),
}
};
Run {
stdout: String::from_utf8_lossy(&stdout.join().expect("stdout is drained")).into_owned(),
stderr: String::from_utf8_lossy(&stderr.join().expect("stderr is drained")).into_owned(),
code: status.code(),
}
}
fn survives(run: &Run, case: &str) {
let Some(code) = run.code else {
panic!("{case}: the binary left by a signal rather than with an exit code");
};
assert!(
(0..=2).contains(&code),
"{case}: exit {code} is not one of 0, 1 or 2\n{}",
run.stderr
);
assert!(
!run.stderr.contains("panicked at"),
"{case}: the binary panicked\n{}",
run.stderr
);
}
#[derive(Debug, Clone, Copy)]
enum Expected {
Value,
NoDates,
Binary,
Skipped,
}
fn content_hazards() -> Vec<(&'static str, &'static str, Vec<u8>, Expected)> {
let hundred_thousand_lines = {
use std::fmt::Write;
let mut document = String::new();
for index in 0..100_000 {
let _ = writeln!(document, "line {index}");
}
document.push_str(VALUE);
document
};
vec![
(
"utf-8 byte-order mark",
"bom.json",
[b"\xef\xbb\xbf".as_slice(), VALUE.as_bytes()].concat(),
Expected::Value,
),
(
"crlf line endings",
"crlf.json",
format!("first\r\n{VALUE}\r\n").into_bytes(),
Expected::Value,
),
(
"a lone carriage return",
"lone-cr.json",
format!("first\r{VALUE}").into_bytes(),
Expected::Value,
),
(
"no trailing newline",
"no-newline.json",
VALUE.as_bytes().to_vec(),
Expected::Value,
),
("an empty file", "empty.json", Vec::new(), Expected::NoDates),
(
"only whitespace",
"whitespace.json",
b" \n\t\n \r\n".to_vec(),
Expected::NoDates,
),
(
"a NUL byte mid-file",
"nul.json",
format!("{VALUE}\0after").into_bytes(),
Expected::Binary,
),
(
"bytes that are not UTF-8",
"latin.json",
b"caf\xe9 2024-01-15\n".to_vec(),
Expected::Skipped,
),
(
"utf-16le with a byte-order mark",
"utf16.json",
utf16le(VALUE),
Expected::Binary,
),
(
"a four-byte emoji before the value",
"emoji.json",
format!("\u{1f4c5} {VALUE}").into_bytes(),
Expected::Value,
),
(
"a line one megabyte long",
"long-line.json",
format!("{} {VALUE}", "x".repeat(1_000_000)).into_bytes(),
Expected::Value,
),
(
"a hundred thousand lines",
"many-lines.json",
hundred_thousand_lines.into_bytes(),
Expected::Value,
),
]
}
fn utf16le(text: &str) -> Vec<u8> {
let mut bytes = vec![0xff, 0xfe];
for unit in text.encode_utf16() {
bytes.extend_from_slice(&unit.to_le_bytes());
}
bytes
}
#[test]
fn every_content_hazard_is_survived_and_answered() {
for (case, name, bytes, expected) in content_hazards() {
let sandbox = Sandbox::new(&format!("content-{}", name.replace('.', "-")));
sandbox.write(name, &bytes);
let run = run(sandbox.path(), &[]);
survives(&run, case);
let report = run.report_for(name);
match expected {
Expected::Value => {
let report = report.unwrap_or_else(|| panic!("{case}: no report line for {name}"));
let dates = report["dates"].as_array().cloned().unwrap_or_default();
assert!(
dates.iter().any(|date| date["value"] == VALUE),
"{case}: {VALUE} is in the document and not in the report: {report}"
);
}
Expected::NoDates => {
let report = report.unwrap_or_else(|| panic!("{case}: no report line for {name}"));
assert_eq!(
report["dates"].as_array().map_or(0, Vec::len),
0,
"{case}: {report}"
);
assert!(report["skipped"].is_null(), "{case}: {report}");
}
Expected::Binary => assert!(
report.is_none(),
"{case}: a file that is not text was reported anyway: {report:?}"
),
Expected::Skipped => {
let report = report.unwrap_or_else(|| panic!("{case}: no report line for {name}"));
assert!(
report["skipped"].is_string(),
"{case}: a text file that could not be read must say so: {report}"
);
}
}
}
}
#[test]
fn a_byte_order_mark_does_not_move_the_reported_column() {
let sandbox = Sandbox::new("bom-column");
let document = format!("{{\"at\":\"{VALUE}\"}}");
sandbox.write("plain.json", document.as_bytes());
sandbox.write(
"marked.json",
&[b"\xef\xbb\xbf".as_slice(), document.as_bytes()].concat(),
);
let run = run(sandbox.path(), &[]);
survives(&run, "a byte-order mark and the same file without one");
let column = |name: &str| -> serde_json::Value {
run.report_for(name)
.unwrap_or_else(|| panic!("no report line for {name}"))["dates"][0]["column"]
.clone()
};
assert_eq!(
column("marked.json"),
column("plain.json"),
"a byte-order mark moved the column: {}",
run.stdout
);
}
#[test]
fn a_binary_file_produces_no_report_line_and_does_not_fail_strict() {
let sandbox = Sandbox::new("binary-and-strict");
sandbox.write("notes.md", format!("shipped {VALUE}\n").as_bytes());
sandbox.write("logo.png", PNG);
let plain = run(sandbox.path(), &[]);
survives(&plain, "a binary file");
assert!(plain.report_for("logo.png").is_none(), "{}", plain.stdout);
assert!(
plain.stderr.contains("1 binary file skipped"),
"the summary must still say the walk covered less than the tree: {}",
plain.stderr
);
let strict = run(sandbox.path(), &["--strict"]);
survives(&strict, "a binary file under --strict");
assert_eq!(strict.code, Some(0), "{}", strict.stderr);
}
#[test]
fn undecodable_text_is_named_and_fails_strict() {
let sandbox = Sandbox::new("undecodable-and-strict");
sandbox.write("notes.md", format!("shipped {VALUE}\n").as_bytes());
sandbox.write("latin.log", b"caf\xe9 2024-01-15\n");
let plain = run(sandbox.path(), &[]);
survives(&plain, "undecodable text");
assert_eq!(
plain.code,
Some(0),
"a skipped file does not fail a plain run"
);
assert!(plain.stderr.contains("skipped"), "{}", plain.stderr);
let strict = run(sandbox.path(), &["--strict"]);
survives(&strict, "undecodable text under --strict");
assert_eq!(strict.code, Some(2), "{}", strict.stderr);
}
#[test]
fn only_a_malformed_question_exits_two() {
let sandbox = Sandbox::new("malformed-question");
sandbox.write("notes.md", format!("shipped {VALUE}\n").as_bytes());
for arguments in [
["--nope", "--iso"],
["--after", "soon"],
["--tz", "Mars/Olympus"],
] {
let run = run(sandbox.path(), &arguments);
survives(&run, &format!("a malformed question: {arguments:?}"));
assert_eq!(run.code, Some(2), "{arguments:?}: {}", run.stderr);
}
let missing = sandbox.path().join("nowhere");
let run = run(&missing, &[]);
survives(&run, "a path that does not exist");
assert_eq!(run.code, Some(2), "{}", run.stderr);
}
#[test]
fn every_filesystem_hazard_is_survived() {
let sandbox = Sandbox::new("filesystem");
sandbox.write("plain.json", VALUE.as_bytes());
sandbox.write("looks-like.json/inside.json", VALUE.as_bytes());
sandbox.write("with spaces.json", VALUE.as_bytes());
sandbox.write("ünïcødé.json", VALUE.as_bytes());
sandbox.write("calendar \u{1f4c5}.json", VALUE.as_bytes());
symlinks(&sandbox);
fifo(&sandbox);
long_path(&sandbox);
let run = run(sandbox.path(), &[]);
survives(&run, "a tree of filesystem hazards");
for name in [
"plain.json",
"inside.json",
"with spaces.json",
"ünïcødé.json",
"calendar \u{1f4c5}.json",
] {
let report = run
.report_for(name)
.unwrap_or_else(|| panic!("{name} was not read: {}", run.stdout));
assert_eq!(report["dates"][0]["value"], VALUE, "{name}");
}
}
#[cfg(unix)]
#[test]
fn a_file_that_cannot_be_opened_is_named_rather_than_fatal() {
use std::os::unix::fs::PermissionsExt;
let sandbox = Sandbox::new("permission-denied");
sandbox.write("readable.json", VALUE.as_bytes());
let secret = sandbox.write("secret.json", VALUE.as_bytes());
std::fs::set_permissions(&secret, std::fs::Permissions::from_mode(0o000))
.expect("permissions are set");
if std::fs::read(&secret).is_ok() {
skipped("a permission-denied file", "this user can read it anyway");
return;
}
let plain = run(sandbox.path(), &[]);
survives(&plain, "a permission-denied file");
assert_eq!(plain.code, Some(0), "an unreadable file is not exit 2");
let report = plain.report_for("secret.json").unwrap_or_else(|| {
panic!(
"the unreadable file vanished from the report: {}",
plain.stdout
)
});
assert!(report["skipped"].is_string(), "{report}");
let strict = run(sandbox.path(), &["--strict"]);
survives(&strict, "a permission-denied file under --strict");
assert_eq!(strict.code, Some(2));
}
fn symlinks(sandbox: &Sandbox) {
#[cfg(unix)]
let link = |target: &Path, at: PathBuf| std::os::unix::fs::symlink(target, at);
#[cfg(windows)]
let link = |target: &Path, at: PathBuf| {
if target.is_dir() {
std::os::windows::fs::symlink_dir(target, at)
} else {
std::os::windows::fs::symlink_file(target, at)
}
};
let target = sandbox.path().join("plain.json");
if link(&target, sandbox.path().join("link-to-file.json")).is_err() {
skipped("a symlink", "this platform will not create one");
return;
}
let _ = link(
Path::new("nowhere-at-all.json"),
sandbox.path().join("broken-link.json"),
);
let loop_root = sandbox.path().join("loop");
std::fs::create_dir_all(&loop_root).expect("the loop directory is created");
let _ = link(&loop_root, loop_root.join("self"));
}
#[cfg(unix)]
fn fifo(sandbox: &Sandbox) {
let path = sandbox.path().join("pipe.json");
let made = Command::new("mkfifo")
.arg(path.as_os_str())
.status()
.is_ok_and(|status| status.success());
if !made {
skipped("a FIFO", "mkfifo is not available here");
}
}
#[cfg(windows)]
fn fifo(_sandbox: &Sandbox) {
skipped("a FIFO", "Windows has no filesystem FIFO");
}
fn long_path(sandbox: &Sandbox) {
let mut path = sandbox.path().to_path_buf();
for _ in 0..5 {
path = path.join("d".repeat(70));
}
if std::fs::create_dir_all(&path).is_err() {
skipped(
"a path over 260 characters",
"this platform will not create one",
);
return;
}
if std::fs::write(path.join("deep.json"), VALUE.as_bytes()).is_err() {
skipped(
"a path over 260 characters",
"the file could not be written",
);
}
}