diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 9153864093..1deb857542 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -1045,6 +1045,15 @@ impl Automation { fn doc_protect(&mut self, a: &Args) -> Result { use pdfcraft_engine::{Algorithm, Changes, Printing, Protection}; let d = Protection::default(); + // Restrictions only exist behind a permissions password (ISO 32000-2 ยง7.6.4.4: /P is + // enforced against the owner password; without one everything stays allowed). Refuse a + // restriction that could not take effect instead of writing an unrestricted file (#134). + let restriction = ["printing", "changes", "copy", "accessibility"].into_iter().find(|k| a.get(k).is_some()); + if let (None, Some(key)) = (a.opt_str("permissions_password")?, restriction) { + return Err(ToolError::InvalidArgs(format!( + "`{key}` needs `permissions_password`: with open_password alone the document is encrypted but nothing is restricted" + ))); + } let p = Protection { open_password: a.opt_str("open_password")?.map(str::to_owned), permissions_password: a.opt_str("permissions_password")?.map(str::to_owned), diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 312b84cfb9..e0b1f5aa5f 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -628,18 +628,18 @@ pub fn tools() -> Vec { t( "doc_protect", "Protect with passwords", - "Encrypt the document (applied by the next doc_save, a full rewrite). open_password is needed to open it; permissions_password is needed to change security and lifts the restrictions given by printing/changes/copy. Passwords are never echoed back. Undoable.", + "Encrypt the document (applied by the next doc_save, a full rewrite). open_password is needed to open it. permissions_password is needed to change security and lifts the restrictions given by printing/changes/copy/accessibility; those restrictions (and their defaults) apply only when permissions_password is given. With open_password alone the document is encrypted and everything stays allowed, so passing a restriction without permissions_password is an error. Passwords are never echoed back. Undoable.", ) .cmd("protect.password") .with(schema( json!({ "doc": doc(), - "open_password": { "type": "string", "minLength": 1 }, - "permissions_password": { "type": "string", "minLength": 1 }, - "printing": { "type": "string", "enum": ["none", "low", "high"], "description": "Default high." }, - "changes": { "type": "string", "enum": ["none", "pages", "fill-sign", "comment-fill-sign", "any-except-extract"], "description": "Default none." }, - "copy": { "type": "boolean", "description": "Allow copying text and images (default false)." }, - "accessibility": { "type": "boolean", "description": "Allow screen readers to read the text (default true)." }, + "open_password": { "type": "string", "minLength": 1, "description": "Required to open the document. On its own it restricts nothing." }, + "permissions_password": { "type": "string", "minLength": 1, "description": "Required to change security; enables printing/changes/copy/accessibility and their defaults." }, + "printing": { "type": "string", "enum": ["none", "low", "high"], "description": "Needs permissions_password. Default high." }, + "changes": { "type": "string", "enum": ["none", "pages", "fill-sign", "comment-fill-sign", "any-except-extract"], "description": "Needs permissions_password. Default none." }, + "copy": { "type": "boolean", "description": "Allow copying text and images (needs permissions_password; default false)." }, + "accessibility": { "type": "boolean", "description": "Allow screen readers to read the text (needs permissions_password; default true)." }, "compatibility": { "type": "string", "enum": ["aes-256", "aes-128", "rc4-128", "rc4-40"], "description": "Default aes-256 (Acrobat X and later)." }, "encrypt_metadata": { "type": "boolean", "description": "Default true." }, }), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 5516f66b66..1866368a10 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -747,6 +747,44 @@ fn protecting_through_tools() { ok(&mut c, "doc_open", json!({ "path": "open.pdf" })); } +/// Restrictions exist only behind a permissions password: open_password alone encrypts and +/// restricts nothing, and asking for a restriction without one is refused rather than ignored (#134). +#[test] +fn protecting_with_an_open_password_alone_restricts_nothing() { + let dir = workdir("protect-open-only"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + for (key, value) in [("copy", json!(false)), ("changes", json!("none")), ("printing", json!("none")), ("accessibility", json!(false))] { + match a.call("doc_protect", &json!({ "doc": doc, "open_password": "openme", key: value })) { + Err(ToolError::InvalidArgs(m)) => assert!(m.contains(key) && m.contains("permissions_password"), "{key}: {m}"), + other => panic!("{key} without permissions_password: {other:?}"), + } + } + assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc }))["security"]["protected"], false, "a refused call changes nothing"); + let r = ok(&mut a, "doc_protect", json!({ "doc": doc, "open_password": "openme" })); + assert_eq!( + (r["security"]["protected"].as_bool(), r["security"]["copy"].as_bool(), r["security"]["modify"].as_bool()), + (Some(true), Some(true), Some(true)) + ); + assert_eq!(r["security"]["printing"], "high"); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "open-only.pdf" })); + let mut b = auto(&dir); + let re = ok(&mut b, "doc_open", json!({ "path": "open-only.pdf", "password": "openme" }))["doc"].as_u64().unwrap(); + let s = ok(&mut b, "doc_info", json!({ "doc": re }))["security"].clone(); + assert_eq!( + (s["protected"].as_bool(), s["copy"].as_bool(), s["modify"].as_bool(), s["printing"].as_str()), + (Some(true), Some(true), Some(true), Some("high")) + ); + ok(&mut b, "page_delete", json!({ "doc": re, "pages": [1] })); + // The schema says so too. + let def = tools().into_iter().find(|t| t.name == "doc_protect").unwrap(); + assert!(def.description.contains("permissions_password"), "{}", def.description); + for key in ["printing", "changes", "copy", "accessibility"] { + let desc = def.input_schema["properties"][key]["description"].as_str().unwrap(); + assert!(desc.contains("permissions_password"), "{key}: {desc}"); + } +} + #[test] fn forms_through_tools() { let dir = workdir("forms");