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:
parent
54e288c5fe
commit
ff641c669e
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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" }));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(¶ms).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());
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue