mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-29 12:38:58 -06:00
fix(engine): undo past a security change saves in full (#3276)
Undo can restore a snapshot from before the last save. Save normally reopens the last saved file and appends the snapshot's objects as an incremental update. When the snapshot's encryption differs from the saved file's (undoing a saved protect, remove protection, password change or rekey, or a compaction that renumbered /Encrypt), that update was read with the wrong key: pages reopened blank, and after undoing a removal the plaintext revision stayed inside the encrypted file. Document::has_same_output_security compares the actual output security: the /Encrypt object number, security dictionary, file key and first file identifier, not passwords or authentication level. Session::save_bytes writes such a snapshot in full, and overlay_changes_from refuses an incompatible overlay before changing anything. Tests cover each transition for every algorithm, inline and indirect /Encrypt, user and owner openings, restricted rights after unlock, the compatible incremental path, redo and byte-stable resaves.
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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<u32>, Vec<u32>, bool, Vec<u8>) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Arc<Vec<u8>>, 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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Vec<u8>> {
|
||||
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"<metadata>Security regression</metadata>".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<Vec<u8>>, 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<Vec<u8>> {
|
||||
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());
|
||||
}
|
||||
Reference in New Issue
Block a user