diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index 3c7f5fbe18..839976f620 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -799,6 +799,22 @@ impl Document { self.output_security().0 } + /// True when an incremental save of `self` can carry `other`'s objects under `self`'s + /// encryption (see [`Document::overlay_changes_from`]). Passwords and authentication level + /// are not an encryption identity: the same passwords can protect a different key, + /// algorithm or permissions. The dictionary's object number also matters, since the writer + /// must leave that object unencrypted. A different file key always changes the dictionary + /// too; comparing it as well costs nothing. + pub fn has_same_output_security(&self, other: &Document) -> bool { + match (self.output_security(), other.output_security()) { + ((None, _), (None, _)) => true, + ((Some(a), a_num), (Some(b), b_num)) => { + a_num == b_num && a.dict() == b.dict() && a.file_key() == b.file_key() && self.first_id() == other.first_id() + } + _ => false, + } + } + /// 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: &pdfcraft_crypt::NewEncryption) -> Result<(), CosError> { @@ -1334,7 +1350,13 @@ impl Document { /// incremental save must still append to this file (the last saved revision). Objects /// that already match are left untouched. Reads go through private readers so neither /// document's editing cache is filled. + /// + /// Output security must match: the copied trailer must describe the handler and + /// encryption-dictionary exemption this writer uses. Otherwise write `current` in full. pub fn overlay_changes_from(&mut self, current: &Document) -> Result<(), CosError> { + if !self.has_same_output_security(current) { + return Err(CosError::Security("cannot overlay different output encryption; write the current document in full".into())); + } let saved_nums = self.object_numbers(); let current_nums = current.object_numbers(); let mut freed = Vec::new(); diff --git a/crates/cos/tests/it/encryption.rs b/crates/cos/tests/it/encryption.rs index 2ee33afc12..52dce63265 100644 --- a/crates/cos/tests/it/encryption.rs +++ b/crates/cos/tests/it/encryption.rs @@ -5,7 +5,7 @@ use std::io::Write; use std::sync::Arc; -use pdfcraft_cos::{Algorithm, Auth, CosError, Document, NewEncryption, ObjRef, Object, PdfString, SaveOptions, write_full, write_incremental}; +use pdfcraft_cos::{Algorithm, Auth, CosError, Dict, Document, NewEncryption, ObjRef, Object, PdfString, SaveOptions, write_full, write_incremental}; const SECRET: &str = "BT /F1 18 Tf 20 100 Td (Secret page) Tj ET"; @@ -494,3 +494,213 @@ fn a_stream_labelled_xref_is_encrypted_when_saved() { let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); assert!(!bytes.windows(SECRET.len()).any(|w| w == SECRET.as_bytes()), "the labelled stream was written in the clear"); } + +// Undo can restore a snapshot whose output security differs from the last saved file (#3276). +// Overlaying such a snapshot onto that file must be refused before anything changes. + +fn set_title(doc: &mut Document, title: &str) { + let info = doc.trailer().reference(b"Info").unwrap(); + doc.update_dict(info, |d| d.set(b"Title".to_vec(), Object::String(PdfString { bytes: title.as_bytes().to_vec(), hex: false }))).unwrap(); +} + +fn rekey(doc: &mut Document, alg: Algorithm, seed: u8) { + doc.set_encryption(&NewEncryption { + algorithm: alg, + user_password: "user", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [seed; 32], + }) + .unwrap(); +} + +fn with_first_id(doc: &mut Document, id: &[u8]) { + let Some(Object::Array(ids)) = doc.trailer().get(b"ID").cloned() else { panic!("no /ID") }; + let id = Object::String(PdfString { bytes: id.to_vec(), hex: true }); + doc.trailer_mut().set(b"ID".to_vec(), Object::Array(vec![id, ids[1].clone()])); +} + +/// Everything an overlay could change: the graph and trailer an incremental save would write. +fn state(doc: &Document) -> (Dict, Vec, Vec, bool, Vec) { + let save = write_incremental(doc, &SaveOptions::default()).unwrap(); + (doc.trailer().clone(), doc.object_numbers(), doc.modified_objects(), doc.is_modified(), save) +} + +#[test] +fn an_overlay_with_different_output_security_is_refused_before_any_change() { + let encrypted = Arc::new(protect(Algorithm::Aes256, "user", "owner", -1)); + let open_owner = || Document::open_with_password(encrypted.clone(), Some("owner")).unwrap(); + // Each target carries an unsaved edit, so a wrongful overlay would have something to change. + let encrypted_target = || { + let mut d = open_owner(); + set_title(&mut d, "Saved title"); + d + }; + let plain_target = || { + let mut d = Document::open(Arc::new(fixture())).unwrap(); + set_title(&mut d, "Saved title"); + d + }; + let mut cases: Vec<(&str, Document, Document)> = Vec::new(); + let mut plain = Document::open(Arc::new(fixture())).unwrap(); + set_title(&mut plain, "Restored title"); + cases.push(("encrypted target, plain snapshot", encrypted_target(), plain)); + let mut protected = open_owner(); + set_title(&mut protected, "Restored title"); + cases.push(("plain target, encrypted snapshot", plain_target(), protected)); + let mut rekeyed = open_owner(); + rekey(&mut rekeyed, Algorithm::Aes256, 3); + set_title(&mut rekeyed, "Restored title"); + cases.push(("same passwords, new key", encrypted_target(), rekeyed)); + let mut other_alg = open_owner(); + rekey(&mut other_alg, Algorithm::Rc4_128, 9); + cases.push(("same passwords, other algorithm", encrypted_target(), other_alg)); + let mut other_id = open_owner(); + with_first_id(&mut other_id, b"another file id!"); + set_title(&mut other_id, "Restored title"); + cases.push(("different first /ID", encrypted_target(), other_id)); + + for (case, mut target, current) in cases { + assert!(!target.has_same_output_security(¤t) && !current.has_same_output_security(&target), "{case}"); + let before = state(&target); + let result = target.overlay_changes_from(¤t); + assert!(matches!(result, Err(CosError::Security(_))), "{case}: {result:?}"); + assert!(before == state(&target), "{case}: a refused overlay changed the target"); + } + + // Control: the same overlay with matching security does change the target. + let mut target = encrypted_target(); + let mut current = open_owner(); + set_title(&mut current, "Restored title"); + assert!(target.has_same_output_security(¤t)); + let before = state(&target); + target.overlay_changes_from(¤t).unwrap(); + assert!(before != state(&target), "a compatible overlay applies the snapshot"); + assert_eq!(title(&target).as_deref(), Some("Restored title")); +} + +/// The user and owner passwords unlock the same file key: either opening may be overlaid on the +/// other, and doing so neither elevates nor drops the user's restrictions in the saved file. +#[test] +fn output_security_does_not_depend_on_the_authentication_level() { + let no_print_or_copy = !(4 | 16); + for alg in ALL { + let bytes = Arc::new(protect(alg, "user", "owner", no_print_or_copy)); + let user = Document::open_with_password(bytes.clone(), Some("user")).unwrap(); + let owner = Document::open_with_password(bytes.clone(), Some("owner")).unwrap(); + assert_eq!((user.security().unwrap().auth(), owner.security().unwrap().auth()), (Auth::User, Auth::Owner), "{alg:?}"); + assert!(user.has_same_output_security(&owner) && owner.has_same_output_security(&user), "{alg:?}"); + let mut unlocked = Document::open_with_password(bytes.clone(), Some("user")).unwrap(); + unlocked.unlock("owner").unwrap(); + assert!(unlocked.has_same_output_security(&user), "{alg:?}: unlocking keeps the output security"); + + // A snapshot edited as user, overlaid on the owner's opening (and the reverse). + for (base, password) in [("owner", "user"), ("user", "owner")] { + let mut target = Document::open_with_password(bytes.clone(), Some(base)).unwrap(); + let mut current = Document::open_with_password(bytes.clone(), Some(password)).unwrap(); + set_title(&mut current, "Edited"); + target.overlay_changes_from(¤t).unwrap(); + let saved = Arc::new(write_incremental(&target, &SaveOptions::default()).unwrap()); + assert!(!String::from_utf8_lossy(&saved).contains("Secret page"), "{alg:?}: plaintext leaked"); + assert!(matches!(Document::open(saved.clone()), Err(CosError::NeedsPassword)), "{alg:?}"); + let reopened = Document::open_with_password(saved.clone(), Some("user")).unwrap(); + assert_eq!(title(&reopened).as_deref(), Some("Edited"), "{alg:?}"); + assert_eq!(content(&reopened), SECRET, "{alg:?}"); + let p = reopened.permissions().unwrap(); + assert!(!p.print() && !p.copy() && p.modify(), "{alg:?}, {base} base: user restrictions survive the save"); + let p = Document::open_with_password(saved, Some("owner")).unwrap().permissions().unwrap(); + assert!(p.print() && p.copy(), "{alg:?}: the owner keeps full rights"); + } + } +} + +/// Compatible output security means the same `/Encrypt` object number, security dictionary, file +/// key and first file identifier. Each differs alone here; unencrypted documents always match. +#[test] +fn output_security_compares_number_dictionary_key_and_first_id() { + let plain = || Document::open(Arc::new(fixture())).unwrap(); + assert!(plain().has_same_output_security(&plain()), "two unencrypted documents"); + + let encrypted = Arc::new(protect(Algorithm::Aes256, "user", "owner", -1)); + let open = || Document::open_with_password(encrypted.clone(), Some("owner")).unwrap(); + assert!(open().has_same_output_security(&open()), "the same file opened twice"); + assert!(!plain().has_same_output_security(&open()) && !open().has_same_output_security(&plain())); + + // Only the first /ID differs: same dictionary, key and object number. + let mut other_id = open(); + with_first_id(&mut other_id, b"another file id!"); + assert!(!open().has_same_output_security(&other_id), "first /ID"); + // The second /ID changes on every save and is not part of the key. + let Some(Object::Array(ids)) = open().trailer().get(b"ID").cloned() else { panic!("no /ID") }; + let mut second = open(); + second.trailer_mut().set(b"ID".to_vec(), Object::Array(vec![ids[0].clone(), Object::String(PdfString { bytes: b"x".to_vec(), hex: true })])); + assert!(open().has_same_output_security(&second), "second /ID"); + + // Same passwords, new key (and so a new dictionary). + for alg in ALL { + let mut rekeyed = open(); + rekey(&mut rekeyed, alg, 3); + assert!(!open().has_same_output_security(&rekeyed), "{alg:?} rekey"); + } + + // Only the dictionary differs: AES-256 draws its file key from the seed, so new permissions + // or metadata encryption keep the key, /ID and object number. + for (permissions, encrypt_metadata) in [(!(4 | 16), true), (-1, false)] { + let mut a = plain(); + rekey(&mut a, Algorithm::Aes256, 5); + let mut b = plain(); + b.set_encryption(&NewEncryption { + algorithm: Algorithm::Aes256, + user_password: "user", + owner_password: "owner", + permissions, + encrypt_metadata, + seed: [5; 32], + }) + .unwrap(); + let (sa, sb) = (a.output_handler().unwrap(), b.output_handler().unwrap()); + assert_eq!(sa.file_key(), sb.file_key(), "{permissions}, {encrypt_metadata}"); + assert_ne!(sa.dict(), sb.dict(), "{permissions}, {encrypt_metadata}"); + assert_eq!(a.trailer().reference(b"Encrypt"), b.trailer().reference(b"Encrypt")); + assert!(!a.has_same_output_security(&b), "{permissions}, {encrypt_metadata}: security dictionary"); + } + + // Only the /Encrypt object number differs: the same seed gives the same /ID, dictionary and + // key, but an extra object moves the new dictionary to the next number. + for alg in ALL { + let mut a = plain(); + rekey(&mut a, alg, 5); + let mut b = plain(); + b.add(Object::Int(0)); + rekey(&mut b, alg, 5); + let (sa, sb) = (a.output_handler().unwrap(), b.output_handler().unwrap()); + assert_eq!(sa.dict(), sb.dict(), "{alg:?}"); + assert_eq!(sa.file_key(), sb.file_key(), "{alg:?}"); + assert_eq!(a.trailer().get(b"ID"), b.trailer().get(b"ID"), "{alg:?}"); + assert_ne!(a.trailer().reference(b"Encrypt"), b.trailer().reference(b"Encrypt"), "{alg:?}"); + assert!(!a.has_same_output_security(&b), "{alg:?}: /Encrypt object number"); + let mut c = plain(); + rekey(&mut c, alg, 5); + assert!(a.has_same_output_security(&c), "{alg:?}: identical setup matches"); + } + + // An inline /Encrypt dictionary has no object number, an indirect one does: the same + // dictionary and key cannot share an overlay, since only one of them is exempt by number. + for alg in ALL { + let inline = || Document::open_with_password(Arc::new(protect_inline(alg, "user", "owner")), Some("owner")).unwrap(); + let indirect = Document::open_with_password(Arc::new(protect(alg, "user", "owner", -1)), Some("owner")).unwrap(); + assert!(matches!(inline().trailer().get(b"Encrypt"), Some(Object::Dict(_))), "{alg:?}"); + assert!(indirect.trailer().reference(b"Encrypt").is_some(), "{alg:?}"); + let (si, sd) = (inline(), &indirect); + assert_eq!(si.security().unwrap().dict(), sd.security().unwrap().dict(), "{alg:?}"); + assert_eq!(si.security().unwrap().file_key(), sd.security().unwrap().file_key(), "{alg:?}"); + assert!(!si.has_same_output_security(sd) && !sd.has_same_output_security(&si), "{alg:?}: inline vs indirect"); + assert!(inline().has_same_output_security(&inline()), "{alg:?}: inline opened twice"); + let mut target = inline(); + set_title(&mut target, "Saved title"); + let before = state(&target); + assert!(matches!(target.overlay_changes_from(&indirect), Err(CosError::Security(_))), "{alg:?}"); + assert!(before == state(&target), "{alg:?}: a refused overlay changed the target"); + } +} diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index c3e34d90bd..7f195b80a5 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -663,6 +663,11 @@ impl Session { /// was edited: its bytes as opened are the damaged file, and this save is how the repair the /// user was told about reaches the disk (#1937). Not a signed one: a full rewrite would /// break its signatures, so with no edits it keeps its bytes, damage and all. + /// + /// Undo past a save that changed security restores a snapshot whose encryption differs from + /// the last saved file. That snapshot is written in full too: an update appended to the saved + /// file would be read with the wrong key, and would keep the revision it was meant to replace + /// (#3276). pub fn save_bytes(&self, id: DocId) -> Result>, EditError> { let doc = self.get(id).ok_or(EditError::NoDocument)?; let Some(editor) = doc.editor.as_ref() else { return Ok(doc.bytes.clone()) }; @@ -682,6 +687,13 @@ impl Session { write_incremental(&cos, &opts) } else { let mut base = pdfcraft_cos::Document::open_with_password(editor.saved.clone(), editor.saved_keys.reopen.as_deref())?; + // Undo may restore security from before the last save even though it has + // not changed relative to the snapshot's own source. Never combine that + // trailer with the saved file's encryption, or retain its obsolete bytes. A full + // write renumbers objects, so older encrypted snapshots usually land here again. + if !base.has_same_output_security(&cos) { + return write_full(&cos, &opts); + } base.overlay_changes_from(&cos)?; write_incremental(&base, &opts) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c622af21b4..72e5511b9b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -8722,3 +8722,4 @@ fn need_appearances_buttons_are_displayed_from_their_captions() { assert!(ink(&shown, inside(x, 100)) > 700, "the file's square at {x}"); } } +mod security_save; diff --git a/crates/engine/src/tests/security_save.rs b/crates/engine/src/tests/security_save.rs new file mode 100644 index 0000000000..69193f48c8 --- /dev/null +++ b/crates/engine/src/tests/security_save.rs @@ -0,0 +1,253 @@ +//! Security changes must stay coherent when undo crosses the last saved revision (#3276). +use super::*; +use pdfcraft_cos::{Algorithm, Document as CosDocument, NewEncryption, Object, SaveOptions, write_full}; + +fn encrypted_fixture(algorithm: Algorithm, inline: bool) -> Arc> { + let mut doc = CosDocument::open(Arc::new(fixture(2))).unwrap(); + pdfcraft_organize::set_info(&mut doc, "Title", "Security regression title").unwrap(); + let mut metadata = pdfcraft_cos::Dict::new(); + metadata.set(b"Type".to_vec(), Object::name("Metadata")); + metadata.set(b"Subtype".to_vec(), Object::name("XML")); + let metadata = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(metadata, b"Security regression".to_vec()))); + doc.update_dict(doc.root().unwrap(), |d| d.set(b"Metadata".to_vec(), Object::Ref(metadata))).unwrap(); + doc.set_encryption(&NewEncryption { + algorithm, + user_password: "user", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + if inline { + let r = doc.trailer().reference(b"Encrypt").unwrap(); + let dict = doc.get(r).as_dict().cloned().unwrap(); + doc.trailer_mut().set(b"Encrypt".to_vec(), Object::Dict(dict)); + } + Arc::new(write_full(&doc, &SaveOptions::default()).unwrap()) +} + +/// Check the saved file, not the still-correct in-memory snapshot. A catalog/page-count +/// check alone accepts the broken reverse transition with two blank pages. +fn assert_saved_content(s: &Session, id: DocId, bytes: Arc>, password: Option<&str>, encrypted: bool) -> Result<(), String> { + let check = s.check_save(id, &bytes); + let plain_marker = bytes.windows(b"(Page 1)".len()).any(|w| w == b"(Page 1)"); + let mut reopened = Session::new(); + let restored = reopened.open("saved.pdf", None, bytes.clone(), password); + let restored = restored.map_err(|e| format!("check_save={check:?}, plaintext marker={plain_marker}, reopen={e:?}"))?; + let texts = page_texts(&reopened, restored); + if check.is_err() || texts != ["Page 1", "Page 2"] { + return Err(format!("check_save={check:?}, plaintext marker={plain_marker}, text={texts:?}")); + } + assert_eq!(reopened.get(restored).unwrap().info.encrypted, encrypted); + assert_eq!(reopened.get(restored).unwrap().info_value("Title"), s.get(id).unwrap().info_value("Title")); + fn metadata(doc: &CosDocument) -> Option> { + let catalog = doc.get(doc.root()?); + let stream = doc.resolve(catalog.as_dict()?.get(b"Metadata")?); + let Object::Stream(stream) = stream.as_ref() else { return None }; + Some(stream.decoded().unwrap().to_vec()) + } + assert_eq!(metadata(&reopened.get(restored).unwrap().editor.as_ref().unwrap().cos), metadata(&s.get(id).unwrap().editor.as_ref().unwrap().cos)); + if encrypted { + assert!(!plain_marker, "encrypted saves must not expose page content"); + assert!(CosDocument::open_with_password(bytes, Some("wrong")).is_err()); + } else { + // The fixture's content is stored unfiltered: without this, the check above could pass + // only because the writer started compressing streams. + assert!(plain_marker, "a plain save shows the page content"); + } + Ok(()) +} + +#[test] +fn undo_saved_security_removal_restores_encrypted_content() { + let mut failures = Vec::new(); + for algorithm in [Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128, Algorithm::Aes256] { + for inline in [false, true] { + for password in ["owner", "user"] { + let mut s = Session::new(); + let id = s.open("encrypted.pdf", None, encrypted_fixture(algorithm, inline), Some(password)).unwrap(); + s.apply(id, Edit::RemoveProtection).unwrap(); + let plain = s.save_bytes(id).unwrap(); + assert_saved_content(&s, id, plain.clone(), None, false).unwrap(); + s.mark_saved(id, plain, None).unwrap(); + s.undo(id).unwrap(); + assert_saved_content(&s, id, s.save_full_bytes(id).unwrap(), Some(password), true).unwrap(); + let restored = s.save_bytes(id).unwrap(); + if let Err(error) = assert_saved_content(&s, id, restored.clone(), Some(password), true) { + failures.push(format!("{algorithm:?}, inline={inline}, {password}: {error}")); + continue; + } + // Both original passwords still work; saving also retains this session's rights. + assert_saved_content(&s, id, restored.clone(), Some("user"), true).unwrap(); + assert_saved_content(&s, id, restored.clone(), Some("owner"), true).unwrap(); + s.mark_saved(id, restored.clone(), None).unwrap(); + assert!(!s.get(id).unwrap().dirty); + assert_eq!(*s.save_bytes(id).unwrap(), *restored, "an unchanged save is byte-stable"); + s.redo(id).unwrap(); + let plain = s.save_bytes(id).unwrap(); + assert_saved_content(&s, id, plain.clone(), None, false).unwrap(); + s.mark_saved(id, plain, None).unwrap(); + } + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn undo_saved_protection_restores_plain_content() { + let mut failures = Vec::new(); + for algorithm in [Algorithm::Rc4_128, Algorithm::Aes128, Algorithm::Aes256] { + let (mut s, id) = session_with(2); + s.apply(id, Edit::Protect(Protection { algorithm, ..protection(Some("user"), Some("owner")) })).unwrap(); + let protected = s.save_bytes(id).unwrap(); + assert_saved_content(&s, id, protected.clone(), Some("owner"), true).unwrap(); + s.mark_saved(id, protected, None).unwrap(); + s.undo(id).unwrap(); + assert_saved_content(&s, id, s.save_full_bytes(id).unwrap(), None, false).unwrap(); + let restored = s.save_bytes(id).unwrap(); + if let Err(error) = assert_saved_content(&s, id, restored.clone(), None, false) { + failures.push(format!("{algorithm:?}: {error}")); + continue; + } + s.mark_saved(id, restored.clone(), None).unwrap(); + assert!(!s.get(id).unwrap().dirty); + assert_eq!(*s.save_bytes(id).unwrap(), *restored); + s.redo(id).unwrap(); + let protected = s.save_bytes(id).unwrap(); + assert_saved_content(&s, id, protected.clone(), Some("owner"), true).unwrap(); + s.mark_saved(id, protected, None).unwrap(); + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn undo_saved_rekey_with_the_same_passwords_restores_original_security() { + let mut failures = Vec::new(); + // Identical password strings are not a security identity: changing algorithm, permission + // bits, metadata encryption, or simply generating a fresh AES-256 key changes the output. + for (algorithm, copy, encrypt_metadata) in [ + (Algorithm::Rc4_128, true, true), + (Algorithm::Aes128, true, true), + (Algorithm::Aes256, false, true), + (Algorithm::Aes256, true, false), + (Algorithm::Aes256, true, true), + ] { + let (mut s, id) = session_with(2); + s.apply(id, Edit::Protect(protection(Some("user"), Some("owner")))).unwrap(); + let protected = s.save_bytes(id).unwrap(); + s.mark_saved(id, protected, None).unwrap(); + let original = s.get(id).unwrap().editor.as_ref().unwrap().cos.output_handler().unwrap().dict().clone(); + s.apply(id, Edit::Protect(Protection { algorithm, copy, encrypt_metadata, ..protection(Some("user"), Some("owner")) })).unwrap(); + let protected = s.save_bytes(id).unwrap(); + s.mark_saved(id, protected, None).unwrap(); + s.undo(id).unwrap(); + let restored = s.save_bytes(id).unwrap(); + let editor = s.get(id).unwrap().editor.as_ref().unwrap(); + assert_eq!(editor.keys, editor.saved_keys, "password strings alone cannot detect this transition"); + if let Err(error) = assert_saved_content(&s, id, restored.clone(), Some("owner"), true) { + failures.push(format!("{algorithm:?}, copy={copy}, metadata={encrypt_metadata}: {error}")); + continue; + } + let reopened = CosDocument::open_with_password(restored, Some("owner")).unwrap(); + assert_eq!(reopened.security().unwrap().dict(), &original); + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn undo_encrypted_compaction_restores_the_encrypt_dictionary_exemption() { + let mut cos = CosDocument::open(Arc::new(fixture(3))).unwrap(); + cos.set_encryption(&NewEncryption { + algorithm: Algorithm::Aes256, + user_password: "user", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + let original = Arc::new(write_full(&cos, &SaveOptions::default()).unwrap()); + let original = CosDocument::open_with_password(original, Some("owner")).unwrap(); + let mut s = Session::new(); + let id = s.open("compact.pdf", None, original.bytes().clone(), Some("owner")).unwrap(); + s.apply(id, Edit::DeletePages { pages: vec![0] }).unwrap(); + let compact = s.save_full_bytes(id).unwrap(); + let compact_doc = CosDocument::open_with_password(compact.clone(), Some("owner")).unwrap(); + assert_eq!(original.security().unwrap().dict(), compact_doc.security().unwrap().dict()); + assert_ne!(original.trailer().reference(b"Encrypt"), compact_doc.trailer().reference(b"Encrypt"), "compaction renumbered /Encrypt"); + s.mark_saved(id, compact, None).unwrap(); + s.undo(id).unwrap(); + let restored = s.save_bytes(id).unwrap(); + s.check_save(id, &restored).unwrap(); + assert!(!restored.windows(b"(Page 1)".len()).any(|w| w == b"(Page 1)"), "page content stays encrypted"); + assert!(CosDocument::open(restored.clone()).is_err() && CosDocument::open_with_password(restored.clone(), Some("wrong")).is_err()); + let mut reopened = Session::new(); + let id = reopened.open("restored.pdf", None, restored, Some("user")).unwrap(); + assert!(reopened.get(id).unwrap().info.encrypted); + assert_eq!(page_texts(&reopened, id), ["Page 1", "Page 2", "Page 3"]); +} + +/// A restricted user who unlocks with the owner password, removes protection, saves and undoes +/// gets the original restrictions back, not an unrestricted or owner-only file. +#[test] +fn undo_saved_removal_after_unlock_keeps_the_user_restrictions() { + for algorithm in [Algorithm::Rc4_128, Algorithm::Aes128, Algorithm::Aes256] { + let (mut s, id) = session_with(2); + s.apply(id, Edit::Protect(Protection { algorithm, ..protection(Some("user"), Some("owner")) })).unwrap(); + let protected = s.save_bytes(id).unwrap(); + let p = CosDocument::open_with_password(protected.clone(), Some("user")).unwrap().permissions().unwrap(); + assert!(!p.owner && !p.copy() && !p.modify(), "{algorithm:?}: the fixture restricts the user"); + + let mut s = Session::new(); + let id = s.open("restricted.pdf", None, protected, Some("user")).unwrap(); + assert!(matches!(s.apply(id, Edit::RemoveProtection), Err(EditError::NotPermitted(_))), "{algorithm:?}"); + s.unlock(id, "owner").unwrap(); + s.apply(id, Edit::RemoveProtection).unwrap(); + let plain = s.save_bytes(id).unwrap(); + assert_saved_content(&s, id, plain.clone(), None, false).unwrap(); + s.mark_saved(id, plain, None).unwrap(); + s.undo(id).unwrap(); + let restored = s.save_bytes(id).unwrap(); + assert_saved_content(&s, id, restored.clone(), Some("owner"), true).unwrap(); + let user = CosDocument::open_with_password(restored.clone(), Some("user")).unwrap(); + let p = user.permissions().unwrap(); + assert!(!p.owner && !p.copy() && !p.modify(), "{algorithm:?}: restrictions survive the undo and save"); + assert!(CosDocument::open_with_password(restored.clone(), Some("owner")).unwrap().permissions().unwrap().owner, "{algorithm:?}"); + s.mark_saved(id, restored.clone(), None).unwrap(); + assert!(!s.get(id).unwrap().dirty, "{algorithm:?}"); + assert_eq!(*s.save_bytes(id).unwrap(), *restored, "{algorithm:?}: an unchanged save is byte-stable"); + } +} + +#[test] +fn undo_ordinary_encrypted_edits_keeps_the_last_saved_revision() { + let mut s = Session::new(); + let id = s.open("encrypted.pdf", None, encrypted_fixture(Algorithm::Aes256, false), Some("user")).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).unwrap(); + s.undo(id).unwrap(); + let restored = s.save_bytes(id).unwrap(); + assert!(restored.starts_with(saved.as_slice()), "compatible encryption still appends to the latest saved bytes"); + assert_saved_content(&s, id, restored.clone(), Some("user"), true).unwrap(); + s.mark_saved(id, restored, None).unwrap(); + s.redo(id).unwrap(); + let rotated = s.save_bytes(id).unwrap(); + assert_saved_content(&s, id, rotated, Some("user"), true).unwrap(); +} + +#[test] +fn undo_saved_password_change_restores_original_passwords() { + let mut s = Session::new(); + let id = s.open("encrypted.pdf", None, encrypted_fixture(Algorithm::Aes256, true), Some("owner")).unwrap(); + s.apply(id, Edit::Protect(protection(Some("new-user"), Some("new-owner")))).unwrap(); + let changed = s.save_bytes(id).unwrap(); + s.mark_saved(id, changed, None).unwrap(); + s.undo(id).unwrap(); + let restored = s.save_bytes(id).unwrap(); + assert_saved_content(&s, id, restored.clone(), Some("user"), true).unwrap(); + assert_saved_content(&s, id, restored.clone(), Some("owner"), true).unwrap(); + assert!(CosDocument::open_with_password(restored.clone(), Some("new-user")).is_err()); + assert!(CosDocument::open_with_password(restored, Some("new-owner")).is_err()); +}