Automation: doc_protect says restrictions need a permissions password, and refuses them without one (#134) (#243)

The schema documented printing/changes/copy/accessibility and their
defaults unconditionally, but the engine (like Acrobat) only enforces
/P behind an owner password: open_password alone encrypts the document
and leaves everything allowed, even when a caller passed copy: false
or changes: "none" explicitly, and the call reported success.

State the condition in the tool and property descriptions, and turn an
explicit restriction without permissions_password into InvalidArgs that
names the argument and what is needed, instead of writing an
unrestricted file. open_password alone is unchanged.

Co-authored-by: wstock <1394687+wstock@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-08 06:53:22 -04:00
1 parent abf4f80a62
commit ca9c653e3a
3 files changed
+54 -7

No files matched your search

+9
View File
@@ -1045,6 +1045,15 @@ impl Automation {
fn doc_protect(&mut self, a: &Args) -> Result<Value> {
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),
+7 -7
View File
@@ -628,18 +628,18 @@ pub fn tools() -> Vec<ToolDef> {
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." },
}),
+38
View File
@@ -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");