M8.1: Protect Using Password: engine Protect/RemoveProtection edits (Acrobat permission levels: printing, changes, copy, screen readers; AES-256/AES-128/RC4 compatibility; encrypt-except-metadata), per-state passwords on the undo stack, owner rights kept after saving, pending security in Document Properties; dialog matching Acrobat's (Viewing/Editing, strength meter, Advanced Options), protect.password/protect.remove commands; doc_protect/doc_unprotect tools (passwords never echoed). Fixes: saving a password-protected document failed to rebase on the encrypted file; DocInfo.encrypted was false for every decrypted file

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 21:55:00 -04:00
parent 54e288c5fe
commit ff641c669e
16 changed files with 918 additions and 58 deletions

View File

@ -209,6 +209,12 @@ impl Automation {
let (path, page) = (a.path("path")?, self.page(&a)?);
self.apply(&a, Edit::SetBookmarkPage { path, page })?
}
"doc_protect" => self.doc_protect(&a)?,
"doc_unprotect" => {
let mut out = self.apply(&a, Edit::RemoveProtection)?;
out["security"] = security(self.doc(&a)?);
out
}
"comment_list" => self.comment_list(&a)?,
"comment_add" => self.comment_add(&a)?,
"comment_reply" => self.comment_reply(&a)?,
@ -272,6 +278,44 @@ impl Automation {
Ok(summary(self.doc(a)?))
}
fn doc_protect(&mut self, a: &Args) -> Result<Value> {
use printcraft_engine::{Algorithm, Changes, Printing, Protection};
let d = Protection::default();
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),
printing: match a.opt_str("printing")? {
None => d.printing,
Some("none") => Printing::None,
Some("low") => Printing::Low,
Some("high") => Printing::High,
Some(o) => return Err(ToolError::InvalidArgs(format!("unknown printing {o:?}"))),
},
changes: match a.opt_str("changes")? {
None => d.changes,
Some("none") => Changes::None,
Some("pages") => Changes::Pages,
Some("fill-sign") => Changes::FillSign,
Some("comment-fill-sign") => Changes::CommentFillSign,
Some("any-except-extract") => Changes::AnyExceptExtract,
Some(o) => return Err(ToolError::InvalidArgs(format!("unknown changes {o:?}"))),
},
copy: a.opt_bool("copy")?.unwrap_or(d.copy),
accessibility: a.opt_bool("accessibility")?.unwrap_or(d.accessibility),
algorithm: match a.opt_str("compatibility")? {
None | Some("aes-256") => Algorithm::Aes256,
Some("aes-128") => Algorithm::Aes128,
Some("rc4-128") => Algorithm::Rc4_128,
Some("rc4-40") => Algorithm::Rc4_40,
Some(o) => return Err(ToolError::InvalidArgs(format!("unknown compatibility {o:?}"))),
},
encrypt_metadata: a.opt_bool("encrypt_metadata")?.unwrap_or(d.encrypt_metadata),
};
let mut out = self.apply(a, Edit::Protect(p))?;
out["security"] = security(self.doc(a)?);
Ok(out)
}
fn insert_blank(&mut self, a: &Args) -> Result<Value> {
let doc = self.doc(a)?;
let at = self.position(a, "at")?;
@ -599,6 +643,24 @@ fn one_based(pages: &[i64]) -> Result<Vec<usize>> {
.collect()
}
/// The document's security as the next save writes it (never includes passwords).
fn security(d: &Document) -> Value {
match d.security_summary() {
None => json!({ "protected": false }),
Some(s) => {
let p = s.permissions;
json!({
"protected": true,
"method": s.method,
"pending": s.pending,
"printing": if !p.print() { "none" } else if p.print_high_quality() { "high" } else { "low" },
"modify": p.modify(), "assemble": p.assemble(), "copy": p.copy(), "annotate": p.annotate(),
"fill_forms": p.fill_forms(), "accessibility": p.extract_for_accessibility(),
})
}
}
}
/// What every tool that changes a document returns.
fn summary(d: &Document) -> Value {
json!({
@ -618,7 +680,10 @@ fn summary(d: &Document) -> Value {
fn info(d: &Document) -> Value {
let i = &d.info;
let page1 = |p: usize| p + 1;
let security = d.security_summary().map(|s| json!({ "method": s.method, "owner": s.owner, "permissions": format!("{:?}", s.permissions) }));
let mut security = security(d);
if let Some(s) = d.security_summary() {
security["opened_as_owner"] = json!(s.owner);
}
json!({
"document": summary(d),
"pdf_version": i.pdf_version,

View File

@ -249,6 +249,30 @@ pub fn tools() -> Vec<ToolDef> {
&["doc"],
)),
t("comment_delete", "Delete a comment", "Delete a comment with its pop-up and replies. Undoable.").destructive().with(schema(comment_ref(json!({})), &["doc"])),
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.",
)
.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)." },
"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." },
}),
&["doc"],
)),
t("doc_unprotect", "Remove security", "Remove password security (the document must have been opened with its permissions password, or have none). Applied by the next doc_save. Undoable.")
.cmd("protect.remove")
.destructive()
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("edit_undo", "Undo", "Undo the last edit of a document.").cmd("edit.undo").with(schema(json!({ "doc": doc() }), &["doc"])),
t("edit_redo", "Redo", "Redo the last undone edit of a document.").cmd("edit.redo").with(schema(json!({ "doc": doc() }), &["doc"])),
t("command_list", "List commands", "Every registered PrintCraft command with its menu, shortcut, whether it is enabled now, and the tool that automates it.")

View File

@ -406,3 +406,37 @@ fn comments_through_tools() {
let re = ok(&mut b, "doc_open", json!({ "path": "commented.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(ok(&mut b, "comment_list", json!({ "doc": re }))["count"], 5);
}
#[test]
fn protecting_through_tools() {
let dir = workdir("protect");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc }))["security"]["protected"], false);
assert!(matches!(a.call("doc_protect", &json!({ "doc": doc })), Err(ToolError::Failed(_))), "a password is required");
let r = ok(
&mut a,
"doc_protect",
json!({ "doc": doc, "open_password": "open", "permissions_password": "boss", "printing": "low", "changes": "comment-fill-sign" }),
);
assert_eq!(r["security"]["pending"], true);
assert_eq!(
(r["security"]["printing"].as_str(), r["security"]["annotate"].as_bool(), r["security"]["copy"].as_bool()),
(Some("low"), Some(true), Some(false))
);
assert!(!r.to_string().contains("boss"), "passwords are never echoed");
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "locked.pdf" }));
// Another session: the open password is required, and the restrictions hold.
let mut b = auto(&dir);
assert!(matches!(b.call("doc_open", &json!({ "path": "locked.pdf" })), Err(ToolError::Failed(_))));
let re = ok(&mut b, "doc_open", json!({ "path": "locked.pdf", "password": "open" }))["doc"].as_u64().unwrap();
assert!(matches!(b.call("page_delete", &json!({ "doc": re, "pages": [1] })), Err(ToolError::Failed(_))));
assert!(matches!(b.call("doc_unprotect", &json!({ "doc": re })), Err(ToolError::Failed(_))));
ok(&mut b, "comment_add", json!({ "doc": re, "page": 1, "type": "note", "at": [10, 10], "contents": "allowed" }));
// With the permissions password everything is possible, including removing security.
let owner = ok(&mut b, "doc_open", json!({ "path": "locked.pdf", "password": "boss" }))["doc"].as_u64().unwrap();
assert_eq!(ok(&mut b, "doc_unprotect", json!({ "doc": owner }))["security"]["protected"], false);
ok(&mut b, "doc_save", json!({ "doc": owner, "path": "open.pdf" }));
let mut c = auto(&dir);
ok(&mut c, "doc_open", json!({ "path": "open.pdf" }));
}

View File

@ -259,7 +259,8 @@ impl Document {
self.security().map(|s| s.permissions())
}
pub(crate) fn encryption_changed(&self) -> bool {
/// Security was set or removed since the document was opened (the next save applies it).
pub fn encryption_changed(&self) -> bool {
self.encryption_changed
}
@ -269,6 +270,12 @@ impl Document {
if self.encryption_changed { (self.out_security.as_deref(), self.out_encrypt_num) } else { (self.security.as_deref(), self.encrypt_num) }
}
/// The security the next save writes: protection applied with `set_encryption`, none after
/// `remove_encryption`, otherwise the security the document was opened with.
pub fn output_handler(&self) -> Option<&printcraft_crypt::SecurityHandler> {
self.output_security().0
}
/// Protect the document with a password (§7.6.4). Takes effect on the next save, which is
/// always a full rewrite. Returns the handler (authenticated as owner).
pub fn set_encryption(&mut self, params: &printcraft_crypt::NewEncryption) -> Result<(), CosError> {

View File

@ -248,7 +248,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
ToolSection {
title: "Protect",
items: &[
item("Protect with password", "lock", "protect.password", Planned("M8")),
item("Protect with password", "lock", "protect.password", Ready),
item("Remove hidden information", "eye-off", "protect.sanitize", Planned("M8")),
],
},
@ -257,7 +257,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
items: &[
item("Encrypt with certificate", "file-lock-2", "protect.certificate", Planned("M8")),
item("Security properties", "shield-check", "protect.properties", Ready),
item("Remove security", "lock", "protect.remove", Planned("M8")),
item("Remove security", "lock-open", "protect.remove", Ready),
],
},
],

View File

@ -24,6 +24,10 @@ pub enum Needs {
Modification,
/// The open document's security allows adding and changing comments.
Annotate,
/// The document's security may be changed (owner, or nothing restricted).
Security,
/// The document is protected and its security may be removed.
ProtectedSecurity,
/// There is something to undo.
Undo,
/// There is something to redo.
@ -156,6 +160,8 @@ pub const COMMANDS: &[CommandSpec] = &[
c("comment.square", "Draw a rectangle", None, None, Annotate, "square"),
c("comment.circle", "Draw an oval", None, None, Annotate, "circle"),
c("form.fields", "Form fields panel", VIEW, None, Document, "list"),
c("protect.password", "Protect using password…", FILE, None, Security, "lock"),
c("protect.remove", "Remove security", FILE, None, ProtectedSecurity, "lock-open"),
c("protect.properties", "Security properties…", FILE, None, Document, "shield-check"),
c("page.organize", "Organize pages", PAGES, None, Document, "layout-grid"),
c("bookmark.add", "New bookmark", PAGES, Some(Shortcut::cmd("B")), Assembly, "bookmark-plus"),
@ -193,6 +199,8 @@ pub fn is_enabled(spec: &CommandSpec, session: &Session, active: Option<DocId>)
Assembly => doc.is_some_and(|d| d.allows_assembly()),
Modification => doc.is_some_and(|d| d.allows_modification()),
Annotate => doc.is_some_and(|d| d.allows_annotation()),
Security => doc.is_some_and(|d| d.allows_security_change()),
ProtectedSecurity => doc.is_some_and(|d| d.allows_security_change() && d.security_summary().is_some()),
Undo => doc.is_some_and(|d| d.can_undo().is_some()),
Redo => doc.is_some_and(|d| d.can_redo().is_some()),
}

View File

@ -19,6 +19,8 @@ pub use printcraft_organize::{SplitBy, split_ranges};
/// One file produced by a split: (1-based first page, last page, PDF bytes).
pub use printcraft_organize::LabelStyle;
pub use printcraft_cos::Algorithm;
/// Comment geometry helpers (text-box line breaking) for frontends.
pub use printcraft_annot::appearance as annot_text;
pub use printcraft_annot::{Markup, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, Style};
@ -37,12 +39,25 @@ pub struct DocId(pub u64);
/// Undo/redo depth. Snapshots share unchanged data, so this is memory-cheap.
const MAX_UNDO: usize = 100;
/// The passwords that go with one state of a document (protection can change them).
#[derive(Clone, Debug, Default, PartialEq)]
struct Keys {
/// What the renderer and inspector open the working file with (the user password).
render: Option<String>,
/// What the editor re-opens a saved file with: the strongest password known (owner if any).
reopen: Option<String>,
}
/// One undo/redo step: its label, the document state and its passwords.
type Snapshot = (String, printcraft_cos::Document, Keys);
/// Editing state of a document (absent when the document cannot be edited yet, e.g. encrypted).
#[derive(Clone)]
struct Editor {
cos: printcraft_cos::Document,
undo: Vec<(String, printcraft_cos::Document)>,
redo: Vec<(String, printcraft_cos::Document)>,
undo: Vec<Snapshot>,
redo: Vec<Snapshot>,
keys: Keys,
}
pub struct Document {
@ -69,11 +84,11 @@ pub struct Document {
impl Document {
pub fn can_undo(&self) -> Option<&str> {
self.editor.as_ref().and_then(|e| e.undo.last()).map(|(l, _)| l.as_str())
self.editor.as_ref().and_then(|e| e.undo.last()).map(|(l, _, _)| l.as_str())
}
pub fn can_redo(&self) -> Option<&str> {
self.editor.as_ref().and_then(|e| e.redo.last()).map(|(l, _)| l.as_str())
self.editor.as_ref().and_then(|e| e.redo.last()).map(|(l, _, _)| l.as_str())
}
pub fn editable(&self) -> bool {
@ -100,10 +115,17 @@ impl Document {
self.editable() && self.permissions().is_none_or(|p| p.annotate())
}
/// A summary of the document's security for Document Properties ▸ Security.
/// The document's security may be changed (Protect, Remove security): it is editable and,
/// if encrypted, was opened with the owner password.
pub fn allows_security_change(&self) -> bool {
self.editable() && self.permissions().is_none_or(|p| unrestricted(&p))
}
/// A summary of the document's security for Document Properties ▸ Security, including
/// protection applied in this session (written by the next save).
pub fn security_summary(&self) -> Option<SecuritySummary> {
let editor = self.editor.as_ref()?;
let h = editor.cos.security()?;
let h = editor.cos.output_handler()?;
let d = h.dict();
let stream = d.crypt_filters.iter().find(|(name, _)| *name == d.stm_f).map(|(_, m)| *m);
let method = match (d.v, stream) {
@ -113,7 +135,9 @@ impl Document {
(_, Some(printcraft_cos::CryptMethod::Aes256)) => "AES, 256-bit",
_ => "Attachments only",
};
Some(SecuritySummary { method: method.into(), owner: h.auth() == printcraft_cos::Auth::Owner, permissions: h.permissions() })
let pending = editor.cos.encryption_changed();
let permissions = if pending { printcraft_cos::Permissions { bits: h.permissions().bits, owner: false } } else { h.permissions() };
Some(SecuritySummary { method: method.into(), owner: h.auth() == printcraft_cos::Auth::Owner && !pending, permissions, pending })
}
/// Current value of a document-information entry (Title, Author, …).
@ -149,7 +173,132 @@ pub struct SecuritySummary {
pub method: String,
/// Opened with the owner password (no restrictions apply).
pub owner: bool,
/// For pending protection: the restrictions as they will apply to others.
pub permissions: printcraft_cos::Permissions,
/// Set in this session; written by the next save.
pub pending: bool,
}
/// What printing a protected document allows (Acrobat: Printing allowed).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Printing {
None,
/// "Low Resolution (150 dpi)".
Low,
High,
}
/// What changes a protected document allows (Acrobat: Changes allowed).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Changes {
None,
/// "Inserting, deleting, and rotating pages".
Pages,
/// "Filling in form fields and signing existing signature fields".
FillSign,
/// "Commenting, filling in form fields, and signing existing signature fields".
CommentFillSign,
/// "Any except extracting pages".
AnyExceptExtract,
}
/// Password protection to apply (Protect Using Password and its Advanced options).
#[derive(Clone, PartialEq)]
pub struct Protection {
/// Required to open the document (the user password).
pub open_password: Option<String>,
/// Required to change security and lift the restrictions below (the owner password).
pub permissions_password: Option<String>,
pub printing: Printing,
pub changes: Changes,
/// "Enable copying of text, images, and other content".
pub copy: bool,
/// "Enable text access for screen reader devices".
pub accessibility: bool,
/// Compatibility level; AES-256 (Acrobat X and later) by default.
pub algorithm: printcraft_cos::Algorithm,
/// `false`: "Encrypt all document contents except metadata".
pub encrypt_metadata: bool,
}
impl std::fmt::Debug for Protection {
// Never print passwords (edits end up in logs and error messages).
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Protection")
.field("open_password", &self.open_password.as_ref().map(|_| "…"))
.field("permissions_password", &self.permissions_password.as_ref().map(|_| "…"))
.field("printing", &self.printing)
.field("changes", &self.changes)
.field("copy", &self.copy)
.field("accessibility", &self.accessibility)
.field("algorithm", &self.algorithm)
.field("encrypt_metadata", &self.encrypt_metadata)
.finish()
}
}
impl Default for Protection {
/// Acrobat's defaults: AES-256, printing and accessibility allowed, no changes, no copying.
fn default() -> Self {
Self {
open_password: None,
permissions_password: None,
printing: Printing::High,
changes: Changes::None,
copy: false,
accessibility: true,
algorithm: printcraft_cos::Algorithm::Aes256,
encrypt_metadata: true,
}
}
}
impl Protection {
/// The `/P` permission bits (ISO 32000-2 Table 22), as Acrobat maps its choices.
pub fn permission_bits(&self) -> i32 {
let bit = |n: u32| 1i32 << (n - 1);
if self.permissions_password.is_none() {
return -1; // nothing restricted without a permissions password
}
let mut p = 0;
match self.printing {
Printing::None => {}
Printing::Low => p |= bit(3),
Printing::High => p |= bit(3) | bit(12),
}
p |= match self.changes {
Changes::None => 0,
Changes::Pages => bit(11),
Changes::FillSign => bit(9),
Changes::CommentFillSign => bit(6) | bit(9),
Changes::AnyExceptExtract => bit(4) | bit(6) | bit(9),
};
if self.copy {
p |= bit(5) | bit(10);
}
if self.accessibility {
p |= bit(10);
}
p
}
fn validate(&self) -> Result<(), EditError> {
let bad = |m: &str| Err(EditError::Protection(m.to_string()));
match (&self.open_password, &self.permissions_password) {
(None, None) => return bad("enter a password to open the document, a permissions password, or both"),
(Some(a), Some(b)) if a == b => return bad("the open password and the permissions password must be different"),
_ => {}
}
if [&self.open_password, &self.permissions_password].into_iter().flatten().any(|p| p.is_empty()) {
return bad("passwords can't be empty");
}
if self.algorithm != printcraft_cos::Algorithm::Aes256
&& [&self.open_password, &self.permissions_password].into_iter().flatten().any(|p| p.chars().any(|c| !(' '..='~').contains(&c)))
{
return bad("this compatibility level supports only plain ASCII passwords; use AES-256 (Acrobat X and later)");
}
Ok(())
}
}
/// Edits that can be applied to a document. Page indices are 0-based.
@ -260,6 +409,10 @@ pub enum Edit {
opacity: Option<f64>,
width: Option<f64>,
},
/// Protect with passwords and permissions (written by the next save, which is a full rewrite).
Protect(Protection),
/// Remove password security (needs the owner password).
RemoveProtection,
/// Several edits applied as one undoable step (all or nothing).
Batch {
label: String,
@ -291,6 +444,8 @@ impl Edit {
Edit::MoveAnnotation { .. } => "Move comment".into(),
Edit::ResizeAnnotation { .. } => "Resize comment".into(),
Edit::StyleAnnotation { .. } => "Change comment properties".into(),
Edit::Protect(_) => "Protect with password".into(),
Edit::RemoveProtection => "Remove security".into(),
Edit::Batch { label, .. } => label.clone(),
}
}
@ -313,6 +468,13 @@ fn annotation_noun(s: &Shape) -> &'static str {
}
}
/// Opened as owner, or nothing is restricted (no permissions password was set): security may
/// be changed, as in Acrobat.
fn unrestricted(p: &printcraft_cos::Permissions) -> bool {
const ALL: i32 = 0b1111_0011_1100; // bits 3–6 and 9–12
p.owner || p.bits & ALL == ALL
}
/// Whether the opening password allows an edit (§7.6.4.2, Table 22).
fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(), EditError> {
match edit {
@ -348,6 +510,13 @@ fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(),
Err(EditError::NotPermitted("comments"))
}
}
Edit::Protect(_) | Edit::RemoveProtection => {
if unrestricted(p) {
Ok(())
} else {
Err(EditError::NotPermitted("changing security"))
}
}
Edit::SetInfo { .. } => {
if p.modify() {
Ok(())
@ -380,6 +549,21 @@ impl EditCtx {
Self { date: now.map(printcraft_cos::pdf_date), seed, count: 0 }
}
/// 32 bytes of entropy for new encryption keys and salts. `RandomState` is seeded by the
/// operating system, so no extra dependency is needed.
fn entropy(&mut self) -> [u8; 32] {
use std::hash::{BuildHasher, Hasher};
let mut out = [0u8; 32];
for (i, chunk) in out.chunks_mut(8).enumerate() {
let mut h = std::collections::hash_map::RandomState::new().build_hasher();
h.write_u64(self.seed ^ i as u64);
h.write_u64(self.count);
self.count += 1;
chunk.copy_from_slice(&h.finish().to_le_bytes());
}
out
}
/// A fresh `/NM`: a random-looking UUID (version 4 layout) from a splitmix64 stream.
fn meta(&mut self) -> printcraft_annot::Meta {
let mut next = || {
@ -446,6 +630,28 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -
Edit::StyleAnnotation { page, index, color, opacity, width } => {
printcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, &cx.meta())?;
}
Edit::Protect(p) => {
p.validate()?;
let seed = cx.entropy();
// Without a permissions password nothing is restricted, so the owner password is a
// random one nobody needs.
let random_owner: String = cx.entropy().iter().map(|b| format!("{b:02x}")).collect();
let params = printcraft_cos::NewEncryption {
algorithm: p.algorithm,
user_password: p.open_password.as_deref().unwrap_or(""),
owner_password: p.permissions_password.as_deref().unwrap_or(&random_owner),
permissions: p.permission_bits(),
encrypt_metadata: p.encrypt_metadata,
seed,
};
doc.set_encryption(&params).map_err(|e| EditError::Protection(e.to_string()))?;
}
Edit::RemoveProtection => {
if doc.output_handler().is_none() {
return Err(EditError::Protection("the document isn't protected".into()));
}
doc.remove_encryption();
}
Edit::Batch { edits, .. } => {
for e in edits {
run_edit(doc, e, cx)?;
@ -455,6 +661,18 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -
Ok(())
}
/// The passwords after `edit`, if it changes them.
fn keys_after(edit: &Edit) -> Option<Keys> {
match edit {
Edit::Protect(p) => {
Some(Keys { render: p.open_password.clone(), reopen: p.permissions_password.clone().or_else(|| p.open_password.clone()) })
}
Edit::RemoveProtection => Some(Keys::default()),
Edit::Batch { edits, .. } => edits.iter().rev().find_map(keys_after),
_ => None,
}
}
/// Parse another PDF to copy pages from.
fn open_source(name: &str, bytes: &Arc<Vec<u8>>) -> Result<printcraft_cos::Document, EditError> {
match std::panic::catch_unwind(|| printcraft_cos::Document::open(bytes.clone())) {
@ -486,6 +704,8 @@ pub enum EditError {
Bookmark(#[from] printcraft_organize::OutlineError),
#[error("{0}")]
Comment(#[from] printcraft_annot::AnnotError),
#[error("{0}")]
Protection(String),
#[error("the edited document could not be written: {0}")]
Write(String),
#[error("the edited document could not be reopened: {0}")]
@ -549,7 +769,10 @@ impl Session {
let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() };
let renderer = RenderPool::new(bytes.clone(), render_threads(), config.clone());
let (editor, read_only_reason) = match cos {
Ok(Ok(cos)) => (Some(Editor { cos, undo: Vec::new(), redo: Vec::new() }), None),
Ok(Ok(cos)) => {
let keys = Keys { render: render_password.clone(), reopen: password.map(str::to_owned) };
(Some(Editor { cos, undo: Vec::new(), redo: Vec::new(), keys }), None)
}
Ok(Err(e)) => (None, Some(e.to_string())),
Err(_) => (None, Some("the document structure could not be read for editing".into())),
};
@ -590,18 +813,23 @@ impl Session {
let mut next = editor.cos.clone();
run_edit(&mut next, &edit, &mut cx)?;
let previous = std::mem::replace(&mut editor.cos, next);
editor.undo.push((edit.label(), previous));
let keys = keys_after(&edit).unwrap_or_else(|| editor.keys.clone());
let previous_keys = std::mem::replace(&mut editor.keys, keys);
editor.undo.push((edit.label(), previous, previous_keys));
if editor.undo.len() > MAX_UNDO {
editor.undo.remove(0);
}
editor.redo.clear();
Self::adopt_keys(doc);
if let Err(e) = Self::refresh(doc) {
// Roll back: the edit produced something we cannot display.
if let Some(ed) = doc.editor.as_mut()
&& let Some((_, prev)) = ed.undo.pop()
&& let Some((_, prev, keys)) = ed.undo.pop()
{
ed.cos = prev;
ed.keys = keys;
}
Self::adopt_keys(doc);
let _ = Self::refresh(doc);
return Err(e);
}
@ -613,9 +841,11 @@ impl Session {
pub fn undo(&mut self, id: DocId) -> Result<String, EditError> {
let doc = self.doc_mut(id)?;
let editor = doc.editor.as_mut().ok_or(EditError::NothingToUndo)?;
let (label, prev) = editor.undo.pop().ok_or(EditError::NothingToUndo)?;
let (label, prev, keys) = editor.undo.pop().ok_or(EditError::NothingToUndo)?;
let current = std::mem::replace(&mut editor.cos, prev);
editor.redo.push((label.clone(), current));
let current_keys = std::mem::replace(&mut editor.keys, keys);
editor.redo.push((label.clone(), current, current_keys));
Self::adopt_keys(doc);
Self::refresh(doc)?;
doc.dirty = true;
doc.generation += 1;
@ -625,15 +855,25 @@ impl Session {
pub fn redo(&mut self, id: DocId) -> Result<String, EditError> {
let doc = self.doc_mut(id)?;
let editor = doc.editor.as_mut().ok_or(EditError::NothingToRedo)?;
let (label, next) = editor.redo.pop().ok_or(EditError::NothingToRedo)?;
let (label, next, keys) = editor.redo.pop().ok_or(EditError::NothingToRedo)?;
let current = std::mem::replace(&mut editor.cos, next);
editor.undo.push((label.clone(), current));
let current_keys = std::mem::replace(&mut editor.keys, keys);
editor.undo.push((label.clone(), current, current_keys));
Self::adopt_keys(doc);
Self::refresh(doc)?;
doc.dirty = true;
doc.generation += 1;
Ok(label)
}
/// Use the current state's password for viewing (protection edits change it).
fn adopt_keys(doc: &mut Document) {
if let Some(e) = doc.editor.as_ref() {
doc.password = e.keys.render.clone();
doc.config.password = e.keys.render.as_deref().map(Arc::from);
}
}
/// Rebuild working bytes, inspection and renderer from the current edit state.
fn refresh(doc: &mut Document) -> Result<(), EditError> {
let Some(editor) = doc.editor.as_ref() else { return Ok(()) };
@ -681,7 +921,10 @@ impl Session {
pub fn mark_saved(&mut self, id: DocId, bytes: Arc<Vec<u8>>, path: Option<String>) -> Result<(), EditError> {
let doc = self.doc_mut(id)?;
if let Some(editor) = doc.editor.as_mut() {
editor.cos = printcraft_cos::Document::open(bytes.clone()).map_err(|e| EditError::Reopen(e.to_string()))?;
// An encrypted file needs the password it is protected with now (the owner
// password when known, so saving never downgrades this session's rights).
editor.cos = printcraft_cos::Document::open_with_password(bytes.clone(), editor.keys.reopen.as_deref())
.map_err(|e| EditError::Reopen(e.to_string()))?;
}
if let Some(p) = path {
doc.name = std::path::Path::new(&p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.clone());

View File

@ -240,6 +240,7 @@ fn encrypted_documents_open_edit_and_save_encrypted() {
let id = s.open("x.pdf", None, bytes.clone(), Some("pw")).unwrap();
assert!(s.get(id).unwrap().editable(), "{:?}", s.get(id).unwrap().read_only_reason);
assert_eq!(s.get(id).unwrap().security_summary().unwrap().method, "AES, 256-bit");
assert!(s.get(id).unwrap().info.encrypted, "reported as encrypted");
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
let saved = s.save_bytes(id).unwrap();
assert_eq!(&saved[..bytes.len()], &bytes[..], "incremental");
@ -484,3 +485,87 @@ fn comment_permission_is_enforced() {
let err = s.apply(id, rect_comment(0, [10.0, 10.0, 50.0, 50.0])).unwrap_err();
assert_eq!(err, EditError::NotPermitted("comments"));
}
#[test]
fn saving_a_password_protected_document_rebases_on_it() {
let bytes = protected("pw", "owner", -1);
let mut s = Session::new();
let id = s.open("x.pdf", None, bytes, Some("pw")).unwrap();
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
let saved = s.save_bytes(id).unwrap();
s.mark_saved(id, saved.clone(), None).expect("rebases on the encrypted file");
assert!(!s.get(id).unwrap().dirty);
s.apply(id, Edit::RotatePages { pages: vec![1], degrees: 90 }).unwrap();
let again = s.save_bytes(id).unwrap();
assert_eq!(&again[..saved.len()], &saved[..], "the next save appends");
}
fn protection(open: Option<&str>, perms: Option<&str>) -> Protection {
Protection { open_password: open.map(Into::into), permissions_password: perms.map(Into::into), ..Protection::default() }
}
#[test]
fn protect_with_an_open_password_then_save_reopen_and_undo() {
let (mut s, id) = session_with(2);
s.apply(id, Edit::Protect(protection(Some("secret"), None))).unwrap();
let d = s.get(id).unwrap();
assert!(d.info.encrypted, "the working file is encrypted: {:?}", d.info.warnings);
assert_eq!(d.security_summary().unwrap().method, "AES, 256-bit");
assert_eq!(page_texts(&s, id), ["Page 1", "Page 2"], "still viewable in this session");
let saved = s.save_bytes(id).unwrap();
assert!(printcraft_cos::Document::open(saved.clone()).is_err(), "needs the password");
s.mark_saved(id, saved.clone(), None).unwrap();
// Further edits keep working and saving stays encrypted.
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
let again = s.save_bytes(id).unwrap();
let mut s2 = Session::new();
assert!(s2.open("p.pdf", None, again.clone(), None).is_err());
let id2 = s2.open("p.pdf", None, again, Some("secret")).unwrap();
assert_eq!(s2.get(id2).unwrap().info.pages[0].rotation, 90);
// Nothing is restricted without a permissions password: the opener may remove security.
assert!(s2.get(id2).unwrap().allows_security_change());
s2.apply(id2, Edit::RemoveProtection).unwrap();
let plain = s2.save_bytes(id2).unwrap();
assert!(printcraft_cos::Document::open(plain).is_ok());
}
#[test]
fn permissions_password_restricts_others_but_not_this_session() {
let (mut s, id) = session_with(1);
let p = Protection { printing: Printing::Low, changes: Changes::CommentFillSign, copy: false, ..protection(None, Some("boss")) };
s.apply(id, Edit::Protect(p.clone())).unwrap();
assert!(s.get(id).unwrap().allows_modification(), "the author keeps full rights");
let saved = s.save_bytes(id).unwrap();
s.mark_saved(id, saved.clone(), None).unwrap();
assert!(s.get(id).unwrap().allows_modification(), "…also after saving (re-opened as owner)");
// Someone else opens it without a password: restricted as chosen.
let mut s2 = Session::new();
let id2 = s2.open("r.pdf", None, saved.clone(), None).unwrap();
let perm = s2.get(id2).unwrap().permissions().unwrap();
assert!(perm.print() && !perm.print_high_quality() && perm.annotate() && perm.fill_forms() && !perm.copy() && !perm.modify());
assert!(perm.extract_for_accessibility());
assert!(!s2.get(id2).unwrap().allows_security_change());
assert_eq!(s2.apply(id2, Edit::RemoveProtection), Err(EditError::NotPermitted("changing security")));
assert!(s2.get(id2).unwrap().allows_annotation(), "commenting was allowed");
// The owner password lifts everything.
let id3 = s2.open("r.pdf", None, saved, Some("boss")).unwrap();
assert!(s2.get(id3).unwrap().allows_security_change());
}
#[test]
fn protection_is_validated_undoable_and_never_logged() {
let (mut s, id) = session_with(1);
assert!(matches!(s.apply(id, Edit::Protect(protection(None, None))), Err(EditError::Protection(_))));
assert!(matches!(s.apply(id, Edit::Protect(protection(Some("same"), Some("same")))), Err(EditError::Protection(_))));
let rc4 = Protection { algorithm: printcraft_cos::Algorithm::Rc4_128, ..protection(Some("pässword"), None) };
assert!(matches!(s.apply(id, Edit::Protect(rc4)), Err(EditError::Protection(_))));
assert!(!format!("{:?}", Edit::Protect(protection(Some("hunter2"), Some("x")))).contains("hunter2"));
s.apply(id, Edit::Protect(protection(Some("pw"), Some("owner")))).unwrap();
assert!(s.get(id).unwrap().info.encrypted);
s.undo(id).unwrap();
assert!(!s.get(id).unwrap().info.encrypted, "undo removes the pending protection");
assert!(s.get(id).unwrap().security_summary().is_none());
s.redo(id).unwrap();
assert!(s.get(id).unwrap().info.encrypted);
assert_eq!(page_texts(&s, id), ["Page 1"]);
}

View File

@ -277,7 +277,8 @@ impl<'a> Inspector<'a> {
}
fn fill(&self, info: &mut DocInfo) {
info.encrypted = self.doc.is_encrypted() || self.doc.trailer.get(b"Encrypt").is_ok();
// lopdf drops /Encrypt from the trailer once it has decrypted the file.
info.encrypted = self.doc.is_encrypted() || self.doc.was_encrypted() || self.doc.trailer.get(b"Encrypt").is_ok();
if let Some(d) = self.doc.trailer.get(b"Info").ok().and_then(|o| self.dict(o)) {
info.title = self.text(d, b"Title");
info.author = self.text(d, b"Author");

View File

@ -22,6 +22,13 @@ impl PrintCraftApp {
let why = match spec.needs {
commands::Needs::Undo => "Nothing to undo".to_string(),
commands::Needs::Redo => "Nothing to redo".to_string(),
commands::Needs::Security | commands::Needs::ProtectedSecurity if self.active.is_some() => {
if self.active_ids().and_then(|(_, id)| self.session.get(id)).is_some_and(|d| d.allows_security_change()) {
"This document isn't password-protected".to_string()
} else {
"Only the document's owner can change its security (open it with the permissions password)".to_string()
}
}
commands::Needs::Assembly | commands::Needs::Modification | commands::Needs::Annotate if self.active.is_some() => {
"The document's security settings don't allow this change".to_string()
}
@ -48,6 +55,15 @@ impl PrintCraftApp {
}
"file.properties" => self.dialog = Some(Dialog::Properties(PropsTab::Description)),
"protect.properties" => self.dialog = Some(Dialog::Properties(PropsTab::Security)),
"protect.password" => {
self.protect_draft = Default::default();
self.dialog = Some(Dialog::Protect);
}
"protect.remove" => {
if self.apply_edit(Edit::RemoveProtection) {
self.notify("Security will be removed when you save");
}
}
"page.number" => {
if let Some(i) = active {
let v = &self.views[i];

View File

@ -30,6 +30,7 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
let mut number_now: Option<Edit> = None;
let mut apply_number = false;
let mut link_command: Option<&'static str> = None;
let mut protect_now = false;
let t = Tokens::get(ctx);
let mut close = false;
let mut next = dialog;
@ -93,6 +94,9 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
None => {
row(ui, "Security method", "No security".into());
row(ui, "Restrictions", "None — everything is allowed".into());
if doc.allows_security_change() && ui.button("Protect using password…").clicked() {
link_command = Some("protect.password");
}
}
Some(sec) => {
row(ui, "Security method", "Password security".into());
@ -100,8 +104,24 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
row(
ui,
"Opened with",
if sec.owner { "Owner password (no restrictions apply)".into() } else { "User password".into() },
if sec.pending {
"— (protection is applied when you save)".into()
} else if sec.owner {
"Owner password (no restrictions apply)".into()
} else {
"User password".into()
},
);
if doc.allows_security_change() {
ui.horizontal(|ui| {
if ui.button("Change settings…").clicked() {
link_command = Some("protect.password");
}
if ui.button("Remove security").clicked() {
link_command = Some("protect.remove");
}
});
}
let p = sec.permissions;
let yes = |b: bool| if b { "Allowed".to_string() } else { "Not allowed".to_string() };
row(
@ -194,6 +214,12 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
split_ready = Some(by);
}
}
Dialog::Protect => {
let (apply, cancel) = crate::protect::body(ui, app, &t);
protect_now = apply;
close = apply || cancel;
return;
}
Dialog::NumberPages => {
use printcraft_engine::LabelStyle as L;
ui.label(egui::RichText::new("Number pages").font(theme::semibold(18.0)));
@ -392,8 +418,9 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
app.discard_recovered(&keys);
}
}
if let Some(cmd) = link_command {
app.execute(cmd);
if protect_now && app.apply_edit(app.protect_draft.edit()) {
app.protect_draft = Default::default();
app.notify("Password protection will be applied when you save");
}
if apply_number && let Some(edit) = number_now {
app.apply_edit(edit);
@ -404,12 +431,17 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
if apply && let Some(edits) = draft_changes(app) {
app.apply_edit(Edit::Batch { label: "Change document properties".into(), edits });
}
if modal.should_close() || close {
// Protect / Remove security replace the Properties dialog.
let replaces = link_command.is_some_and(|c| c.starts_with("protect."));
if modal.should_close() || close || replaces {
app.dialog = None;
app.props_draft = None;
} else {
app.dialog = Some(next);
}
if let Some(cmd) = link_command {
app.execute(cmd);
}
}
/// Info edits needed to make the document match the Description draft.

View File

@ -19,6 +19,7 @@ mod icon_data;
pub mod icons;
mod palette;
mod panels;
mod protect;
mod recovery;
pub mod theme;
mod widgets;
@ -76,6 +77,8 @@ pub enum Dialog {
Split,
/// Pages ▸ Number pages… (page labels).
NumberPages,
/// Protect Using Password.
Protect,
/// Documents from a session that ended unexpectedly.
Recovery,
}
@ -163,6 +166,8 @@ pub struct PrintCraftApp {
pub bookmark_rename: Option<(Vec<usize>, String)>,
/// Number pages dialog settings (1-based pages).
pub number_draft: NumberDraft,
/// Protect Using Password dialog state.
pub protect_draft: protect::ProtectDraft,
/// The last web link the app asked the system to open (tests and automation).
pub last_opened_url: Option<String>,
}
@ -225,6 +230,7 @@ impl PrintCraftApp {
control: None,
bookmark_rename: None,
last_opened_url: None,
protect_draft: Default::default(),
number_draft: NumberDraft { from: 1, to: 1, style: printcraft_engine::LabelStyle::Decimal, prefix: String::new(), start: 1 },
}
}
@ -514,6 +520,7 @@ impl PrintCraftApp {
"advanced" => Some(Dialog::Properties(PropsTab::Advanced)),
"shortcuts" => Some(Dialog::Shortcuts),
"split" => Some(Dialog::Split),
"protect" => Some(Dialog::Protect),
"number-pages" => {
// Same path as the menu, so the page range is seeded.
self.execute("page.number");

View File

@ -0,0 +1,251 @@
//! Protect Using Password (Acrobat's simplified dialog, screenshot 13b), with the classic
//! Password Security settings behind "Advanced Options" (compatibility, what to encrypt,
//! printing, changes, copying, screen readers). Execution plan M8.1.
use egui::{Align, Color32, Layout};
use printcraft_engine::{Changes, Edit, Printing, Protection};
use crate::theme::{self, Tokens};
use crate::{PrintCraftApp, widgets};
/// The dialog's state while it is open.
#[derive(Clone, Debug, PartialEq)]
pub struct ProtectDraft {
/// `true`: password required for Viewing; `false`: for Editing.
pub viewing: bool,
pub password: String,
pub confirm: String,
pub advanced: bool,
pub protection: Protection,
}
impl Default for ProtectDraft {
fn default() -> Self {
Self { viewing: true, password: String::new(), confirm: String::new(), advanced: false, protection: Protection::default() }
}
}
impl ProtectDraft {
/// Why Apply is disabled, if it is.
pub fn problem(&self) -> Option<&'static str> {
if self.password.is_empty() {
Some("Type a password.")
} else if self.password != self.confirm {
Some("The passwords don't match.")
} else if self.protection.algorithm != printcraft_engine::Algorithm::Aes256 && !self.password.chars().all(|c| (' '..='~').contains(&c)) {
Some("This compatibility level supports only plain ASCII passwords.")
} else {
None
}
}
/// The edit Apply makes.
pub fn edit(&self) -> Edit {
let mut p = self.protection.clone();
if self.viewing {
p.open_password = Some(self.password.clone());
p.permissions_password = None;
} else {
p.open_password = None;
p.permissions_password = Some(self.password.clone());
}
Edit::Protect(p)
}
}
/// A rough password strength, as Acrobat's meter shows (Weak / Medium / Strong).
pub fn strength(pw: &str) -> (&'static str, Color32) {
let classes = [
pw.chars().any(|c| c.is_lowercase()),
pw.chars().any(|c| c.is_uppercase()),
pw.chars().any(|c| c.is_ascii_digit()),
pw.chars().any(|c| !c.is_alphanumeric()),
]
.iter()
.filter(|b| **b)
.count();
let n = pw.chars().count();
if n >= 12 && classes >= 3 {
("Strong", Color32::from_rgb(0x2D, 0x9D, 0x5B))
} else if n >= 8 && classes >= 2 {
("Medium", Color32::from_rgb(0xE8, 0x8A, 0x1A))
} else {
("Weak", Color32::from_rgb(0xD3, 0x2F, 0x2F))
}
}
/// A radio button drawn like Acrobat's: an outlined circle, filled blue with a white dot when on.
fn radio(ui: &mut egui::Ui, t: &Tokens, on: bool, label: &str) -> egui::Response {
let (rect, resp) = ui.allocate_exact_size(egui::vec2(220.0, 26.0), egui::Sense::click());
let c = egui::pos2(rect.left() + 9.0, rect.center().y);
if on {
ui.painter().circle_filled(c, 8.0, t.accent);
ui.painter().circle_filled(c, 3.0, Color32::WHITE);
} else {
ui.painter().circle_stroke(c, 7.5, egui::Stroke::new(1.5, t.text_muted));
}
ui.painter().text(egui::pos2(rect.left() + 26.0, rect.center().y), egui::Align2::LEFT_CENTER, label, theme::regular(14.0), t.text);
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, true, on, label));
resp.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// Draw the dialog body; returns (apply, cancel).
pub(crate) fn body(ui: &mut egui::Ui, app: &mut PrintCraftApp, t: &Tokens) -> (bool, bool) {
use printcraft_engine::Algorithm as A;
let d = &mut app.protect_draft;
ui.label(egui::RichText::new("Protect Using Password").font(theme::semibold(18.0)));
ui.add_space(4.0);
ui.separator();
ui.add_space(6.0);
ui.label("Requires user to enter a password for:");
ui.add_space(4.0);
if radio(ui, t, d.viewing, "Viewing").clicked() {
d.viewing = true;
}
if radio(ui, t, !d.viewing, "Editing").clicked() {
d.viewing = false;
}
ui.add_space(10.0);
// Bordered fields (Acrobat: 1 pt #B1B1B1, radius 4).
let field = |ui: &mut egui::Ui, label: &str, text: &mut String, id: &str| {
let l = ui.label(label);
egui::Frame::new()
.stroke(egui::Stroke::new(1.0, t.border))
.corner_radius(egui::CornerRadius::same(4))
.inner_margin(egui::Margin::symmetric(8, 6))
.show(ui, |ui| {
ui.add(egui::TextEdit::singleline(text).password(true).desired_width(290.0).frame(egui::Frame::NONE).id_salt(id)).labelled_by(l.id)
})
.inner
};
let r = field(ui, "Type Password", &mut d.password, "protect-pw");
if r.changed() || !d.password.is_empty() {
let (s, c) = strength(&d.password);
if !d.password.is_empty() {
ui.label(egui::RichText::new(format!("Strength: {s}")).font(theme::medium(11.5)).color(c));
}
}
ui.add_space(6.0);
field(ui, "Re-type Password", &mut d.confirm, "protect-pw2");
ui.add_space(6.0);
let chevron = if d.advanced { "⌃" } else { "⌄" };
if ui.add(egui::Button::new(egui::RichText::new(format!("Advanced Options {chevron}")).font(theme::semibold(13.0))).frame(false)).clicked() {
d.advanced = !d.advanced;
}
if d.advanced {
let p = &mut d.protection;
egui::Grid::new("protect-advanced").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
ui.label("Compatibility");
let algos = [
(A::Aes256, "Acrobat X and later (256-bit AES)"),
(A::Aes128, "Acrobat 7.0 and later (128-bit AES)"),
(A::Rc4_128, "Acrobat 5.0 and later (128-bit RC4)"),
(A::Rc4_40, "Acrobat 3.0 and later (40-bit RC4)"),
];
let cur = algos.iter().find(|(a, _)| *a == p.algorithm).map_or(algos[0].1, |(_, l)| *l);
egui::ComboBox::from_id_salt("protect-algo").width(300.0).selected_text(cur).show_ui(ui, |ui| {
for (a, l) in algos {
ui.selectable_value(&mut p.algorithm, a, l);
}
});
ui.end_row();
ui.label("Encrypt");
ui.vertical(|ui| {
ui.radio_value(&mut p.encrypt_metadata, true, "All document contents");
// Unencrypted metadata needs crypt filters (Acrobat 6.0 and later).
ui.add_enabled_ui(p.algorithm != A::Rc4_40 && p.algorithm != A::Rc4_128, |ui| {
ui.radio_value(&mut p.encrypt_metadata, false, "All document contents except metadata");
});
});
ui.end_row();
if !d.viewing {
ui.label("Printing allowed");
let opts = [(Printing::None, "None"), (Printing::Low, "Low Resolution (150 dpi)"), (Printing::High, "High Resolution")];
let cur = opts.iter().find(|(o, _)| *o == p.printing).map_or("High Resolution", |(_, l)| *l);
egui::ComboBox::from_id_salt("protect-print").width(300.0).selected_text(cur).show_ui(ui, |ui| {
for (o, l) in opts {
ui.selectable_value(&mut p.printing, o, l);
}
});
ui.end_row();
ui.label("Changes allowed");
let opts = [
(Changes::None, "None"),
(Changes::Pages, "Inserting, deleting, and rotating pages"),
(Changes::FillSign, "Filling in form fields and signing existing signature fields"),
(Changes::CommentFillSign, "Commenting, filling in form fields, and signing existing signature fields"),
(Changes::AnyExceptExtract, "Any except extracting pages"),
];
let cur = opts.iter().find(|(o, _)| *o == p.changes).map_or("None", |(_, l)| *l);
egui::ComboBox::from_id_salt("protect-changes").width(300.0).selected_text(cur).show_ui(ui, |ui| {
for (o, l) in opts {
ui.selectable_value(&mut p.changes, o, l);
}
});
ui.end_row();
ui.label("");
ui.vertical(|ui| {
ui.checkbox(&mut p.copy, "Enable copying of text, images, and other content");
ui.checkbox(&mut p.accessibility, "Enable text access for screen reader devices for the visually impaired");
});
ui.end_row();
}
});
if p.algorithm == A::Rc4_40 || p.algorithm == A::Rc4_128 {
p.encrypt_metadata = true;
}
}
ui.add_space(8.0);
if let Some(problem) = d.problem().filter(|_| !d.password.is_empty() || !d.confirm.is_empty()) {
ui.label(egui::RichText::new(problem).color(t.text_muted).font(theme::regular(12.0)));
}
ui.label(
egui::RichText::new(if d.viewing {
"Anyone opening the document will need this password. It's applied when you save."
} else {
"The document opens without a password; this password is needed to change the restrictions. It's applied when you save."
})
.color(t.text_faint)
.font(theme::regular(11.5)),
);
ui.add_space(12.0);
let (mut apply, mut cancel) = (false, false);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
let ok = d.problem().is_none();
if ui.add_enabled_ui(ok, |ui| widgets::pill_button(ui, "Apply", true)).inner.clicked() {
apply = true;
}
if widgets::pill_button(ui, "Cancel", false).clicked() {
cancel = true;
}
});
(apply, cancel)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn drafts_validate_and_map_to_the_right_password() {
let mut d = ProtectDraft::default();
assert!(d.problem().is_some());
d.password = "abc".into();
d.confirm = "abd".into();
assert_eq!(d.problem(), Some("The passwords don't match."));
d.confirm = "abc".into();
assert_eq!(d.problem(), None);
match d.edit() {
Edit::Protect(p) => assert_eq!((p.open_password.as_deref(), p.permissions_password), (Some("abc"), None)),
other => panic!("{other:?}"),
}
d.viewing = false;
match d.edit() {
Edit::Protect(p) => assert_eq!((p.open_password, p.permissions_password.as_deref()), (None, Some("abc"))),
other => panic!("{other:?}"),
}
assert_eq!(strength("abc").0, "Weak");
assert_eq!(strength("Abcdefg1").0, "Medium");
assert_eq!(strength("Correct-Horse-9").0, "Strong");
}
}

View File

@ -58,6 +58,10 @@ fn every_registered_command_is_implemented() {
if spec.id == "edit.redo" {
app.undo();
}
if spec.id == "protect.remove" {
let p = printcraft_engine::Protection { open_password: Some("pw".into()), ..Default::default() };
app.apply_edit(printcraft_engine::Edit::Protect(p));
}
let dir = std::env::temp_dir().join(format!("printcraft-cmd-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
app.save_override = Some(dir.join("out.pdf").to_string_lossy().into_owned());

View File

@ -0,0 +1,83 @@
//! Protect Using Password in the real shell (egui_kittest).
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use printcraft_ui_egui::{Dialog, PrintCraftApp};
const FIXTURE: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] >> endobj
trailer << /Root 1 0 R >>
%%EOF";
fn harness() -> Harness<'static, PrintCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PrintCraftApp::new();
app.open_bytes("doc.pdf", None, FIXTURE.to_vec()).unwrap();
app
});
h.run_steps(4);
h
}
fn type_into(h: &mut Harness<'static, PrintCraftApp>, label: &str, text: &str) {
h.get_by_label(label).click();
h.run_steps(2);
h.get_by_label(label).type_text(text);
h.run_steps(2);
}
#[test]
fn protecting_for_viewing_requires_matching_passwords_and_saves_encrypted() {
let mut h = harness();
assert!(h.state_mut().execute("protect.password"));
h.run_steps(2);
assert_eq!(h.state().dialog, Some(Dialog::Protect));
h.get_by_label("Viewing");
type_into(&mut h, "Type Password", "Open-Sesame-42");
type_into(&mut h, "Re-type Password", "Open-Sesame-4");
h.get_by_label_contains("don't match");
assert!(h.query_by(|n| n.label().as_deref() == Some("Apply") && n.is_disabled()).is_some(), "Apply is disabled");
type_into(&mut h, "Re-type Password", "2");
h.get_by_label("Strength: Strong");
h.get_by_label("Apply").click();
h.run_steps(3);
assert_eq!(h.state().dialog, None);
let id = h.state().views[0].id;
let doc = h.state().session.get(id).unwrap();
assert!(doc.info.encrypted && doc.dirty);
assert!(doc.security_summary().unwrap().pending);
// What Save writes needs the password.
let bytes = h.state().session.save_bytes(id).unwrap();
let mut s = printcraft_engine::Session::new();
assert!(s.open("p.pdf", None, bytes.clone(), None).is_err());
assert!(s.open("p.pdf", None, bytes, Some("Open-Sesame-42")).is_ok());
// Remove security is now offered, and undo also takes the protection back.
assert!(h.state_mut().execute("protect.remove"));
h.state_mut().undo();
h.state_mut().undo();
assert!(!h.state().session.get(id).unwrap().info.encrypted);
}
#[test]
fn editing_restrictions_via_advanced_options() {
let mut h = harness();
h.state_mut().execute("protect.password");
h.run_steps(2);
h.get_by_label("Editing").click();
h.run_steps(2);
h.get_by_label("Advanced Options ⌄").click();
h.run_steps(2);
h.get_by_label("Enable copying of text, images, and other content").click();
h.run_steps(1);
type_into(&mut h, "Type Password", "boss");
type_into(&mut h, "Re-type Password", "boss");
h.get_by_label("Apply").click();
h.run_steps(3);
let id = h.state().views[0].id;
let sec = h.state().session.get(id).unwrap().security_summary().unwrap();
assert!(sec.permissions.copy() && sec.permissions.print() && !sec.permissions.modify() && !sec.permissions.annotate());
// Remove security is disabled for nobody here: this session set it.
assert!(h.state().session.get(id).unwrap().allows_security_change());
}

View File

@ -5251,11 +5251,11 @@ title = "Protect with a password to open"
area = "G"
tier = "P0"
milestone = "M8"
status = "partial"
commands = []
tools = []
evidence = ["crates/cos/tests/encryption.rs::changing_and_removing_passwords"]
notes = "engine can set passwords; Protect UI and command are M8.1"
status = "shipped"
commands = ["protect.password"]
tools = ["doc_protect"]
evidence = ["crates/ui-egui/tests/protect.rs::protecting_for_viewing_requires_matching_passwords_and_saves_encrypted", "crates/engine/src/tests.rs::protect_with_an_open_password_then_save_reopen_and_undo", "crates/automation/tests/automation.rs::protecting_through_tools"]
notes = "Protect Using Password (Viewing); applied by the next save"
[[feature]]
id = "protect.permissions-password"
@ -5263,11 +5263,11 @@ title = "Restrict editing and printing with a permissions password"
area = "G"
tier = "P0"
milestone = "M8"
status = "partial"
commands = []
tools = []
evidence = ["crates/crypt/src/tests.rs::created_encryption_authenticates_owner_and_user"]
notes = "engine only; no UI"
status = "shipped"
commands = ["protect.password"]
tools = ["doc_protect"]
evidence = ["crates/ui-egui/tests/protect.rs::editing_restrictions_via_advanced_options", "crates/engine/src/tests.rs::permissions_password_restricts_others_but_not_this_session", "crates/automation/tests/automation.rs::protecting_through_tools"]
notes = "Protect Using Password (Editing); the author keeps full rights after saving"
[[feature]]
id = "protect.permissions-matrix"
@ -5275,11 +5275,11 @@ title = "Choose printing, changes, copying and accessibility permissions"
area = "G"
tier = "P0"
milestone = "M8"
status = "partial"
commands = []
tools = []
evidence = ["crates/crypt/src/tests.rs::permission_bits_follow_table_22"]
notes = "engine only; no UI"
status = "shipped"
commands = ["protect.password"]
tools = ["doc_protect"]
evidence = ["crates/ui-egui/tests/protect.rs::editing_restrictions_via_advanced_options", "crates/engine/src/tests.rs::permissions_password_restricts_others_but_not_this_session"]
notes = "Advanced Options: printing (none/low/high), changes (5 levels), copying, screen readers"
[[feature]]
id = "protect.encryption-level"
@ -5287,11 +5287,11 @@ title = "Choose encryption compatibility (RC4 40/128, AES-128, AES-256)"
area = "G"
tier = "P1"
milestone = "M8"
status = "partial"
commands = []
tools = []
evidence = ["crates/cos/tests/encryption.rs::every_algorithm_round_trips_and_hides_the_content"]
notes = "engine only; AES-256 default in the planned UI"
status = "shipped"
commands = ["protect.password"]
tools = ["doc_protect"]
evidence = ["crates/engine/src/tests.rs::protection_is_validated_undoable_and_never_logged"]
notes = "AES-256 (default), AES-128, RC4-128, RC4-40"
[[feature]]
id = "protect.encrypt-except-metadata"
@ -5299,11 +5299,11 @@ title = "Encrypt everything except metadata"
area = "G"
tier = "P1"
milestone = "M8"
status = "partial"
commands = []
tools = []
evidence = ["crates/cos/tests/encryption.rs::unencrypted_metadata_is_left_readable"]
notes = "engine only"
status = "shipped"
commands = ["protect.password"]
tools = ["doc_protect"]
evidence = ["crates/engine/src/tests.rs::protection_is_validated_undoable_and_never_logged"]
notes = "Advanced Options; disabled for RC4 levels as in Acrobat"
[[feature]]
id = "protect.encrypt-attachments-only"
@ -5323,11 +5323,11 @@ title = "Password strength meter"
area = "G"
tier = "P2"
milestone = "M8"
status = "planned"
commands = []
status = "shipped"
commands = ["protect.password"]
tools = []
evidence = []
notes = ""
evidence = ["crates/ui-egui/tests/protect.rs::protecting_for_viewing_requires_matching_passwords_and_saves_encrypted"]
notes = "Weak / Medium / Strong under the password field"
[[feature]]
id = "protect.remove-security"
@ -5335,11 +5335,11 @@ title = "Remove security"
area = "G"
tier = "P0"
milestone = "M8"
status = "partial"
commands = []
tools = []
evidence = ["crates/cos/tests/encryption.rs::changing_and_removing_passwords"]
notes = "engine only"
status = "shipped"
commands = ["protect.remove"]
tools = ["doc_unprotect"]
evidence = ["crates/engine/src/tests.rs::protect_with_an_open_password_then_save_reopen_and_undo", "crates/automation/tests/automation.rs::protecting_through_tools"]
notes = "needs the permissions password (or none set)"
[[feature]]
id = "protect.certificate-encryption"