From d335ff375d4de3d1f9e6ae253915eef929a2d558 Mon Sep 17 00:00:00 2001 From: Unhumanje <54317404+petarlaf@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:57:00 +0200 Subject: [PATCH] fix(xfdf): a field name full of periods no longer overflows the stack on export The XFDF export nested one element per period-separated part of a field's full name. A /T can hold periods of its own, so a name can have far more parts than the field tree has levels. Past 64 levels the import refuses the file the export wrote, and around 100,000 parts the recursive write overflows the main thread's stack and aborts the process. The export now nests at most as many levels as the import reads back. The parts past the last level stay in the deepest element's name, and the import joins them back into the same full name. --- crates/xfdf/src/lib.rs | 8 +++++++- crates/xfdf/src/tests.rs | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 35bc78154..30512f04e 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -415,7 +415,13 @@ fn xfdf_fields(doc: &Document, out: &mut String) { continue; } let mut node = &mut root; - for part in f.name.split('.') { + // One `` level per part of the name, but no more levels than the import reads + // back. A `/T` can hold periods of its own, so a name can split into far more parts than + // the field tree has levels: nesting every part wrote a file the import refuses and, for + // a long enough name, recursed in `write_node` until the stack overflowed. The parts past + // the last level stay together in the deepest element's name, and the import joins them + // back with periods into the same full name. + for part in f.name.splitn(MAX_DEPTH, '.') { let i = match node.kids.iter().position(|(k, _)| k == part) { Some(i) => i, None => { diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index c33c115e7..8e6f753a8 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1104,6 +1104,45 @@ fn wide_or_commented_xml_is_not_mistaken_for_deep() { assert!(check_form_xml_depth(&nested(MAX_XML_DEPTH + 1)).is_err()); } +/// [`one_field`] with its field's `/T` set to `name`, periods and all, holding `value`. +fn one_field_named(name: &str, value: &str) -> Document { + let mut doc = one_field(); + let r = fields(&doc).into_iter().find(|f| f.name == "Name").unwrap().obj; + doc.update_dict(r, |d| { + d.set(b"T".to_vec(), Object::String(PdfString::text(name))); + d.set(b"V".to_vec(), Object::String(PdfString::text(value))); + }) + .unwrap(); + doc +} + +/// A `/T` can hold periods of its own, so a field name can split into more parts than the field +/// tree has levels. The XFDF export nested one `` per part, and the import refuses more +/// than 64 levels, so the form's own export could not be read back. +#[test] +fn field_names_with_more_parts_than_the_import_reads_still_round_trip() { + for parts in [1, 2, 63, 64, 65, 66, 100, 1_000] { + let name = (0..parts).map(|i| format!("p{i}")).collect::>().join("."); + let doc = one_field_named(&name, "v"); + assert_eq!(values(&doc), vec![(name.clone(), vec!["v".to_string()])], "{parts} parts"); + let xfdf = export_xfdf(&doc, false, true, "form.pdf"); + assert_eq!(data_values(xfdf.as_bytes()), Ok(vec![(name.clone(), vec!["v".to_string()])]), "{parts} parts"); + let mut back = one_field_named(&name, "old"); + let report = import(&mut back, xfdf.as_bytes()).unwrap(); + assert_eq!((report.fields, values(&back)), (1, vec![(name.clone(), vec!["v".to_string()])]), "{parts} parts"); + } +} + +/// A name of a hundred thousand parts nested as deep, and writing it out recursed once per level +/// until the stack overflowed, which aborts the process. +#[test] +fn a_field_name_of_many_parts_exports_without_overflowing_the_stack() { + let name = vec!["p"; 100_000].join("."); + let want = vec![(name.clone(), vec!["v".to_string()])]; + let read = on_small_stack(move || data_values(export_xfdf(&one_field_named(&name, "v"), false, true, "form.pdf").as_bytes())); + assert_eq!(read, Ok(want)); +} + #[test] fn duplicate_xfdf_annotation_flags_do_not_turn_on_unrelated_bits() { // Flags are a set of bit masks, not additive integers. A repeated "print" flag