1use otf_pdf_syntax::{Dict, ObjId, Object, Result};
10
11use crate::document::Document;
12use crate::store::Store;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct SanitizeOptions {
18 pub javascript: bool,
21 pub external_actions: bool,
24 pub links: bool,
26 pub attachments: bool,
29 pub xfa: bool,
31 pub multimedia: bool,
34 pub metadata: bool,
36}
37
38impl Default for SanitizeOptions {
39 fn default() -> Self {
40 Self {
41 javascript: true,
42 external_actions: true,
43 links: false,
44 attachments: true,
45 xfa: true,
46 multimedia: true,
47 metadata: false,
48 }
49 }
50}
51
52impl SanitizeOptions {
53 pub const ALL: SanitizeOptions = SanitizeOptions {
55 javascript: true,
56 external_actions: true,
57 links: true,
58 attachments: true,
59 xfa: true,
60 multimedia: true,
61 metadata: true,
62 };
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
67pub struct SanitizeReport {
68 pub javascript: usize,
70 pub external_actions: usize,
72 pub links: usize,
74 pub attachments: usize,
76 pub xfa: usize,
78 pub multimedia: usize,
80 pub metadata: usize,
82}
83
84impl SanitizeReport {
85 pub fn is_empty(&self) -> bool {
87 *self == Self::default()
88 }
89}
90
91struct Cleaner<'a> {
92 store: &'a Store,
93 options: SanitizeOptions,
94 report: SanitizeReport,
95}
96
97impl Cleaner<'_> {
98 fn removes_action(&mut self, action: &Object, depth: u32) -> bool {
101 if depth > 32 {
102 return false;
103 }
104 let Some(dict) = self.store.resolve_dict(action) else {
105 return false;
106 };
107 let o = self.options;
108 let removed = match dict.get_name(b"S") {
109 Some(b"JavaScript") if o.javascript => {
110 self.report.javascript += 1;
111 true
112 }
113 Some(
114 b"Launch" | b"SubmitForm" | b"ImportData" | b"GoToE" | b"Rendition"
115 | b"RichMediaExecute",
116 ) if o.external_actions => {
117 self.report.external_actions += 1;
118 true
119 }
120 Some(b"URI" | b"GoToR") if o.links => {
121 self.report.links += 1;
122 true
123 }
124 _ => false,
125 };
126 if removed {
127 return true;
128 }
129 match dict.get(b"Next").map(|n| self.store.resolve(n)) {
131 Some(Object::Array(items)) => items.iter().any(|n| self.removes_action(n, depth + 1)),
132 Some(next @ Object::Dict(_)) => self.removes_action(&next, depth + 1),
133 _ => false,
134 }
135 }
136
137 fn removes_annotation(&mut self, annot: &Object) -> bool {
138 let Some(dict) = self.store.resolve_dict(annot) else {
139 return false;
140 };
141 match dict.get_name(b"Subtype") {
142 Some(b"FileAttachment") if self.options.attachments => {
143 self.report.attachments += 1;
144 true
145 }
146 Some(b"RichMedia" | b"Movie" | b"Sound" | b"Screen" | b"3D")
147 if self.options.multimedia =>
148 {
149 self.report.multimedia += 1;
150 true
151 }
152 _ => false,
153 }
154 }
155
156 fn clean_dict(&mut self, dict: &mut Dict, depth: u32) -> bool {
159 if depth > 64 {
160 return false;
161 }
162 let o = self.options;
163 let mut changed = false;
164 let drop = |dict: &mut Dict, key: &[u8], counter: &mut usize| {
165 if dict.remove(key).is_some() {
166 *counter += 1;
167 true
168 } else {
169 false
170 }
171 };
172
173 for key in [&b"A"[..], b"OpenAction"] {
174 if let Some(action) = dict.get(key).cloned()
175 && self.removes_action(&action, 0)
176 {
177 dict.remove(key);
178 changed = true;
179 }
180 }
181 if let Some(aa) = dict.get(b"AA").cloned()
182 && let Some(mut triggers) = self.store.resolve_dict(&aa)
183 {
184 let keys: Vec<_> = triggers.iter().map(|(k, _)| k.clone()).collect();
185 for k in keys {
186 let action = triggers.get(k.as_bytes()).cloned().unwrap_or_default();
187 if self.removes_action(&action, 0) {
188 triggers.remove(k.as_bytes());
189 changed = true;
190 }
191 }
192 if triggers.is_empty() {
193 dict.remove(b"AA");
194 } else if changed {
195 dict.insert("AA", triggers);
198 }
199 }
200 if o.attachments {
201 changed |= drop(dict, b"AF", &mut self.report.attachments);
202 if dict.is_type(b"Catalog") {
203 changed |= drop(dict, b"Collection", &mut self.report.attachments);
204 }
205 }
206 if o.xfa {
207 changed |= drop(dict, b"XFA", &mut self.report.xfa);
208 if dict.remove(b"NeedsRendering").is_some() {
209 changed = true;
210 }
211 }
212 if o.metadata {
213 changed |= drop(dict, b"Metadata", &mut self.report.metadata);
214 changed |= drop(dict, b"PieceInfo", &mut self.report.metadata);
215 }
216 if let Some(Object::Array(annots)) = dict.get(b"Annots").cloned() {
217 let kept: Vec<Object> = annots
218 .iter()
219 .filter(|a| !self.removes_annotation(a))
220 .cloned()
221 .collect();
222 if kept.len() != annots.len() {
223 dict.insert("Annots", kept);
224 changed = true;
225 }
226 }
227 let keys: Vec<_> = dict.iter().map(|(k, _)| k.clone()).collect();
229 for k in keys {
230 if let Some(mut v) = dict.remove(k.as_bytes()) {
231 changed |= self.clean_object(&mut v, depth + 1);
232 dict.insert(k, v);
233 }
234 }
235 changed
236 }
237
238 fn clean_object(&mut self, object: &mut Object, depth: u32) -> bool {
239 match object {
240 Object::Dict(d) => self.clean_dict(d, depth),
241 Object::Stream(s) => self.clean_dict(&mut s.dict, depth),
242 Object::Array(items) => {
243 let mut changed = false;
244 for i in items {
245 changed |= self.clean_object(i, depth + 1);
246 }
247 changed
248 }
249 _ => false,
250 }
251 }
252}
253
254impl Document {
255 pub fn sanitize(&mut self, options: &SanitizeOptions) -> Result<SanitizeReport> {
259 let ids: Vec<ObjId> = self.store().object_ids();
260 let mut changes = Vec::new();
261 let mut cleaner = Cleaner {
262 store: self.store(),
263 options: *options,
264 report: SanitizeReport::default(),
265 };
266 for id in ids {
267 cleaner.store.limits().charge(1)?;
268 let Ok(obj) = cleaner.store.get(id) else {
269 continue;
270 };
271 let mut copy = (*obj).clone();
274 let changed = cleaner.clean_object(&mut copy, 0);
275 if changed {
276 changes.push((id, copy));
277 }
278 }
279 let mut report = cleaner.report;
280 for (id, obj) in changes {
281 self.store_mut().set(id, obj);
282 }
283 let mut catalog = self.catalog();
285 if let Some(names_obj) = catalog.get(b"Names").cloned()
286 && let Some(mut names) = self.store().resolve_dict(&names_obj)
287 {
288 let mut changed = false;
289 if options.javascript && names.remove(b"JavaScript").is_some() {
290 report.javascript += 1;
291 changed = true;
292 }
293 if options.attachments && names.remove(b"EmbeddedFiles").is_some() {
294 report.attachments += 1;
295 changed = true;
296 }
297 if changed {
298 match names_obj {
299 Object::Ref(id) => self.store_mut().set(id, Object::Dict(names)),
300 _ => {
301 catalog.insert("Names", names);
302 if let Some(id) = self.catalog_id() {
303 self.store_mut().set(id, Object::Dict(catalog));
304 }
305 }
306 }
307 }
308 }
309 if options.metadata && self.store().trailer().contains_key(b"Info") {
310 self.store_mut().set_trailer_entry("Info", Object::Null);
311 report.metadata += 1;
312 }
313 self.invalidate();
314 Ok(report)
315 }
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 #[test]
323 fn report_emptiness() {
324 assert!(SanitizeReport::default().is_empty());
325 assert!(
326 !SanitizeReport {
327 xfa: 1,
328 ..SanitizeReport::default()
329 }
330 .is_empty()
331 );
332 assert!(SanitizeOptions::ALL.links && !SanitizeOptions::default().links);
333 }
334}