Skip to main content

document_svg/document/
fdf.rs

1//! Bounded Adobe Forms Data Format (FDF) previews.
2//!
3//! FDF carries PDF form field values without the original page content. This
4//! adapter lists field hierarchy, field types, safe values and option counts;
5//! actions, JavaScript, submit targets and embedded files remain inert.
6
7use 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    // FDF uses the PDF object/xref grammar but has a different magic header.
73    // lopdf intentionally accepts only PDF headers, so normalize the fixed
74    // eight-byte signature in the bounded in-memory buffer before parsing.
75    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}