1use std::path::Path;
8
9use lopdf::{Dictionary, Document, Object};
10
11use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
12use crate::document::html::{HtmlBlock, render_blocks_to_pages};
13use crate::error::{Error, Result};
14use crate::table::{TableAlign, TableData};
15
16const MAX_FDF_BYTES: u64 = 64 * 1024 * 1024;
17const MAX_FDF_OBJECTS: usize = 500_000;
18const MAX_FDF_FIELDS: usize = 100_000;
19const MAX_FDF_DEPTH: usize = 64;
20const MAX_FDF_VALUE_BYTES: usize = 2 * 1024 * 1024;
21const MAX_FDF_TOTAL_VALUE_BYTES: usize = 32 * 1024 * 1024;
22const MAX_FDF_DISPLAY_BYTES: usize = 512;
23
24pub(crate) fn looks_like_prefix(bytes: &[u8]) -> bool {
25 bytes.windows(5).any(|window| window == b"%FDF-")
26}
27
28struct FdfPageSink<'a> {
29 inner: &'a mut dyn PageConsumer,
30 warnings: &'a [String],
31}
32
33impl PageConsumer for FdfPageSink<'_> {
34 fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
35 page.source_format = "fdf".into();
36 if page.title.is_empty() {
37 page.title = "FDF form data".into();
38 }
39 page.description =
40 "FDF form fields are rendered as a bounded inert summary; actions, scripts, submit targets and embedded files are not executed".into();
41 for warning in self.warnings {
42 page.warn(warning.clone());
43 }
44 self.inner.consume(page)
45 }
46}
47
48#[derive(Default)]
49struct Summary {
50 fields: usize,
51 password_fields: usize,
52 options: usize,
53 actions: usize,
54 attachments: usize,
55 total_value_bytes: usize,
56 rows: Vec<Vec<String>>,
57}
58
59pub(crate) fn convert(
60 path: &Path,
61 options: &ConvertOptions,
62 sink: &mut dyn PageConsumer,
63) -> Result<Vec<String>> {
64 let mut bytes = read_limited_file(
65 path,
66 options.max_input_bytes.min(MAX_FDF_BYTES),
67 "FDF input",
68 )?;
69 if !looks_like_prefix(&bytes) {
70 return Err(Error::InvalidInput("FDF header must contain %FDF-".into()));
71 }
72 if bytes.len() < 8 {
76 return Err(Error::InvalidInput("FDF header is truncated".into()));
77 }
78 bytes[1..4].copy_from_slice(b"PDF");
79 bytes[5..8].copy_from_slice(b"1.7");
80 let document = Document::load_mem(&bytes)?;
81 if document.objects.len() > MAX_FDF_OBJECTS {
82 return Err(Error::LimitExceeded(format!(
83 "FDF indirect objects exceed {MAX_FDF_OBJECTS}"
84 )));
85 }
86 let root_id = match document.trailer.get(b"Root") {
87 Ok(Object::Reference(id)) => *id,
88 Ok(_) => {
89 return Err(Error::InvalidInput(
90 "FDF trailer Root is not a reference".into(),
91 ));
92 }
93 Err(_) => {
94 return Err(Error::InvalidInput(
95 "FDF trailer has no Root reference".into(),
96 ));
97 }
98 };
99 let root = document.get_object(root_id)?.as_dict()?;
100 let fdf_object = root
101 .get(b"FDF")
102 .map_err(|_| Error::InvalidInput("FDF catalog has no FDF dictionary".into()))?;
103 let fdf = resolve_dict(fdf_object, &document)?;
104 let fields_object = fdf
105 .get(b"Fields")
106 .map_err(|_| Error::InvalidInput("FDF dictionary has no Fields array".into()))?;
107 let fields = resolve_array(fields_object, &document)?;
108
109 let mut summary = Summary::default();
110 if fields.len() > MAX_FDF_FIELDS {
111 return Err(Error::LimitExceeded(format!(
112 "FDF fields exceed {MAX_FDF_FIELDS}"
113 )));
114 }
115 for field in fields {
116 walk_field(field, "", 1, &document, &mut summary)?;
117 }
118 summary.actions = count_action_objects(&document)
119 + usize::from(fdf.has(b"A") || fdf.has(b"AA") || fdf.has(b"JavaScript") || fdf.has(b"JS"));
120 summary.attachments = count_named_objects(&document, b"EmbeddedFile");
121 if summary.fields == 0 {
122 return Err(Error::InvalidInput(
123 "FDF Fields array contains no field dictionaries".into(),
124 ));
125 }
126
127 let metadata = format!(
128 "Fields: {}\nPassword fields: {}\nChoice options: {}\nAction dictionaries: {}\nEmbedded files: {}",
129 summary.fields,
130 summary.password_fields,
131 summary.options,
132 summary.actions,
133 summary.attachments
134 );
135 let blocks = vec![
136 HtmlBlock::Heading {
137 level: 1,
138 text: "FDF form data".into(),
139 },
140 HtmlBlock::Paragraph { text: metadata },
141 HtmlBlock::Table(TableData {
142 headers: vec![
143 "Field".into(),
144 "Type".into(),
145 "Value".into(),
146 "Detail".into(),
147 ],
148 rows: summary.rows,
149 alignments: vec![TableAlign::Left; 4],
150 raw_source: String::new(),
151 }),
152 ];
153 let warnings = vec![
154 "FDF field hierarchy, types, safe values and option counts are shown; password values, actions, submit targets, JavaScript, file specifications, embedded files and external URLs are omitted or redacted".into(),
155 "FDF parsing and rendered rows are bounded; no form submission, URI dereference, JavaScript, launch action, attachment extraction or PDF page rendering runs".into(),
156 ];
157 let mut page_sink = FdfPageSink {
158 inner: sink,
159 warnings: &warnings,
160 };
161 render_blocks_to_pages(&blocks, &mut page_sink, options)?;
162 Ok(warnings)
163}
164
165fn walk_field(
166 object: &Object,
167 parent_path: &str,
168 depth: usize,
169 document: &Document,
170 summary: &mut Summary,
171) -> Result<()> {
172 if depth > MAX_FDF_DEPTH {
173 return Err(Error::LimitExceeded(format!(
174 "FDF field depth exceeds {MAX_FDF_DEPTH}"
175 )));
176 }
177 let dict = resolve_dict(object, document)?;
178 if summary.fields >= MAX_FDF_FIELDS {
179 return Err(Error::LimitExceeded(format!(
180 "FDF fields exceed {MAX_FDF_FIELDS}"
181 )));
182 }
183 summary.fields = summary.fields.saturating_add(1);
184 let name = object_text(dict.get(b"T").ok(), document).unwrap_or_default();
185 let path = if name.is_empty() {
186 parent_path.to_owned()
187 } else if parent_path.is_empty() {
188 name.clone()
189 } else {
190 format!("{parent_path}.{name}")
191 };
192 let field_type =
193 object_name(dict.get(b"FT").ok(), document).unwrap_or_else(|| "inherited/unknown".into());
194 let flags = object_integer(dict.get(b"Ff").ok(), document).unwrap_or(0);
195 let password = field_type.eq_ignore_ascii_case("PS") || (flags & (1 << 13)) != 0;
196 if password {
197 summary.password_fields = summary.password_fields.saturating_add(1);
198 }
199 let value = if password {
200 "[password omitted]".into()
201 } else {
202 object_text(dict.get(b"V").ok(), document).unwrap_or_else(|| "-".into())
203 };
204 summary.total_value_bytes = summary.total_value_bytes.saturating_add(value.len());
205 if summary.total_value_bytes > MAX_FDF_TOTAL_VALUE_BYTES {
206 return Err(Error::LimitExceeded(format!(
207 "FDF field values exceed {MAX_FDF_TOTAL_VALUE_BYTES} bytes"
208 )));
209 }
210 let option_count = dict
211 .get(b"Opt")
212 .ok()
213 .and_then(|object| resolve_array(object, document).ok())
214 .map(|values| values.len())
215 .unwrap_or(0);
216 summary.options = summary.options.saturating_add(option_count);
217 let detail = if option_count > 0 {
218 format!("depth={depth} options={option_count}")
219 } else {
220 format!("depth={depth}")
221 };
222 push_row(&mut summary.rows, &path, &field_type, &value, &detail)?;
223
224 if let Some(kids) = dict
225 .get(b"Kids")
226 .ok()
227 .and_then(|object| resolve_array(object, document).ok())
228 {
229 for child in kids {
230 walk_field(child, &path, depth.saturating_add(1), document, summary)?;
231 }
232 }
233 Ok(())
234}
235
236fn resolve_dict<'a>(object: &'a Object, document: &'a Document) -> Result<&'a Dictionary> {
237 match object {
238 Object::Dictionary(dict) => Ok(dict),
239 Object::Reference(id) => Ok(document.get_object(*id)?.as_dict()?),
240 _ => Err(Error::InvalidInput("FDF value is not a dictionary".into())),
241 }
242}
243
244fn resolve_array<'a>(object: &'a Object, document: &'a Document) -> Result<Vec<&'a Object>> {
245 let object = match object {
246 Object::Array(array) => array,
247 Object::Reference(id) => document.get_object(*id)?.as_array()?,
248 _ => return Err(Error::InvalidInput("FDF value is not an array".into())),
249 };
250 Ok(object.iter().collect())
251}
252
253fn object_text(object: Option<&Object>, document: &Document) -> Option<String> {
254 let object = object?;
255 let object = match object {
256 Object::Reference(id) => document.get_object(*id).ok()?,
257 object => object,
258 };
259 let value = match object {
260 Object::String(bytes, _) => String::from_utf8_lossy(bytes).into_owned(),
261 Object::Name(bytes) => String::from_utf8_lossy(bytes).into_owned(),
262 Object::Integer(value) => value.to_string(),
263 Object::Real(value) => value.to_string(),
264 Object::Boolean(value) => value.to_string(),
265 Object::Array(values) => format!("[{} values]", values.len()),
266 _ => return None,
267 };
268 if value.len() > MAX_FDF_VALUE_BYTES {
269 Some("[value omitted: exceeds FDF value limit]".into())
270 } else if value.contains("://") {
271 Some("[URL omitted]".into())
272 } else {
273 Some(truncate(&value))
274 }
275}
276
277fn object_name(object: Option<&Object>, document: &Document) -> Option<String> {
278 object_text(object, document).map(|value| value.trim_start_matches('/').to_owned())
279}
280
281fn object_integer(object: Option<&Object>, document: &Document) -> Option<i64> {
282 let object = object?;
283 let object = match object {
284 Object::Reference(id) => document.get_object(*id).ok()?,
285 object => object,
286 };
287 match object {
288 Object::Integer(value) => Some(*value),
289 _ => None,
290 }
291}
292
293fn count_action_objects(document: &Document) -> usize {
294 document
295 .objects
296 .values()
297 .filter(|object| match object {
298 Object::Dictionary(dict) => {
299 dict.has(b"A") || dict.has(b"AA") || dict.has(b"JavaScript") || dict.has(b"JS")
300 }
301 Object::Stream(stream) => {
302 stream.dict.has(b"A")
303 || stream.dict.has(b"AA")
304 || stream.dict.has(b"JavaScript")
305 || stream.dict.has(b"JS")
306 }
307 _ => false,
308 })
309 .count()
310}
311
312fn count_named_objects(document: &Document, name: &[u8]) -> usize {
313 document
314 .objects
315 .values()
316 .filter(|object| match object {
317 Object::Dictionary(dict) => {
318 matches!(dict.get(b"Type"), Ok(Object::Name(value)) if value == name)
319 }
320 Object::Stream(stream) => {
321 matches!(stream.dict.get(b"Type"), Ok(Object::Name(value)) if value == name)
322 }
323 _ => false,
324 })
325 .count()
326}
327
328fn push_row(
329 rows: &mut Vec<Vec<String>>,
330 field: &str,
331 field_type: &str,
332 value: &str,
333 detail: &str,
334) -> Result<()> {
335 if rows.len() >= MAX_FDF_FIELDS {
336 return Err(Error::LimitExceeded(format!(
337 "FDF rendered rows exceed {MAX_FDF_FIELDS}"
338 )));
339 }
340 rows.push(vec![
341 truncate(field),
342 truncate(field_type),
343 truncate(value),
344 truncate(detail),
345 ]);
346 Ok(())
347}
348
349fn truncate(value: &str) -> String {
350 if value.len() <= MAX_FDF_DISPLAY_BYTES {
351 return value.to_owned();
352 }
353 let mut end = MAX_FDF_DISPLAY_BYTES;
354 while !value.is_char_boundary(end) {
355 end -= 1;
356 }
357 format!("{}…", &value[..end])
358}
359
360#[cfg(test)]
361mod tests {
362 use super::looks_like_prefix;
363 #[test]
364 fn recognizes_fdf_header() {
365 assert!(looks_like_prefix(b"%FDF-1.2\n"));
366 }
367 #[test]
368 fn rejects_pdf_header() {
369 assert!(!looks_like_prefix(b"%PDF-1.7\n"));
370 }
371}