diff --git a/crates/automation/src/assemble.rs b/crates/automation/src/assemble.rs index 5fd9d9b0be..b091e54d19 100644 --- a/crates/automation/src/assemble.rs +++ b/crates/automation/src/assemble.rs @@ -239,6 +239,8 @@ impl Automation { Ok(json!({ "files": out })) } + /// `page_extract`: the chosen pages as one new PDF, or with `separate` one file per page in + /// `out_dir`; with `delete` the pages are then removed from the original. pub(crate) fn page_extract(&mut self, a: &Args) -> Result { let doc = self.doc(a)?; let (id, name) = (doc.id, format!("{} (extract)", doc.name)); @@ -246,16 +248,8 @@ impl Automation { let pages = self.pages(a, "pages")?; let mut out = if a.opt_bool("separate")?.unwrap_or(false) { // Each page as its own file. - let dir = self.resolve(a.str("out_dir").map_err(|_| ToolError::InvalidArgs("separate files need out_dir".into()))?, true)?; - std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; - let mut files = Vec::new(); - for &p in &pages { - let bytes = self.session.extract(id, &[p]).map_err(failed)?; - let path = child(&dir, &format!("{stem}-page{}.pdf", p + 1)); - write_atomic(&path, &bytes)?; - files.push(path.to_string_lossy().into_owned()); - } - json!({ "files": files }) + let out_dir = a.str("out_dir").map_err(|_| ToolError::InvalidArgs("separate files need out_dir".into()))?; + json!({ "files": self.extract_separate(id, &pages, out_dir, &stem)? }) } else { let bytes = self.session.extract(id, &pages).map_err(failed)?; self.deliver(a, &name, bytes)? @@ -267,6 +261,45 @@ impl Automation { Ok(out) } + /// Each of `pages` as its own `-page.pdf` in `out_dir`, extracted and written one at a + /// time, so only one page's PDF is held at once. The first page is extracted before `out_dir` is + /// resolved and made, so a refusal (the permissions refuse every page alike, #5081) leaves nothing + /// behind; if a later page fails, the files this call created, and the directory if it made it, + /// are removed. + fn extract_separate(&self, id: pdfcraft_engine::DocId, pages: &[usize], out_dir: &str, stem: &str) -> Result> { + let Some(&first) = pages.first() else { return Ok(Vec::new()) }; + let mut first_bytes = Some(self.session.extract(id, &[first]).map_err(failed)?); + let dir = self.resolve(out_dir, true)?; + let made_dir = !dir.exists(); + std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; + let (mut files, mut created) = (Vec::new(), Vec::new()); + let outcome = pages.iter().try_for_each(|&p| -> Result<()> { + let bytes = match first_bytes.take() { + Some(bytes) => bytes, + None => self.session.extract(id, &[p]).map_err(failed)?, + }; + let path = child(&dir, &format!("{stem}-page{}.pdf", p.saturating_add(1))); + let existed = path.exists(); + write_atomic(&path, &bytes)?; + if !existed { + created.push(path.clone()); + } + files.push(path.to_string_lossy().into_owned()); + Ok(()) + }); + if let Err(e) = outcome { + // Best effort: whatever can't be removed stays, and the error reported is the extraction's. + for path in &created { + let _ = std::fs::remove_file(path); + } + if made_dir { + let _ = std::fs::remove_dir(&dir); + } + return Err(e); + } + Ok(files) + } + pub(crate) fn doc_combine(&mut self, a: &Args) -> Result { let paths = a.strs("paths")?; if paths.len() < 2 { diff --git a/crates/automation/src/json.rs b/crates/automation/src/json.rs index e6ca95e905..f612c3050d 100644 --- a/crates/automation/src/json.rs +++ b/crates/automation/src/json.rs @@ -256,7 +256,7 @@ pub(crate) fn security(d: &Document) -> Value { "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(), + "modify": p.modify(), "assemble": p.assemble(), "extract_pages": p.extract_pages(), "copy": p.copy(), "annotate": p.annotate(), "fill_forms": p.fill_forms(), "accessibility": p.extract_for_accessibility(), }) } diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 39f8ab7a16..3a87607373 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -3040,6 +3040,8 @@ fn replace_text_lists_its_replacement() { } } +/// Protect through the tools, then reopen: the open password is required, the restrictions +/// (including page extraction) hold for the user, and the permissions password lifts them. #[test] fn protecting_through_tools() { let dir = workdir("protect"); @@ -3057,12 +3059,14 @@ fn protecting_through_tools() { (r["security"]["printing"].as_str(), r["security"]["annotate"].as_bool(), r["security"]["copy"].as_bool()), (Some("low"), Some(true), Some(false)) ); + assert_eq!(r["security"]["extract_pages"], 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_eq!(ok(&mut b, "doc_info", json!({ "doc": re }))["security"]["extract_pages"], false); 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" })); @@ -3086,6 +3090,7 @@ fn protecting_through_tools() { assert_eq!(ok(&mut b, "text_find", json!({ "doc": re, "query": "Page" }))["count"], 3); // 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_info", json!({ "doc": owner }))["security"]["extract_pages"], true); assert!(ok(&mut b, "text_extract", json!({ "doc": owner }))["pages"][0]["text"].as_str().is_some_and(|t| t.contains("Page"))); assert_eq!(ok(&mut b, "doc_unprotect", json!({ "doc": owner }))["security"]["protected"], false); ok(&mut b, "doc_save", json!({ "doc": owner, "path": "open.pdf" })); @@ -3093,6 +3098,45 @@ fn protecting_through_tools() { ok(&mut c, "doc_open", json!({ "path": "open.pdf" })); } +/// A refused `doc_split` or separate-file `page_extract` leaves no output directory behind +/// (#5081), whether the protection allows no page changes ("Any except extracting pages") or +/// page changes without copying; with copying allowed as well, the user may extract. +#[test] +fn refused_extraction_writes_no_split_or_page_output() { + let dir = workdir("refused-extraction-output"); + // (changes, copy, assemble, extract_pages) as the user sees them. + let cases = [("any-except-extract", false, false, false), ("pages", false, true, false), ("pages", true, true, true)]; + for (i, (changes, copy, assemble, extract)) in cases.into_iter().enumerate() { + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let protect = json!({ "doc": doc, "open_password": "open", "permissions_password": "boss", "changes": changes, "copy": copy }); + ok(&mut a, "doc_protect", protect); + let path = format!("restricted-{i}.pdf"); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": path })); + + let mut b = auto(&dir); + let restricted = ok(&mut b, "doc_open", json!({ "path": path, "password": "open" }))["doc"].as_u64().unwrap(); + let security = ok(&mut b, "doc_info", json!({ "doc": restricted }))["security"].clone(); + assert_eq!((security["assemble"].as_bool(), security["extract_pages"].as_bool()), (Some(assemble), Some(extract)), "{changes}, copy {copy}"); + let (split_dir, extract_dir) = (format!("split-{i}"), format!("extract-{i}")); + let split = b.call("doc_split", &json!({ "doc": restricted, "every": 1, "out_dir": split_dir })); + let pages = b.call("page_extract", &json!({ "doc": restricted, "pages": [1, 2], "separate": true, "out_dir": extract_dir })); + if extract { + assert!(split.is_ok() && dir.join(&split_dir).exists(), "{changes}, copy {copy}"); + let files = match pages.as_deref() { + Ok([Content::Json(v), ..]) => v["files"].as_array().map(Vec::len), + other => panic!("{changes}, copy {copy}: page_extract: {other:?}"), + }; + assert_eq!(files, Some(2), "{changes}, copy {copy}"); + } else { + assert!(matches!(split, Err(ToolError::Failed(_))), "{changes}, copy {copy}"); + assert!(!dir.join(&split_dir).exists(), "refused split creates no output directory"); + assert!(matches!(pages, Err(ToolError::Failed(_))), "{changes}, copy {copy}"); + assert!(!dir.join(&extract_dir).exists(), "refused extraction creates no output directory"); + } + } +} + /// A document that allows changes but not copying: the tools that return a page's text refuse, /// or leave the text out where the tool also changes the page (a restyled paragraph, recognized /// words). diff --git a/crates/crypt/src/lib.rs b/crates/crypt/src/lib.rs index 8b520d281d..31c4963ae5 100644 --- a/crates/crypt/src/lib.rs +++ b/crates/crypt/src/lib.rs @@ -127,6 +127,13 @@ impl Permissions { pub fn assemble(&self) -> bool { if self.is_revision_3_or_later() { self.bit(11) } else { self.bit(4) } } + /// Take pages out into another document (Extract, Split, a source of Combine or Insert + /// Pages): [`Self::assemble`] and copying (bit 5), since the pages carry their text and + /// graphics into a new file without the document's protection (#5081). "Any except + /// extracting pages" sets bit 4 but not bit 11, so at revision 3 and later it grants neither. + pub fn extract_pages(&self) -> bool { + self.assemble() && self.copy() + } /// Revision 3 and later need bit 3 as well as bit 12. Table 22 defines bit 12 for those /// revisions only: at revision 2 the print flag (bit 3) is all there is. pub fn print_high_quality(&self) -> bool { diff --git a/crates/crypt/src/tests.rs b/crates/crypt/src/tests.rs index f7bbb874bb..39853f33ad 100644 --- a/crates/crypt/src/tests.rs +++ b/crates/crypt/src/tests.rs @@ -248,6 +248,23 @@ fn permission_bits_follow_table_22() { assert!(p.modify(), "owners ignore /P"); } +/// Page extraction (#5081) is assembly as the revision reads it, and copying (bit 5). +#[test] +fn page_extraction_needs_assembly_and_copying() { + let bit = |n: u32| 1i32 << (n - 1); + let user = |bits: i32, revision: i64| Permissions { bits, owner: false, revision }; + // "Any except extracting pages" (bits 4, 6, 9; copying off) at revision 3 and later. + assert!(!user(bit(4) | bit(6) | bit(9), 4).extract_pages()); + // Pages may change in place, but copying is withheld. + assert!(user(bit(11), 6).assemble() && !user(bit(11), 6).extract_pages()); + assert!(user(bit(11) | bit(5), 6).extract_pages()); + // Revision 2: bit 4 covers assembly, and its reserved bit 11 grants nothing. + assert!(user(bit(4), 2).assemble() && !user(bit(4) | bit(11), 2).extract_pages(), "copying withheld"); + assert!(user(bit(4) | bit(5), 2).extract_pages()); + assert!(!user(bit(5) | bit(11), 2).extract_pages(), "no assembly without bit 4 at revision 2"); + assert!(Permissions { bits: 0, owner: true, revision: 2 }.extract_pages(), "owners ignore /P"); +} + #[test] fn perms_limits_what_an_edited_p_grants() { let made = new(Algorithm::Aes256, "user pw", "owner pw", 0b0100); // print only diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index dfbc33d658..55a5d840ba 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -18,8 +18,10 @@ pub enum Needs { Nothing, /// A document is open. Document, - /// The open document's security allows page changes (insert, delete, rotate, extract…). + /// The open document's security allows page changes (insert, delete, rotate…). Assembly, + /// The open document's security allows taking pages out into new files (extract, split). + PageExtraction, /// The open document's security allows content and metadata changes. Modification, /// The open document's security allows adding and changing comments. @@ -416,8 +418,8 @@ pub const COMMANDS: &[CommandSpec] = &[ c("page.boxes", "Set page boxes…", PAGES, None, Assembly, "square-dashed-mouse-pointer"), c("page.insert", "Insert pages from file…", PAGES, None, Assembly, "file-input"), c("page.replace", "Replace pages…", PAGES, None, Assembly, "replace"), - c("page.extract", "Extract pages…", PAGES, None, Assembly, "file-output"), - c("page.split", "Split document…", PAGES, None, Assembly, "scissors"), + c("page.extract", "Extract pages…", PAGES, None, PageExtraction, "file-output"), + c("page.split", "Split document…", PAGES, None, PageExtraction, "scissors"), c("page.number", "Number pages…", PAGES, None, Assembly, "hash"), c("prepress.marks", "Add printer marks…", None, None, Modification, "ruler"), c("prepress.marks.remove", "Remove printer marks", None, None, Marks(crate::MarkKind::PrinterMarks), "ruler"), @@ -449,6 +451,7 @@ pub fn is_enabled(spec: &CommandSpec, session: &Session, active: Option) Nothing => true, Document | TwoPageView => doc.is_some(), Assembly => doc.is_some_and(|d| d.allows_assembly()), + PageExtraction => doc.is_some_and(|d| d.allows_page_extraction()), Modification => doc.is_some_and(|d| d.allows_modification()), Annotate => doc.is_some_and(|d| d.allows_annotation()), Copying => doc.is_some_and(|d| d.allows_copying()), diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index 937fd84bf9..602410922f 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -554,11 +554,17 @@ impl Document { if self.allows_copying() { Ok(()) } else { Err(EditError::NotPermitted("copying text and images")) } } - /// Page changes (insert, delete, rotate, move, extract) are allowed. + /// Page changes (insert, delete, rotate, move) are allowed. pub fn allows_assembly(&self) -> bool { self.editable() && self.permissions().is_none_or(|p| p.assemble()) } + /// Pages may be taken out into new files (Extract, Split): the page changes above and copying, + /// since the new files don't keep the document's protection (#5081). + pub fn allows_page_extraction(&self) -> bool { + self.editable() && self.permissions().is_none_or(|p| p.extract_pages()) + } + /// Redaction marks waiting to be applied. pub fn redaction_marks(&self) -> usize { self.info.annotations.iter().filter(|a| a.subtype == "Redact").count() diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index b00acfb8a8..80c51658e4 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -6,8 +6,8 @@ use crate::{ AddedContent, AddedText, Background, BorderShape, BoxSpec, EditError, FieldProps, FieldValue, FillMark, Hairlines, HeaderFooter, Hidden, ImageEdit, InitialView, Label, LineEnding, LinkAction, LinkStyle, MarkFile, MarkKind, Markup, NewAnnotation, NewField, NoteIcon, ObjectTarget, PageBox, PrinterMarks, Protection, ReviewState, Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image, - decode_data_file, embed_image, field_table, fit_picture, image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, - open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change, + decode_data_file, embed_image, field_table, fit_picture, image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, strip_xfa, + unrestricted, xfa, xfa_after_data_change, }; mod labels; diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 067f64929d..57e5729fc8 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -1,6 +1,6 @@ use super::{ AddedContent, Arc, Edit, EditError, FieldValue, ImageEdit, NewAnnotation, Shape, a11y, copy_image, decode_data_file, embed_image, fit_picture, - image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change, + image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, strip_xfa, unrestricted, xfa, xfa_after_data_change, }; use crate::SourceProblem; use crate::sources::{comment_source, import_pdf_comments}; @@ -531,13 +531,13 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Edit::SetInfo { key, value } => pdfcraft_organize::set_info(doc, key, value)?, Edit::DuplicatePages { pages, at } => pdfcraft_organize::duplicate_pages(doc, pages, *at)?, Edit::ReplacePages { pages, name, bytes, src_pages } => { - let src = open_source(name, bytes)?; + let src = crate::sources::open_source_into(name, bytes, doc)?; pdfcraft_organize::replace_pages(doc, pages, &src, src_pages)?; } Edit::SetPageBox { pages, which, spec } => pdfcraft_organize::set_page_box(doc, pages, *which, *spec)?, Edit::SetTransition { pages, spec } => pdfcraft_organize::set_transition(doc, pages, spec)?, Edit::InsertPagesFrom { name, bytes, pages, at, field_conflicts } => { - let src = open_source(name, bytes)?; + let src = crate::sources::open_source_into(name, bytes, doc)?; let pages = match pages { Some(p) => p.clone(), None => (0..pdfcraft_organize::page_count(&src)?).collect(), diff --git a/crates/engine/src/session/files.rs b/crates/engine/src/session/files.rs index 4177d1705f..cbdf178e9d 100644 --- a/crates/engine/src/session/files.rs +++ b/crates/engine/src/session/files.rs @@ -58,6 +58,26 @@ impl Session { Ok((kind, created?)) } + /// A file for Insert Pages into `dest`, as PDF bytes and its page count: an image or text + /// file is converted, and a PDF is checked as the insert itself checks it, so a file under + /// `dest`'s own protection that withholds only page extraction is accepted, as pasted pages + /// are (#5081). + pub fn insert_source(&self, dest: DocId, name: &str, bytes: &Arc>) -> Result<(usize, Arc>), EditError> { + if !matches!(source_kind(name, bytes), Some(SourceKind::Pdf)) { + let (_, pdf) = self.convert_to_pdf(name, bytes)?; + return Ok((self.page_count_of(name, &pdf)?, pdf)); + } + let src = crate::sources::open_source_into(name, bytes, self.cos(dest)?)?; + Ok((pdfcraft_organize::page_count(&src)?, bytes.clone())) + } + + /// The page count of a PDF for Replace Pages in `dest`, checked as the replace itself checks + /// it: as with [`Self::insert_source`], a file under `dest`'s own protection that withholds + /// only page extraction is accepted (#5081). + pub fn replace_source(&self, dest: DocId, name: &str, bytes: &Arc>) -> Result { + Ok(pdfcraft_organize::page_count(&crate::sources::open_source_into(name, bytes, self.cos(dest)?)?)?) + } + /// A file for Combine as PDF bytes: an image or text file is converted; a PDF is returned as /// it is, unchecked (Combine reads it itself, with its password), as is a file named .pdf /// without a `%PDF-` header, which the reader may still open. @@ -397,7 +417,7 @@ impl Session { /// New PDF bytes containing copies of `pages` of the document (Extract Pages). pub fn extract(&self, id: DocId, pages: &[usize]) -> Result>, EditError> { let src = self.cos(id)?; - if src.permissions().is_some_and(|p| !p.assemble()) { + if src.permissions().is_some_and(|p| !p.extract_pages()) { return Err(EditError::NotPermitted("extracting pages")); } let out = pdfcraft_organize::extract_pages(src, pages)?; @@ -407,7 +427,7 @@ impl Session { /// Split the document into several new PDFs. pub fn split(&self, id: DocId, by: &pdfcraft_organize::SplitBy) -> Result, EditError> { let src = self.cos(id)?; - if src.permissions().is_some_and(|p| !p.assemble()) { + if src.permissions().is_some_and(|p| !p.extract_pages()) { return Err(EditError::NotPermitted("splitting the document")); } let n = pdfcraft_organize::page_count(src)?; @@ -425,7 +445,7 @@ impl Session { /// images count once per part) fits; a page larger than the limit is a part of its own. pub fn split_by_size(&self, id: DocId, max_bytes: usize) -> Result, EditError> { let src = self.cos(id)?; - if src.permissions().is_some_and(|p| !p.assemble()) { + if src.permissions().is_some_and(|p| !p.extract_pages()) { return Err(EditError::NotPermitted("splitting the document")); } let n = pdfcraft_organize::page_count(src)?; diff --git a/crates/engine/src/sources.rs b/crates/engine/src/sources.rs index 91d6efa3ee..e7646e223c 100644 --- a/crates/engine/src/sources.rs +++ b/crates/engine/src/sources.rs @@ -25,6 +25,19 @@ pub(crate) fn open_source_with(name: &str, bytes: &Arc>, password: Optio source_document(bytes, password).map_err(|p| EditError::Source(format!("{name}: {p}"))) } +/// [`open_source`] for pages inserted into `dest`. A source that withholds page extraction but +/// allows assembly is accepted when `dest` will be saved under the very same protection (file +/// key and `/Encrypt` values), as when pages cut or copied from a document are pasted back into +/// it: they never leave its protection (#5081). +pub(crate) fn open_source_into(name: &str, bytes: &Arc>, dest: &pdfcraft_cos::Document) -> Result { + let same_protection = |src: &pdfcraft_cos::Document| match (src.security(), dest.output_handler()) { + (Some(s), Some(d)) => s.file_key() == d.file_key() && s.dict() == d.dict(), + _ => false, + }; + let permitted = |src: &pdfcraft_cos::Document| src.permissions().is_none_or(|p| p.extract_pages() || (p.assemble() && same_protection(src))); + source_document_if(bytes, None, permitted).map_err(|p| EditError::Source(format!("{name}: {p}"))) +} + /// Import the comments of `src` onto the pages of `doc` with the same numbers. pub(crate) fn import_pdf_comments(doc: &mut pdfcraft_cos::Document, src: &pdfcraft_cos::Document) -> Result<(), EditError> { if pdfcraft_organize::import_comments(doc, src)? == 0 { @@ -44,8 +57,17 @@ pub(crate) fn comment_source(bytes: &Arc>) -> Result>, password: Option<&str>) -> Result { + source_document_if(bytes, password, |d| d.permissions().is_none_or(|p| p.extract_pages())) +} + +/// Open `bytes` as a page source, refused as [`SourceProblem::NotPermitted`] unless `permitted`. +fn source_document_if( + bytes: &Arc>, + password: Option<&str>, + permitted: impl Fn(&pdfcraft_cos::Document) -> bool, +) -> Result { match std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)) { - Ok(Ok(d)) if d.permissions().is_some_and(|p| !p.assemble()) => Err(SourceProblem::NotPermitted), + Ok(Ok(d)) if !permitted(&d) => Err(SourceProblem::NotPermitted), Ok(Ok(d)) => Ok(d), Ok(Err(pdfcraft_cos::CosError::NeedsPassword)) => Err(SourceProblem::Password), Ok(Err(pdfcraft_cos::CosError::WrongPassword)) => Err(SourceProblem::WrongPassword), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 8bad98eea4..7b82a9b8e7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1615,6 +1615,206 @@ fn protected(user: &str, owner: &str, permissions: i32) -> Arc> { Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()) } +/// Two pages protected with `algorithm` under the permissions password "boss" (no open +/// password), restricting changes to `changes`. +fn protected_with_changes(algorithm: pdfcraft_cos::Algorithm, changes: Changes) -> Arc> { + protected_pages(2, algorithm, changes, 8) +} + +/// [`protected_with_changes`] with `pages` pages; `seed` gives the file key and salts, so two +/// files with the same arguments but `pages` share their protection exactly. +fn protected_pages(pages: usize, algorithm: pdfcraft_cos::Algorithm, changes: Changes, seed: u8) -> Arc> { + let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(pages))).unwrap(); + let protection = Protection { permissions_password: Some("boss".into()), changes, algorithm, ..Protection::default() }; + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm, + user_password: "", + owner_password: "boss", + permissions: protection.permission_bits(), + encrypt_metadata: true, + seed: [seed; 32], + }) + .unwrap(); + Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()) +} + +/// "Any except extracting pages" (#5081) withholds page extraction on every handler revision. At +/// revision 3 and later the preset (bit 4 without bit 11) allows no page changes either; at +/// revision 2, where bit 4 covers assembly, extraction, splitting and being a page source are +/// still refused because copying is withheld. +#[test] +fn extraction_permissions_refuse_any_except_extract_on_every_revision() { + use pdfcraft_cos::Algorithm::{Aes128, Aes256, Rc4_40, Rc4_128}; + + for algorithm in [Rc4_128, Aes128, Aes256] { + let bytes = protected_with_changes(algorithm, Changes::AnyExceptExtract); + let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), None).unwrap(); + let permissions = doc.permissions().unwrap(); + assert!(!permissions.extract_pages(), "{algorithm:?}, /P={:#x}", permissions.bits); + assert!(!permissions.assemble(), "{algorithm:?}: bit 4 without bit 11 allows no page changes"); + } + let r2 = protected_with_changes(Rc4_40, Changes::AnyExceptExtract); + let mut session = Session::new(); + let r2_id = session.open("r2.pdf", None, r2.clone(), None).unwrap(); + let permissions = session.get(r2_id).unwrap().permissions().unwrap(); + assert!(permissions.assemble() && !permissions.extract_pages(), "revision 2, /P={:#x}", permissions.bits); + assert_eq!(session.extract(r2_id, &[0]), Err(EditError::NotPermitted("extracting pages"))); + assert_eq!(session.split(r2_id, &pdfcraft_organize::SplitBy::PageCount(1)), Err(EditError::NotPermitted("splitting the document"))); + assert_eq!(crate::combine_source_check(&r2, None), Err(crate::SourceProblem::NotPermitted)); +} + +/// "Any except extracting pages" (#5081): Extract, Split and Combine refuse the file without the +/// permissions password and leave it unchanged; Combine takes it with that password. +#[test] +fn extraction_permissions_cover_revision_three_to_six_and_leave_no_partial_output() { + let bytes = protected_with_changes(pdfcraft_cos::Algorithm::Aes256, Changes::AnyExceptExtract); + assert_eq!(crate::combine_source_check(&bytes, None), Err(crate::SourceProblem::NotPermitted)); + assert_eq!(crate::combine_source_check(&bytes, Some("boss")), Ok(())); + let mut restricted = Session::new(); + let id = restricted.open("restricted.pdf", None, bytes.clone(), None).unwrap(); + let before = restricted.get(id).unwrap().bytes.clone(); + assert_eq!(restricted.extract(id, &[0]), Err(EditError::NotPermitted("extracting pages"))); + assert_eq!(restricted.split(id, &pdfcraft_organize::SplitBy::PageCount(1)), Err(EditError::NotPermitted("splitting the document"))); + assert_eq!(restricted.split_by_size(id, 1), Err(EditError::NotPermitted("splitting the document"))); + let doc = restricted.get(id).unwrap(); + assert_eq!(doc.info.pages.len(), 2); + assert!(!doc.dirty); + assert_eq!(doc.bytes, before, "a refused extraction leaves the source untouched"); + + let sources = vec![("restricted.pdf".into(), bytes.clone(), None)]; + assert!(matches!(restricted.combine_unlocked(&sources, &[None]), Err(EditError::Source(_)))); + let combined = restricted.combine_unlocked(&sources, &[Some("boss")]).unwrap(); + assert_eq!(pdfcraft_organize::page_count(&pdfcraft_cos::Document::open(combined).unwrap()).unwrap(), 2); +} + +/// A file under "Any except extracting pages" (#5081) is refused as an Insert Pages or Replace +/// Pages source for an unprotected document, which is left as it was, with nothing to undo. +#[test] +fn extraction_permissions_refuse_a_restricted_page_source() { + let bytes = protected_with_changes(pdfcraft_cos::Algorithm::Aes256, Changes::AnyExceptExtract); + let plain = Arc::new(fixture(1)); + let mut destination = Session::new(); + let dest = destination.open("destination.pdf", None, plain.clone(), None).unwrap(); + let insert = Edit::InsertPagesFrom { + name: "restricted.pdf".into(), + bytes: bytes.clone(), + pages: None, + at: 1, + field_conflicts: pdfcraft_organize::FieldConflicts::Rename, + }; + assert!(matches!(destination.apply(dest, insert), Err(EditError::Source(_)))); + assert_eq!(destination.get(dest).unwrap().bytes, plain, "a refused insert leaves the destination untouched"); + assert_eq!(destination.get(dest).unwrap().info.pages.len(), 1); + assert_eq!(destination.get(dest).unwrap().can_undo(), None); + assert!(matches!( + destination.apply(dest, Edit::ReplacePages { pages: vec![0], name: "restricted.pdf".into(), bytes: bytes.clone(), src_pages: vec![0] }), + Err(EditError::Source(_)) + )); + assert_eq!(destination.get(dest).unwrap().bytes, plain, "a refused replace leaves the destination untouched"); +} + +/// Opened with the permissions password, a file under "Any except extracting pages" (#5081) can +/// be extracted from and split. +#[test] +fn extraction_permissions_yield_to_the_owner_password() { + let bytes = protected_with_changes(pdfcraft_cos::Algorithm::Aes256, Changes::AnyExceptExtract); + let mut owner = Session::new(); + let owner_id = owner.open("restricted.pdf", None, bytes, Some("boss")).unwrap(); + assert!(owner.get(owner_id).unwrap().permissions().unwrap().extract_pages()); + assert!(owner.extract(owner_id, &[0]).is_ok()); + assert_eq!(owner.split(owner_id, &pdfcraft_organize::SplitBy::PageCount(1)).unwrap().len(), 2); + assert_eq!(owner.split_by_size(owner_id, 1).unwrap().len(), 2); +} + +/// A document that allows page changes but not copying, so not page extraction (#5081), takes +/// pages back from a file under exactly its own protection (a cut and paste, or another file +/// made with the same key and `/Encrypt` values), through Insert Pages' file check as well, and +/// through Replace Pages and its check, and keeps the restriction; the same file is refused as a +/// source for an unprotected or a differently protected document. +#[test] +fn pages_under_the_same_protection_are_inserted_without_page_extraction() { + use pdfcraft_cos::Algorithm::Aes256; + + let bytes = protected_pages(2, Aes256, Changes::Pages, 8); + let mut same = Session::new(); + let same_id = same.open("same.pdf", None, bytes.clone(), None).unwrap(); + let permissions = same.get(same_id).unwrap().permissions().unwrap(); + assert!(permissions.assemble() && !permissions.extract_pages(), "/P={:#x}", permissions.bits); + let insert = |name: &str, bytes: &Arc>, at| Edit::InsertPagesFrom { + name: name.into(), + bytes: bytes.clone(), + pages: None, + at, + field_conflicts: pdfcraft_organize::FieldConflicts::Rename, + }; + // Its own bytes, as Paste inserts them after a cut or copy. + same.apply(same_id, insert("same.pdf", &bytes, 2)).unwrap(); + assert_eq!(same.get(same_id).unwrap().info.pages.len(), 4); + assert!(same.get(same_id).unwrap().dirty); + + // A separate file made with the same key and /Encrypt values, and other pages. + let other = protected_pages(3, Aes256, Changes::Pages, 8); + let (a, b) = (pdfcraft_cos::Document::open(bytes.clone()).unwrap(), pdfcraft_cos::Document::open(other.clone()).unwrap()); + let (a, b) = (a.security().unwrap(), b.security().unwrap()); + assert!(a.file_key() == b.file_key() && a.dict() == b.dict(), "the two files share their protection"); + assert!(matches!(same.page_count_of("other.pdf", &other), Err(EditError::Source(_))), "a plain page source must allow extraction"); + assert_eq!(same.insert_source(same_id, "other.pdf", &other).map(|(n, _)| n), Ok(3)); + same.apply(same_id, insert("other.pdf", &other, 4)).unwrap(); + // Replace Pages takes them on the same terms. + let replace = |bytes: &Arc>| Edit::ReplacePages { pages: vec![0], name: "other.pdf".into(), bytes: bytes.clone(), src_pages: vec![2] }; + assert_eq!(same.replace_source(same_id, "other.pdf", &other), Ok(3)); + same.apply(same_id, replace(&other)).unwrap(); + assert_eq!(same.get(same_id).unwrap().info.pages.len(), 7); + let saved = same.save_bytes(same_id).unwrap(); + let reopened = pdfcraft_cos::Document::open_with_password(saved, None).unwrap(); + assert_eq!(pdfcraft_organize::page_count(&reopened).unwrap(), 7); + assert!(!reopened.permissions().unwrap().extract_pages(), "same-protection assembly keeps the extraction restriction"); + + // Neither an unprotected document nor one under other protection takes them. + for destination in [Arc::new(fixture(1)), protected_pages(1, Aes256, Changes::Pages, 9)] { + let mut session = Session::new(); + let dest = session.open("destination.pdf", None, destination, None).unwrap(); + let before = session.get(dest).unwrap().bytes.clone(); + assert!(matches!(session.insert_source(dest, "same.pdf", &bytes), Err(EditError::Source(_)))); + assert!(matches!(session.apply(dest, insert("same.pdf", &bytes, 1)), Err(EditError::Source(_)))); + assert!(matches!(session.replace_source(dest, "same.pdf", &bytes), Err(EditError::Source(_)))); + assert!(matches!(session.apply(dest, replace(&bytes)), Err(EditError::Source(_)))); + assert_eq!(session.get(dest).unwrap().bytes, before, "a refused insert or replace leaves the destination untouched"); + assert_eq!(session.get(dest).unwrap().info.pages.len(), 1); + } +} + +/// Taking pages out also needs copying (bit 5, #5081): a document that withholds only copying +/// refuses Extract, Split and being a page source, on a revision-2 handler too, whose reserved +/// bit 11 is always set. The owner password still lifts it. +#[test] +fn page_extraction_needs_the_copy_permission_on_every_revision() { + for algorithm in [pdfcraft_cos::Algorithm::Rc4_40, pdfcraft_cos::Algorithm::Aes256] { + let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(2))).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm, + user_password: "", + owner_password: "boss", + permissions: -1 ^ 0b1_0000, // everything but copying + encrypt_metadata: true, + seed: [9; 32], + }) + .unwrap(); + let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()); + let mut s = Session::new(); + let id = s.open("no-copy.pdf", None, bytes.clone(), None).unwrap(); + let permissions = s.get(id).unwrap().permissions().unwrap(); + assert!(permissions.assemble() && !permissions.extract_pages(), "{algorithm:?}"); + assert!(s.get(id).unwrap().allows_assembly() && !s.get(id).unwrap().allows_page_extraction(), "{algorithm:?}"); + assert_eq!(s.extract(id, &[0]), Err(EditError::NotPermitted("extracting pages")), "{algorithm:?}"); + assert_eq!(s.split(id, &pdfcraft_organize::SplitBy::PageCount(1)), Err(EditError::NotPermitted("splitting the document"))); + assert_eq!(crate::combine_source_check(&bytes, None), Err(crate::SourceProblem::NotPermitted), "{algorithm:?}"); + assert_eq!(crate::combine_source_check(&bytes, Some("boss")), Ok(()), "{algorithm:?}"); + let owner = s.open("no-copy.pdf", None, bytes, Some("boss")).unwrap(); + assert!(s.extract(owner, &[0]).is_ok(), "{algorithm:?}"); + } +} + #[test] fn encrypted_documents_open_edit_and_save_encrypted() { let bytes = protected("pw", "owner", -1); diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 549a877172..3b422181a9 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -156,7 +156,9 @@ impl PdfCraftApp { } } commands::Needs::Unlock if self.active.is_some() => tl!("This document has no restrictions to lift").to_string(), - commands::Needs::Assembly | commands::Needs::Modification | commands::Needs::Annotate if self.active.is_some() => { + commands::Needs::Assembly | commands::Needs::PageExtraction | commands::Needs::Modification | commands::Needs::Annotate + if self.active.is_some() => + { tl!("The document's security settings don't allow this change").to_string() } commands::Needs::Copying if self.active.is_some() => crate::copy_refused().to_string(), diff --git a/crates/ui-egui/src/workflows/files.rs b/crates/ui-egui/src/workflows/files.rs index 6a7a0434b3..18c1c24e0f 100644 --- a/crates/ui-egui/src/workflows/files.rs +++ b/crates/ui-egui/src/workflows/files.rs @@ -619,9 +619,9 @@ impl PdfCraftApp { /// Open the Replace Pages dialog for `bytes`, replacing the selection (or the current page). pub fn start_replace(&mut self, name: String, bytes: Vec) { - let Some(i) = self.active else { return }; + let Some((i, id)) = self.active_ids() else { return }; let bytes = Arc::new(bytes); - let src_pages = match self.session.page_count_of(&name, &bytes) { + let src_pages = match self.session.replace_source(id, &name, &bytes) { Ok(n) => n, Err(e) => { self.notify_fmt("Couldn't use {name}: {e}", &[("name", &name), ("e", &e.to_string())]); @@ -666,9 +666,7 @@ impl PdfCraftApp { continue; } }; - let converted = - self.session.convert_to_pdf(&name, &Arc::new(bytes)).and_then(|(_, pdf)| Ok((self.session.page_count_of(&name, &pdf)?, pdf))); - match converted { + match self.session.insert_source(id, &name, &Arc::new(bytes)) { Ok((pages, bytes)) => { if self.apply_edit(Edit::InsertPagesFrom { name, diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 173e64bdea..719f871f48 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -1723,18 +1723,20 @@ fn undo_and_redo_restore_the_combine_list() { assert!(h.state().session.get(h.state().views[0].id).unwrap().can_undo().is_none()); } +/// A protected file that still allows page extraction (only printing is withheld) is listed +/// for Combine with a warning that the result won't keep its security, not as a problem. #[test] fn protected_files_that_can_be_combined_are_flagged() { let mut h = harness(1, |app| { app.set_option("combine-view", "list").unwrap(); app.use_files( pdfcraft_ui_egui::FilePurpose::Combine, - vec![("one.pdf".into(), fixture(1)), ("no-copy.pdf".into(), protected("", "owner", -1 ^ 16))], + vec![("one.pdf".into(), fixture(1)), ("no-print.pdf".into(), protected("", "owner", -1 ^ 4))], ); }); h.run_steps(3); let app = h.state(); - assert!(app.combine_draft[1].problem.is_none(), "copying text is withheld, assembling pages isn't"); + assert!(app.combine_draft[1].problem.is_none(), "printing is withheld, extracting pages isn't"); h.get_by_label("Protected: the combined file won't keep its security settings"); assert!(!h.get_by_label("Combine").accesskit_node().is_disabled(), "a warning, not a blocker"); } diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 1019a4a811..a5e052341a 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3047,7 +3047,7 @@ status = "shipped" commands = ["page.combine"] tools = ["doc_combine"] evidence = ["crates/organize/src/tests.rs::combine_concatenates_with_a_bookmark_per_file", "crates/ui-egui/tests/it/editing.rs::combining_files_opens_a_new_unsaved_tab", "crates/ui-egui/tests/it/ui.rs::files_to_combine_from_the_operating_system", "apps/pdfcraft/src/single_instance.rs::a_second_launch_hands_its_files_to_the_first", "crates/ui-egui/tests/it/editing.rs::combine_files_opens_in_its_own_tab", "crates/ui-egui/tests/it/editing.rs::combine_lists_size_and_warns_before_combining", "crates/ui-egui/tests/it/editing.rs::a_password_protected_file_is_unlocked_with_its_password_and_combined", "crates/ui-egui/tests/it/editing.rs::a_folder_adds_its_pdfs_images_and_text_in_order_and_optionally_its_subfolders", "crates/ui-egui/tests/it/editing.rs::images_and_text_files_join_the_combine_list_as_pdfs", "crates/ui-egui/tests/it/editing.rs::files_that_cannot_be_read_are_listed_and_left_out_once_the_user_agrees", "crates/ui-egui/tests/it/ui.rs::pdfs_dropped_on_the_combine_tab_join_its_list", "crates/ui-egui/tests/it/ui.rs::files_dropped_on_the_combine_tab_join_the_end_of_its_list_in_the_order_dropped", "crates/ui-egui/tests/it/ui.rs::a_file_combine_does_not_take_is_left_out_of_a_drop_and_named", "crates/ui-egui/tests/it/ui.rs::a_pdf_dropped_on_a_document_opens_while_the_combine_tab_waits_behind_it", "crates/automation/tests/it/automation.rs::combine_extract_and_split", "crates/automation/tests/it/automation.rs::combine_opens_protected_files_with_their_passwords", "crates/automation/tests/it/automation.rs::combine_takes_images_and_text_files", "crates/engine/src/tests.rs::files_for_combine_are_made_into_pdfs_and_pdfs_pass_through", "crates/organize/src/tests.rs::combine_keeps_pdfx_when_every_source_agrees", "crates/organize/src/tests.rs::combine_claims_no_pdfx_when_the_sources_disagree"] -notes = "Combine files is its own tab: a table of the files (name, pages, size, modified, warnings) with column sorting, resizing and moving; files, folders (with subfolders) and open documents can be added, or dropped on the tab (desktop and web): a drop joins the end of the list in the order dropped, as one undo step, not at the place under the pointer; files of a kind Combine does not take are left out and named. Dropped elsewhere, files open as before. File Explorer's \"Combine files in PdfCraft\" and `pdfcraft --combine files…` add files from outside, to the running PdfCraft's tab (#585). Dropping adds no engine behaviour, so it has no tool of its own (doc_combine takes the files). Images (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) and .txt files become PDFs as they are added (#569; also in doc_combine). A file that can't be read stays in the list with why, and Combine asks before leaving it out; Office documents, HTML and other formats are not converted yet. Problems show before Combine (password, security settings that withhold page assembly, bad page ranges, duplicates); protected files are unlocked with their open or permissions password (also doc_combine passwords). The result is never encrypted, and is not yet offered for protection in the same step. A PDF/X result (output intent, GTS_PDFXVersion, Trapped, XMP pdfxid) only when every source declares the same version and output intent (#263). When they differ the result claims none, and nothing reports that yet; Acrobat behaviour not observed." +notes = "Combine files is its own tab: a table of the files (name, pages, size, modified, warnings) with column sorting, resizing and moving; files, folders (with subfolders) and open documents can be added, or dropped on the tab (desktop and web): a drop joins the end of the list in the order dropped, as one undo step, not at the place under the pointer; files of a kind Combine does not take are left out and named. Dropped elsewhere, files open as before. File Explorer's \"Combine files in PdfCraft\" and `pdfcraft --combine files…` add files from outside, to the running PdfCraft's tab (#585). Dropping adds no engine behaviour, so it has no tool of its own (doc_combine takes the files). Images (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) and .txt files become PDFs as they are added (#569; also in doc_combine). A file that can't be read stays in the list with why, and Combine asks before leaving it out; Office documents, HTML and other formats are not converted yet. Problems show before Combine (password, security settings that withhold page extraction, bad page ranges, duplicates); protected files are unlocked with their open or permissions password (also doc_combine passwords). The result is never encrypted, and is not yet offered for protection in the same step. A PDF/X result (output intent, GTS_PDFXVersion, Trapped, XMP pdfxid) only when every source declares the same version and output intent (#263). When they differ the result claims none, and nothing reports that yet; Acrobat behaviour not observed." [[feature]] id = "organize.combine-bookmarks"