test(automation): give sign_document a real digital ID and document in the hostile-arguments sweep (#4277)

* test(automation): give sign_document a real digital ID and document in the hostile-arguments sweep

sign_id_create, earlier in the sweep, wrote a digital ID to the in.pdf
placeholder that sign_document then took as its id. Almost every
sign_document call failed to decrypt it and never reached the hostile
value under test, and the decrypts took most of the test's time.

Each tool now starts from an empty root, and sign_document gets a test
ID with its password and a fresh blank document per call. 370 of its
440 calls get past the ID (1 before) and 340 past the document (0
before); the sweep takes 7 s instead of 89 s.

* test(automation): empty the sweep's root through its canonical path

form_merge_data, given the hostile name " ", writes a file of that name
into the root through the canonical \\?\ path, where Windows takes it
literally. Emptying the root through the plain path dropped the trailing
space, so the entry named the root itself and removing it deleted the
root. Entries are now reached through the canonical path, their type is
read from the directory entry, and a failed removal is reported.

* test(automation): fail the sweep when the root can't be emptied

Listing, typing or removing an entry left in the root now fails the test
with the entry's path, so no tool runs against an earlier tool's files.
This commit is contained in:
aiongg
2026-10-23 14:22:19 -04:00
committed by GitHub
parent d1756e487d
commit c9ac28a048
+61 -8
View File
@@ -190,6 +190,54 @@ fn guarded(a: &mut Automation, tool: &str, args: &Value) -> std::result::Result<
}
}
/// A new one-page blank document, by id.
fn blank_doc(a: &mut Automation) -> u64 {
let created = a.call("doc_create", &json!({ "from": "blank", "pages": 1 })).expect("create a blank document");
created
.into_iter()
.find_map(|c| match c {
pdfcraft_automation::Content::Json(v) => v["doc"].as_u64(),
pdfcraft_automation::Content::Png { .. } => None,
})
.expect("the new document has an id")
}
/// Delete everything inside `dir`, so one tool never sees files an earlier tool wrote: a digital ID
/// that `sign_id_create` wrote to the `in.pdf` placeholder would stop every later `sign_document`
/// call at its decrypt, before the argument under test.
///
/// The entries are reached through the canonical path, as the tools reach them: on Windows that
/// is a `\\?\` path, whose names are taken literally. Through the plain path a file the tools
/// named `" "` would lose its trailing space and name `dir` itself, which would then be deleted.
fn empty_dir(dir: &Path) {
let dir = dir.canonicalize().expect("the root exists");
for entry in std::fs::read_dir(&dir).expect("read the root") {
let entry = entry.unwrap_or_else(|e| panic!("list {}: {e}", dir.display()));
let path = entry.path();
let is_dir = entry.file_type().unwrap_or_else(|e| panic!("file type of {}: {e}", path.display())).is_dir();
let removed = if is_dir { std::fs::remove_dir_all(&path) } else { std::fs::remove_file(&path) };
removed.unwrap_or_else(|e| panic!("remove {} before the next tool: {e}", path.display()));
}
}
/// Real values for the arguments a tool checks before the others, so a hostile value reaches the
/// argument it is meant for instead of failing on a placeholder. `sign_document` checks its
/// digital ID first and its document second: this ID opens with its password, and each call gets
/// a fresh document (which the caller closes), since a successful call signs it. The ID is a test
/// key with a low key derivation count, so the hundreds of calls that open it stay fast.
fn fixture(a: &mut Automation, root: &Path, tool: &str) -> serde_json::Map<String, Value> {
match tool {
"sign_document" => {
let id = Path::new(env!("CARGO_MANIFEST_DIR")).join("../sign/tests/data/ec-p256.p12");
std::fs::copy(&id, root.join("id.p12")).expect("copy the test digital ID");
let doc = blank_doc(a);
let args = json!({ "doc": doc, "id": "id.p12", "password": "test", "out": "signed.pdf" });
args.as_object().cloned().unwrap_or_default()
}
_ => serde_json::Map::new(),
}
}
#[test]
fn no_tool_panics_on_hostile_arguments() {
let root = tmp("args");
@@ -199,6 +247,7 @@ fn no_tool_panics_on_hostile_arguments() {
let mut calls = 0usize;
for def in tools() {
empty_dir(&root);
// The whole arguments object, not just its properties.
for bad in [json!(null), json!([]), json!("string"), json!(7), json!(true), json!({})] {
calls += 1;
@@ -210,13 +259,24 @@ fn no_tool_panics_on_hostile_arguments() {
for prop in &props {
for value in &values {
let mut obj = plausible(&def);
let fixed = fixture(&mut a, &root, def.name);
let fixture_doc = fixed.get("doc").cloned();
obj.extend(fixed);
obj.insert(prop.clone(), value.clone());
calls += 1;
if let Err(e) = guarded(&mut a, def.name, &Value::Object(obj)) {
failures.push(e);
}
// Later tools pass placeholder document ids, which must not find this one open.
if let Some(doc) = fixture_doc {
a.call("doc_close", &json!({ "doc": doc, "discard_changes": true })).expect("close the fixture's document");
}
}
}
// A signed file shows the fixture got calls past the ID and document checks.
if def.name == "sign_document" {
assert!(root.join("signed.pdf").is_file(), "no sign_document call signed: the hostile values stop at the ID or document");
}
}
eprintln!("hostile arguments: {calls} calls over {} tools, {} panics", tools().len(), failures.len());
@@ -256,14 +316,7 @@ fn no_tool_escapes_the_root() {
let mut a = Automation::new().with_root(&root).expect("root");
// A real document, so tools reach their path arguments instead of failing on `doc` first.
let created = a.call("doc_create", &json!({ "from": "blank", "pages": 1 })).expect("create a blank document");
let doc = created
.into_iter()
.find_map(|c| match c {
pdfcraft_automation::Content::Json(v) => v["doc"].as_u64(),
pdfcraft_automation::Content::Png { .. } => None,
})
.expect("the new document has an id");
let doc = blank_doc(&mut a);
let mut leaked = Vec::new();
let mut tried = 0usize;