mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-14 14:52:22 +00:00
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:
3 files changed
+54
-7
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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." },
|
||||
}),
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user