diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f257865d3..835b5888b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2274,6 +2274,15 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed Err(EditError::NotPermitted("changing security")) } } + // Converting to PDF/A takes the encryption off (PDF/A forbids it), so on an encrypted + // document it is a change of security (#3921). + Edit::ConvertPdfA { .. } => { + if unrestricted(p) { + Ok(()) + } else { + Err(EditError::NotPermitted("removing the encryption, which PDF/A forbids")) + } + } Edit::SetInfo { .. } | Edit::AddHeaderFooter { .. } | Edit::AddWatermark { .. } @@ -2314,7 +2323,6 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed | Edit::AddOcrText { .. } | Edit::SetDocumentScript { .. } | Edit::SetFieldScript { .. } - | Edit::ConvertPdfA { .. } | Edit::RemoveStandardId { .. } | Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c8f0eb886..ec774b65b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1888,6 +1888,34 @@ fn permissions_password_restricts_others_but_not_this_session() { assert!(s2.get(id3).unwrap().allows_security_change()); } +/// Converting to PDF/A takes the encryption off (PDF/A forbids it), so on an encrypted document +/// it needs the same rights as removing security, not just the right to make changes (#3921). +#[test] +fn convert_to_pdfa_needs_the_owners_rights_on_an_encrypted_document() { + let pdfa = || Edit::ConvertPdfA { level: pdfcraft_preflight::Level::A2b }; + let refused = Err(EditError::NotPermitted("removing the encryption, which PDF/A forbids")); + // Changes allowed; printing (bit 3) and copying (bit 5) withheld. + let bytes = protected("pw", "owner", !((1 << 2) | (1 << 4))); + let mut s = Session::new(); + let id = s.open("x.pdf", None, bytes.clone(), Some("pw")).unwrap(); + assert!(s.get(id).unwrap().allows_modification()); + assert_eq!(s.apply(id, pdfa()), refused); + // Nor inside a batch. + let batch = Edit::Batch { label: "x".into(), edits: vec![Edit::RotatePages { pages: vec![0], degrees: 90 }, pdfa()] }; + assert_eq!(s.apply(id, batch), refused); + // Nothing was applied: no undo step, and saving gives back the encrypted original. + assert!(s.get(id).unwrap().can_undo().is_none()); + assert_eq!(s.save_bytes(id).unwrap().as_slice(), bytes.as_slice()); + // The owner's password allows it, and the result is no longer encrypted. + let id2 = s.open("x.pdf", None, bytes, Some("owner")).unwrap(); + s.apply(id2, pdfa()).unwrap(); + let saved = s.save_bytes(id2).unwrap(); + assert!(pdfcraft_cos::Document::open(saved).unwrap().trailer().get(b"Encrypt").is_none()); + // An unencrypted document needs nothing more. + let (mut s, id) = session_with(1); + s.apply(id, pdfa()).unwrap(); +} + #[test] fn combine_source_check_says_why_a_file_cannot_be_combined() { let (mut s, id) = session_with(1); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index fb1f2e0e2..9329c2b35 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -214,7 +214,7 @@ milestone = "M1" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/editing.rs::restricted_documents_show_a_notice_and_block_page_changes", "crates/crypt/src/tests.rs::permission_bits_follow_table_22", "crates/crypt/src/tests.rs::perms_limits_what_an_edited_p_grants", "crates/engine/src/tests.rs::combining_protected_files_is_refused_clearly", "crates/engine/src/tests.rs::print_restricted_documents_disable_print_unless_opened_by_the_owner", "crates/ui-egui/tests/it/print.rs::print_restricted_documents_say_so_before_the_dialog"] +evidence = ["crates/ui-egui/tests/it/editing.rs::restricted_documents_show_a_notice_and_block_page_changes", "crates/crypt/src/tests.rs::permission_bits_follow_table_22", "crates/crypt/src/tests.rs::perms_limits_what_an_edited_p_grants", "crates/engine/src/tests.rs::combining_protected_files_is_refused_clearly", "crates/engine/src/tests.rs::print_restricted_documents_disable_print_unless_opened_by_the_owner", "crates/ui-egui/tests/it/print.rs::print_restricted_documents_say_so_before_the_dialog", "crates/engine/src/tests.rs::convert_to_pdfa_needs_the_owners_rights_on_an_encrypted_document"] notes = "" [[feature]] @@ -7817,8 +7817,8 @@ milestone = "M11" status = "partial" commands = ["standards.pdfa"] tools = ["pdfa_convert"] -evidence = ["crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::convert_does_not_give_cmyk_documents_an_srgb_output_intent", "crates/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts"] -notes = "Fixes XMP, output intent (generated sRGB ICC profile, only for documents without DeviceCMYK; CMYK documents need a user-chosen CMYK output intent, not yet offered), forbidden actions, annotation flags, interpolation, encryption, AFRelationship. Not yet: embedding fonts, converting device CMYK, flattening transparency for PDF/A-1." +evidence = ["crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::convert_does_not_give_cmyk_documents_an_srgb_output_intent", "crates/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts", "crates/engine/src/tests.rs::convert_to_pdfa_needs_the_owners_rights_on_an_encrypted_document"] +notes = "Fixes XMP, output intent (generated sRGB ICC profile, only for documents without DeviceCMYK; CMYK documents need a user-chosen CMYK output intent, not yet offered), forbidden actions, annotation flags, interpolation, encryption, AFRelationship. Not yet: embedding fonts, converting device CMYK, flattening transparency for PDF/A-1. On an encrypted document it needs the owner password, as Protect and Remove security do, since it removes the encryption (#3921)." [[feature]] id = "optimize.pdfa-accessible-levels"