This commit is contained in:
Brandon Thomas
2026-11-01 21:50:53 -05:00
3 changed files with 179 additions and 15 deletions
+18 -11
View File
@@ -275,19 +275,26 @@ fn hexv(c: u8) -> u8 {
}
}
fn decode_name(w: &[u8]) -> String {
let mut out = vec![];
/// The bytes of a name token (after its `/`), with `#xx` escapes decoded.
pub(crate) fn name_bytes(w: &[u8]) -> impl Iterator<Item = u8> {
let mut i = 0;
while i < w.len() {
if w[i] == b'#' && w.get(i + 1).is_some_and(u8::is_ascii_hexdigit) && w.get(i + 2).is_some_and(u8::is_ascii_hexdigit) {
out.push((hexv(w[i + 1]) << 4) | hexv(w[i + 2]));
i += 3;
} else {
out.push(w[i]);
i += 1;
std::iter::from_fn(move || {
let c = *w.get(i)?;
match (c, w.get(i + 1), w.get(i + 2)) {
(b'#', Some(&hi), Some(&lo)) if hi.is_ascii_hexdigit() && lo.is_ascii_hexdigit() => {
i += 3;
Some((hexv(hi) << 4) | hexv(lo))
}
_ => {
i += 1;
Some(c)
}
}
}
String::from_utf8_lossy(&out).into_owned()
})
}
fn decode_name(w: &[u8]) -> String {
String::from_utf8_lossy(&name_bytes(w).collect::<Vec<_>>()).into_owned()
}
pub fn parse_num(w: &[u8]) -> Option<f64> {
+42 -4
View File
@@ -2,6 +2,7 @@
//! Only reachable objects are retained, references keep their generation, and native parser
//! panics (including lazy object reads) discard the entire partially populated adapter.
use std::cell::LazyCell;
use std::collections::{HashMap, HashSet, VecDeque};
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::Arc;
@@ -10,12 +11,21 @@ use craft_pdf::syntax::object::{Dict as PdfDict, MaybeRef, Name, ObjRef, Object,
use craft_pdf::syntax::xref::XRef;
use crate::Error;
use crate::object::{Dict, File, Lexer, Obj};
use crate::object::{Dict, File, Lexer, Obj, is_delim, is_white, name_bytes};
const MAX_OBJECTS: usize = 100_000;
const MAX_VALUES: usize = 1_000_000;
const MAX_DEPTH: usize = 64;
const MAX_BYTES: usize = 512 * 1024 * 1024;
/// How far after each `trailer` marker the encryption pre-scan lexes. A classic trailer
/// dictionary is a few hundred bytes. If the lexer reaches the window's end before any object
/// or inside a dictionary, the dictionary may start or continue past it, so the file is refused
/// when a name token that decodes to `Encrypt`, in any spelling (`/Encrypt`, `/Encr#79pt`), ends
/// past the window. This fallback is conservative: it refuses on any such name there, which in
/// practice only encrypted files contain. The pre-scan stays linear in file size, at most
/// `MAX_OBJECTS` windows (about 400 MB) and one search for the name, even when a marker is
/// followed by an unterminated string or dictionary the lexer would follow to the end.
const TRAILER_WINDOW: usize = 4 * 1024;
fn limit(what: &str) -> Error {
Error::Read(format!("PDF exceeds the {what} import limit"))
@@ -41,6 +51,15 @@ fn open_inner(bytes: &[u8]) -> Result<File, Error> {
// Preserve the app's refusal of encrypted PDFs, including empty-user-password files.
// Classic trailer dictionaries use the existing lexer; xref-stream dictionaries below
// are read by the shared parser. This does not parse document objects a second time.
let regular = |c: u8| !is_white(c) && !is_delim(c);
// Where the last name token decoding to `Encrypt` (escaped or not) ends, searched at most once.
// Each token scan stops at the next `/`, so the search is linear.
let last_encrypt = LazyCell::new(|| {
bytes.iter().enumerate().rev().filter(|&(_, &c)| c == b'/').find_map(|(at, _)| {
let token = bytes.get(at + 1..).unwrap_or_default().split(|&c| !regular(c)).next().unwrap_or_default();
name_bytes(token).eq(*b"Encrypt").then_some(at + 1 + token.len())
})
});
let mut markers = 0;
for (offset, word) in bytes.windows(7).enumerate() {
if word == b"trailer" {
@@ -48,9 +67,28 @@ fn open_inner(bytes: &[u8]) -> Result<File, Error> {
if markers > MAX_OBJECTS {
return Err(limit("trailer count"));
}
if let Some(Obj::Dict(dict)) = Lexer::new(bytes, offset + 7).next()
&& dict.contains_key("Encrypt")
{
let start = offset + 7;
let mut end = bytes.len().min(start.saturating_add(TRAILER_WINDOW));
// If the first object after the marker starts in the window and does not open a dictionary
// (`<<`), such as a string running on through a stream, no trailer dictionary follows the
// marker, so the fallback below does not apply.
let mut first = Lexer::new(bytes.get(..end).unwrap_or_default(), start);
first.skip_ws();
let maybe_dict = first.pos >= end || bytes.get(first.pos..).is_some_and(|rest| rest.starts_with(b"<<"));
// A window ending inside a token (a regular byte on each side of the cut) would read its
// first part as all of it, such as `/Encrypt` of `/Encrypted`. It ends before that token
// instead, so the fallback below reads the token whole.
if bytes.get(end).is_some_and(|&c| regular(c)) {
end = end.saturating_sub(bytes.get(start..end).unwrap_or_default().iter().rev().take_while(|&&c| regular(c)).count());
}
let window = bytes.get(start..end).unwrap_or_default();
let mut lexer = Lexer::new(window, 0);
let encrypt = matches!(lexer.next(), Some(Obj::Dict(dict)) if dict.contains_key("Encrypt"));
// Whatever it returned, a lexer at the window's end (one past it after an unterminated
// hex string) may have stopped before the dictionary started or partway through it, so
// an `Encrypt` name in any spelling that ends past the window refuses the file.
let cut = maybe_dict && lexer.pos >= window.len() && last_encrypt.is_some_and(|name_end| name_end > end);
if encrypt || cut {
return Err(Error::Encrypted);
}
}
+119
View File
@@ -74,3 +74,122 @@ fn duplicate_unreferenced_compressed_objects_do_not_block_repair() {
assert!(doc.skipped.iter().any(|warning| warning.contains("recovered PDF object from an object stream: 10 0")));
assert!(matches!(parse(&document("/Missing 30 0 R", &objects)), Err(Error::Read(message)) if message.contains("ambiguous recovered object 30")));
}
#[test]
fn many_trailer_markers_with_unterminated_objects_do_not_rescan_the_file() {
// The encryption pre-scan lexes after every `trailer` marker. Unbounded, each unterminated
// string or dictionary here ran to the end of the file: about 10^11 bytes lexed (minutes),
// against 2 × 10^8 within the window. The markers sit in an unreferenced stream, so the
// file still opens as the plain document.
let junk = "trailer(trailer<<".repeat(25_000) + &" ".repeat(2 << 20);
let stream = (6, 0, format!("<< /Length {} >>\nstream\n{junk}\nendstream", junk.len()));
let plain = parse(&document("", &[])).unwrap();
assert_eq!(parse(&document("", &[stream])), Ok(plain));
}
#[test]
fn a_classic_trailer_with_an_encrypt_entry_is_refused() {
let plain = String::from_utf8(document("", &[])).unwrap();
let encrypted = plain.replace("/Root 1 0 R >>", "/Root 1 0 R /Encrypt 9 0 R >>");
assert_ne!(encrypted, plain);
// Object 9 does not exist: the trailer pre-scan refuses the file before the shared parser runs.
assert_eq!(parse(encrypted.as_bytes()), Err(Error::Encrypted));
}
#[test]
fn a_trailer_with_a_long_id_fits_the_window() {
// Two 128-byte file identifiers leave the trailer dictionary under 600 bytes, well inside the
// 4 KiB pre-scan window, so /Encrypt after them is read there.
let plain = String::from_utf8(document("", &[])).unwrap();
let id = format!("<{}>", "0123456789ABCDEF".repeat(16));
let with_id = plain.replace("/Root 1 0 R >>", &format!("/Root 1 0 R /ID [{id} {id}] >>"));
let encrypted = plain.replace("/Root 1 0 R >>", &format!("/Root 1 0 R /ID [{id} {id}] /Encrypt 9 0 R >>"));
let doc = parse(plain.as_bytes()).unwrap();
assert_eq!(parse(with_id.as_bytes()), Ok(doc));
assert_eq!(parse(encrypted.as_bytes()), Err(Error::Encrypted));
}
#[test]
fn an_encrypt_entry_past_the_trailer_window_is_refused() {
// The filler pushes /Encrypt past the pre-scan's 4 KiB window. Object 9 does not exist, and the
// shared parser reads /Encrypt only as a dictionary, so it alone would open the file.
let plain = String::from_utf8(document("", &[])).unwrap();
let filler = "x".repeat(8 * 1024);
let long = plain.replace("/Root 1 0 R >>", &format!("/Root 1 0 R /Filler ({filler}) >>"));
let encrypted = plain.replace("/Root 1 0 R >>", &format!("/Root 1 0 R /Filler ({filler}) /Encrypt 9 0 R >>"));
let doc = parse(plain.as_bytes()).unwrap();
assert_eq!(parse(long.as_bytes()), Ok(doc));
assert_eq!(parse(encrypted.as_bytes()), Err(Error::Encrypted));
}
#[test]
fn an_escaped_encrypt_entry_past_the_trailer_window_is_refused() {
// A name may write any byte as `#` and two hex digits, so these keys all read as /Encrypt and
// the past-the-window fallback refuses them. The decoys decode to other names and still open.
let plain = String::from_utf8(document("", &[])).unwrap();
let doc = parse(plain.as_bytes()).unwrap();
let filler = "x".repeat(8 * 1024);
let trailer = |key: &str| plain.replace("/Root 1 0 R >>", &format!("/Root 1 0 R /Filler ({filler}) {key} 9 0 R >>"));
for key in ["/Encr#79pt", "/#45#6E#63rypt", "/E#6e#63rypt"] {
assert_eq!(parse(trailer(key).as_bytes()), Err(Error::Encrypted), "{key}");
}
for decoy in ["/Encrypted", "/Encr#7Apt"] {
assert_eq!(parse(trailer(decoy).as_bytes()).as_ref(), Ok(&doc), "{decoy}");
}
}
#[test]
fn a_hash_without_two_hex_digits_stays_in_a_name() {
// `#` escapes a byte only before two hex digits. Otherwise it and what follows stay as written,
// so none of these reads as `Encrypt`.
let name = |token: &str| String::from_utf8(crate::object::name_bytes(token.as_bytes()).collect()).unwrap();
assert_eq!(name("Encry#pt"), "Encry#pt");
assert_eq!(name("Encrypt#"), "Encrypt#");
assert_eq!(name("Encrypt#7"), "Encrypt#7");
}
#[test]
fn an_encrypt_entry_after_a_window_cut_before_the_trailer_dictionary_is_refused() {
// The 4 KiB pre-scan window ends in the whitespace before the trailer dictionary (the lexer
// returns nothing) or between the two `<` of its `<<` (the lexer reads a hex string).
let plain = String::from_utf8(document("", &[])).unwrap();
let doc = parse(plain.as_bytes()).unwrap();
for pad in [4 * 1024, 4 * 1024 - 2] {
let spaced = plain.replace("trailer\n<<", &format!("trailer\n{}<<", " ".repeat(pad)));
let encrypted = spaced.replace("/Root 1 0 R >>", "/Root 1 0 R /Encrypt 9 0 R >>");
assert_eq!(parse(spaced.as_bytes()).as_ref(), Ok(&doc));
assert_eq!(parse(encrypted.as_bytes()), Err(Error::Encrypted));
}
}
#[test]
fn a_key_cut_by_the_trailer_window_is_read_whole() {
// Spaces put the 4 KiB pre-scan window's end inside a trailer key, `inside` bytes into it. The
// `/Encrypt` of the decoy `/Encrypted` is not taken for the key, a real `/Encrypt` cut after
// `/Encryp` is still refused, and one ending exactly at the window's end is read there.
let plain = String::from_utf8(document("", &[])).unwrap();
let doc = parse(plain.as_bytes()).unwrap();
let window_end = plain.find("trailer").unwrap() + "trailer".len() + 4 * 1024;
let key_at = plain.find("/Root 1 0 R >>").unwrap() + "/Root 1 0 R ".len();
let trailer = |key: &str, inside: usize| {
let file = plain.replace("/Root 1 0 R >>", &format!("/Root 1 0 R {}{key} 9 0 R >>", " ".repeat(window_end - inside - key_at)));
assert!(file[window_end - inside..].starts_with(key), "{key}");
file
};
assert_eq!(parse(trailer("/Encrypted", 8).as_bytes()).as_ref(), Ok(&doc));
assert_eq!(parse(trailer("/Encrypt", 7).as_bytes()), Err(Error::Encrypted));
assert_eq!(parse(trailer("/Encrypt", 8).as_bytes()), Err(Error::Encrypted));
}
#[test]
fn a_trailer_string_in_a_stream_does_not_refuse_a_later_encrypt_name() {
// A `trailer` marker in an unreferenced stream opens a string or array that runs on past the
// 4 KiB pre-scan window to an `Encrypt` name. The unbounded lexer read that object, not a
// trailer dictionary, so the past-the-window fallback does not refuse the file either.
let plain = parse(&document("", &[])).unwrap();
for (open, key) in [("(", "/Encrypt"), ("[", "/Encr#79pt")] {
let content = format!("trailer{open}{}{key} 9 0 R", "text ".repeat(1024));
let stream = (6, 0, format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()));
assert_eq!(parse(&document("", &[stream])).as_ref(), Ok(&plain), "{open}");
}
}