lopdf 0.44.0

A Rust library for PDF document manipulation.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
use lopdf::encryption::crypt_filters::{Aes128CryptFilter, Aes256CryptFilter, CryptFilter};
use lopdf::xref::XrefType;
use lopdf::{
    Document, EncryptionState, EncryptionVersion, IncrementalDocument, LoadOptions, Object, Permissions, Result,
    Stream, dictionary,
};
use std::collections::BTreeMap;
use std::fs;
use std::io;
use std::sync::Arc;
use tempfile::tempdir;

mod utils;

#[test]
fn load_incremental_file_as_linear_file() -> Result<()> {
    let doc = utils::load_document("assets/Incremental.pdf")?;
    assert_eq!(doc.version, "1.5".to_string());

    Ok(())
}

#[test]
fn load_incremental_file() -> Result<()> {
    let mut doc = utils::load_incremental_document("assets/Incremental.pdf")?;
    assert_eq!(doc.get_prev_documents().version, "1.5".to_string());

    // Create temporary folder to store file.
    let temp_dir = tempdir()?;
    let file_path = temp_dir.path().join("test_4_incremental.pdf");
    doc.save(file_path)?;

    Ok(())
}

/// Build a minimal document that has everything `EncryptionVersion::V1`
/// needs to derive an encryption key (a `Root` entry and a file `ID`).
fn minimal_encryptable_document() -> Document {
    let mut doc = Document::with_version("1.5");
    let catalog_id = doc.add_object(lopdf::dictionary! { "Type" => "Catalog" });
    doc.trailer.set("Root", Object::Reference(catalog_id));
    doc.trailer.set(
        "ID",
        Object::Array(vec![
            Object::string_literal(vec![1u8; 16]),
            Object::string_literal(vec![2u8; 16]),
        ]),
    );
    doc
}

/// After `decrypt`, `EncryptionState::encrypt_object_id()` must expose the
/// object id of the `/Encrypt` dictionary that was in the trailer before
/// decryption — an incremental save needs it to restore `/Encrypt` in the
/// appended trailer.
#[test]
fn decrypt_records_encrypt_object_id() {
    let mut doc = minimal_encryptable_document();

    let encryption_version = EncryptionVersion::V1 {
        document: &doc,
        owner_password: "owner",
        user_password: "user",
        permissions: Permissions::all(),
    };
    let encryption_state = EncryptionState::try_from(encryption_version).unwrap();
    doc.encrypt(&encryption_state).unwrap();

    let mut prev_bytes = Vec::new();
    doc.save_to(&mut prev_bytes).unwrap();

    // Capture the /Encrypt reference id from the on-disk trailer before decrypt.
    let expected_id = Document::load_mem(&prev_bytes)
        .unwrap()
        .trailer
        .get(b"Encrypt")
        .unwrap()
        .as_reference()
        .unwrap();

    let mut loaded = Document::load_mem(&prev_bytes).unwrap();
    loaded.decrypt("user").unwrap();

    let recorded = loaded
        .encryption_state
        .as_ref()
        .expect("encryption_state set after decrypt")
        .encrypt_object_id()
        .expect("encrypt_object_id recorded during decrypt");
    assert_eq!(recorded, expected_id);
}

/// Prepare an encrypted PDF: build a minimal encryptable document, apply the
/// supplied `EncryptionState`, and save it to bytes.
fn build_encrypted_pdf(state: &EncryptionState) -> Vec<u8> {
    let mut doc = minimal_encryptable_document();
    doc.encrypt(state).unwrap();
    let mut bytes = Vec::new();
    doc.save_to(&mut bytes).unwrap();
    bytes
}

/// Return the `/Encrypt` object id currently stored in the on-disk trailer
/// of `bytes` (the file must not be decrypted before calling this).
fn read_encrypt_id(bytes: &[u8]) -> lopdf::ObjectId {
    Document::load_mem(bytes)
        .unwrap()
        .trailer
        .get(b"Encrypt")
        .unwrap()
        .as_reference()
        .unwrap()
}

/// Load `bytes` and immediately decrypt using `password`, returning the
/// fully-populated `Document`. `Document::load_mem` defers object parsing for
/// encrypted files that arrive without a password, so tests that need the
/// content objects present must go through `load_mem_with_options`.
fn load_and_decrypt(bytes: &[u8], password: &str) -> Document {
    Document::load_mem_with_options(bytes, LoadOptions::with_password(password)).unwrap()
}

/// End-to-end verification of an incremental save on a decrypted document:
/// build encrypted `prev_bytes`, decrypt them, append a new plaintext-marker
/// stream, save incrementally, then check that the appended region does not
/// leak the plaintext, that decrypt+read still round-trips, and that the
/// trailer's `/Encrypt` reference resolves to the original id.
fn encrypted_incremental_round_trip(label: &str, state: EncryptionState) {
    let prev_bytes = build_encrypted_pdf(&state);
    let original_encrypt_id = read_encrypt_id(&prev_bytes);

    let loaded = load_and_decrypt(&prev_bytes, "user");
    assert!(
        loaded.encryption_state.as_ref().unwrap().encrypt_object_id().is_some(),
        "{label}: encrypt_object_id must be recorded during load-and-decrypt"
    );

    let mut incremental = IncrementalDocument::create_from(prev_bytes.clone(), loaded);
    let marker: Vec<u8> = format!("PR520-MARKER-{label}").into_bytes();
    let stream = Stream::new(dictionary! {}, marker.clone()).with_compression(false);
    let new_stream_id = incremental.new_document.add_object(Object::Stream(stream));

    let mut out = Vec::new();
    incremental.save_to(&mut out).unwrap();

    // Assert A: the plaintext marker must not appear anywhere in the
    // appended (encrypted) region.
    let appended = &out[prev_bytes.len()..];
    assert!(
        !appended.windows(marker.len()).any(|w| w == marker.as_slice()),
        "{label}: plaintext marker leaked in the appended region"
    );

    // Assert B: round-trip. Reloading and decrypting the appended file must
    // reveal the marker again.
    let reloaded = load_and_decrypt(&out, "user");
    let stream = reloaded.get_object(new_stream_id).unwrap().as_stream().unwrap();
    assert_eq!(
        stream.content, marker,
        "{label}: appended stream content mismatch after round-trip"
    );

    // Assert C: the trailer's /Encrypt reference in the appended file resolves
    // to the same object id as the original encrypted revision. The dictionary
    // bytes themselves live in the previous revision and are still intact.
    let round_encrypt_id = read_encrypt_id(&out);
    assert_eq!(
        round_encrypt_id, original_encrypt_id,
        "{label}: trailer /Encrypt reference mismatch after round-trip"
    );
}

/// Incremental save of a document decrypted from V1 (RC4-40) round-trips —
/// replaces the earlier PR #521 rejection test now that #520 supports this.
#[test]
fn incremental_save_of_decrypted_document_v1_round_trip() {
    let doc = minimal_encryptable_document();
    let state = EncryptionState::try_from(EncryptionVersion::V1 {
        document: &doc,
        owner_password: "owner",
        user_password: "user",
        permissions: Permissions::all(),
    })
    .unwrap();
    encrypted_incremental_round_trip("V1", state);
}

#[test]
fn incremental_save_of_decrypted_document_v2_round_trip() {
    let doc = minimal_encryptable_document();
    let state = EncryptionState::try_from(EncryptionVersion::V2 {
        document: &doc,
        owner_password: "owner",
        user_password: "user",
        key_length: 128,
        permissions: Permissions::all(),
    })
    .unwrap();
    encrypted_incremental_round_trip("V2", state);
}

/// V2 (RC4-128) × `CrossReferenceTable`: exercises the classical
/// `trailer <<...>>` writer path (`Document::write_trailer`) rather than the
/// xref-stream path (`Document::write_cross_reference_stream`). Both paths
/// serialize `self.new_document.trailer`, and the `/Encrypt` swap in
/// `IncrementalDocument::save_internal` targets exactly that field, so a
/// table-based prior revision must produce an appended trailer that carries
/// `/Encrypt` — verified via `read_encrypt_id` plus a byte-level check that
/// the classical `trailer` keyword appears in the appended region.
#[test]
fn incremental_save_of_decrypted_document_v2_cross_reference_table_round_trip() {
    let mut doc = minimal_encryptable_document();
    doc.reference_table.cross_reference_type = XrefType::CrossReferenceTable;
    let state = EncryptionState::try_from(EncryptionVersion::V2 {
        document: &doc,
        owner_password: "owner",
        user_password: "user",
        key_length: 128,
        permissions: Permissions::all(),
    })
    .unwrap();
    doc.encrypt(&state).unwrap();
    let mut prev_bytes = Vec::new();
    doc.save_to(&mut prev_bytes).unwrap();

    // Sanity check on the fixture: the base revision must use the classical
    // `trailer` keyword. If this fails, the test setup slipped back to the
    // xref-stream path and no longer covers what it claims to cover.
    let trailer_keyword: &[u8] = b"trailer\n";
    assert!(
        prev_bytes
            .windows(trailer_keyword.len())
            .any(|w| w == trailer_keyword),
        "base revision must use classical trailer for this test to be meaningful"
    );

    let original_encrypt_id = read_encrypt_id(&prev_bytes);

    let loaded = load_and_decrypt(&prev_bytes, "user");
    assert!(
        matches!(loaded.reference_table.cross_reference_type, XrefType::CrossReferenceTable),
        "loaded document must retain the CrossReferenceTable format"
    );

    let mut incremental = IncrementalDocument::create_from(prev_bytes.clone(), loaded);
    let marker: Vec<u8> = b"PR520-MARKER-V2-Table".to_vec();
    let stream = Stream::new(dictionary! {}, marker.clone()).with_compression(false);
    let new_stream_id = incremental.new_document.add_object(Object::Stream(stream));

    let mut out = Vec::new();
    incremental.save_to(&mut out).unwrap();

    let appended = &out[prev_bytes.len()..];

    // The appended region must go through the classical `trailer` path,
    // which is where `write_trailer` reads `self.trailer` — the swap target.
    assert!(
        appended
            .windows(trailer_keyword.len())
            .any(|w| w == trailer_keyword),
        "appended region must use classical trailer (CrossReferenceTable path)"
    );

    // Assert A: no plaintext leak in the appended region.
    assert!(
        !appended.windows(marker.len()).any(|w| w == marker.as_slice()),
        "V2/table: plaintext marker leaked in the appended region"
    );

    // Assert B: round-trip.
    let reloaded = load_and_decrypt(&out, "user");
    let stream = reloaded.get_object(new_stream_id).unwrap().as_stream().unwrap();
    assert_eq!(
        stream.content, marker,
        "V2/table: appended stream content mismatch after round-trip"
    );

    // Assert C: the trailer's /Encrypt reference resolves to the original id.
    let round_encrypt_id = read_encrypt_id(&out);
    assert_eq!(
        round_encrypt_id, original_encrypt_id,
        "V2/table: trailer /Encrypt reference mismatch after round-trip"
    );
}

#[test]
fn incremental_save_of_decrypted_document_v4_round_trip() {
    let doc = minimal_encryptable_document();
    let crypt_filter: Arc<dyn CryptFilter> = Arc::new(Aes128CryptFilter);
    let state = EncryptionState::try_from(EncryptionVersion::V4 {
        document: &doc,
        encrypt_metadata: true,
        crypt_filters: BTreeMap::from([(b"StdCF".to_vec(), crypt_filter)]),
        stream_filter: b"StdCF".to_vec(),
        string_filter: b"StdCF".to_vec(),
        owner_password: "owner",
        user_password: "user",
        permissions: Permissions::all(),
    })
    .unwrap();
    encrypted_incremental_round_trip("V4", state);
}

#[test]
fn incremental_save_of_decrypted_document_v5_round_trip() {
    // Fixed key: this is a deterministic test; `Aes256CryptFilter` combined
    // with V5's per-object IV keeps the outputs distinct anyway.
    let file_encryption_key = [7u8; 32];
    let crypt_filter: Arc<dyn CryptFilter> = Arc::new(Aes256CryptFilter);
    let state = EncryptionState::try_from(EncryptionVersion::V5 {
        encrypt_metadata: true,
        crypt_filters: BTreeMap::from([(b"StdCF".to_vec(), crypt_filter)]),
        file_encryption_key: &file_encryption_key,
        stream_filter: b"StdCF".to_vec(),
        string_filter: b"StdCF".to_vec(),
        owner_password: "owner",
        user_password: "user",
        permissions: Permissions::all(),
    })
    .unwrap();
    encrypted_incremental_round_trip("V5", state);
}

/// Regression guard for the "clone before encrypt" invariant: two successive
/// incremental saves on the same `IncrementalDocument` must not double-encrypt
/// the objects added in the earlier round. If we mutated in-place, the second
/// save would re-encrypt already-encrypted bytes and decrypt would return
/// garbage instead of the marker.
#[test]
fn incremental_save_of_decrypted_document_does_not_double_encrypt_on_repeated_save() {
    let doc = minimal_encryptable_document();
    let state = EncryptionState::try_from(EncryptionVersion::V1 {
        document: &doc,
        owner_password: "owner",
        user_password: "user",
        permissions: Permissions::all(),
    })
    .unwrap();
    let prev_bytes = build_encrypted_pdf(&state);

    let loaded = load_and_decrypt(&prev_bytes, "user");
    let mut incremental = IncrementalDocument::create_from(prev_bytes, loaded);

    let marker_a: Vec<u8> = b"PR520-A".to_vec();
    let stream_a = Stream::new(dictionary! {}, marker_a.clone()).with_compression(false);
    let id_a = incremental.new_document.add_object(Object::Stream(stream_a));

    let mut buf1 = Vec::new();
    incremental.save_to(&mut buf1).unwrap();

    let marker_b: Vec<u8> = b"PR520-B".to_vec();
    let stream_b = Stream::new(dictionary! {}, marker_b.clone()).with_compression(false);
    let id_b = incremental.new_document.add_object(Object::Stream(stream_b));

    let mut buf2 = Vec::new();
    incremental.save_to(&mut buf2).unwrap();

    let reloaded = load_and_decrypt(&buf2, "user");
    let content_a = reloaded.get_object(id_a).unwrap().as_stream().unwrap().content.clone();
    let content_b = reloaded.get_object(id_b).unwrap().as_stream().unwrap().content.clone();
    assert_eq!(
        content_a, marker_a,
        "stream A must survive a second incremental save without double-encryption"
    );
    assert_eq!(
        content_b, marker_b,
        "stream B must be readable after the second incremental save"
    );
}

/// Incremental save of a still-*encrypted* (never decrypted) document must
/// also be rejected: the trailer still carries `/Encrypt`, but any newly
/// appended objects would be written as plaintext, which readers would
/// misinterpret as ciphertext.
#[test]
fn incremental_save_of_still_encrypted_document_is_rejected() {
    let mut doc = minimal_encryptable_document();

    let encryption_version = EncryptionVersion::V1 {
        document: &doc,
        owner_password: "owner",
        user_password: "user",
        permissions: Permissions::all(),
    };
    let encryption_state = EncryptionState::try_from(encryption_version).unwrap();
    doc.encrypt(&encryption_state).unwrap();

    let mut prev_bytes = Vec::new();
    doc.save_to(&mut prev_bytes).unwrap();

    // Load without decrypting: `is_encrypted()` stays true.
    let loaded = Document::load_mem(&prev_bytes).unwrap();
    assert!(loaded.is_encrypted());
    assert!(!loaded.was_encrypted());

    let mut incremental = IncrementalDocument::create_from(prev_bytes, loaded);
    incremental.new_document.add_object(Object::Integer(42));

    let mut out = Vec::new();
    let err = incremental.save_to(&mut out).unwrap_err();
    assert_eq!(err.kind(), io::ErrorKind::Unsupported);
}

/// `IncrementalDocument::save(path)` must not truncate a pre-existing file
/// when the incremental save is unsupported. Prior to this check the guard
/// ran only after `File::create` had already zeroed the target.
#[test]
fn incremental_save_to_path_does_not_truncate_on_unsupported_input() {
    let mut doc = minimal_encryptable_document();
    let encryption_state = EncryptionState::try_from(EncryptionVersion::V1 {
        document: &doc,
        owner_password: "owner",
        user_password: "user",
        permissions: Permissions::all(),
    })
    .unwrap();
    doc.encrypt(&encryption_state).unwrap();

    let mut prev_bytes = Vec::new();
    doc.save_to(&mut prev_bytes).unwrap();

    // Load without decrypting: this is the still-encrypted (unsupported) case.
    let loaded = Document::load_mem(&prev_bytes).unwrap();
    let mut incremental = IncrementalDocument::create_from(prev_bytes, loaded);

    let temp_dir = tempdir().unwrap();
    let target_path = temp_dir.path().join("existing.pdf");
    let sentinel: &[u8] = b"do-not-truncate-me";
    fs::write(&target_path, sentinel).unwrap();

    let err = incremental.save(&target_path).unwrap_err();
    assert_eq!(err.kind(), io::ErrorKind::Unsupported);

    // File must be untouched on disk.
    let on_disk = fs::read(&target_path).unwrap();
    assert_eq!(
        on_disk, sentinel,
        "unsupported save must not truncate or overwrite the target file"
    );
}