use std::fmt::Write as _;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
const BINARY: &str = env!("CARGO_BIN_EXE_scrape-le");
const LIMIT: Duration = Duration::from_secs(60);
const SIGNATURE: &str =
"key = \"kasada\"\nlabel = \"Kasada\"\nscript_substrings = [\"kasada.io\"]\n";
static COUNTER: AtomicUsize = AtomicUsize::new(0);
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!(
"scrape-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 write(&self, relative: &str, contents: &str) -> PathBuf {
self.write_bytes(relative, contents.as_bytes())
}
fn write_bytes(&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) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
struct Run {
code: i32,
stdout: String,
stderr: String,
}
fn run(case: &str, args: &[&str]) -> Run {
let mut child = Command::new(BINARY)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("the binary runs");
let mut out = child.stdout.take().expect("stdout");
let mut err = child.stderr.take().expect("stderr");
let out = std::thread::spawn(move || {
let mut buffer = Vec::new();
let _ = out.read_to_end(&mut buffer);
buffer
});
let err = std::thread::spawn(move || {
let mut buffer = Vec::new();
let _ = err.read_to_end(&mut buffer);
buffer
});
let deadline = Instant::now() + LIMIT;
let status = loop {
match child.try_wait().expect("the child is waitable") {
Some(status) => break status,
None if Instant::now() >= deadline => {
let _ = child.kill();
let _ = child.wait();
panic!("{case}: hung for {LIMIT:?} on {args:?}");
}
None => std::thread::sleep(Duration::from_millis(10)),
}
};
let code = status.code().unwrap_or_else(|| {
panic!("{case}: died on a signal rather than exiting ({status:?}) on {args:?}")
});
assert!(
(0..=2).contains(&code),
"{case}: exit {code} is outside the documented 0/1/2 on {args:?}"
);
Run {
code,
stdout: String::from_utf8_lossy(&out.join().expect("stdout thread")).into_owned(),
stderr: String::from_utf8_lossy(&err.join().expect("stderr thread")).into_owned(),
}
}
fn load_signatures(case: &str, path: &Path) -> Run {
let run = run(case, &["--signatures", &path.to_string_lossy()]);
assert_eq!(
run.code, 2,
"{case}: no URL was given, so the run must refuse — {}",
run.stderr
);
assert!(
run.stdout.is_empty(),
"{case}: a refusal wrote to the protocol stream — {}",
run.stdout
);
run
}
fn read_batch(case: &str, path: &Path) -> Run {
let run = run(case, &["--input", &path.to_string_lossy()]);
assert_eq!(run.code, 2, "{case}: {}", run.stderr);
assert!(
run.stdout.is_empty(),
"{case}: a refusal wrote to the protocol stream — {}",
run.stdout
);
assert!(
!run.stderr.contains("clear ·"),
"{case}: a batch actually ran — a request left the machine\n{}",
run.stderr
);
run
}
fn loaded(run: &Run) -> bool {
run.stderr.contains("loaded 1 signature(s)")
}
fn entries_named(run: &Run) -> usize {
run.stderr
.lines()
.filter(|line| line.contains("is not an http(s) URL"))
.count()
}
fn skipped(case: &str, why: &str) {
eprintln!("SKIPPED {case}: {why}");
}
fn malformed_entries(count: usize, decoration: &str) -> Vec<String> {
(0..count)
.map(|index| format!("not a url {index}{decoration}"))
.collect()
}
#[test]
fn a_byte_order_mark_does_not_change_a_signature_file() {
let tree = Tree::new("bom-signatures");
let plain = tree.write("plain.toml", SIGNATURE);
let marked = tree.write("marked.toml", &format!("\u{feff}{SIGNATURE}"));
let without = load_signatures("bom-signatures", &plain);
assert!(
loaded(&without),
"the control case did not load: {}",
without.stderr
);
let with = load_signatures("bom-signatures", &marked);
assert!(
loaded(&with),
"a byte-order mark made a valid signature file unreadable: {}",
with.stderr
);
}
#[test]
fn a_byte_order_mark_does_not_change_a_batch_file() {
let tree = Tree::new("bom-input");
let body = serde_json::to_string(&malformed_entries(3, "")).expect("JSON");
let plain = tree.write("plain.json", &body);
let marked = tree.write("marked.json", &format!("\u{feff}{body}"));
let without = read_batch("bom-input", &plain);
assert_eq!(entries_named(&without), 3, "{}", without.stderr);
let with = read_batch("bom-input", &marked);
assert_eq!(
entries_named(&with),
3,
"a byte-order mark changed how a JSON batch was read: {}",
with.stderr
);
}
#[test]
fn line_endings_do_not_change_a_batch_file() {
let tree = Tree::new("line-endings");
let entries = malformed_entries(4, "");
for (case, separator) in [("crlf", "\r\n"), ("lf", "\n"), ("no-trailing", "\n")] {
let mut body = entries.join(separator);
if case != "no-trailing" {
body.push_str(separator);
}
let path = tree.write(&format!("{case}.csv"), &body);
let run = read_batch(case, &path);
assert_eq!(entries_named(&run), 4, "{case}: {}", run.stderr);
}
let lone = tree.write("cr.csv", &entries.join("\r"));
let run = read_batch("lone-cr", &lone);
assert_eq!(entries_named(&run), 1, "{}", run.stderr);
}
#[test]
fn an_empty_or_whitespace_batch_file_is_refused_by_name() {
let tree = Tree::new("empty-input");
for (case, body) in [("empty", ""), ("whitespace", " \n\t\n \n")] {
let path = tree.write(&format!("{case}.csv"), body);
let run = run(case, &["--input", &path.to_string_lossy()]);
assert_eq!(run.code, 2, "{case}");
assert!(
run.stderr.contains("no URLs in"),
"{case}: the refusal does not say what was wrong — {}",
run.stderr
);
}
}
#[test]
fn a_nul_byte_mid_file_refuses_rather_than_panics() {
let tree = Tree::new("nul");
let mut body = String::new();
for entry in malformed_entries(3, "") {
let _ = writeln!(body, "{entry}");
}
body.push_str("not a url \u{0} three\n");
let path = tree.write("nul.csv", &body);
let run = read_batch("nul", &path);
assert_eq!(entries_named(&run), 4, "{}", run.stderr);
}
#[test]
fn an_undecodable_file_is_refused_by_name_and_writes_no_report() {
for (case, bytes) in [
("invalid-utf8", vec![b'n', b'o', 0xff, 0xfe, b'\n']),
(
"utf16le",
vec![0xff, 0xfe, b'n', 0x00, b'o', 0x00, b't', 0x00],
),
] {
let tree = Tree::new(case);
let path = tree.write_bytes("input.csv", &bytes);
let batch = run(case, &["--input", &path.to_string_lossy()]);
assert_eq!(batch.code, 2, "{case}");
assert!(
batch.stderr.contains("could not read"),
"{case}: the refusal does not name the file — {}",
batch.stderr
);
assert!(batch.stdout.is_empty(), "{case}: a refusal wrote a report");
let signatures = run(case, &["--signatures", &path.to_string_lossy()]);
assert_eq!(signatures.code, 2, "{case}: signatures");
assert!(signatures.stdout.is_empty());
}
}
#[test]
fn a_binary_signature_file_is_refused_rather_than_guessed_at() {
let tree = Tree::new("binary");
let path = tree.write_bytes(
"logo.toml",
&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a],
);
let run = run("binary", &["--signatures", &path.to_string_lossy()]);
assert_eq!(run.code, 2);
assert!(run.stdout.is_empty(), "a refusal wrote a report");
assert!(
run.stderr.contains("logo.toml"),
"the refusal does not name the file — {}",
run.stderr
);
}
#[test]
fn a_four_byte_character_does_not_break_a_signature_label() {
let tree = Tree::new("emoji");
let path = tree.write(
"emoji.toml",
"key = \"kasada\"\nlabel = \"Kasada \u{1f389}\"\nglobals = [\"KPSDK\"]\n",
);
let run = load_signatures("emoji", &path);
assert!(loaded(&run), "{}", run.stderr);
}
#[test]
fn a_one_megabyte_line_completes() {
let tree = Tree::new("long-line");
let mut body = String::from("not a url ");
body.push_str(&"x".repeat(1024 * 1024));
body.push('\n');
body.push_str("not a url two\n");
let path = tree.write("long.csv", &body);
let run = read_batch("long-line", &path);
assert_eq!(entries_named(&run), 2, "{}", run.stderr);
}
#[test]
fn a_hundred_thousand_lines_complete() {
let tree = Tree::new("many-lines");
let mut body = String::new();
for line in 0..100_000 {
if line % 100 == 0 {
let _ = writeln!(body, "not a url {line}");
continue;
}
let _ = writeln!(body, "# a comment line");
}
let path = tree.write("many.csv", &body);
let run = read_batch("many-lines", &path);
assert_eq!(entries_named(&run), 1_000, "{}", run.stderr);
}
#[cfg(unix)]
fn symlink(original: &Path, link: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(original, link)
}
#[cfg(windows)]
fn symlink(original: &Path, link: &Path) -> std::io::Result<()> {
if original.is_dir() {
return std::os::windows::fs::symlink_dir(original, link);
}
std::os::windows::fs::symlink_file(original, link)
}
#[test]
fn a_symlinked_signature_file_is_followed_and_a_broken_one_is_refused() {
let tree = Tree::new("symlink");
let target = tree.write("real.toml", SIGNATURE);
let link = tree.path().join("link.toml");
if symlink(&target, &link).is_err() {
skipped("symlink", "this platform refused to create a symlink");
return;
}
let followed = load_signatures("symlink", &link);
assert!(loaded(&followed), "{}", followed.stderr);
let broken = tree.path().join("broken.toml");
let _ = symlink(&tree.path().join("nowhere-at-all"), &broken);
let refused = run(
"symlink-broken",
&["--signatures", &broken.to_string_lossy()],
);
assert_eq!(refused.code, 2);
assert!(refused.stdout.is_empty());
}
#[test]
fn a_symlink_loop_is_refused_rather_than_followed() {
let tree = Tree::new("symlink-loop");
let first = tree.path().join("loop-a.toml");
let second = tree.path().join("loop-b.toml");
if symlink(&second, &first).is_err() || symlink(&first, &second).is_err() {
skipped("symlink-loop", "this platform refused to create a symlink");
return;
}
let looped = run("symlink-loop", &["--signatures", &first.to_string_lossy()]);
assert_eq!(looped.code, 2, "{}", looped.stderr);
assert!(looped.stdout.is_empty());
}
#[cfg(unix)]
#[test]
fn a_fifo_does_not_block_the_run() {
let tree = Tree::new("fifo");
let fifo = tree.path().join("pipe.toml");
let made = Command::new("mkfifo")
.arg(&fifo)
.status()
.is_ok_and(|status| status.success());
if !made {
skipped("fifo", "mkfifo is not available on this runner");
return;
}
let signatures = run(
"fifo-signatures",
&["--signatures", &fifo.to_string_lossy()],
);
assert_eq!(
signatures.code, 2,
"a named pipe must be refused, not read: {}",
signatures.stderr
);
let input = run("fifo-input", &["--input", &fifo.to_string_lossy()]);
assert_eq!(input.code, 2, "{}", input.stderr);
}
#[cfg(not(unix))]
#[test]
fn a_fifo_does_not_block_the_run() {
skipped("fifo", "Windows has no FIFO in a directory tree");
}
#[cfg(unix)]
#[test]
fn a_permission_denied_file_is_refused_by_name() {
use std::os::unix::fs::PermissionsExt;
let tree = Tree::new("denied");
let denied = tree.write("denied.toml", SIGNATURE);
std::fs::set_permissions(&denied, std::fs::Permissions::from_mode(0o000)).expect("chmod");
if std::fs::read(&denied).is_ok() {
skipped(
"permission-denied",
"this runner reads a mode-000 file anyway (root)",
);
return;
}
let run = run("denied", &["--signatures", &denied.to_string_lossy()]);
assert_eq!(run.code, 2);
assert!(
run.stderr.contains("denied.toml"),
"the refusal does not name the file — {}",
run.stderr
);
assert!(run.stdout.is_empty());
}
#[cfg(not(unix))]
#[test]
fn a_permission_denied_file_is_refused_by_name() {
skipped(
"permission-denied",
"Windows ACLs are not chmod; the unix case covers the read failure",
);
}
#[test]
fn a_directory_named_like_a_signature_file_is_refused() {
let tree = Tree::new("dir-toml");
let directory = tree.path().join("signatures.toml");
std::fs::create_dir_all(&directory).expect("a directory");
let run = run("dir-toml", &["--signatures", &directory.to_string_lossy()]);
assert_eq!(run.code, 2);
assert!(run.stdout.is_empty());
}
#[test]
fn awkward_file_names_are_read() {
let tree = Tree::new("names");
let mut checked = 0;
for name in [
"with space.toml",
"\u{fc}nicode.toml",
"\u{1f389}.toml",
"trailing.dots..toml",
] {
if tree.root.join(name).parent().is_none() {
continue;
}
if std::fs::write(tree.root.join(name), SIGNATURE).is_err() {
skipped("awkward-names", name);
continue;
}
let run = load_signatures("names", &tree.root.join(name));
assert!(loaded(&run), "{name}: {}", run.stderr);
checked += 1;
}
assert!(checked > 0, "this filesystem refused every awkward name");
}
#[test]
fn a_path_over_260_characters_is_read_or_refused_cleanly() {
let tree = Tree::new("long-path");
let mut deep = String::new();
while deep.len() < 300 {
deep.push_str("a-directory-with-a-long-name/");
}
deep.push_str("signatures.toml");
let target = tree.root.join(&deep);
if std::fs::create_dir_all(target.parent().expect("a parent")).is_err()
|| std::fs::write(&target, SIGNATURE).is_err()
{
skipped(
"long-path",
"this platform refused a path over 260 characters",
);
return;
}
let run = load_signatures("long-path", &target);
assert!(loaded(&run), "{}", run.stderr);
}
#[test]
fn every_refusal_exits_two_and_leaves_stdout_empty() {
let tree = Tree::new("exit-two");
let missing = tree
.path()
.join("not-here.toml")
.to_string_lossy()
.into_owned();
for args in [
vec!["--signatures", missing.as_str()],
vec!["--input", missing.as_str()],
vec!["--not-a-flag"],
vec!["--concurrency", "0"],
vec!["--agent"],
] {
let refused = run("exit-two", &args);
assert_eq!(refused.code, 2, "{args:?}: {}", refused.stderr);
assert!(
refused.stdout.is_empty(),
"{args:?} wrote to the protocol stream"
);
}
}