use lopdf::{Dictionary, Document, Object, ObjectId};
use crate::detect::Format;
use crate::error::{MalformedDetail, ResourceLimit, Result, StryptError};
use crate::formats::xmp::name_of;
use crate::formats::{MetadataHandler, ParseLimits, StripOptions, Stripped, xmp};
use crate::report::{
Finding, InspectOptions, MetadataKind, MetadataReport, MetadataValue, Note, StripReport,
};
#[derive(Debug, Clone, Copy, Default)]
pub struct PdfHandler;
impl MetadataHandler for PdfHandler {
fn name(&self) -> &'static str {
Format::Pdf.id()
}
fn format(&self) -> Format {
Format::Pdf
}
fn inspect(&self, input: &[u8], options: &InspectOptions) -> Result<MetadataReport> {
let limits = ParseLimits::default();
let mut doc = load(input, &limits)?;
let scrubbed = scrub(&mut doc, input, options, &limits)?;
Ok(MetadataReport {
format: Format::Pdf,
findings: scrubbed.findings,
notes: scrubbed.notes,
})
}
fn strip(&self, input: &[u8], options: &StripOptions) -> Result<Stripped> {
let mut doc = load(input, &options.limits)?;
let mut scrubbed = scrub(&mut doc, input, &options.inspect, &options.limits)?;
let pruned = doc.prune_objects();
if !pruned.is_empty() {
scrubbed.notes.push(Note::OrphanedObjectsRemoved {
objects: pruned.len(),
});
}
renumber_stably(&mut doc)?;
normalise_negative_zero(&mut doc, &options.limits)?;
let mut bytes = Vec::new();
crate::panic_guard::guard(
|| {
doc.save_to(&mut bytes).map_err(|source| StryptError::Io {
action: crate::error::IoAction::WritingOutput,
source,
})
},
|| StryptError::Malformed {
format: Format::Pdf,
offset: None,
detail: MalformedDetail::DependencyPanic,
},
)?;
verify_round_trip(&doc, &bytes)?;
Ok(Stripped {
report: StripReport {
format: Format::Pdf,
removed: scrubbed.findings,
retained: Vec::new(),
notes: scrubbed.notes,
input_bytes: as_u64(input.len()),
output_bytes: as_u64(bytes.len()),
},
bytes,
})
}
}
fn verify_round_trip(written: &Document, bytes: &[u8]) -> Result<()> {
let reloaded = crate::panic_guard::guard(
|| Document::load_mem(bytes).map_err(|e| map_parse_error(&e)),
|| StryptError::Malformed {
format: Format::Pdf,
offset: None,
detail: MalformedDetail::DependencyPanic,
},
)
.map_err(|_| StryptError::Malformed {
format: Format::Pdf,
offset: None,
detail: MalformedDetail::NotRoundTrippable,
})?;
let written_ids: Vec<ObjectId> = written.objects.keys().copied().collect();
let reloaded_ids: Vec<ObjectId> = reloaded.objects.keys().copied().collect();
if written_ids != reloaded_ids {
return Err(StryptError::Malformed {
format: Format::Pdf,
offset: None,
detail: MalformedDetail::NotRoundTrippable,
});
}
if written.page_iter().next().is_some() && reloaded.page_iter().next().is_none() {
return Err(StryptError::Malformed {
format: Format::Pdf,
offset: None,
detail: MalformedDetail::NotRoundTrippable,
});
}
Ok(())
}
const MAX_RENUMBER_ROUNDS: usize = 4;
fn renumber_stably(doc: &mut Document) -> Result<()> {
for _ in 0..MAX_RENUMBER_ROUNDS {
let ids_before: Vec<ObjectId> = doc.objects.keys().copied().collect();
let pages_before: Vec<ObjectId> = doc.page_iter().collect();
doc.renumber_objects();
let ids_after: Vec<ObjectId> = doc.objects.keys().copied().collect();
let pages_after: Vec<ObjectId> = doc.page_iter().collect();
if ids_before == ids_after && pages_before == pages_after {
return Ok(());
}
}
Err(StryptError::Malformed {
format: Format::Pdf,
offset: None,
detail: MalformedDetail::CyclicReference,
})
}
struct Scrubbed {
findings: Vec<Finding>,
notes: Vec<Note>,
}
fn load(input: &[u8], limits: &ParseLimits) -> Result<Document> {
let doc = crate::panic_guard::guard(
|| Document::load_mem(input).map_err(|e| map_parse_error(&e)),
|| StryptError::Malformed {
format: Format::Pdf,
offset: None,
detail: MalformedDetail::DependencyPanic,
},
)?;
if doc.is_encrypted() || doc.was_encrypted() {
return Err(StryptError::Malformed {
format: Format::Pdf,
offset: None,
detail: MalformedDetail::UnsupportedFeature,
});
}
if !doc.trailer.has(b"Root") {
return Err(StryptError::Malformed {
format: Format::Pdf,
offset: None,
detail: MalformedDetail::MissingMarker,
});
}
let too_many = u32::try_from(doc.objects.len()).map_or(true, |count| count > limits.max_items);
if too_many {
return Err(StryptError::LimitExceeded {
format: Format::Pdf,
limit: ResourceLimit::ItemCount,
});
}
for object in doc.objects.values() {
let Ok(stream) = object.as_stream() else {
continue;
};
let declared = stream
.dict
.get(b"Length")
.ok()
.and_then(|length| length.as_i64().ok())
.and_then(|length| usize::try_from(length).ok());
if declared != Some(stream.content.len()) {
return Err(StryptError::Malformed {
format: Format::Pdf,
offset: None,
detail: MalformedDetail::LengthOutOfRange,
});
}
}
Ok(doc)
}
fn map_parse_error(error: &lopdf::Error) -> StryptError {
use lopdf::Error as E;
let detail = match *error {
E::Parse(_) | E::Syntax(_) | E::IndirectObject { .. } | E::ObjectIdMismatch => {
MalformedDetail::UnexpectedMarker
}
E::Xref(_) | E::MissingXrefEntry | E::InvalidObjectStream(_) => {
MalformedDetail::BrokenIndex
}
E::InvalidOffset(_) | E::ObjectNotFound(_) | E::NumericCast(_) | E::TryFromInt(_) => {
MalformedDetail::LengthOutOfRange
}
E::ReferenceCycle(_) | E::ReferenceLimit => MalformedDetail::CyclicReference,
E::IO(_) => MalformedDetail::Truncated,
E::Decryption(_)
| E::InvalidPassword
| E::AlreadyEncrypted
| E::UnsupportedSecurityHandler(_)
| E::Unimplemented(_) => MalformedDetail::UnsupportedFeature,
_ => MalformedDetail::MissingMarker,
};
let offset = match *error {
E::InvalidOffset(at) | E::IndirectObject { offset: at } => u64::try_from(at).ok(),
_ => None,
};
StryptError::Malformed {
format: Format::Pdf,
offset,
detail,
}
}
const INFO_KEYS: &[(&[u8], MetadataKind)] = &[
(b"Author", MetadataKind::PersonalIdentity),
(b"Creator", MetadataKind::SoftwareFingerprint),
(b"Producer", MetadataKind::SoftwareFingerprint),
(b"CreationDate", MetadataKind::Timestamp),
(b"ModDate", MetadataKind::Timestamp),
(b"Title", MetadataKind::Comment),
(b"Subject", MetadataKind::Comment),
(b"Keywords", MetadataKind::Comment),
(b"Trapped", MetadataKind::Other),
];
const GLOBAL_KEYS: &[(&[u8], MetadataKind)] = &[
(b"Metadata", MetadataKind::Other),
(b"PieceInfo", MetadataKind::EditingHistory),
(b"LastModified", MetadataKind::Timestamp),
];
const MARKUP_ANNOTATION_SUBTYPES: &[&[u8]] = &[
b"Text",
b"FreeText",
b"Line",
b"Square",
b"Circle",
b"Polygon",
b"PolyLine",
b"Highlight",
b"Underline",
b"Squiggly",
b"StrikeOut",
b"Stamp",
b"Caret",
b"Ink",
b"FileAttachment",
b"Sound",
b"Movie",
b"Redact",
];
fn scrub(
doc: &mut Document,
raw: &[u8],
options: &InspectOptions,
limits: &ParseLimits,
) -> Result<Scrubbed> {
let mut findings = Vec::new();
let mut notes = Vec::new();
let info_id = trailer_reference(doc, b"Info");
let object_ids: Vec<ObjectId> = doc.objects.keys().copied().collect();
for id in &object_ids {
let Some(object) = doc.objects.get(id) else {
continue;
};
examine_object(object, *id, info_id, options, limits, 0, &mut findings)?;
}
if doc.trailer.has(b"ID") {
findings.push(Finding::new(
MetadataKind::DocumentIdentifier,
"trailer /ID",
0,
));
}
let revisions = count_revisions(raw);
if revisions > 1 {
notes.push(Note::IncrementalHistory {
revisions: revisions.saturating_sub(1),
});
}
doc.trailer.remove(b"Info");
doc.trailer.remove(b"ID");
for id in &object_ids {
if let Some(object) = doc.objects.get_mut(id) {
remove_from_object(object, *id, info_id, limits, 0)?;
}
}
if object_ids
.iter()
.filter_map(|id| doc.objects.get(id))
.any(is_embedded_file_holder)
{
notes.push(Note::OutOfScopeContent {
location: "embedded file attachment".into(),
});
}
Ok(Scrubbed { findings, notes })
}
fn trailer_reference(doc: &Document, key: &[u8]) -> Option<ObjectId> {
doc.trailer
.get(key)
.ok()
.and_then(|o| o.as_reference().ok())
}
fn count_revisions(raw: &[u8]) -> usize {
const EOF: &[u8] = b"%%EOF";
raw.windows(EOF.len()).filter(|w| *w == EOF).count()
}
fn examine_object(
object: &Object,
id: ObjectId,
info_id: Option<ObjectId>,
options: &InspectOptions,
limits: &ParseLimits,
depth: u32,
out: &mut Vec<Finding>,
) -> Result<()> {
if depth > limits.max_depth {
return Err(StryptError::LimitExceeded {
format: Format::Pdf,
limit: ResourceLimit::Depth,
});
}
match object {
Object::Dictionary(dict) => {
if Some(id) == info_id {
examine_info(dict, options, out);
}
examine_dictionary(dict, id, info_id, options, limits, depth, out)?;
}
Object::Stream(stream) => {
if is_metadata_stream(&stream.dict) {
examine_xmp(stream, options, out);
}
examine_dictionary(&stream.dict, id, info_id, options, limits, depth, out)?;
}
Object::Array(items) => {
for item in items {
examine_object(
item,
id,
info_id,
options,
limits,
depth.saturating_add(1),
out,
)?;
}
}
_ => {}
}
Ok(())
}
fn examine_info(dict: &Dictionary, options: &InspectOptions, out: &mut Vec<Finding>) {
for (key, value) in dict {
let kind = INFO_KEYS
.iter()
.find(|(name, _)| *name == key.as_slice())
.map_or(MetadataKind::Other, |(_, kind)| *kind);
out.push(
Finding::new(kind, "/Info", value_size(value))
.with_field(name_of(key))
.with_value(options, || describe(value)),
);
}
}
fn examine_dictionary(
dict: &Dictionary,
id: ObjectId,
info_id: Option<ObjectId>,
options: &InspectOptions,
limits: &ParseLimits,
depth: u32,
out: &mut Vec<Finding>,
) -> Result<()> {
for (name, kind) in GLOBAL_KEYS {
if let Ok(value) = dict.get(name) {
out.push(
Finding::new(*kind, format!("/{}", name_of(name)), value_size(value))
.with_field(name_of(name)),
);
}
}
if is_markup_annotation(dict) {
for (name, kind) in [
(&b"T"[..], MetadataKind::PersonalIdentity),
(&b"M"[..], MetadataKind::Timestamp),
(&b"CreationDate"[..], MetadataKind::Timestamp),
(&b"NM"[..], MetadataKind::DocumentIdentifier),
] {
if let Ok(value) = dict.get(name) {
out.push(
Finding::new(kind, "annotation", value_size(value))
.with_field(name_of(name))
.with_value(options, || describe(value)),
);
}
}
}
if let Ok(Object::Dictionary(params)) = dict.get(b"Params") {
for name in [&b"CreationDate"[..], &b"ModDate"[..], &b"CheckSum"[..]] {
if let Ok(value) = params.get(name) {
out.push(
Finding::new(MetadataKind::Timestamp, "/Params", value_size(value))
.with_field(name_of(name)),
);
}
}
}
for (_, value) in dict {
examine_object(
value,
id,
info_id,
options,
limits,
depth.saturating_add(1),
out,
)?;
}
Ok(())
}
fn examine_xmp(stream: &lopdf::Stream, options: &InspectOptions, out: &mut Vec<Finding>) {
if stream.dict.has(b"Filter") {
out.push(
Finding::new(
MetadataKind::Other,
"XMP packet",
as_u64(stream.content.len()),
)
.with_field("Metadata (encoded)"),
);
return;
}
out.extend(xmp::scan(&stream.content, "XMP packet", options));
}
fn remove_from_object(
object: &mut Object,
id: ObjectId,
info_id: Option<ObjectId>,
limits: &ParseLimits,
depth: u32,
) -> Result<()> {
if depth > limits.max_depth {
return Err(StryptError::LimitExceeded {
format: Format::Pdf,
limit: ResourceLimit::Depth,
});
}
match object {
Object::Dictionary(dict) => {
if Some(id) == info_id {
*dict = Dictionary::new();
return Ok(());
}
remove_from_dictionary(dict, id, info_id, limits, depth)?;
}
Object::Stream(stream) => {
remove_from_dictionary(&mut stream.dict, id, info_id, limits, depth)?;
}
Object::Array(items) => {
for item in items {
remove_from_object(item, id, info_id, limits, depth.saturating_add(1))?;
}
}
_ => {}
}
Ok(())
}
fn normalise_negative_zero(doc: &mut Document, limits: &ParseLimits) -> Result<()> {
for object in doc.objects.values_mut() {
normalise_object(object, limits, 0)?;
}
for (_, value) in &mut doc.trailer {
normalise_object(value, limits, 0)?;
}
Ok(())
}
fn normalise_object(object: &mut Object, limits: &ParseLimits, depth: u32) -> Result<()> {
if depth > limits.max_depth {
return Err(StryptError::LimitExceeded {
format: Format::Pdf,
limit: ResourceLimit::Depth,
});
}
match object {
Object::Real(value) if value.is_sign_negative() && *value == 0.0 => {
*value = 0.0;
}
Object::Dictionary(dict) => {
for (_, value) in dict.iter_mut() {
normalise_object(value, limits, depth.saturating_add(1))?;
}
}
Object::Stream(stream) => {
for (_, value) in &mut stream.dict {
normalise_object(value, limits, depth.saturating_add(1))?;
}
}
Object::Array(items) => {
for item in items {
normalise_object(item, limits, depth.saturating_add(1))?;
}
}
_ => {}
}
Ok(())
}
fn remove_from_dictionary(
dict: &mut Dictionary,
id: ObjectId,
info_id: Option<ObjectId>,
limits: &ParseLimits,
depth: u32,
) -> Result<()> {
for (name, _) in GLOBAL_KEYS {
dict.remove(name);
}
if is_markup_annotation(dict) {
dict.remove(b"T");
dict.remove(b"M");
dict.remove(b"CreationDate");
dict.remove(b"NM");
}
if let Ok(Object::Dictionary(params)) = dict.get_mut(b"Params") {
params.remove(b"CreationDate");
params.remove(b"ModDate");
params.remove(b"CheckSum");
}
for (_, value) in &mut *dict {
remove_from_object(value, id, info_id, limits, depth.saturating_add(1))?;
}
Ok(())
}
fn is_metadata_stream(dict: &Dictionary) -> bool {
dict.get_type().is_ok_and(|t| t == b"Metadata")
|| dict
.get(b"Subtype")
.and_then(Object::as_name)
.is_ok_and(|s| s == b"XML")
}
fn is_markup_annotation(dict: &Dictionary) -> bool {
let Ok(subtype) = dict.get(b"Subtype").and_then(Object::as_name) else {
return false;
};
MARKUP_ANNOTATION_SUBTYPES.contains(&subtype)
}
fn is_embedded_file_holder(object: &Object) -> bool {
let dict = match object {
Object::Dictionary(dict) => dict,
Object::Stream(stream) => &stream.dict,
_ => return false,
};
dict.has_type(b"Filespec") || dict.has(b"EmbeddedFiles")
}
fn value_size(object: &Object) -> u64 {
match object {
Object::String(bytes, _) | Object::Name(bytes) => as_u64(bytes.len()),
Object::Stream(stream) => as_u64(stream.content.len()),
_ => 0,
}
}
fn describe(object: &Object) -> MetadataValue {
match object {
Object::String(bytes, _) | Object::Name(bytes) => MetadataValue::Text(name_of(bytes)),
Object::Integer(n) => MetadataValue::Text(n.to_string()),
Object::Boolean(b) => MetadataValue::Text(b.to_string()),
other => MetadataValue::Opaque {
bytes: value_size(other),
},
}
}
fn as_u64(value: usize) -> u64 {
u64::try_from(value).unwrap_or(u64::MAX)
}