Skip to main content

dbmd_core/
extract.rs

1//! Document text extraction — the `dbmd extract` engine.
2//!
3//! `sources/` is where raw evidence lands: invoices, contracts, reports,
4//! exports. Most of it arrives as binary documents (PDF, Word, Excel, EPUB) or
5//! HTML, not markdown. Before an agent can reason over that evidence — wiki-link
6//! it, summarize it into the wiki layer, file a typed record that cites it — the
7//! text has to come out. This module is that step: a binary document in, plain
8//! UTF-8 text out, format chosen by file extension.
9//!
10//! # What this is, and is not
11//!
12//! - **Deterministic decoders only.** Every adapter is a format parser
13//!   (`pdf-extract`, `calamine`, `html2text`, `quick-xml`+`zip`). There is **no
14//!   AI, no OCR, no embeddings** here — consistent with the crate-wide invariant
15//!   (`lib.rs`). The agent driving `dbmd` is the semantic layer; this is plumbing.
16//! - **Text layer, not pixels.** A scanned PDF with no text layer yields the
17//!   empty string — *empty in, empty out, never hallucinated text.* OCR is an
18//!   explicit non-goal (a future `dbmd-ocr`).
19//! - **Single document, single call.** [`extract`] handles one file. Walking a
20//!   store and extracting every document is the caller's loop, not this module's.
21//!
22//! # Format dispatch
23//!
24//! [`Format::from_path`] maps the file extension to an adapter; [`extract`]
25//! dispatches:
26//!
27//! | Extension                | Format            | Adapter                          |
28//! |--------------------------|-------------------|----------------------------------|
29//! | `.pdf`                   | [`Format::Pdf`]   | `pdf-extract`                    |
30//! | `.docx`                  | [`Format::Docx`]  | `zip` + `quick-xml` (`w:t` runs) |
31//! | `.xlsx` / `.xlsm` / `.xlsb` / `.ods` | [`Format::Spreadsheet`] | `calamine` |
32//! | `.epub`                  | [`Format::Epub`]  | `zip` + `quick-xml` + `html2text`|
33//! | `.html` / `.htm` / `.xhtml` | [`Format::Html`] | `html2text`                    |
34//!
35//! Anything else is [`ExtractError::UnsupportedFormat`] — a typed refusal the
36//! CLI surfaces with a stable code, never a panic.
37
38use std::collections::BTreeMap;
39use std::io::{Cursor, Read, Seek, SeekFrom};
40use std::panic::{catch_unwind, AssertUnwindSafe};
41use std::path::Path;
42
43use serde::{Deserialize, Serialize};
44
45/// Compressed/input bytes accepted by any in-process document adapter. The
46/// individual ZIP-entry and extracted-output limits remain independent. A
47/// source larger than this must be handled by an externally sandboxed importer,
48/// not parsed in the toolkit process.
49const MAX_DOCUMENT_INPUT_BYTES: u64 = 128 * 1024 * 1024;
50
51/// The result of extracting one document: the plain text plus a small,
52/// format-tagged metadata map.
53///
54/// This is the `--json` shape the CLI emits verbatim (`{text, metadata}`); in
55/// plain mode the CLI prints [`Extracted::text`] and discards the metadata.
56/// Metadata is intentionally minimal and best-effort — extraction never *fails*
57/// for want of a title; it just omits the key.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct Extracted {
60    /// The extracted plain text (UTF-8), normalized to `\n` line endings with
61    /// trailing whitespace trimmed per line and a single trailing newline. For
62    /// a document with no recoverable text layer (e.g. a scanned, image-only
63    /// PDF) this is the empty string — the contract is "empty in, empty out."
64    pub text: String,
65
66    /// Best-effort key/value metadata. Always carries `format` (the adapter
67    /// that ran, e.g. `"pdf"`). Adapters add what they cheaply know:
68    /// `pages`/`sheets`/`sheet_names` (counts), `title` (when the container
69    /// declares one). A `BTreeMap` so `--json` output is key-ordered and stable.
70    pub metadata: BTreeMap<String, MetaValue>,
71}
72
73impl Extracted {
74    /// Build an [`Extracted`] from raw adapter text + the detected format,
75    /// applying the canonical text normalization ([`normalize_text`]) and
76    /// seeding the `format` metadata key.
77    fn new(raw_text: String, format: Format) -> Self {
78        let mut metadata = BTreeMap::new();
79        metadata.insert(
80            "format".to_string(),
81            MetaValue::Str(format.tag().to_string()),
82        );
83        Extracted {
84            text: normalize_text(&raw_text),
85            metadata,
86        }
87    }
88
89    /// Insert a string metadata key only when the value is non-empty (keeps the
90    /// map free of empty `title: ""` noise).
91    fn put_str(&mut self, key: &str, value: impl Into<String>) {
92        let v = value.into();
93        if !v.trim().is_empty() {
94            self.metadata.insert(key.to_string(), MetaValue::Str(v));
95        }
96    }
97
98    /// Insert a numeric (count) metadata key.
99    fn put_num(&mut self, key: &str, value: u64) {
100        self.metadata.insert(key.to_string(), MetaValue::Num(value));
101    }
102}
103
104/// A metadata value: a string (title, format tag, sheet name list joined) or a
105/// non-negative count (pages, sheets). Serializes to a bare JSON string or
106/// number — no wrapper object — so `{text, metadata}` stays flat and readable.
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(untagged)]
109pub enum MetaValue {
110    /// A textual value (e.g. document title, the `format` tag).
111    Str(String),
112    /// A non-negative count (e.g. page count, sheet count).
113    Num(u64),
114}
115
116/// The document formats `dbmd extract` understands, one per adapter. Detected
117/// from the file extension by [`Format::from_path`].
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum Format {
120    /// Portable Document Format (`.pdf`) — text layer via `pdf-extract`.
121    Pdf,
122    /// Office Open XML WordprocessingML (`.docx`) — `w:t` runs via `quick-xml`.
123    Docx,
124    /// A spreadsheet (`.xlsx`/`.xlsm`/`.xlsb`/`.ods`) — cells via `calamine`.
125    Spreadsheet,
126    /// EPUB e-book (`.epub`) — spine XHTML via `zip` + `quick-xml` + `html2text`.
127    Epub,
128    /// HTML (`.html`/`.htm`/`.xhtml`) — plain text via `html2text`.
129    Html,
130}
131
132impl Format {
133    /// Detect the format from a path's extension (case-insensitive). Returns
134    /// `None` for an unrecognized or missing extension; [`extract`] turns that
135    /// into [`ExtractError::UnsupportedFormat`] with the offending extension.
136    pub fn from_path(path: &Path) -> Option<Format> {
137        let ext = path.extension()?.to_str()?.to_ascii_lowercase();
138        Some(match ext.as_str() {
139            "pdf" => Format::Pdf,
140            "docx" => Format::Docx,
141            "xlsx" | "xlsm" | "xlsb" | "ods" => Format::Spreadsheet,
142            "epub" => Format::Epub,
143            "html" | "htm" | "xhtml" => Format::Html,
144            _ => return None,
145        })
146    }
147
148    /// The short, stable tag recorded in `metadata.format` and used in error
149    /// messages. Distinct from the file extension (one tag can cover several
150    /// extensions, e.g. `spreadsheet`).
151    pub fn tag(self) -> &'static str {
152        match self {
153            Format::Pdf => "pdf",
154            Format::Docx => "docx",
155            Format::Spreadsheet => "spreadsheet",
156            Format::Epub => "epub",
157            Format::Html => "html",
158        }
159    }
160}
161
162/// Errors from document extraction. Every variant is a typed refusal the CLI
163/// maps to a stable machine code — extraction never panics on a bad or
164/// encrypted input.
165#[derive(Debug, thiserror::Error)]
166pub enum ExtractError {
167    /// The file extension is missing or not one of the supported document
168    /// formats. Carries the offending extension (or `""` when absent).
169    #[error("unsupported document format: {0:?} (supported: pdf, docx, xlsx/xlsm/xlsb/ods, epub, html/htm/xhtml)")]
170    UnsupportedFormat(String),
171
172    /// The document is encrypted/password-protected and could not be opened
173    /// without a password (or with the wrong one). A clean refusal — the
174    /// extractor must never emit partial/garbled bytes for a locked file.
175    #[error("document is encrypted or password-protected: {0}")]
176    Encrypted(String),
177
178    /// A format adapter failed to parse a structurally invalid or corrupt
179    /// document. Carries the adapter's diagnostic.
180    #[error("failed to parse {format} document: {message}")]
181    Parse {
182        /// The format tag whose adapter failed (e.g. `"pdf"`, `"docx"`).
183        format: &'static str,
184        /// The underlying parser diagnostic.
185        message: String,
186    },
187
188    /// An underlying I/O failure (file missing, unreadable, etc.).
189    #[error(transparent)]
190    Io(#[from] std::io::Error),
191}
192
193impl ExtractError {
194    /// A short, stable machine code for this error, mirrored at the CLI
195    /// boundary for `--json` output and exit-code mapping.
196    pub fn code(&self) -> &'static str {
197        match self {
198            ExtractError::UnsupportedFormat(_) => "UNSUPPORTED_FORMAT",
199            ExtractError::Encrypted(_) => "DOCUMENT_ENCRYPTED",
200            ExtractError::Parse { .. } => "EXTRACT_PARSE_ERROR",
201            ExtractError::Io(_) => "IO_ERROR",
202        }
203    }
204}
205
206/// Result alias for extraction operations.
207pub type Result<T> = std::result::Result<T, ExtractError>;
208
209/// Extract plain text (and best-effort metadata) from a document, choosing the
210/// adapter by the file's extension.
211///
212/// This is the single entry point the CLI calls. It reads exactly one file and
213/// returns one [`Extracted`]; there is no whole-store walk here (per the
214/// crate-wide O(changed) invariant — a store-wide extraction is the caller's
215/// loop). An unsupported extension is [`ExtractError::UnsupportedFormat`]; an
216/// encrypted PDF is [`ExtractError::Encrypted`]; neither panics.
217///
218/// # Examples
219///
220/// ```no_run
221/// use std::path::Path;
222/// let out = dbmd_core::extract::extract(Path::new("sources/docs/invoice.pdf"))?;
223/// println!("{}", out.text);
224/// # Ok::<(), dbmd_core::extract::ExtractError>(())
225/// ```
226pub fn extract(path: &Path) -> Result<Extracted> {
227    let format = Format::from_path(path).ok_or_else(|| {
228        let ext = path
229            .extension()
230            .and_then(|e| e.to_str())
231            .unwrap_or("")
232            .to_string();
233        ExtractError::UnsupportedFormat(ext)
234    })?;
235    let bytes =
236        crate::fsx::read_bounded_nofollow(path, MAX_DOCUMENT_INPUT_BYTES).map_err(|error| {
237            if error.kind() == std::io::ErrorKind::InvalidData {
238                ExtractError::Parse {
239                    format: format.tag(),
240                    message: format!(
241                        "input must be one regular file within the {} MiB extraction cap: {error}",
242                        MAX_DOCUMENT_INPUT_BYTES / (1024 * 1024)
243                    ),
244                }
245            } else {
246                ExtractError::Io(error)
247            }
248        })?;
249
250    match format {
251        Format::Pdf => extract_pdf(&bytes),
252        Format::Docx => extract_docx(&bytes),
253        Format::Spreadsheet => extract_spreadsheet(
254            &bytes,
255            path.extension()
256                .and_then(|extension| extension.to_str())
257                .is_some_and(|extension| extension.eq_ignore_ascii_case("ods")),
258        ),
259        Format::Epub => extract_epub(&bytes),
260        Format::Html => extract_html(&bytes),
261    }
262}
263
264// ─────────────────────────────────────────────────────────────────────────────
265// Text normalization
266// ─────────────────────────────────────────────────────────────────────────────
267
268/// Canonicalize extracted text so output is stable across adapters:
269///
270/// 1. Normalize line endings to `\n` (drop `\r`).
271/// 2. Trim trailing whitespace on each line.
272/// 3. Collapse three-or-more consecutive blank lines to a single blank line.
273/// 4. Trim leading/trailing blank lines, then append exactly one `\n` (unless
274///    the whole text is empty, which stays empty — the image-only-PDF contract).
275///
276/// This is *layout* tid-up only; it never reorders or drops words. Word-level
277/// content is whatever the adapter recovered.
278pub fn normalize_text(raw: &str) -> String {
279    let unix = raw.replace("\r\n", "\n").replace('\r', "\n");
280
281    let lines: Vec<&str> = unix.lines().map(|l| l.trim_end()).collect();
282
283    // Trim leading/trailing blank lines by locating the first and last
284    // non-blank line ONCE, then slicing. The previous `while … lines.remove(0)`
285    // shifted every remaining element on each removal — O(n²) when the document
286    // is dominated by leading blanks (e.g. an adapter that emits millions of
287    // empty paragraphs), letting a few-hundred-KB document hang extraction for
288    // minutes. Index-and-slice is O(n) regardless of how many blanks lead.
289    let Some(first) = lines.iter().position(|l| !l.is_empty()) else {
290        return String::new();
291    };
292    // `first` exists, so a last non-blank line exists too (rposition can't be None).
293    let last = lines
294        .iter()
295        .rposition(|l| !l.is_empty())
296        .expect("a non-blank line exists once `first` is found");
297    let lines = &lines[first..=last];
298
299    // Collapse runs of 2+ blank lines down to a single blank line.
300    let mut out = String::new();
301    let mut blank_run = 0usize;
302    for &line in lines {
303        if line.is_empty() {
304            blank_run += 1;
305            if blank_run >= 2 {
306                continue;
307            }
308        } else {
309            blank_run = 0;
310        }
311        out.push_str(line);
312        out.push('\n');
313    }
314    out
315}
316
317// ─────────────────────────────────────────────────────────────────────────────
318// PDF — pdf-extract
319// ─────────────────────────────────────────────────────────────────────────────
320
321/// Extract a PDF's text layer via `pdf-extract`.
322///
323/// A PDF with no text layer (a scanned image) yields the empty string — that is
324/// correct, not an error (OCR is out of scope). A password-protected PDF that
325/// cannot be opened is mapped to [`ExtractError::Encrypted`] rather than a raw
326/// parse error so the caller can branch on it. Metadata carries the page count
327/// when the document tree exposes it.
328///
329/// `pdf-extract`/`lopdf` `panic!` internally on some malformed-but-openable
330/// PDFs (e.g. an out-of-set base `/Encoding` name), so both parser calls are
331/// wrapped in [`std::panic::catch_unwind`]: an internal abort is contained and
332/// surfaced as [`ExtractError::Parse`], upholding this module's "never panics"
333/// contract on untrusted `sources/` input.
334fn extract_pdf(bytes: &[u8]) -> Result<Extracted> {
335    let text = match guard_pdf_panic(|| pdf_extract::extract_text_from_mem(bytes))? {
336        Ok(t) => t,
337        Err(e) => return Err(classify_pdf_error(e)),
338    };
339
340    let mut out = Extracted::new(text, Format::Pdf);
341
342    // Page count is best-effort; derive it from the parsed document. A parse
343    // failure OR an internal panic here is non-fatal — the text already
344    // succeeded — so a contained panic (outer `Err`) and a load failure (inner
345    // `Err`) are both silently skipped.
346    if let Ok(Ok(doc)) = guard_pdf_panic(|| pdf_extract::Document::load_mem(bytes)) {
347        out.put_num("pages", doc.get_pages().len() as u64);
348    }
349
350    Ok(out)
351}
352
353/// Run a panic-prone `pdf-extract`/`lopdf` call, converting an internal unwind
354/// into a typed [`ExtractError::Parse`] tagged `pdf` so the module's "never
355/// panics" contract holds on adversarial PDFs. `AssertUnwindSafe` is sound: the
356/// closure borrows only `&[u8]`, and on a caught unwind we discard any partial
357/// state and return an owned error. The default panic hook still writes the
358/// panic line to stderr — library code must not mutate the process-global hook.
359fn guard_pdf_panic<T>(f: impl FnOnce() -> T) -> Result<T> {
360    catch_unwind(AssertUnwindSafe(f)).map_err(|_| ExtractError::Parse {
361        format: "pdf",
362        message: "pdf parser aborted on malformed input".to_string(),
363    })
364}
365
366/// Map a `pdf-extract` error onto the right [`ExtractError`] variant.
367/// Decryption failures become [`ExtractError::Encrypted`]; everything else is a
368/// [`ExtractError::Parse`] tagged `pdf`.
369fn classify_pdf_error(err: pdf_extract::OutputError) -> ExtractError {
370    let msg = err.to_string();
371    let lower = msg.to_ascii_lowercase();
372    if lower.contains("password") || lower.contains("decrypt") || lower.contains("encrypt") {
373        ExtractError::Encrypted(msg)
374    } else {
375        ExtractError::Parse {
376            format: "pdf",
377            message: msg,
378        }
379    }
380}
381
382// ─────────────────────────────────────────────────────────────────────────────
383// DOCX — zip + quick-xml (no docx-rs dependency; quick-xml is already needed
384// for epub, so docx, xlsx-via-calamine, and epub share one XML/zip surface)
385// ─────────────────────────────────────────────────────────────────────────────
386
387/// Extract a `.docx` (WordprocessingML) by unzipping `word/document.xml` and
388/// concatenating the `<w:t>` run text, one logical line per `<w:p>` paragraph.
389///
390/// `<w:tab/>` becomes a tab and `<w:br/>` / `<w:cr>` a newline so table-ish and
391/// line-broken content keeps its shape; everything else is structural and
392/// ignored. This is the same minimal-but-faithful path `docx-rs` takes for text
393/// extraction, without pulling in a second XML/zip stack.
394fn extract_docx(bytes: &[u8]) -> Result<Extracted> {
395    let mut archive = open_zip(Cursor::new(bytes), "docx")?;
396    let mut budget = ExtractionBudget::default();
397
398    let xml = read_zip_entry(&mut archive, "word/document.xml", "docx", &mut budget)?;
399    let text = wordprocessing_text(&xml, "docx")?;
400
401    Ok(Extracted::new(text, Format::Docx))
402}
403
404/// Pull paragraph text out of a WordprocessingML / DrawingML XML body.
405///
406/// Shared by [`extract_docx`]. Walks the event stream collecting `<w:t>` text;
407/// `<w:p>` ends a line, `<w:tab/>` is a tab, `<w:br>`/`<w:cr>` a newline.
408///
409/// Output-bounded for parity with the HTML/EPUB adapters. A docx is a zip, and
410/// `word/document.xml` is attacker-controlled `sources/` input that can compress
411/// enormously: a few-hundred-KB `.docx` whose `document.xml` inflates to hundreds
412/// of MB of `<w:t>` runs would otherwise accumulate without bound. We cap the
413/// running output at [`MAX_EXTRACT_OUTPUT_BYTES`] *during* accumulation — the
414/// same ceiling EPUB enforces — so peak memory stays bounded rather than only
415/// being checked after the full string is materialized.
416fn wordprocessing_text(xml: &str, format: &'static str) -> Result<String> {
417    use quick_xml::events::Event;
418    use quick_xml::reader::Reader;
419
420    let mut reader = Reader::from_str(xml);
421    let mut buf = Vec::new();
422    let mut out = String::new();
423    let mut in_text_run = false;
424
425    // Refuse once accumulated text crosses the cap. Checked after each append so a
426    // single huge run can't blow past the ceiling before the next loop turn.
427    macro_rules! bound_output {
428        () => {
429            if out.len() > MAX_EXTRACT_OUTPUT_BYTES {
430                return Err(ExtractError::Parse {
431                    format,
432                    message: format!(
433                        "extracted text exceeds the {MAX_EXTRACT_OUTPUT_BYTES} byte cap \
434                         (malformed or hostile input)"
435                    ),
436                });
437            }
438        };
439    }
440
441    loop {
442        match reader.read_event_into(&mut buf) {
443            Ok(Event::Start(e)) => {
444                if local_name(e.name().as_ref()) == b"t" {
445                    in_text_run = true;
446                }
447            }
448            Ok(Event::End(e)) => {
449                let name = e.name();
450                match local_name(name.as_ref()) {
451                    b"t" => in_text_run = false,
452                    b"p" => {
453                        out.push('\n');
454                        bound_output!();
455                    }
456                    _ => {}
457                }
458            }
459            Ok(Event::Empty(e)) => {
460                // Self-closing run-level breaks inside a paragraph.
461                match local_name(e.name().as_ref()) {
462                    b"tab" => out.push('\t'),
463                    b"br" | b"cr" => out.push('\n'),
464                    _ => {}
465                }
466            }
467            // quick-xml 0.40 surfaces text verbatim in `Event::Text` but routes
468            // every entity reference to a separate `Event::GeneralRef` and CDATA
469            // to `Event::CData` — all three carry run content.
470            Ok(Event::Text(t)) => {
471                if in_text_run {
472                    out.push_str(&String::from_utf8_lossy(&t.into_inner()));
473                    bound_output!();
474                }
475            }
476            // `Smith &amp; Co` arrives as Text("Smith ") + GeneralRef("amp") +
477            // Text(" Co"); resolve the ref so `&`/`<`/`>`/numeric chars survive.
478            Ok(Event::GeneralRef(r)) => {
479                if in_text_run {
480                    out.push_str(&resolve_entity_ref(&r));
481                    bound_output!();
482                }
483            }
484            // CDATA inside a `<w:t>` run is valid WordprocessingML; its payload
485            // is literal text and must be appended like `Event::Text`.
486            Ok(Event::CData(c)) => {
487                if in_text_run {
488                    out.push_str(&String::from_utf8_lossy(&c.into_inner()));
489                    bound_output!();
490                }
491            }
492            Ok(Event::Eof) => break,
493            Err(e) => {
494                return Err(ExtractError::Parse {
495                    format,
496                    message: format!("malformed XML: {e}"),
497                });
498            }
499            _ => {}
500        }
501        buf.clear();
502    }
503
504    Ok(out)
505}
506
507/// The local part of a possibly-namespaced XML name: `w:t` → `t`, `t` → `t`.
508/// docx/epub XML uses prefixes (`w:`, `dc:`) the writer chose; matching the
509/// local name is prefix-agnostic and robust to that choice.
510fn local_name(qname: &[u8]) -> &[u8] {
511    match qname.iter().rposition(|&b| b == b':') {
512        Some(i) => &qname[i + 1..],
513        None => qname,
514    }
515}
516
517/// Resolve a `quick_xml` general-entity / character reference to its literal
518/// text. quick-xml 0.40 does NOT inline-resolve entity references inside
519/// `Event::Text`; instead it surfaces each `&name;` / `&#nnn;` as a separate
520/// `Event::GeneralRef`. Routing those to a `_ => {}` arm silently drops `&`,
521/// `<`, `>`, numeric refs, etc. from extracted text — corrupting any title,
522/// company name, or amount that contains them. This resolves the five
523/// XML-predefined named entities and any numeric character reference; an
524/// unknown named entity falls back to its bare name (best-effort, never a
525/// panic), matching the "recover what we can" stance of `sources/` extraction.
526fn resolve_entity_ref(reference: &quick_xml::events::BytesRef<'_>) -> String {
527    // Numeric character reference (`&#8212;`, `&#x2014;`): resolve to the char.
528    if let Ok(Some(ch)) = reference.resolve_char_ref() {
529        return ch.to_string();
530    }
531    // Named entity: map the five XML-predefined names; fall back to the bare
532    // name for anything else (custom DTD entities are out of scope here).
533    match reference.decode().as_deref() {
534        Ok("amp") => "&".to_string(),
535        Ok("lt") => "<".to_string(),
536        Ok("gt") => ">".to_string(),
537        Ok("quot") => "\"".to_string(),
538        Ok("apos") => "'".to_string(),
539        Ok(other) => other.to_string(),
540        Err(_) => String::new(),
541    }
542}
543
544// ─────────────────────────────────────────────────────────────────────────────
545// Spreadsheet — calamine (xlsx / xlsm / xlsb / ods)
546// ─────────────────────────────────────────────────────────────────────────────
547
548/// Ceiling on a single sheet's dense cell grid (`rows × cols`). `calamine`
549/// materializes a worksheet as a DENSE `Vec<Data>` sized from the MIN/MAX cell
550/// positions (`Range::from_sparse`), so two cells at `A1` and `XFD1048576` in a
551/// few-hundred-byte file force a ~1.7e10-element (~400 GB) allocation that
552/// **aborts** the process — bypassing the docx/epub zip-entry cap and the
553/// PDF panic guard (an allocation failure aborts, it does not unwind, so
554/// `catch_unwind` cannot contain it). `sources/` is untrusted input, so we
555/// bound the read the same way docx/epub do: refuse before the allocation.
556///
557/// Two million cells is still a large working sheet while bounding calamine's
558/// dense `Vec<Data>` to roughly tens of MiB instead of the previous ~1.2 GiB.
559const MAX_SPREADSHEET_CELLS: u64 = 2_000_000;
560
561/// Extract every sheet of a spreadsheet via `calamine`, rendering each row as
562/// tab-separated cells, one row per line, sheets in workbook order separated by
563/// a blank line.
564///
565/// Cell rendering: text verbatim; integers and whole-valued floats without a
566/// trailing `.0` (`1200`, not `1200.0`); other floats via their default
567/// formatting; booleans as `TRUE`/`FALSE`; empty/error cells as the empty
568/// string. Metadata carries the sheet count and the joined sheet-name list.
569///
570/// Before materializing each sheet, [`spreadsheet_dense_cells`] bounds the
571/// would-be dense grid against [`MAX_SPREADSHEET_CELLS`] and returns a typed
572/// [`ExtractError::Parse`] refusal rather than letting an attacker-supplied
573/// sheet OOM/abort the process — upholding the module's "never panics on
574/// untrusted `sources/` input" contract for the spreadsheet adapter.
575fn extract_spreadsheet(bytes: &[u8], is_ods: bool) -> Result<Extracted> {
576    use calamine::{open_workbook_auto_from_rs, Reader};
577
578    // ODS has no sparse-iterator pre-scan (see `spreadsheet_dense_cells`), so the
579    // xlsx-family fail-fast on a truncated/unclosed `content.xml` does not protect
580    // it: a `.ods` whose `content.xml` opens `<table:table>` then hits EOF makes
581    // calamine's ODS reader spin forever (an UNBOUNDED loop, not a panic —
582    // `catch_unwind` cannot recover it). The hang is reachable from the very first
583    // calamine call (`open_workbook_auto` parses the ODS document on open), so the
584    // structural validity gate has to run BEFORE we hand the file to calamine at
585    // all — not merely before `worksheet_range`. Gate by extension (the `.ods`
586    // backend is the only one with this unbounded shape; `.xls`/BIFF is
587    // format-bounded and the xlsx-family is pre-scanned). A truncated/unclosed
588    // document fails fast here with a typed Parse refusal — the same shape the
589    // xlsx pre-scan produces on a truncated sheet.
590    if is_ods {
591        ods_content_xml_well_formed(bytes)?;
592    }
593
594    let mut workbook =
595        open_workbook_auto_from_rs(Cursor::new(bytes)).map_err(|e| ExtractError::Parse {
596            format: "spreadsheet",
597            message: e.to_string(),
598        })?;
599
600    let sheet_names = workbook.sheet_names().to_vec();
601    let mut text = String::new();
602
603    for (idx, name) in sheet_names.iter().enumerate() {
604        if idx > 0 {
605            text.push('\n'); // blank line between sheets
606        }
607
608        // Bound the dense grid BEFORE calamine allocates it. For the zip-XML /
609        // record backends that expose a sparse cell iterator (xlsx-family,
610        // xlsb) this never densely allocates; over-cap sheets refuse cleanly.
611        if let Some(cells) = spreadsheet_dense_cells(&mut workbook, name)? {
612            if cells > MAX_SPREADSHEET_CELLS {
613                return Err(ExtractError::Parse {
614                    format: "spreadsheet",
615                    message: format!(
616                        "sheet {name:?} declares a {cells}-cell grid, over the \
617                         {MAX_SPREADSHEET_CELLS}-cell cap (malformed or hostile spreadsheet)"
618                    ),
619                });
620            }
621        }
622
623        let range = workbook
624            .worksheet_range(name)
625            .map_err(|e| ExtractError::Parse {
626                format: "spreadsheet",
627                message: format!("sheet {name:?}: {e}"),
628            })?;
629
630        for row in range.rows() {
631            let cells: Vec<String> = row.iter().map(render_cell).collect();
632            text.push_str(&cells.join("\t"));
633            text.push('\n');
634            if text.len() > MAX_EXTRACT_OUTPUT_BYTES {
635                return Err(ExtractError::Parse {
636                    format: "spreadsheet",
637                    message: format!(
638                        "extracted text exceeds the {MAX_EXTRACT_OUTPUT_BYTES} byte cap \
639                         (malformed or hostile spreadsheet)"
640                    ),
641                });
642            }
643        }
644    }
645
646    let mut out = Extracted::new(text, Format::Spreadsheet);
647    out.put_num("sheets", sheet_names.len() as u64);
648    if !sheet_names.is_empty() {
649        out.put_str("sheet_names", sheet_names.join(", "));
650    }
651    Ok(out)
652}
653
654/// Structurally validate an `.ods` `content.xml` before the unbounded calamine
655/// ODS reader touches it.
656///
657/// calamine's ODS backend exposes no sparse-cell iterator, so it gets none of the
658/// streaming pre-scan that bounds (and fails fast on truncated input) the
659/// xlsx/xlsb path in [`spreadsheet_dense_cells`]. On a `.ods` whose `content.xml`
660/// opens `<table:table>` and then hits EOF before the matching `</table:table>`,
661/// `worksheet_range` spins forever at full CPU — a resource-exhaustion DoS on
662/// untrusted `sources/` input, and an *infinite loop* that [`catch_unwind`]
663/// cannot recover (it catches panics, not hangs).
664///
665/// This gate reuses the shared zip helpers ([`open_zip`] / [`read_zip_entry`],
666/// bounded by [`MAX_ZIP_ENTRY_BYTES`]) to read `content.xml`, then streams it
667/// through `quick-xml` exactly like [`wordprocessing_text`] does for docx. A
668/// truncated/unclosed document surfaces as a `quick-xml` error (e.g. "Unexpected
669/// end of xml") or as an at-EOF tag-balance mismatch; either way we return a
670/// typed [`ExtractError::Parse`] (format `"spreadsheet"`) in well under a second,
671/// matching how a truncated `.xlsx` already fails — instead of letting calamine
672/// hang. A well-formed `content.xml` passes through untouched, so valid `.ods`
673/// extraction is unchanged. Peak memory stays bounded by the zip-entry cap; the
674/// scan never densely materializes anything.
675fn ods_content_xml_well_formed(bytes: &[u8]) -> Result<()> {
676    use quick_xml::events::Event;
677    use quick_xml::reader::Reader;
678
679    let mut archive = open_zip(Cursor::new(bytes), "spreadsheet")?;
680    let mut budget = ExtractionBudget::default();
681    let xml = read_zip_entry(&mut archive, "content.xml", "spreadsheet", &mut budget)?;
682
683    let mut reader = Reader::from_str(&xml);
684    let mut depth: i64 = 0;
685    let mut events = 0usize;
686    let mut in_row = false;
687    let mut row_cells = 0u64;
688    let mut row_repeat = 1u64;
689    let mut logical_rows = 0u64;
690    let mut declared_cells = 0u64;
691    loop {
692        events += 1;
693        if events > MAX_XML_EVENTS {
694            return Err(ExtractError::Parse {
695                format: "spreadsheet",
696                message: format!(
697                    "ODS content.xml exceeds the {MAX_XML_EVENTS}-event parser budget"
698                ),
699            });
700        }
701        match reader.read_event() {
702            // Any structural malformation (including the unclosed `<table:table>`
703            // at EOF, which quick-xml reports as "Unexpected end of xml") is a
704            // typed refusal — never a hang.
705            Err(e) => {
706                return Err(ExtractError::Parse {
707                    format: "spreadsheet",
708                    message: format!("malformed ODS content.xml: {e}"),
709                });
710            }
711            Ok(Event::Start(element)) => {
712                depth += 1;
713                match local_name(element.name().as_ref()) {
714                    b"table-row" => {
715                        in_row = true;
716                        row_cells = 0;
717                        row_repeat = ods_repeat(&element, b"number-rows-repeated")?;
718                        logical_rows = logical_rows.checked_add(row_repeat).ok_or_else(|| {
719                            ExtractError::Parse {
720                                format: "spreadsheet",
721                                message: "ODS repeated-row count overflow".to_string(),
722                            }
723                        })?;
724                        if logical_rows > MAX_SPREADSHEET_CELLS {
725                            return Err(ExtractError::Parse {
726                                format: "spreadsheet",
727                                message: format!(
728                                    "ODS declares {logical_rows} logical rows, over the \
729                                     {MAX_SPREADSHEET_CELLS}-row structural cap"
730                                ),
731                            });
732                        }
733                    }
734                    b"table-cell" | b"covered-table-cell" if in_row => {
735                        row_cells = row_cells
736                            .checked_add(ods_repeat(&element, b"number-columns-repeated")?)
737                            .ok_or_else(|| ExtractError::Parse {
738                                format: "spreadsheet",
739                                message: "ODS repeated-column count overflow".to_string(),
740                            })?;
741                    }
742                    _ => {}
743                }
744            }
745            Ok(Event::Empty(element)) => match local_name(element.name().as_ref()) {
746                b"table-row" => {
747                    let repeated = ods_repeat(&element, b"number-rows-repeated")?;
748                    logical_rows =
749                        logical_rows
750                            .checked_add(repeated)
751                            .ok_or_else(|| ExtractError::Parse {
752                                format: "spreadsheet",
753                                message: "ODS repeated-row count overflow".to_string(),
754                            })?;
755                    if logical_rows > MAX_SPREADSHEET_CELLS {
756                        return Err(ExtractError::Parse {
757                            format: "spreadsheet",
758                            message: format!(
759                                "ODS declares {logical_rows} logical rows, over the \
760                                 {MAX_SPREADSHEET_CELLS}-row structural cap"
761                            ),
762                        });
763                    }
764                }
765                b"table-cell" | b"covered-table-cell" if in_row => {
766                    row_cells = row_cells
767                        .checked_add(ods_repeat(&element, b"number-columns-repeated")?)
768                        .ok_or_else(|| ExtractError::Parse {
769                            format: "spreadsheet",
770                            message: "ODS repeated-column count overflow".to_string(),
771                        })?;
772                }
773                _ => {}
774            },
775            Ok(Event::End(element)) => {
776                depth -= 1;
777                if local_name(element.name().as_ref()) == b"table-row" && in_row {
778                    let expanded =
779                        row_cells
780                            .checked_mul(row_repeat)
781                            .ok_or_else(|| ExtractError::Parse {
782                                format: "spreadsheet",
783                                message: "ODS repeated-cell grid overflow".to_string(),
784                            })?;
785                    declared_cells = declared_cells.checked_add(expanded).ok_or_else(|| {
786                        ExtractError::Parse {
787                            format: "spreadsheet",
788                            message: "ODS declared-cell count overflow".to_string(),
789                        }
790                    })?;
791                    if declared_cells > MAX_SPREADSHEET_CELLS {
792                        return Err(ExtractError::Parse {
793                            format: "spreadsheet",
794                            message: format!(
795                                "ODS declares {declared_cells} expanded cells, over the \
796                                 {MAX_SPREADSHEET_CELLS}-cell cap"
797                            ),
798                        });
799                    }
800                    in_row = false;
801                }
802            }
803            Ok(Event::Eof) => break,
804            _ => {}
805        }
806    }
807
808    // Belt-and-suspenders: even if a quirk let the stream reach EOF with elements
809    // still open, an unbalanced tree is not a document the ODS reader can finish.
810    // Refuse rather than risk the unbounded path.
811    if depth != 0 {
812        return Err(ExtractError::Parse {
813            format: "spreadsheet",
814            message: "malformed ODS content.xml: unbalanced elements (truncated document)"
815                .to_string(),
816        });
817    }
818
819    Ok(())
820}
821
822fn ods_repeat(element: &quick_xml::events::BytesStart<'_>, key: &[u8]) -> Result<u64> {
823    let Some(raw) = attr_value(element, key) else {
824        return Ok(1);
825    };
826    let repeat = raw.parse::<u64>().map_err(|_| ExtractError::Parse {
827        format: "spreadsheet",
828        message: format!(
829            "ODS attribute {} has an invalid repeat count",
830            String::from_utf8_lossy(key)
831        ),
832    })?;
833    if repeat == 0 {
834        return Err(ExtractError::Parse {
835            format: "spreadsheet",
836            message: format!(
837                "ODS attribute {} must be at least 1",
838                String::from_utf8_lossy(key)
839            ),
840        });
841    }
842    Ok(repeat)
843}
844
845/// Compute the would-be dense cell count (`rows × cols`) of one sheet WITHOUT
846/// the dense allocation, by streaming the sheet's sparse cells and tracking the
847/// MIN/MAX non-empty position — exactly the bounds `Range::from_sparse` uses.
848///
849/// Returns `Some(rows * cols)` for the formats that expose a sparse cell
850/// iterator (`.xlsx`/`.xlsm`/`.xlsb`/`.xlam`), which are the realistic
851/// decompression/dimension-bomb vectors (an OOXML/record sheet can place two
852/// cells 1e10 apart in a few hundred bytes). Returns `None` for `.xls` (BIFF,
853/// format-bounded to ≤ 65 536 × 256 ≈ 1.7e7 cells) and `.ods`, neither of which
854/// exposes a sparse iterator on the auto-detected reader; those fall through to
855/// the normal materialization path. A row/col delta is saturated into `u64` so
856/// the multiply cannot overflow.
857fn spreadsheet_dense_cells<RS>(
858    workbook: &mut calamine::Sheets<RS>,
859    name: &str,
860) -> Result<Option<u64>>
861where
862    RS: std::io::Read + std::io::Seek + Clone,
863{
864    use calamine::{DataRef, Sheets};
865
866    // Stream cells, tracking the non-empty MIN/MAX extent that `from_sparse`
867    // would allocate. Empty cells are excluded (calamine drops them before
868    // computing the dense bounds), matching the dense grid exactly.
869    fn extent<E: std::fmt::Display>(
870        mut next: impl FnMut() -> std::result::Result<Option<((u32, u32), bool)>, E>,
871    ) -> Result<Option<u64>> {
872        let (mut r0, mut r1, mut c0, mut c1) = (u32::MAX, 0u32, u32::MAX, 0u32);
873        let mut any = false;
874        loop {
875            match next() {
876                Ok(Some(((r, c), is_empty))) => {
877                    if is_empty {
878                        continue;
879                    }
880                    any = true;
881                    r0 = r0.min(r);
882                    r1 = r1.max(r);
883                    c0 = c0.min(c);
884                    c1 = c1.max(c);
885                }
886                Ok(None) => break,
887                Err(e) => {
888                    return Err(ExtractError::Parse {
889                        format: "spreadsheet",
890                        message: format!("scanning sheet dimensions: {e}"),
891                    })
892                }
893            }
894        }
895        if !any {
896            return Ok(Some(0));
897        }
898        let rows = u64::from(r1 - r0) + 1;
899        let cols = u64::from(c1 - c0) + 1;
900        Ok(Some(rows.saturating_mul(cols)))
901    }
902
903    match workbook {
904        Sheets::Xlsx(xlsx) => {
905            let mut reader =
906                xlsx.worksheet_cells_reader(name)
907                    .map_err(|e| ExtractError::Parse {
908                        format: "spreadsheet",
909                        message: format!("sheet {name:?}: {e}"),
910                    })?;
911            extent(|| {
912                reader.next_cell().map(|opt| {
913                    opt.map(|c| (c.get_position(), matches!(c.get_value(), DataRef::Empty)))
914                })
915            })
916        }
917        Sheets::Xlsb(xlsb) => {
918            let mut reader =
919                xlsb.worksheet_cells_reader(name)
920                    .map_err(|e| ExtractError::Parse {
921                        format: "spreadsheet",
922                        message: format!("sheet {name:?}: {e}"),
923                    })?;
924            extent(|| {
925                reader.next_cell().map(|opt| {
926                    opt.map(|c| (c.get_position(), matches!(c.get_value(), DataRef::Empty)))
927                })
928            })
929        }
930        // `.xls` (BIFF, format-bounded) and `.ods` expose no sparse iterator on
931        // the auto reader; let them materialize normally.
932        Sheets::Xls(_) | Sheets::Ods(_) => Ok(None),
933    }
934}
935
936/// Render one spreadsheet cell to its text form. Whole-valued floats drop the
937/// `.0` (so `3450.0` → `3450`), matching how spreadsheet apps display an
938/// integer-typed amount.
939fn render_cell(cell: &calamine::Data) -> String {
940    use calamine::Data;
941    match cell {
942        Data::Empty => String::new(),
943        Data::String(s) => s.clone(),
944        Data::Int(i) => i.to_string(),
945        Data::Float(f) => {
946            if f.fract() == 0.0 && f.is_finite() && f.abs() < 1e15 {
947                format!("{}", *f as i64)
948            } else {
949                f.to_string()
950            }
951        }
952        Data::Bool(b) => {
953            if *b {
954                "TRUE".to_string()
955            } else {
956                "FALSE".to_string()
957            }
958        }
959        // A date/datetime cell is an Excel SERIAL number (days since the 1900
960        // epoch, fractional part = time of day). `ExcelDateTime`'s `Display`
961        // writes the raw serial (`46188`, `46143.5`), which is meaningless to an
962        // agent filing the value into a record, so render the calendar date
963        // instead. `to_ymd_hms_milli` is available without the `chrono` feature.
964        Data::DateTime(dt) => render_excel_datetime(dt),
965        Data::DateTimeIso(s) => s.clone(),
966        Data::DurationIso(s) => s.clone(),
967        Data::Error(e) => format!("{e:?}"),
968    }
969}
970
971/// Render an Excel serial date/datetime to an ISO calendar string. A pure date
972/// (midnight, no sub-day component) renders `YYYY-MM-DD`; a datetime with a time
973/// component renders `YYYY-MM-DD HH:MM:SS`. A duration (Excel `[hh]:mm:ss`
974/// elapsed-time format) is not a calendar date, so it keeps its raw serial form
975/// (the prior behavior) rather than being misrendered as a date.
976fn render_excel_datetime(dt: &calamine::ExcelDateTime) -> String {
977    // Guard the serial BEFORE calling `to_ymd_hms_milli`. A date cell carries an
978    // arbitrary (attacker-controlled in `sources/`) f64; calamine's conversion is
979    // only defined over its calendar window (~1899-12-31..9999-12-31, i.e. serial
980    // 0..=2_958_465). Outside it, calamine saturates `floor() as u64` and then
981    // overflows on `days += 109_571` — a panic in debug (abort, exit 101) and a
982    // fabricated far-past date in release (`1e308` → `1899-12-29`), both of which
983    // violate the module contract ("never panics on untrusted input, never
984    // hallucinated text"). A duration is likewise not a calendar point. In every
985    // such case keep the raw serial, exactly as the duration branch always did.
986    let serial = dt.as_f64();
987    if dt.is_duration() || !(0.0..=2_958_465.0).contains(&serial) {
988        return serial.to_string();
989    }
990    let (y, mo, d, h, mi, s, _ms) = dt.to_ymd_hms_milli();
991    if h == 0 && mi == 0 && s == 0 {
992        format!("{y:04}-{mo:02}-{d:02}")
993    } else {
994        format!("{y:04}-{mo:02}-{d:02} {h:02}:{mi:02}:{s:02}")
995    }
996}
997
998// ─────────────────────────────────────────────────────────────────────────────
999// EPUB — zip + quick-xml (spine order) + html2text (per-chapter)
1000// ─────────────────────────────────────────────────────────────────────────────
1001//
1002// We do NOT use the `epub` crate: it is GPL-3.0, which violates the toolkit's
1003// permissive-only license rule. An EPUB is a zip whose OPF package declares a
1004// reading-order `spine`; each spine item is an XHTML document. zip + quick-xml
1005// (already dependencies) read the container/OPF, and html2text (already a
1006// dependency for `.html`) flattens each chapter. Same machinery, no GPL.
1007
1008/// Max spine itemrefs an `.epub` may declare before extraction refuses it. The
1009/// spine is attacker-controlled (`parse_opf` pushes every `<itemref>`), so a
1010/// few-KB file can declare millions; this bounds the read loop. Far above any
1011/// real book (which has well under a few hundred reading-order items).
1012const MAX_EPUB_SPINE_ITEMS: usize = 10_000;
1013const MAX_EPUB_MANIFEST_ITEMS: usize = 20_000;
1014const MAX_XML_EVENTS: usize = 1_000_000;
1015
1016/// Hard cap on accumulated extracted-text bytes, shared by every adapter that
1017/// concatenates or materializes a large string from untrusted `sources/` input:
1018/// EPUB chapter concatenation, the HTML/XHTML flattener ([`html_to_text`]), and
1019/// the WordprocessingML run accumulator ([`wordprocessing_text`]). The common
1020/// backstop against output amplification — a long EPUB spine, a renderer
1021/// pathology, or a docx whose `document.xml` inflates to hundreds of MB — so
1022/// extracted text (and stdout) can't balloon without bound. Each adapter checks
1023/// it *during* accumulation, not only at the end, to keep peak memory bounded.
1024/// Far above any real document's flattened text; only hostile/corrupt input hits.
1025const MAX_EXTRACT_OUTPUT_BYTES: usize = 64 * 1024 * 1024;
1026
1027/// Extract an EPUB's reading-order text:
1028/// 1. read `META-INF/container.xml` → the OPF package path;
1029/// 2. parse the OPF `manifest` (id→href) and `spine` (ordered idref list);
1030/// 3. for each spine item, read its XHTML and flatten it with [`html_to_text`];
1031/// 4. join chapters with a blank line.
1032///
1033/// Bounded against spine amplification: the spine length is capped, each
1034/// distinct chapter is rendered at most once (memoized), and the total output is
1035/// capped — so a tiny crafted `.epub` can neither peg a core nor balloon memory.
1036///
1037/// Metadata carries `title` (the OPF `dc:title`) and `chapters` (spine length).
1038fn extract_epub(bytes: &[u8]) -> Result<Extracted> {
1039    let mut archive = open_zip(Cursor::new(bytes), "epub")?;
1040    let mut budget = ExtractionBudget::default();
1041
1042    // 1. container.xml → OPF path.
1043    let container = read_zip_entry(&mut archive, "META-INF/container.xml", "epub", &mut budget)?;
1044    let opf_path = epub_opf_path(&container)?;
1045
1046    // 2. OPF → base dir, manifest, spine, title.
1047    let opf = read_zip_entry(&mut archive, &opf_path, "epub", &mut budget)?;
1048    let parsed = parse_opf(&opf)?;
1049    let base = opf_base_dir(&opf_path);
1050
1051    // Bound the spine length BEFORE the loop: `parse_opf` pushes every
1052    // attacker-controlled `<itemref idref>` verbatim, so a tiny crafted .epub can
1053    // declare millions of items. Even spine entries that render to empty text
1054    // still cost a zip read each, so the output cap below can't bound the loop on
1055    // its own — this guard does. Real books have well under a few hundred items.
1056    if parsed.spine.len() > MAX_EPUB_SPINE_ITEMS {
1057        return Err(ExtractError::Parse {
1058            format: "epub",
1059            message: format!(
1060                "spine declares {} items, exceeding the {} cap",
1061                parsed.spine.len(),
1062                MAX_EPUB_SPINE_ITEMS
1063            ),
1064        });
1065    }
1066
1067    // 3. Spine items in order → flattened chapter text.
1068    let mut text = String::new();
1069    let mut chapters = 0u64;
1070    // Memoize rendered chapters by zip-entry path: a spine that references the
1071    // SAME manifest item repeatedly must re-render it in O(1), not re-decode the
1072    // zip entry and re-flatten its XHTML each time (the dominant CPU cost of the
1073    // spine-amplification DoS — a few-KB file could peg a core indefinitely).
1074    let mut rendered: std::collections::HashMap<String, String> = std::collections::HashMap::new();
1075    for idref in &parsed.spine {
1076        let Some(href) = parsed.manifest.get(idref) else {
1077            continue; // dangling spine ref; skip rather than fail
1078        };
1079        let entry = join_zip_path(&base, href);
1080        let chapter_text = match rendered.get(&entry) {
1081            Some(cached) => cached.clone(),
1082            None => {
1083                // A missing spine target is skipped (best-effort), not fatal.
1084                let Ok(chapter_xhtml) = read_zip_entry(&mut archive, &entry, "epub", &mut budget)
1085                else {
1086                    continue;
1087                };
1088                let t = html_to_text(chapter_xhtml.as_bytes())?;
1089                rendered.insert(entry.clone(), t.clone());
1090                t
1091            }
1092        };
1093        if !chapter_text.trim().is_empty() {
1094            if chapters > 0 {
1095                text.push('\n');
1096            }
1097            text.push_str(&chapter_text);
1098            text.push('\n');
1099            chapters += 1;
1100            // Hard output backstop: a long spine of DISTINCT items, or a near-cap
1101            // chapter referenced many times, must not balloon the extracted text
1102            // (and stdout) without bound.
1103            if text.len() > MAX_EXTRACT_OUTPUT_BYTES {
1104                return Err(ExtractError::Parse {
1105                    format: "epub",
1106                    message: format!(
1107                        "extracted text exceeds the {MAX_EXTRACT_OUTPUT_BYTES} byte cap"
1108                    ),
1109                });
1110            }
1111        }
1112    }
1113
1114    let mut out = Extracted::new(text, Format::Epub);
1115    out.put_num("chapters", chapters);
1116    if let Some(title) = parsed.title {
1117        out.put_str("title", title);
1118    }
1119    Ok(out)
1120}
1121
1122/// The full-path of the OPF package file, read from `META-INF/container.xml`'s
1123/// first `<rootfile full-path="…">`.
1124fn epub_opf_path(container_xml: &str) -> Result<String> {
1125    use quick_xml::events::Event;
1126    use quick_xml::reader::Reader;
1127
1128    let mut reader = Reader::from_str(container_xml);
1129    let mut buf = Vec::new();
1130    loop {
1131        match reader.read_event_into(&mut buf) {
1132            Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
1133                if local_name(e.name().as_ref()) == b"rootfile" {
1134                    if let Some(p) = attr_value(&e, b"full-path") {
1135                        return Ok(p);
1136                    }
1137                }
1138            }
1139            Ok(Event::Eof) => break,
1140            Err(e) => {
1141                return Err(ExtractError::Parse {
1142                    format: "epub",
1143                    message: format!("container.xml: {e}"),
1144                })
1145            }
1146            _ => {}
1147        }
1148        buf.clear();
1149    }
1150    Err(ExtractError::Parse {
1151        format: "epub",
1152        message: "container.xml has no <rootfile full-path>".to_string(),
1153    })
1154}
1155
1156/// The parsed-out pieces of an OPF package we need for reading-order text.
1157struct OpfParsed {
1158    /// Manifest: item id → href (relative to the OPF's directory).
1159    manifest: BTreeMap<String, String>,
1160    /// Spine: ordered list of manifest item ids (the reading order).
1161    spine: Vec<String>,
1162    /// `dc:title`, if present.
1163    title: Option<String>,
1164}
1165
1166/// Parse an OPF package document into its manifest, spine, and title.
1167fn parse_opf(opf_xml: &str) -> Result<OpfParsed> {
1168    use quick_xml::events::Event;
1169    use quick_xml::reader::Reader;
1170
1171    let mut reader = Reader::from_str(opf_xml);
1172    let mut buf = Vec::new();
1173
1174    let mut manifest = BTreeMap::new();
1175    let mut spine = Vec::new();
1176    let mut title: Option<String> = None;
1177    // Whether we are inside the FIRST `<dc:title>` element, and the text we have
1178    // accumulated for it. We accumulate across every Text/GeneralRef/CData event
1179    // until the matching End so an entity, comment, or nested element inside the
1180    // title does not truncate it.
1181    let mut in_title = false;
1182    let mut title_buf = String::new();
1183    let mut events = 0usize;
1184
1185    loop {
1186        events += 1;
1187        if events > MAX_XML_EVENTS {
1188            return Err(ExtractError::Parse {
1189                format: "epub",
1190                message: format!("OPF exceeds the {MAX_XML_EVENTS}-event parser budget"),
1191            });
1192        }
1193        match reader.read_event_into(&mut buf) {
1194            Ok(Event::Start(e)) => match local_name(e.name().as_ref()) {
1195                b"item" => {
1196                    if let (Some(id), Some(href)) = (attr_value(&e, b"id"), attr_value(&e, b"href"))
1197                    {
1198                        if !manifest.contains_key(&id) && manifest.len() >= MAX_EPUB_MANIFEST_ITEMS
1199                        {
1200                            return Err(ExtractError::Parse {
1201                                format: "epub",
1202                                message: format!(
1203                                    "manifest exceeds the {MAX_EPUB_MANIFEST_ITEMS}-item cap"
1204                                ),
1205                            });
1206                        }
1207                        manifest.insert(id, href);
1208                    }
1209                }
1210                b"itemref" => {
1211                    if let Some(idref) = attr_value(&e, b"idref") {
1212                        if spine.len() >= MAX_EPUB_SPINE_ITEMS {
1213                            return Err(ExtractError::Parse {
1214                                format: "epub",
1215                                message: format!(
1216                                    "spine exceeds the {MAX_EPUB_SPINE_ITEMS}-item cap"
1217                                ),
1218                            });
1219                        }
1220                        spine.push(idref);
1221                    }
1222                }
1223                // Only a Start (not a self-closing Empty) opens the title: an
1224                // Empty `<dc:title/>` has no content and produces no End event,
1225                // so latching `in_title` on it would wrongly capture the next
1226                // text node (e.g. the author) as the title.
1227                b"title" if title.is_none() => in_title = true,
1228                _ => {}
1229            },
1230            // Self-closing manifest/spine entries are Empty events; the title is
1231            // never captured from Empty (see the Start arm's note).
1232            Ok(Event::Empty(e)) => match local_name(e.name().as_ref()) {
1233                b"item" => {
1234                    if let (Some(id), Some(href)) = (attr_value(&e, b"id"), attr_value(&e, b"href"))
1235                    {
1236                        if !manifest.contains_key(&id) && manifest.len() >= MAX_EPUB_MANIFEST_ITEMS
1237                        {
1238                            return Err(ExtractError::Parse {
1239                                format: "epub",
1240                                message: format!(
1241                                    "manifest exceeds the {MAX_EPUB_MANIFEST_ITEMS}-item cap"
1242                                ),
1243                            });
1244                        }
1245                        manifest.insert(id, href);
1246                    }
1247                }
1248                b"itemref" => {
1249                    if let Some(idref) = attr_value(&e, b"idref") {
1250                        if spine.len() >= MAX_EPUB_SPINE_ITEMS {
1251                            return Err(ExtractError::Parse {
1252                                format: "epub",
1253                                message: format!(
1254                                    "spine exceeds the {MAX_EPUB_SPINE_ITEMS}-item cap"
1255                                ),
1256                            });
1257                        }
1258                        spine.push(idref);
1259                    }
1260                }
1261                _ => {}
1262            },
1263            Ok(Event::End(e)) => {
1264                if in_title && local_name(e.name().as_ref()) == b"title" {
1265                    in_title = false;
1266                    let s = title_buf.trim();
1267                    if !s.is_empty() {
1268                        title = Some(s.to_string());
1269                    }
1270                }
1271            }
1272            Ok(Event::Text(t)) => {
1273                if in_title {
1274                    title_buf.push_str(&String::from_utf8_lossy(&t.into_inner()));
1275                    if title_buf.len() > 1024 * 1024 {
1276                        return Err(ExtractError::Parse {
1277                            format: "epub",
1278                            message: "OPF title exceeds the 1 MiB metadata cap".to_string(),
1279                        });
1280                    }
1281                }
1282            }
1283            // An entity (`&amp;`) or numeric ref inside the title resolves into
1284            // the accumulated value rather than truncating it.
1285            Ok(Event::GeneralRef(r)) => {
1286                if in_title {
1287                    title_buf.push_str(&resolve_entity_ref(&r));
1288                }
1289            }
1290            // CDATA inside `<dc:title>` is literal title text.
1291            Ok(Event::CData(c)) => {
1292                if in_title {
1293                    title_buf.push_str(&String::from_utf8_lossy(&c.into_inner()));
1294                }
1295            }
1296            Ok(Event::Eof) => break,
1297            Err(e) => {
1298                return Err(ExtractError::Parse {
1299                    format: "epub",
1300                    message: format!("OPF: {e}"),
1301                })
1302            }
1303            _ => {}
1304        }
1305        buf.clear();
1306    }
1307
1308    Ok(OpfParsed {
1309        manifest,
1310        spine,
1311        title,
1312    })
1313}
1314
1315/// The directory portion of an OPF path (`"OEBPS/content.opf"` → `"OEBPS"`,
1316/// `"content.opf"` → `""`), used to resolve manifest hrefs against the OPF's own
1317/// location inside the zip.
1318fn opf_base_dir(opf_path: &str) -> String {
1319    match opf_path.rfind('/') {
1320        Some(i) => opf_path[..i].to_string(),
1321        None => String::new(),
1322    }
1323}
1324
1325/// Join an OPF base dir with a (possibly `./`-prefixed) manifest href into a zip
1326/// entry name. Forward-slash only — zip paths are always `/`-separated.
1327///
1328/// OPF manifest hrefs are URLs: the EPUB spec requires reserved characters
1329/// (spaces, non-ASCII) to be percent-encoded, but zip entry NAMES are raw. So an
1330/// href `my%20chapter.xhtml` must be percent-decoded to `my chapter.xhtml`
1331/// before it can match the zip entry, or the chapter is silently dropped. We
1332/// percent-decode the href and then normalize `.`/`..` segments so a relative
1333/// href like `../text/ch1.xhtml` resolves against the OPF's directory.
1334fn join_zip_path(base: &str, href: &str) -> String {
1335    let decoded = percent_decode(href);
1336    let combined = if base.is_empty() {
1337        decoded
1338    } else {
1339        format!("{base}/{decoded}")
1340    };
1341    normalize_zip_path(&combined)
1342}
1343
1344/// Percent-decode a URL path component (`%20` → space, `%C3%A9` → `é`).
1345/// Decodes byte-by-byte then UTF-8-lossy-reinterprets, so a multi-byte
1346/// percent-encoded codepoint (`%C3%A9`) round-trips. A stray `%` not followed by
1347/// two hex digits is emitted verbatim (best-effort, never a panic).
1348fn percent_decode(s: &str) -> String {
1349    let bytes = s.as_bytes();
1350    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
1351    let mut i = 0;
1352    while i < bytes.len() {
1353        if bytes[i] == b'%' && i + 2 < bytes.len() {
1354            let hi = (bytes[i + 1] as char).to_digit(16);
1355            let lo = (bytes[i + 2] as char).to_digit(16);
1356            if let (Some(hi), Some(lo)) = (hi, lo) {
1357                out.push((hi * 16 + lo) as u8);
1358                i += 3;
1359                continue;
1360            }
1361        }
1362        out.push(bytes[i]);
1363        i += 1;
1364    }
1365    String::from_utf8_lossy(&out).into_owned()
1366}
1367
1368/// Resolve `.` and `..` segments in a `/`-separated zip path so a manifest href
1369/// like `../text/ch1.xhtml` (relative to the OPF's directory) maps to the real
1370/// entry name. A leading `..` that would escape the archive root is dropped
1371/// (zip entries have no parent of the root).
1372fn normalize_zip_path(path: &str) -> String {
1373    let mut out: Vec<&str> = Vec::new();
1374    for seg in path.split('/') {
1375        match seg {
1376            "" | "." => {}
1377            ".." => {
1378                out.pop();
1379            }
1380            other => out.push(other),
1381        }
1382    }
1383    out.join("/")
1384}
1385
1386// ─────────────────────────────────────────────────────────────────────────────
1387// HTML — html2text + light markdown-decoration cleanup
1388// ─────────────────────────────────────────────────────────────────────────────
1389
1390/// Extract plain text from an `.html` file.
1391fn extract_html(bytes: &[u8]) -> Result<Extracted> {
1392    let text = html_to_text(bytes)?;
1393    Ok(Extracted::new(text, Format::Html))
1394}
1395
1396/// Flatten an HTML/XHTML byte stream to clean plain text.
1397///
1398/// Renders with [`PlainContentDecorator`] — `html2text`'s plain renderer driven
1399/// by a decorator that emits **no** link brackets and **no** `#` heading
1400/// markers, while keeping list-item markers (`*` / `N.`). This removes the two
1401/// decorations at the source instead of post-stripping them: the previous
1402/// approach blindly deleted every `[bracketed]` substring and every leading `#`
1403/// run from the rendered text, which also destroyed *literal* content —
1404/// citation markers (`[1]`, `[sic]`), code subscripts (`x[i]`), and ranking
1405/// prose (`#1 in sales`). The renderer knows which `[`/`#` it produced; literal
1406/// brackets and hashes in the source now survive untouched.
1407///
1408/// A very wide wrap width (10_000) is used so paragraphs are not hard-wrapped by
1409/// the renderer; paragraph structure comes from the source's block elements, and
1410/// final layout is canonicalized by [`normalize_text`].
1411fn html_to_text(html: &[u8]) -> Result<String> {
1412    // Bound block-element nesting BEFORE handing the bytes to html2text. The
1413    // layout engine is super-linear in nesting depth (O(depth^2) observed), so a
1414    // tiny crafted file (`<div>`×40_000 …`</div>`×40_000`, ~440 KB) hangs
1415    // extraction for tens of seconds. `sources/` is untrusted, and every other
1416    // adapter bounds its untrusted input (MAX_ZIP_ENTRY_BYTES, MAX_SPREADSHEET_
1417    // CELLS); the HTML path is the lone unbounded one. This is the missing bound.
1418    // A pure byte cap can't distinguish a 440 KB bomb from a 440 KB legitimate
1419    // article, so we bound the structural cause (depth) rather than size. EPUB
1420    // chapters route through here too, so the guard covers them as well.
1421    if let Some(depth) = html_block_nesting_exceeds(html, MAX_HTML_NESTING_DEPTH) {
1422        return Err(ExtractError::Parse {
1423            format: "html",
1424            message: format!(
1425                "HTML block nesting depth exceeds the {MAX_HTML_NESTING_DEPTH} cap (reached {depth}; \
1426                 malformed or hostile input)"
1427            ),
1428        });
1429    }
1430    // Bound table size BEFORE html2text lays the table out. Depth alone misses
1431    // the *width* amplification: a flat `<table><tr><td>x</td>×200_000</tr>` is
1432    // only ~3 deep, so the nesting guard never fires — but html2text lays the row
1433    // out at the 10_000 wrap width and draws full-width U+2500 box rules per row
1434    // boundary, turning a ~2 MB input into multi-GB output and 9 GB+ peak RSS
1435    // (resource-exhaustion DoS on untrusted `sources/` input). The MAX_EXTRACT_
1436    // OUTPUT_BYTES backstop below cannot prevent that spike — html2text has
1437    // already materialized the giant string by the time it's measured. So we
1438    // refuse the layout BEFORE it happens, on the structural cause (table cell
1439    // counts — both single-row width and the overall total), mirroring the
1440    // refuse-before-allocate precedent of MAX_SPREADSHEET_CELLS / MAX_ZIP_ENTRY_
1441    // BYTES. EPUB/xhtml chapters route through here too, so this covers them.
1442    if let Some(bomb) =
1443        html_table_amplification(html, MAX_HTML_TABLE_ROW_CELLS, MAX_HTML_TABLE_CELLS)
1444    {
1445        let message = match bomb {
1446            TableBomb::RowTooWide(width) => format!(
1447                "a table row declares {width} cells, exceeding the \
1448                 {MAX_HTML_TABLE_ROW_CELLS}-cell-per-row cap (malformed or hostile input)"
1449            ),
1450            TableBomb::TooManyCells(total) => format!(
1451                "HTML declares over {total} table cells, exceeding the \
1452                 {MAX_HTML_TABLE_CELLS}-cell cap (malformed or hostile input)"
1453            ),
1454        };
1455        return Err(ExtractError::Parse {
1456            format: "html",
1457            message,
1458        });
1459    }
1460    let text = html2text::config::with_decorator(PlainContentDecorator)
1461        .string_from_read(html, 10_000)
1462        .map_err(|e| ExtractError::Parse {
1463            format: "html",
1464            message: e.to_string(),
1465        })?;
1466    // Hard output backstop. The structural pre-checks above stop the known
1467    // amplifier (wide tables) before the layout pass, but they cannot anticipate
1468    // every renderer pathology; this final byte cap guarantees the HTML path can
1469    // never return (or stream to stdout) more than the same ceiling EPUB enforces,
1470    // independent of *why* the output grew. A real document's flattened text is
1471    // far under 64 MB; only hostile or corrupt input reaches it.
1472    if text.len() > MAX_EXTRACT_OUTPUT_BYTES {
1473        return Err(ExtractError::Parse {
1474            format: "html",
1475            message: format!(
1476                "extracted text exceeds the {MAX_EXTRACT_OUTPUT_BYTES} byte cap \
1477                 (malformed or hostile input)"
1478            ),
1479        });
1480    }
1481    Ok(text)
1482}
1483
1484/// The deepest block-element nesting `html_to_text` tolerates. No legitimate
1485/// document nests containers anywhere near this deep; the cap exists purely to
1486/// refuse the deeply-nested bomb that makes html2text's layout pass run for
1487/// minutes. Set with large headroom so it can only fire on pathological input.
1488const MAX_HTML_NESTING_DEPTH: usize = 4_096;
1489
1490/// Ceiling on the number of cells (`<td>`/`<th>`) in any SINGLE table row before
1491/// extraction refuses the document. This is the primary structural guard against
1492/// the wide-table amplification DoS: html2text lays a table out at the 10_000
1493/// wrap width and draws full-width U+2500 box rules sized to the row, so a flat
1494/// `<td>`×N single row is the worst case — N=200_000 in a ~2 MB file balloons to
1495/// multi-GB output and 9 GB+ peak RSS. *Row width* is what drives the spike (a
1496/// tall narrow table of the same total cell count costs an order of magnitude
1497/// less), so we bound it directly and BEFORE html2text runs — the same
1498/// refuse-before-allocate precedent as MAX_SPREADSHEET_CELLS / MAX_ZIP_ENTRY_BYTES.
1499///
1500/// 4_096 columns is far beyond any real document's table width — a spreadsheet
1501/// export with thousands of columns is already unreadable as flattened text —
1502/// yet keeps the worst-case (all in one row) layout under ~16 MB peak, measured.
1503const MAX_HTML_TABLE_ROW_CELLS: usize = 4_096;
1504
1505/// Ceiling on the TOTAL number of table cells (`<td>`/`<th>`) across the whole
1506/// document. The backstop to [`MAX_HTML_TABLE_ROW_CELLS`] for the *tall* shape:
1507/// even narrow rows, if there are enough of them, grow html2text's layout memory
1508/// roughly linearly in total cells (independent of output size). The row-width
1509/// cap alone wouldn't bound a million-row × few-column table, so this caps the
1510/// aggregate too. Checked in the same single scan, before html2text runs.
1511///
1512/// 200_000 cells is far above any real tabular document (a 20_000-row × 10-column
1513/// table) yet keeps the worst measured tall-table peak under ~450 MB. Set
1514/// generously so it can only fire on pathological input.
1515const MAX_HTML_TABLE_CELLS: usize = 200_000;
1516
1517/// HTML5 void elements — they have no closing tag, so they must NOT increment
1518/// the nesting depth (a document of many sibling `<br>`/`<img>` is flat, not
1519/// deep). Kept lowercase; the scan lowercases the tag name before matching.
1520const HTML_VOID_ELEMENTS: &[&str] = &[
1521    "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source",
1522    "track", "wbr",
1523];
1524
1525/// Scan an HTML byte stream once and return `Some(depth)` if open-tag nesting
1526/// ever exceeds `limit`, else `None`. This is a deliberately crude, allocation-
1527/// free tag scanner — NOT a parser. It tracks only nesting *depth* to bound
1528/// html2text's super-linear layout cost; correctness of the depth count past the
1529/// limit does not matter (we only care whether it is exceeded). Closing tags
1530/// decrement (saturating at 0), void/self-closing tags and comments/doctype/PI
1531/// are ignored, and a `<` not followed by a tag-ish character is treated as
1532/// literal text rather than a tag open (so `a < b` in prose does not inflate it).
1533fn html_block_nesting_exceeds(html: &[u8], limit: usize) -> Option<usize> {
1534    let mut stack: Vec<&[u8]> = Vec::with_capacity(limit.min(256));
1535    let mut i = 0usize;
1536    while let Some(tag) = next_html_tag(html, &mut i) {
1537        if tag.closing {
1538            // An unmatched closing tag must not lower the security counter.
1539            // HTML5 ignores or repairs many mismatches; blindly decrementing on
1540            // any `</x>` let an attacker interleave bogus closes to hide a
1541            // deeply nested tree from this guard.
1542            if stack
1543                .last()
1544                .is_some_and(|open| open.eq_ignore_ascii_case(tag.name))
1545            {
1546                stack.pop();
1547            }
1548            continue;
1549        }
1550        let is_void = std::str::from_utf8(tag.name)
1551            .map(|name| {
1552                HTML_VOID_ELEMENTS
1553                    .iter()
1554                    .any(|void| name.eq_ignore_ascii_case(void))
1555            })
1556            .unwrap_or(false);
1557        if !tag.self_closing && !is_void {
1558            stack.push(tag.name);
1559            if stack.len() > limit {
1560                return Some(stack.len());
1561            }
1562            if skip_raw_text_element(html, &mut i, tag.name) {
1563                stack.pop();
1564            }
1565        }
1566    }
1567    None
1568}
1569
1570/// Why a table-cell pre-check refused an HTML document, with the offending count.
1571/// Returned by [`html_table_amplification`] so the caller can name the exact
1572/// structural cause (row width vs. total cells) in the typed error.
1573enum TableBomb {
1574    /// A single row holds more than [`MAX_HTML_TABLE_ROW_CELLS`] cells — the wide
1575    /// shape that html2text amplifies into multi-GB output. Carries the row width.
1576    RowTooWide(usize),
1577    /// The document holds more than [`MAX_HTML_TABLE_CELLS`] cells in total — the
1578    /// tall shape whose aggregate grows html2text's layout memory. Carries the
1579    /// total count (at the moment the cap was crossed).
1580    TooManyCells(usize),
1581}
1582
1583/// Scan an HTML byte stream once and return `Some(TableBomb)` if its table cells
1584/// would amplify html2text's layout past a safe bound, else `None`. Two bounds
1585/// are checked in the single pass: the max cells in any one `<tr>` (the *width*
1586/// amplifier, the dominant cost) against `row_limit`, and the total cell count
1587/// (the *tall* aggregate) against `total_limit`. Whichever trips first wins.
1588///
1589/// Like [`html_block_nesting_exceeds`] this is a crude, allocation-free tag
1590/// scanner — NOT a parser. It counts cell *opens* (`<td>`/`<th>`); closing tags
1591/// and self-closing forms add no cell. A `<tr>` open resets the per-row counter.
1592/// Comments/doctype/PI are skipped (so a `<td>` inside a comment isn't counted)
1593/// and a stray `<` in prose is ignored. The exact tally past a limit doesn't
1594/// matter, only whether the limit is crossed — so we can early-return.
1595fn html_table_amplification(
1596    html: &[u8],
1597    row_limit: usize,
1598    total_limit: usize,
1599) -> Option<TableBomb> {
1600    let mut total: usize = 0;
1601    let mut row_cells: usize = 0;
1602    let mut i = 0usize;
1603    while let Some(tag) = next_html_tag(html, &mut i) {
1604        if tag.closing {
1605            continue;
1606        }
1607        if tag.name.eq_ignore_ascii_case(b"tr") {
1608            // A new row resets the per-row width tally. (A `<td>` outside any row
1609            // still counts toward both totals; resetting only on `<tr>` is the
1610            // conservative choice — it can never under-count a real row's width.)
1611            row_cells = 0;
1612        } else if tag.name.eq_ignore_ascii_case(b"td") || tag.name.eq_ignore_ascii_case(b"th") {
1613            total += 1;
1614            row_cells += 1;
1615            if row_cells > row_limit {
1616                return Some(TableBomb::RowTooWide(row_cells));
1617            }
1618            if total > total_limit {
1619                return Some(TableBomb::TooManyCells(total));
1620            }
1621        }
1622        let _ = skip_raw_text_element(html, &mut i, tag.name);
1623    }
1624    None
1625}
1626
1627#[derive(Clone, Copy)]
1628struct HtmlTag<'a> {
1629    name: &'a [u8],
1630    closing: bool,
1631    self_closing: bool,
1632}
1633
1634/// Return the next real tag using an allocation-free lexical pass that honors
1635/// quoted attributes and full comment/CDATA bodies. The previous `first '>'`
1636/// scanner could be desynchronized by `data=\"></tr>\"` or `<!-- > ... -->`,
1637/// letting hostile table/depth markup reach html2text uncounted.
1638fn next_html_tag<'a>(html: &'a [u8], cursor: &mut usize) -> Option<HtmlTag<'a>> {
1639    while *cursor < html.len() {
1640        let start = html[*cursor..].iter().position(|byte| *byte == b'<')? + *cursor;
1641        if html[start..].starts_with(b"<!--") {
1642            *cursor = find_bytes(html, start + 4, b"-->").unwrap_or(html.len());
1643            if *cursor < html.len() {
1644                *cursor += 3;
1645            }
1646            continue;
1647        }
1648        if html[start..].starts_with(b"<![CDATA[") {
1649            *cursor = find_bytes(html, start + 9, b"]]>").unwrap_or(html.len());
1650            if *cursor < html.len() {
1651                *cursor += 3;
1652            }
1653            continue;
1654        }
1655
1656        let mut pos = start + 1;
1657        let closing = html.get(pos) == Some(&b'/');
1658        if closing {
1659            pos += 1;
1660        }
1661        while html.get(pos).is_some_and(u8::is_ascii_whitespace) {
1662            pos += 1;
1663        }
1664        if !html.get(pos).is_some_and(u8::is_ascii_alphabetic) {
1665            // Declaration, processing instruction, or literal `<`: skip its
1666            // quote-aware terminator, then keep looking.
1667            *cursor = html_tag_end(html, pos).unwrap_or(html.len());
1668            continue;
1669        }
1670        let name_start = pos;
1671        while html
1672            .get(pos)
1673            .is_some_and(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'-'))
1674        {
1675            pos += 1;
1676        }
1677        let end = html_tag_end(html, pos)?;
1678        let mut before_end = end.saturating_sub(1);
1679        while before_end > start && html[before_end - 1].is_ascii_whitespace() {
1680            before_end -= 1;
1681        }
1682        let self_closing = before_end > start && html[before_end - 1] == b'/';
1683        *cursor = end;
1684        return Some(HtmlTag {
1685            name: &html[name_start..pos],
1686            closing,
1687            self_closing,
1688        });
1689    }
1690    None
1691}
1692
1693fn html_tag_end(html: &[u8], from: usize) -> Option<usize> {
1694    let mut quote: Option<u8> = None;
1695    let mut pos = from;
1696    while pos < html.len() {
1697        match (quote, html[pos]) {
1698            (Some(active), byte) if byte == active => quote = None,
1699            (None, byte @ (b'\'' | b'"')) => quote = Some(byte),
1700            (None, b'>') => return Some(pos + 1),
1701            _ => {}
1702        }
1703        pos += 1;
1704    }
1705    None
1706}
1707
1708fn find_bytes(haystack: &[u8], from: usize, needle: &[u8]) -> Option<usize> {
1709    haystack
1710        .get(from..)?
1711        .windows(needle.len())
1712        .position(|window| window == needle)
1713        .map(|offset| from + offset)
1714}
1715
1716/// HTML raw-text/RCDATA elements do not tokenize `<tr>`/`<td>` strings in their
1717/// contents as markup. Skip directly to the matching closing tag so script or
1718/// style text cannot reset the row counter and bypass the table guard.
1719fn skip_raw_text_element(html: &[u8], cursor: &mut usize, name: &[u8]) -> bool {
1720    if ![b"script".as_slice(), b"style", b"textarea", b"title"]
1721        .iter()
1722        .any(|raw| name.eq_ignore_ascii_case(raw))
1723    {
1724        return false;
1725    }
1726    let mut pos = *cursor;
1727    while let Some(relative) = html[pos..].iter().position(|byte| *byte == b'<') {
1728        let start = pos + relative;
1729        let mut probe = start + 1;
1730        if html.get(probe) != Some(&b'/') {
1731            pos = start + 1;
1732            continue;
1733        }
1734        probe += 1;
1735        while html.get(probe).is_some_and(u8::is_ascii_whitespace) {
1736            probe += 1;
1737        }
1738        let end_name = probe.saturating_add(name.len());
1739        if html
1740            .get(probe..end_name)
1741            .is_some_and(|candidate| candidate.eq_ignore_ascii_case(name))
1742            && html
1743                .get(end_name)
1744                .is_some_and(|byte| byte.is_ascii_whitespace() || matches!(byte, b'>' | b'/'))
1745        {
1746            *cursor = html_tag_end(html, end_name).unwrap_or(html.len());
1747            return true;
1748        }
1749        pos = start + 1;
1750    }
1751    *cursor = html.len();
1752    true
1753}
1754
1755/// A `html2text` decorator that flattens HTML to plain text WITHOUT emitting the
1756/// markup that would otherwise have to be post-stripped: no `[`/`]` around link
1757/// text, no `#` heading prefix, no `^{…}` superscript braces. List-item markers
1758/// (`* ` for unordered, `N. ` for ordered) ARE emitted — they are content-
1759/// faithful and match the corpus convention. Quote prefixes are kept as in the
1760/// stock plain decorator. This is the fix for the literal-content corruption the
1761/// old `strip_markdown_decorations`/`unwrap_brackets` post-pass caused.
1762#[derive(Clone, Debug)]
1763struct PlainContentDecorator;
1764
1765impl html2text::render::TextDecorator for PlainContentDecorator {
1766    type Annotation = ();
1767
1768    fn decorate_link_start(&mut self, _url: &str) -> (String, Self::Annotation) {
1769        (String::new(), ())
1770    }
1771    fn decorate_link_end(&mut self) -> String {
1772        String::new()
1773    }
1774    fn decorate_em_start(&self) -> (String, Self::Annotation) {
1775        (String::new(), ())
1776    }
1777    fn decorate_em_end(&self) -> String {
1778        String::new()
1779    }
1780    fn decorate_strong_start(&self) -> (String, Self::Annotation) {
1781        (String::new(), ())
1782    }
1783    fn decorate_strong_end(&self) -> String {
1784        String::new()
1785    }
1786    fn decorate_strikeout_start(&self) -> (String, Self::Annotation) {
1787        (String::new(), ())
1788    }
1789    fn decorate_strikeout_end(&self) -> String {
1790        String::new()
1791    }
1792    fn decorate_code_start(&self) -> (String, Self::Annotation) {
1793        (String::new(), ())
1794    }
1795    fn decorate_code_end(&self) -> String {
1796        String::new()
1797    }
1798    fn decorate_preformat_first(&self) -> Self::Annotation {}
1799    fn decorate_preformat_cont(&self) -> Self::Annotation {}
1800    fn decorate_image(&mut self, _src: &str, title: &str) -> (String, Self::Annotation) {
1801        // Alt/title text only — no surrounding brackets (the stock plain
1802        // decorator wraps it in `[...]`, which would read as literal content).
1803        (title.to_string(), ())
1804    }
1805    fn header_prefix(&self, _level: usize) -> String {
1806        // No `#` heading marker — heading text reads as plain prose.
1807        String::new()
1808    }
1809    fn quote_prefix(&self) -> String {
1810        "> ".to_string()
1811    }
1812    fn unordered_item_prefix(&self) -> String {
1813        "* ".to_string()
1814    }
1815    fn ordered_item_prefix(&self, i: i64) -> String {
1816        format!("{i}. ")
1817    }
1818    fn decorate_superscript_start(&self) -> (String, Self::Annotation) {
1819        // Plain text: no `^{…}` braces (which would corrupt literal content).
1820        (String::new(), ())
1821    }
1822    fn decorate_superscript_end(&self) -> String {
1823        String::new()
1824    }
1825    fn make_subblock_decorator(&self) -> Self {
1826        PlainContentDecorator
1827    }
1828}
1829
1830/// Strip the residual markdown decorations `html2text`'s plain renderer emits:
1831/// leading run of `#` (ATX heading markers) at the start of a line, and `[...]`
1832/// brackets around link/anchor text (the reference-style `[n]` suffix is already
1833/// gone under `plain_no_decorate`). Bullet (`*`) and ordered (`N.`) markers are
1834/// left intact — they are content, not decoration.
1835///
1836/// No longer used by [`html_to_text`] (the [`PlainContentDecorator`] now removes
1837/// these decorations at the source so literal `[brackets]`/`#hashes` survive);
1838/// retained only for its unit test documenting the old renderer's behavior.
1839#[allow(dead_code)]
1840fn strip_markdown_decorations(text: &str) -> String {
1841    let mut out = String::with_capacity(text.len());
1842    for line in text.lines() {
1843        // Strip a leading "#"-run + the single space after it (ATX heading).
1844        let trimmed = line.trim_start();
1845        let after_hashes = trimmed.trim_start_matches('#');
1846        let line = if after_hashes.len() != trimmed.len() {
1847            // It was a heading line: keep indentation-free heading text.
1848            after_hashes.trim_start()
1849        } else {
1850            line
1851        };
1852        out.push_str(&unwrap_brackets(line));
1853        out.push('\n');
1854    }
1855    out
1856}
1857
1858/// Replace every `[inner]` with `inner` (one pass, non-nested). `html2text`'s
1859/// plain renderer wraps link/anchor text in single brackets; unwrapping yields
1860/// the bare text. Escaped or unmatched brackets are left as-is.
1861///
1862/// No longer used by [`html_to_text`] (see [`strip_markdown_decorations`]);
1863/// retained only for its unit test.
1864#[allow(dead_code)]
1865fn unwrap_brackets(line: &str) -> String {
1866    if !line.contains('[') {
1867        return line.to_string();
1868    }
1869    let mut out = String::with_capacity(line.len());
1870    let mut chars = line.chars().peekable();
1871    while let Some(c) = chars.next() {
1872        if c == '[' {
1873            // Collect until the matching ']'; if none, emit the '[' literally.
1874            let mut inner = String::new();
1875            let mut closed = false;
1876            for d in chars.by_ref() {
1877                if d == ']' {
1878                    closed = true;
1879                    break;
1880                }
1881                inner.push(d);
1882            }
1883            if closed {
1884                out.push_str(&inner);
1885            } else {
1886                out.push('[');
1887                out.push_str(&inner);
1888            }
1889        } else {
1890            out.push(c);
1891        }
1892    }
1893    out
1894}
1895
1896// ─────────────────────────────────────────────────────────────────────────────
1897// Shared zip helpers (docx + epub)
1898// ─────────────────────────────────────────────────────────────────────────────
1899
1900/// Open a zip archive from a reader, mapping any failure to a typed
1901/// [`ExtractError::Parse`] tagged with the calling format.
1902fn open_zip<R: Read + std::io::Seek>(
1903    mut reader: R,
1904    format: &'static str,
1905) -> Result<zip::ZipArchive<R>> {
1906    preflight_zip_directory(&mut reader, format)?;
1907    reader
1908        .seek(SeekFrom::Start(0))
1909        .map_err(|e| ExtractError::Parse {
1910            format,
1911            message: format!("rewinding zip container after preflight: {e}"),
1912        })?;
1913    zip::ZipArchive::new(reader).map_err(|e| ExtractError::Parse {
1914        format,
1915        message: format!("not a valid zip container: {e}"),
1916    })
1917}
1918
1919/// Document ZIPs are small structured containers, not general-purpose backup
1920/// archives. Bound the attacker-controlled central directory before `zip`
1921/// materializes one entry record per member. Per-entry inflation caps alone do
1922/// not help a file with millions of empty members: parsing its central directory
1923/// can exhaust memory before any named document member is opened.
1924const MAX_ZIP_ENTRIES: u16 = 20_000;
1925const MAX_ZIP_CENTRAL_DIRECTORY_BYTES: u32 = 32 * 1024 * 1024;
1926
1927/// Parse the classic EOCD from the bounded tail of a ZIP and reject oversized,
1928/// multi-disk, inconsistent, or ZIP64 containers before `ZipArchive::new`.
1929///
1930/// ZIP64 is deliberately refused for in-process document adapters. The complete
1931/// compressed document is already capped at 128 MiB and legitimate DOCX/XLSX/
1932/// ODS/EPUB files do not need 65k entries or 4-GiB offsets; a ZIP64 sentinel
1933/// here is therefore hostile/corrupt for this surface.
1934fn preflight_zip_directory<R: Read + Seek>(reader: &mut R, format: &'static str) -> Result<()> {
1935    const EOCD_LEN: usize = 22;
1936    const MAX_COMMENT: usize = u16::MAX as usize;
1937
1938    let file_len = reader
1939        .seek(SeekFrom::End(0))
1940        .map_err(|e| ExtractError::Parse {
1941            format,
1942            message: format!("sizing zip container: {e}"),
1943        })?;
1944    let tail_len = usize::try_from(file_len.min((EOCD_LEN + MAX_COMMENT) as u64))
1945        .expect("bounded ZIP tail fits usize");
1946    if tail_len < EOCD_LEN {
1947        return Err(ExtractError::Parse {
1948            format,
1949            message: "not a valid zip container: missing end-of-central-directory".to_string(),
1950        });
1951    }
1952    reader
1953        .seek(SeekFrom::Start(file_len - tail_len as u64))
1954        .map_err(|e| ExtractError::Parse {
1955            format,
1956            message: format!("seeking to zip directory tail: {e}"),
1957        })?;
1958    let mut tail = vec![0u8; tail_len];
1959    reader
1960        .read_exact(&mut tail)
1961        .map_err(|e| ExtractError::Parse {
1962            format,
1963            message: format!("reading zip directory tail: {e}"),
1964        })?;
1965
1966    let eocd = (0..=tail_len - EOCD_LEN).rev().find(|&offset| {
1967        tail[offset..].starts_with(b"PK\x05\x06")
1968            && offset
1969                + EOCD_LEN
1970                + usize::from(u16::from_le_bytes([tail[offset + 20], tail[offset + 21]]))
1971                == tail_len
1972    });
1973    let Some(offset) = eocd else {
1974        return Err(ExtractError::Parse {
1975            format,
1976            message: "not a valid zip container: missing end-of-central-directory".to_string(),
1977        });
1978    };
1979    let u16_at = |position: usize| u16::from_le_bytes([tail[position], tail[position + 1]]);
1980    let u32_at = |position: usize| {
1981        u32::from_le_bytes([
1982            tail[position],
1983            tail[position + 1],
1984            tail[position + 2],
1985            tail[position + 3],
1986        ])
1987    };
1988    let disk = u16_at(offset + 4);
1989    let central_disk = u16_at(offset + 6);
1990    let entries_on_disk = u16_at(offset + 8);
1991    let entries = u16_at(offset + 10);
1992    let central_size = u32_at(offset + 12);
1993    let central_offset = u32_at(offset + 16);
1994
1995    if disk != 0 || central_disk != 0 || entries_on_disk != entries {
1996        return Err(ExtractError::Parse {
1997            format,
1998            message: "multi-disk zip containers are not accepted".to_string(),
1999        });
2000    }
2001    if entries == u16::MAX || central_size == u32::MAX || central_offset == u32::MAX {
2002        return Err(ExtractError::Parse {
2003            format,
2004            message: "ZIP64 document containers are not accepted".to_string(),
2005        });
2006    }
2007    if entries > MAX_ZIP_ENTRIES {
2008        return Err(ExtractError::Parse {
2009            format,
2010            message: format!(
2011                "zip central directory declares {entries} entries, over the {MAX_ZIP_ENTRIES}-entry cap"
2012            ),
2013        });
2014    }
2015    if central_size > MAX_ZIP_CENTRAL_DIRECTORY_BYTES {
2016        return Err(ExtractError::Parse {
2017            format,
2018            message: format!(
2019                "zip central directory declares {central_size} bytes, over the \
2020                 {MAX_ZIP_CENTRAL_DIRECTORY_BYTES}-byte cap"
2021            ),
2022        });
2023    }
2024    let eocd_absolute = file_len - tail_len as u64 + offset as u64;
2025    let central_end = u64::from(central_offset)
2026        .checked_add(u64::from(central_size))
2027        .ok_or_else(|| ExtractError::Parse {
2028            format,
2029            message: "zip central-directory bounds overflow".to_string(),
2030        })?;
2031    if central_end > eocd_absolute {
2032        return Err(ExtractError::Parse {
2033            format,
2034            message: "zip central directory extends beyond its end record".to_string(),
2035        });
2036    }
2037    Ok(())
2038}
2039
2040/// Cap on a single decompressed zip entry. docx/epub members are XML text — a
2041/// member that inflates past this ceiling is a decompression bomb or corruption,
2042/// not real evidence. `sources/` is untrusted input, so bound the read rather
2043/// than let `read_to_end` follow a hostile DEFLATE stream until OOM.
2044const MAX_ZIP_ENTRY_BYTES: u64 = 32 * 1024 * 1024;
2045const MAX_ZIP_INFLATED_BYTES: u64 = 64 * 1024 * 1024;
2046
2047#[derive(Default)]
2048struct ExtractionBudget {
2049    inflated_bytes: u64,
2050}
2051
2052impl ExtractionBudget {
2053    fn charge_inflated(&mut self, bytes: u64, format: &'static str) -> Result<()> {
2054        self.inflated_bytes =
2055            self.inflated_bytes
2056                .checked_add(bytes)
2057                .ok_or_else(|| ExtractError::Parse {
2058                    format,
2059                    message: "aggregate inflated-byte budget overflow".to_string(),
2060                })?;
2061        if self.inflated_bytes > MAX_ZIP_INFLATED_BYTES {
2062            return Err(ExtractError::Parse {
2063                format,
2064                message: format!(
2065                    "document inflates to over the {MAX_ZIP_INFLATED_BYTES}-byte aggregate cap"
2066                ),
2067            });
2068        }
2069        Ok(())
2070    }
2071}
2072
2073/// Read a single zip entry to a UTF-8 string, bounded by [`MAX_ZIP_ENTRY_BYTES`]
2074/// so a zip-bomb member cannot exhaust memory. A missing entry, an over-cap
2075/// entry, or a read failure is a typed [`ExtractError::Parse`]; invalid UTF-8 is
2076/// lossily decoded (OOXML / XHTML are declared UTF-8, but we never panic on a
2077/// stray byte).
2078fn read_zip_entry<R: Read + std::io::Seek>(
2079    archive: &mut zip::ZipArchive<R>,
2080    name: &str,
2081    format: &'static str,
2082    budget: &mut ExtractionBudget,
2083) -> Result<String> {
2084    let entry = archive.by_name(name).map_err(|e| ExtractError::Parse {
2085        format,
2086        message: format!("missing zip entry {name:?}: {e}"),
2087    })?;
2088    // Reject up front when the central directory declares an over-cap size...
2089    let declared = entry.size();
2090    if declared > MAX_ZIP_ENTRY_BYTES {
2091        return Err(ExtractError::Parse {
2092            format,
2093            message: format!(
2094                "zip entry {name:?} declares {declared} bytes, over the {MAX_ZIP_ENTRY_BYTES}-byte cap"
2095            ),
2096        });
2097    }
2098    budget.charge_inflated(declared, format)?;
2099    // ...and bound the actual decompressed read so a lying header (a bomb that
2100    // understates its uncompressed size) still cannot allocate past the cap.
2101    let mut bytes = Vec::new();
2102    entry
2103        .take(MAX_ZIP_ENTRY_BYTES + 1)
2104        .read_to_end(&mut bytes)
2105        .map_err(|e| ExtractError::Parse {
2106            format,
2107            message: format!("reading {name:?}: {e}"),
2108        })?;
2109    if bytes.len() as u64 > MAX_ZIP_ENTRY_BYTES {
2110        return Err(ExtractError::Parse {
2111            format,
2112            message: format!(
2113                "zip entry {name:?} exceeds the {MAX_ZIP_ENTRY_BYTES}-byte cap (decompression bomb?)"
2114            ),
2115        });
2116    }
2117    // A lying header may declare fewer bytes than the stream produces. Charge
2118    // the delta after the bounded read so the aggregate cap follows actual
2119    // inflation without double-counting the declared portion.
2120    if bytes.len() as u64 > declared {
2121        budget.charge_inflated(bytes.len() as u64 - declared, format)?;
2122    }
2123    Ok(String::from_utf8_lossy(&bytes).into_owned())
2124}
2125
2126/// Look up a start/empty element's attribute value by local name, returning it
2127/// unescaped as an owned `String`. Prefix-agnostic on the attribute key.
2128fn attr_value(elem: &quick_xml::events::BytesStart<'_>, key: &[u8]) -> Option<String> {
2129    elem.attributes().flatten().find_map(|attr| {
2130        if local_name(attr.key.as_ref()) == key {
2131            let encoded = std::str::from_utf8(attr.value.as_ref()).ok()?;
2132            quick_xml::escape::unescape(encoded)
2133                .ok()
2134                .map(|cow| cow.into_owned())
2135        } else {
2136            None
2137        }
2138    })
2139}
2140
2141#[cfg(test)]
2142mod tests {
2143    use super::*;
2144    use std::path::PathBuf;
2145
2146    #[test]
2147    fn extract_refuses_oversized_sparse_input_before_adapter_allocation() {
2148        let dir = tempfile::tempdir().unwrap();
2149        let path = dir.path().join("hostile.pdf");
2150        let file = std::fs::File::create(&path).unwrap();
2151        file.set_len(MAX_DOCUMENT_INPUT_BYTES + 1).unwrap();
2152
2153        let err = extract(&path).unwrap_err();
2154        assert!(
2155            matches!(err, ExtractError::Parse { format: "pdf", .. }),
2156            "oversized document must fail at the metadata gate: {err:?}"
2157        );
2158    }
2159
2160    #[cfg(unix)]
2161    #[test]
2162    fn extract_refuses_symlink_input_instead_of_reopening_its_target() {
2163        use std::os::unix::fs::symlink;
2164
2165        let dir = tempfile::tempdir().unwrap();
2166        let secret = dir.path().join("secret.pdf");
2167        std::fs::write(&secret, b"not actually a pdf; still private").unwrap();
2168        let selected = dir.path().join("selected.pdf");
2169        symlink(&secret, &selected).unwrap();
2170
2171        let error = extract(&selected).expect_err("document input symlinks must fail closed");
2172        assert!(matches!(error, ExtractError::Io(_)), "got {error:?}");
2173    }
2174
2175    fn classic_eocd(entries: u16, central_size: u32, central_offset: u32) -> Vec<u8> {
2176        let mut bytes = Vec::with_capacity(22);
2177        bytes.extend_from_slice(b"PK\x05\x06");
2178        bytes.extend_from_slice(&0u16.to_le_bytes()); // this disk
2179        bytes.extend_from_slice(&0u16.to_le_bytes()); // central directory disk
2180        bytes.extend_from_slice(&entries.to_le_bytes());
2181        bytes.extend_from_slice(&entries.to_le_bytes());
2182        bytes.extend_from_slice(&central_size.to_le_bytes());
2183        bytes.extend_from_slice(&central_offset.to_le_bytes());
2184        bytes.extend_from_slice(&0u16.to_le_bytes()); // comment length
2185        bytes
2186    }
2187
2188    #[test]
2189    fn zip_preflight_accepts_a_bounded_classic_directory() {
2190        let mut archive = Cursor::new(classic_eocd(0, 0, 0));
2191        preflight_zip_directory(&mut archive, "docx").unwrap();
2192    }
2193
2194    #[test]
2195    fn zip_preflight_rejects_entry_count_before_zip_allocates_records() {
2196        let mut archive = Cursor::new(classic_eocd(MAX_ZIP_ENTRIES + 1, 0, 0));
2197        let error = preflight_zip_directory(&mut archive, "docx")
2198            .expect_err("hostile central-directory count must be refused");
2199        assert!(
2200            matches!(error, ExtractError::Parse { format: "docx", ref message }
2201                if message.contains("entry cap")),
2202            "got {error:?}"
2203        );
2204    }
2205
2206    #[test]
2207    fn zip_preflight_rejects_declared_central_directory_size_before_allocation() {
2208        let mut archive = Cursor::new(classic_eocd(1, MAX_ZIP_CENTRAL_DIRECTORY_BYTES + 1, 0));
2209        let error = preflight_zip_directory(&mut archive, "epub")
2210            .expect_err("hostile central-directory size must be refused");
2211        assert!(
2212            matches!(error, ExtractError::Parse { format: "epub", ref message }
2213                if message.contains("byte cap")),
2214            "got {error:?}"
2215        );
2216    }
2217
2218    #[test]
2219    fn zip_preflight_rejects_zip64_sentinels() {
2220        let mut archive = Cursor::new(classic_eocd(u16::MAX, u32::MAX, u32::MAX));
2221        let error = preflight_zip_directory(&mut archive, "spreadsheet")
2222            .expect_err("ZIP64 documents are outside the bounded adapter contract");
2223        assert!(
2224            matches!(error, ExtractError::Parse { format: "spreadsheet", ref message }
2225                if message.contains("ZIP64")),
2226            "got {error:?}"
2227        );
2228    }
2229
2230    /// Absolute path to a corpus-c-formats fixture under `sources/docs/`.
2231    fn fixture(name: &str) -> PathBuf {
2232        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2233            .join("../../tests/corpora/corpus-c-formats/sources/docs")
2234            .join(name)
2235    }
2236
2237    /// Read the known-good `.txt` sibling of a fixture.
2238    fn expected(name: &str) -> String {
2239        std::fs::read_to_string(fixture(&format!("{name}.txt"))).unwrap()
2240    }
2241
2242    /// Token-level normalization: collapse every run of whitespace (incl.
2243    /// newlines) to one space and trim. This is the corpus's recommended,
2244    /// layout-agnostic comparison ("same words, same order").
2245    fn tokens(s: &str) -> String {
2246        s.split_whitespace().collect::<Vec<_>>().join(" ")
2247    }
2248
2249    /// The sorted set of non-blank, token-normalized lines — order-agnostic
2250    /// content comparison (used where extractor reading-order legitimately
2251    /// differs, e.g. multi-column PDF).
2252    fn line_set(s: &str) -> Vec<String> {
2253        let mut v: Vec<String> = s.lines().map(tokens).filter(|l| !l.is_empty()).collect();
2254        v.sort();
2255        v
2256    }
2257
2258    // ── untrusted-input guards (adversarial review) ──────────────────────────
2259
2260    /// A crafted spreadsheet date cell carries an arbitrary f64 serial. An
2261    /// out-of-range serial must NOT panic (debug `attempt to add with overflow`)
2262    /// and must NOT fabricate a calendar date (release `1e308` → `1899-12-29`);
2263    /// it keeps the raw serial, exactly like the duration fallback.
2264    #[test]
2265    fn excel_datetime_out_of_range_serial_stays_raw_and_never_panics() {
2266        use calamine::{ExcelDateTime, ExcelDateTimeType};
2267        // In-range serial → a real calendar date (contains a `-`).
2268        let in_range = render_excel_datetime(&ExcelDateTime::new(
2269            46_188.0,
2270            ExcelDateTimeType::DateTime,
2271            false,
2272        ));
2273        assert!(
2274            in_range.contains('-'),
2275            "an in-range serial should render a calendar date, got {in_range}"
2276        );
2277        // Out-of-range / hostile serials keep the raw serial string, no panic.
2278        for serial in [1e308_f64, 3_000_000.0, 9e18, -5.0] {
2279            let out = render_excel_datetime(&ExcelDateTime::new(
2280                serial,
2281                ExcelDateTimeType::DateTime,
2282                false,
2283            ));
2284            assert_eq!(
2285                out,
2286                serial.to_string(),
2287                "out-of-range serial {serial} must stay raw, got {out}"
2288            );
2289        }
2290    }
2291
2292    /// The HTML adapter's block-nesting guard refuses a deeply-nested bomb (the
2293    /// O(depth^2) html2text blowup) while passing flat documents — including ones
2294    /// with tens of thousands of sibling VOID elements (which must not count as
2295    /// depth) and prose containing a literal `<`.
2296    #[test]
2297    fn html_nesting_guard_refuses_deep_bomb_passes_flat() {
2298        let deep = format!(
2299            "<html><body>{}x{}</body></html>",
2300            "<div>".repeat(8_000),
2301            "</div>".repeat(8_000)
2302        );
2303        assert!(
2304            html_block_nesting_exceeds(deep.as_bytes(), MAX_HTML_NESTING_DEPTH).is_some(),
2305            "an 8000-deep nest must trip the guard"
2306        );
2307        assert!(
2308            html_to_text(deep.as_bytes()).is_err(),
2309            "html_to_text must refuse the bomb (typed error), not hang"
2310        );
2311
2312        let flat = format!("<html><body>{}</body></html>", "<br>".repeat(50_000));
2313        assert!(
2314            html_block_nesting_exceeds(flat.as_bytes(), MAX_HTML_NESTING_DEPTH).is_none(),
2315            "50k sibling void <br> are flat, not deep — must pass"
2316        );
2317
2318        let normal =
2319            "<html><body><div><p>hi <a href=\"u\">link</a>; a < b in prose</p></div></body></html>";
2320        assert!(
2321            html_block_nesting_exceeds(normal.as_bytes(), MAX_HTML_NESTING_DEPTH).is_none(),
2322            "ordinary nesting (and a stray `<`) must pass"
2323        );
2324        assert!(
2325            html_to_text(normal.as_bytes()).is_ok(),
2326            "a normal document must still flatten fine"
2327        );
2328    }
2329
2330    #[test]
2331    fn regression_html_self_closing_non_void_is_flat_not_deep() {
2332        // Adversarial review #17: a self-closing NON-void element (`<div/>`,
2333        // `<section />`) is flat, not a nesting increment. The off-by-one read the
2334        // `>` byte (always present) instead of the `/` (at end-2), so the
2335        // self-closing check was dead and N such elements miscounted as depth N,
2336        // falsely tripping the cap on a valid, flat document (XHTML/EPUB chapters
2337        // commonly self-close).
2338        let flat = "<div/>".repeat(MAX_HTML_NESTING_DEPTH + 1000);
2339        assert!(
2340            html_block_nesting_exceeds(flat.as_bytes(), MAX_HTML_NESTING_DEPTH).is_none(),
2341            "a flat run of self-closing <div/> must not trip the nesting cap"
2342        );
2343        let spaced = "<section />".repeat(MAX_HTML_NESTING_DEPTH + 1000);
2344        assert!(
2345            html_block_nesting_exceeds(spaced.as_bytes(), MAX_HTML_NESTING_DEPTH).is_none(),
2346            "`<section />` (space before slash) is self-closing too"
2347        );
2348        // Defense intact: genuine deep nesting of the SAME tag still trips it.
2349        let deep = "<div>".repeat(MAX_HTML_NESTING_DEPTH + 1);
2350        assert!(
2351            html_block_nesting_exceeds(deep.as_bytes(), MAX_HTML_NESTING_DEPTH).is_some(),
2352            "real deep nesting must still trip the cap"
2353        );
2354    }
2355
2356    /// The table scanner counts `<td>`/`<th>` opens, ignores closing and
2357    /// commented-out cells, resets the per-row tally on `<tr>`, and reports the
2358    /// right bomb variant (row-too-wide vs. too-many-cells). Small-limit probes
2359    /// keep the test fast.
2360    #[test]
2361    fn html_table_scanner_counts_cells_and_classifies_shape() {
2362        // 5 real cells (td + th, case-insensitive) in ONE row; the commented cell
2363        // and the closing tags must NOT be counted.
2364        let one_row = b"<table><tr><td>a</td><TH>b</TH><td>c</td>\
2365<!-- <td>x</td> --><td>d</td><td>e</td></tr></table>";
2366        // Row-width cap of 4 trips on the 5-wide row.
2367        assert!(
2368            matches!(
2369                html_table_amplification(one_row, 4, 1000),
2370                Some(TableBomb::RowTooWide(w)) if w == 5
2371            ),
2372            "a 5-wide row must trip the row-width cap as RowTooWide(5)"
2373        );
2374        // Generous row cap, generous total → no bomb (commented cell not counted).
2375        assert!(
2376            html_table_amplification(one_row, 100, 100).is_none(),
2377            "5 cells under both caps must not fire"
2378        );
2379
2380        // Many narrow rows: width stays at 1, total accumulates → TooManyCells.
2381        let tall: String = "<table>".to_string() + &"<tr><td>x</td></tr>".repeat(20) + "</table>";
2382        assert!(
2383            matches!(
2384                html_table_amplification(tall.as_bytes(), 100, 10),
2385                Some(TableBomb::TooManyCells(t)) if t == 11
2386            ),
2387            "20 single-cell rows must trip the total cap at 11 (width stays under)"
2388        );
2389
2390        // A document with no tables never trips it.
2391        assert!(
2392            html_table_amplification(b"<p>plain prose, a < b</p>", 0, 0).is_none(),
2393            "no table cells means the scanner never fires"
2394        );
2395    }
2396
2397    #[test]
2398    fn html_guards_cannot_be_desynchronized_by_quoted_gt_or_comment_markup() {
2399        // `>` and a fake closing row inside an attribute are data, not tokens.
2400        // The old first-`>` scanner ended the `<td ...>` early and then treated
2401        // `</tr>` inside the quoted value as markup, resetting/undercounting the
2402        // real row that follows.
2403        let quoted = br#"<table><tr><td data="></tr>">a</td><td>b</td><td>c</td></tr></table>"#;
2404        assert!(matches!(
2405            html_table_amplification(quoted, 2, 100),
2406            Some(TableBomb::RowTooWide(3))
2407        ));
2408
2409        // A `>` does not terminate an HTML comment; fake tags after it remain
2410        // commented out until `-->`.
2411        let commented = b"<table><tr><!-- > <tr><td>fake</td> --><td>a</td><td>b</td></tr></table>";
2412        assert!(matches!(
2413            html_table_amplification(commented, 1, 100),
2414            Some(TableBomb::RowTooWide(2))
2415        ));
2416
2417        // Raw script text is not parsed as table markup by HTML5. It therefore
2418        // cannot inject fake `<tr>` resets between real cells.
2419        let script =
2420            b"<table><tr><td>a</td><script>\"<tr><td>fake</td>\"</script><td>b</td></tr></table>";
2421        assert!(matches!(
2422            html_table_amplification(script, 1, 100),
2423            Some(TableBomb::RowTooWide(2))
2424        ));
2425
2426        // Bogus closing tags must not lower the nesting counter.
2427        let mut depth_bypass = String::new();
2428        for _ in 0..=MAX_HTML_NESTING_DEPTH {
2429            depth_bypass.push_str("<div></bogus>");
2430        }
2431        assert!(
2432            html_block_nesting_exceeds(depth_bypass.as_bytes(), MAX_HTML_NESTING_DEPTH).is_some()
2433        );
2434    }
2435
2436    /// The wide-table amplification bomb (HIGH DoS): a tiny flat `<td>`×N row
2437    /// makes html2text emit gigantic U+2500 box rules (multi-GB output, 9 GB+
2438    /// RSS) from a ~MB input. The row-width pre-check refuses it BEFORE the
2439    /// layout pass — fast, typed, never materializing the giant string — while a
2440    /// normal small table still extracts intact (no regression).
2441    #[test]
2442    fn regression_html_wide_table_bomb_is_refused_small_table_ok() {
2443        // Just over the per-row width cap in a single row — the exact shape of the
2444        // real exploit (a flat `<td>`×N row), kept small enough that the test is
2445        // fast precisely BECAUSE the pre-check refuses before html2text runs.
2446        let cells = MAX_HTML_TABLE_ROW_CELLS + 10;
2447        let bomb = format!(
2448            "<html><body><table><tr>{}</tr></table></body></html>",
2449            "<td>x</td>".repeat(cells)
2450        );
2451        // The pre-check fires; html2text is never reached, so no giant string is
2452        // materialized (the test would OOM/hang otherwise).
2453        assert!(
2454            matches!(
2455                html_table_amplification(
2456                    bomb.as_bytes(),
2457                    MAX_HTML_TABLE_ROW_CELLS,
2458                    MAX_HTML_TABLE_CELLS
2459                ),
2460                Some(TableBomb::RowTooWide(_))
2461            ),
2462            "an over-cap wide row must trip the scanner as RowTooWide"
2463        );
2464        let err = html_to_text(bomb.as_bytes()).unwrap_err();
2465        assert!(
2466            matches!(&err, ExtractError::Parse { format, message }
2467                if *format == "html" && message.contains("cell-per-row")),
2468            "the wide-table bomb must be refused with a typed row-width error; got {err:?}"
2469        );
2470        assert_eq!(err.code(), "EXTRACT_PARSE_ERROR");
2471
2472        // A tall table whose TOTAL cells exceed the aggregate cap is also refused
2473        // (narrow rows, but too many of them) — bounding the other shape.
2474        let rows = MAX_HTML_TABLE_CELLS / 2 + 5; // 2 cells/row, just over the total cap
2475        let tall = format!(
2476            "<html><body><table>{}</table></body></html>",
2477            "<tr><td>a</td><td>b</td></tr>".repeat(rows)
2478        );
2479        let err = html_to_text(tall.as_bytes()).unwrap_err();
2480        assert!(
2481            matches!(&err, ExtractError::Parse { message, .. } if message.contains("table cells")),
2482            "an over-cap tall table must be refused with the total-cell error; got {err:?}"
2483        );
2484
2485        // A normal small table still extracts its cell content cleanly.
2486        let ok = "<html><body><table>\
2487<tr><td>Name</td><td>Amount</td></tr>\
2488<tr><td>Acme</td><td>1200</td></tr></table></body></html>";
2489        let out = html_to_text(ok.as_bytes()).unwrap();
2490        for token in ["Name", "Amount", "Acme", "1200"] {
2491            assert!(
2492                out.contains(token),
2493                "small table must keep {token:?}, got {out:?}"
2494            );
2495        }
2496        // And the output is far under the byte cap.
2497        assert!(
2498            out.len() < MAX_EXTRACT_OUTPUT_BYTES,
2499            "a 2x2 table must not approach the output cap (got {} bytes)",
2500            out.len()
2501        );
2502    }
2503
2504    /// Build an `.epub` whose single chapter body is `chapter_body` (spliced
2505    /// inside `<body>…</body>`). Lets a test exercise a hostile chapter shape
2506    /// (e.g. a wide table) through the real EPUB → html_to_text path.
2507    fn write_epub_with_chapter_body(dest: &Path, chapter_body: &str) {
2508        use std::io::Write;
2509        let container = "<?xml version=\"1.0\"?>\
2510<container version=\"1.0\" xmlns=\"urn:oasis:names:tc:opendocument:xmlns:container\">\
2511<rootfiles><rootfile full-path=\"OEBPS/content.opf\" \
2512media-type=\"application/oebps-package+xml\"/></rootfiles></container>";
2513        let opf = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\
2514<package xmlns=\"http://www.idpf.org/2007/opf\" version=\"3.0\" unique-identifier=\"id\">\
2515<metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\"><dc:title>Wide</dc:title></metadata>\
2516<manifest><item id=\"c1\" href=\"chapter.xhtml\" media-type=\"application/xhtml+xml\"/></manifest>\
2517<spine><itemref idref=\"c1\"/></spine></package>";
2518        let chapter = format!(
2519            "<?xml version=\"1.0\" encoding=\"utf-8\"?>\
2520<html xmlns=\"http://www.w3.org/1999/xhtml\"><body>{chapter_body}</body></html>"
2521        );
2522        let file = std::fs::File::create(dest).unwrap();
2523        let mut writer = zip::ZipWriter::new(file);
2524        let stored = zip::write::SimpleFileOptions::default()
2525            .compression_method(zip::CompressionMethod::Stored);
2526        writer.start_file("mimetype", stored).unwrap();
2527        writer.write_all(b"application/epub+zip").unwrap();
2528        writer.start_file("META-INF/container.xml", stored).unwrap();
2529        writer.write_all(container.as_bytes()).unwrap();
2530        writer.start_file("OEBPS/content.opf", stored).unwrap();
2531        writer.write_all(opf.as_bytes()).unwrap();
2532        writer.start_file("OEBPS/chapter.xhtml", stored).unwrap();
2533        writer.write_all(chapter.as_bytes()).unwrap();
2534        writer.finish().unwrap();
2535    }
2536
2537    /// An EPUB chapter that is itself a wide-table bomb routes through
2538    /// `html_to_text` and must be refused with the same typed table-cell error,
2539    /// before any giant chapter string is materialized — so EPUB peak memory
2540    /// stays bounded per chapter, not just at the final concatenation check.
2541    #[test]
2542    fn regression_epub_wide_table_chapter_is_refused() {
2543        let tmp = tempfile::TempDir::new().unwrap();
2544        let bomb = tmp.path().join("wide.epub");
2545        let body = format!(
2546            "<table><tr>{}</tr></table>",
2547            "<td>x</td>".repeat(MAX_HTML_TABLE_ROW_CELLS + 10)
2548        );
2549        write_epub_with_chapter_body(&bomb, &body);
2550        let err = extract(&bomb).unwrap_err();
2551        assert!(
2552            matches!(&err, ExtractError::Parse { message, .. } if message.contains("cell-per-row")),
2553            "a wide-table EPUB chapter must be refused with the row-width error; got {err:?}"
2554        );
2555
2556        // A normal EPUB chapter with a small table still extracts.
2557        let ok = tmp.path().join("ok.epub");
2558        write_epub_with_chapter_body(
2559            &ok,
2560            "<p>Chapter one.</p><table><tr><td>Cell A</td><td>Cell B</td></tr></table>",
2561        );
2562        let got = extract(&ok).unwrap();
2563        assert_eq!(got.metadata["chapters"], MetaValue::Num(1));
2564        assert!(
2565            got.text.contains("Cell A") && got.text.contains("Cell B"),
2566            "small EPUB table must extract, got {:?}",
2567            got.text
2568        );
2569    }
2570
2571    /// A `.docx` whose `word/document.xml` expands to an enormous run of `<w:t>`
2572    /// text must be refused by the output-byte cap during accumulation (docx
2573    /// parity with HTML/EPUB), while a normal docx extracts unchanged.
2574    #[test]
2575    fn regression_docx_oversized_text_is_bounded() {
2576        let tmp = tempfile::TempDir::new().unwrap();
2577        let bomb = tmp.path().join("huge.docx");
2578        // One paragraph whose single run holds > MAX_EXTRACT_OUTPUT_BYTES of text.
2579        // (Built as one big string so the body XML itself is the amplified input;
2580        // a real exploit relies on zip deflate to ship this compactly.)
2581        let big = "A".repeat(MAX_EXTRACT_OUTPUT_BYTES + 1024);
2582        let body = format!("<w:p><w:r><w:t>{big}</w:t></w:r></w:p>");
2583        write_docx(&bomb, &body);
2584        let err = extract(&bomb).unwrap_err();
2585        assert!(
2586            matches!(&err, ExtractError::Parse { format, message }
2587                if *format == "docx" && message.contains("byte cap")),
2588            "an oversized docx must be refused with the output-cap error; got {err:?}"
2589        );
2590
2591        // A normal docx still extracts intact (no regression).
2592        let ok = tmp.path().join("ok.docx");
2593        write_docx(
2594            &ok,
2595            "<w:p><w:r><w:t>Quarterly report total 1200.</w:t></w:r></w:p>",
2596        );
2597        let got = extract(&ok).unwrap();
2598        assert_eq!(got.text, "Quarterly report total 1200.\n");
2599    }
2600
2601    // ── format detection ────────────────────────────────────────────────────
2602
2603    #[test]
2604    fn detects_format_by_extension_case_insensitively() {
2605        assert_eq!(Format::from_path(Path::new("a.pdf")), Some(Format::Pdf));
2606        assert_eq!(Format::from_path(Path::new("a.PDF")), Some(Format::Pdf));
2607        assert_eq!(Format::from_path(Path::new("a.docx")), Some(Format::Docx));
2608        assert_eq!(
2609            Format::from_path(Path::new("a.xlsx")),
2610            Some(Format::Spreadsheet)
2611        );
2612        assert_eq!(
2613            Format::from_path(Path::new("a.ods")),
2614            Some(Format::Spreadsheet)
2615        );
2616        assert_eq!(Format::from_path(Path::new("a.epub")), Some(Format::Epub));
2617        assert_eq!(Format::from_path(Path::new("a.html")), Some(Format::Html));
2618        assert_eq!(Format::from_path(Path::new("a.htm")), Some(Format::Html));
2619        assert_eq!(Format::from_path(Path::new("a.txt")), None);
2620        assert_eq!(Format::from_path(Path::new("noext")), None);
2621    }
2622
2623    #[test]
2624    fn unsupported_extension_is_typed_error() {
2625        let err = extract(Path::new("/tmp/whatever.txt")).unwrap_err();
2626        assert!(matches!(err, ExtractError::UnsupportedFormat(ref e) if e == "txt"));
2627        assert_eq!(err.code(), "UNSUPPORTED_FORMAT");
2628    }
2629
2630    #[test]
2631    fn missing_extension_is_unsupported() {
2632        let err = extract(Path::new("/tmp/noext")).unwrap_err();
2633        assert!(matches!(err, ExtractError::UnsupportedFormat(ref e) if e.is_empty()));
2634    }
2635
2636    // ── normalization ─────────────────────────────────────────────────────────
2637
2638    #[test]
2639    fn normalize_collapses_blanks_and_trims() {
2640        let raw = "\r\n\r\nHeading\r\n\r\n\r\n\r\nBody line   \r\n\r\n";
2641        assert_eq!(normalize_text(raw), "Heading\n\nBody line\n");
2642    }
2643
2644    #[test]
2645    fn normalize_empty_stays_empty() {
2646        assert_eq!(normalize_text(""), "");
2647        assert_eq!(normalize_text("   \n\n  \n"), "");
2648    }
2649
2650    // ── per-format extraction against corpus-c fixtures ───────────────────────
2651
2652    #[test]
2653    fn extract_text_pdf_matches_known_good() {
2654        let got = extract(&fixture("text.pdf")).unwrap();
2655        assert_eq!(got.metadata["format"], MetaValue::Str("pdf".into()));
2656        assert_eq!(got.metadata["pages"], MetaValue::Num(1));
2657        assert_eq!(tokens(&got.text), tokens(&expected("text.pdf")));
2658    }
2659
2660    #[test]
2661    fn extract_weird_fonts_pdf_matches_known_good() {
2662        let got = extract(&fixture("weird-fonts.pdf")).unwrap();
2663        assert_eq!(tokens(&got.text), tokens(&expected("weird-fonts.pdf")));
2664    }
2665
2666    #[test]
2667    fn extract_multi_column_pdf_matches_content_order_agnostic() {
2668        // pdf-extract reads column-by-column; the known-good `.txt` captures the
2669        // interleaved (pdftotext) order. Both carry identical content — assert
2670        // the line SET, not the order. (README § multi-column.)
2671        let got = extract(&fixture("multi-column.pdf")).unwrap();
2672        assert_eq!(line_set(&got.text), line_set(&expected("multi-column.pdf")));
2673    }
2674
2675    #[test]
2676    fn extract_image_only_pdf_yields_empty() {
2677        // No text layer → empty out, never hallucinated text. OCR out of scope.
2678        let got = extract(&fixture("image-only.pdf")).unwrap();
2679        assert_eq!(got.text, "");
2680        assert!(expected("image-only.pdf").trim().is_empty());
2681    }
2682
2683    #[test]
2684    fn extract_encrypted_pdf_without_password_refuses_cleanly() {
2685        let err = extract(&fixture("encrypted.pdf")).unwrap_err();
2686        assert!(
2687            matches!(err, ExtractError::Encrypted(_)),
2688            "expected Encrypted, got {err:?}"
2689        );
2690        assert_eq!(err.code(), "DOCUMENT_ENCRYPTED");
2691    }
2692
2693    #[test]
2694    fn guard_pdf_panic_contains_unwind_as_parse_error() {
2695        // The "never panics" contract: an internal pdf-extract/lopdf panic must
2696        // surface as a typed ExtractError::Parse, not abort the process. (cargo
2697        // captures the unwind's stderr line for a passing test.)
2698        let contained: Result<()> = guard_pdf_panic(|| panic!("simulated pdf-extract abort"));
2699        assert!(
2700            matches!(contained, Err(ExtractError::Parse { format: "pdf", .. })),
2701            "panic must be contained as a pdf Parse error, got {contained:?}"
2702        );
2703        // The success path is transparent — the value passes straight through.
2704        let ok: Result<u32> = guard_pdf_panic(|| 42);
2705        assert_eq!(ok.unwrap(), 42);
2706    }
2707
2708    #[test]
2709    fn extract_docx_matches_known_good() {
2710        let got = extract(&fixture("sample.docx")).unwrap();
2711        assert_eq!(got.metadata["format"], MetaValue::Str("docx".into()));
2712        assert_eq!(tokens(&got.text), tokens(&expected("sample.docx")));
2713    }
2714
2715    #[test]
2716    fn extract_xlsx_matches_known_good() {
2717        let got = extract(&fixture("sample.xlsx")).unwrap();
2718        assert_eq!(got.metadata["format"], MetaValue::Str("spreadsheet".into()));
2719        assert_eq!(got.metadata["sheets"], MetaValue::Num(1));
2720        assert_eq!(
2721            got.metadata["sheet_names"],
2722            MetaValue::Str("Expenses".into())
2723        );
2724        // Tab-separated, integers without `.0` — exact match (no soft-wrap risk).
2725        assert_eq!(got.text.trim_end(), expected("sample.xlsx").trim_end());
2726    }
2727
2728    #[test]
2729    fn extract_epub_matches_known_good() {
2730        let got = extract(&fixture("sample.epub")).unwrap();
2731        assert_eq!(got.metadata["format"], MetaValue::Str("epub".into()));
2732        assert_eq!(got.metadata["chapters"], MetaValue::Num(1));
2733        assert_eq!(
2734            got.metadata["title"],
2735            MetaValue::Str("Operations Playbook".into())
2736        );
2737        assert_eq!(tokens(&got.text), tokens(&expected("sample.epub")));
2738    }
2739
2740    #[test]
2741    fn extract_html_matches_known_good() {
2742        let got = extract(&fixture("sample.html")).unwrap();
2743        assert_eq!(got.metadata["format"], MetaValue::Str("html".into()));
2744        assert_eq!(tokens(&got.text), tokens(&expected("sample.html")));
2745    }
2746
2747    // ── helper-level unit tests ───────────────────────────────────────────────
2748
2749    #[test]
2750    fn unwrap_brackets_flattens_link_text() {
2751        assert_eq!(
2752            unwrap_brackets("contact [ops@acme.example] or the [handbook]."),
2753            "contact ops@acme.example or the handbook."
2754        );
2755        // Unmatched '[' is preserved.
2756        assert_eq!(unwrap_brackets("a [b c"), "a [b c");
2757        // No brackets → untouched.
2758        assert_eq!(unwrap_brackets("plain text"), "plain text");
2759    }
2760
2761    #[test]
2762    fn strip_markdown_decorations_drops_heading_hashes() {
2763        let input = "# Title\n## Section\n* bullet\n1. ordered\nplain\n";
2764        let out = strip_markdown_decorations(input);
2765        assert_eq!(out, "Title\nSection\n* bullet\n1. ordered\nplain\n");
2766    }
2767
2768    #[test]
2769    fn local_name_strips_prefix() {
2770        assert_eq!(local_name(b"w:t"), b"t");
2771        assert_eq!(local_name(b"t"), b"t");
2772        assert_eq!(local_name(b"dc:title"), b"title");
2773    }
2774
2775    #[test]
2776    fn extracted_serializes_to_text_metadata_json() {
2777        let got = extract(&fixture("sample.xlsx")).unwrap();
2778        let json = serde_json::to_value(&got).unwrap();
2779        assert!(json.get("text").is_some());
2780        assert_eq!(json["metadata"]["format"], "spreadsheet");
2781        assert_eq!(json["metadata"]["sheets"], 1);
2782        // MetaValue::Num serializes as a bare JSON number, Str as a bare string.
2783        assert!(json["metadata"]["sheets"].is_number());
2784        assert!(json["metadata"]["format"].is_string());
2785    }
2786
2787    // ── regression: leading-blank normalization is linear (finding #13) ────────
2788
2789    /// `normalize_text` must trim leading blank lines in O(n), not O(n²). The
2790    /// pre-fix loop used `lines.remove(0)` per blank line — O(n) shift each, so a
2791    /// document dominated by leading blanks took O(n²) and hung extraction.
2792    ///
2793    /// 500_000 leading blank lines is ~2.5e11 element shifts under the old code
2794    /// (minutes-to-hours, effectively a hang) but instant under the index-and-
2795    /// slice path; the test reconstructs the finding's trigger (an adapter output
2796    /// that is mostly leading blanks then one line of text) and asserts the
2797    /// correct, fully-trimmed result. Against the pre-fix code this test does not
2798    /// complete in a reasonable time — encoding the quadratic regression.
2799    #[test]
2800    fn regression_normalize_text_leading_blanks_is_linear() {
2801        let blanks = "\n".repeat(500_000);
2802        let raw = format!("{blanks}only real line\n");
2803        // Leading blanks fully trimmed; single trailing newline; body intact.
2804        assert_eq!(normalize_text(&raw), "only real line\n");
2805
2806        // A wholly-blank giant input still collapses to empty (the other branch).
2807        assert_eq!(normalize_text(&"   \n".repeat(500_000)), "");
2808    }
2809
2810    // ── regression: spreadsheet dense-grid bomb is refused (finding #4) ────────
2811
2812    /// Build a VALID `.xlsx` whose single sheet declares two real cells at the
2813    /// opposite corners of Excel's grid (`A1` and `XFD1048576`). `calamine`
2814    /// materializes a sheet as a DENSE `Vec<Data>` sized from the MIN/MAX cell
2815    /// positions, so this two-cell sheet would force a ~1.7e10-element (~400 GB)
2816    /// allocation and abort the process. We reuse the corpus `sample.xlsx`
2817    /// container verbatim and swap ONLY `xl/worksheets/sheet1.xml`, so every
2818    /// other part (workbook, rels, content-types) is a real, openable workbook.
2819    fn write_dense_bomb_xlsx(dest: &Path) {
2820        use std::io::Write;
2821
2822        let base = std::fs::read(fixture("sample.xlsx")).expect("corpus sample.xlsx exists");
2823        let mut archive =
2824            zip::ZipArchive::new(std::io::Cursor::new(base)).expect("sample.xlsx is a valid zip");
2825
2826        let bomb_sheet = b"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\
2827<worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">\
2828<sheetData>\
2829<row r=\"1\"><c r=\"A1\"><v>1</v></c></row>\
2830<row r=\"1048576\"><c r=\"XFD1048576\"><v>2</v></c></row>\
2831</sheetData></worksheet>";
2832
2833        let out = std::fs::File::create(dest).unwrap();
2834        let mut writer = zip::ZipWriter::new(out);
2835        let opts = zip::write::SimpleFileOptions::default()
2836            .compression_method(zip::CompressionMethod::Stored);
2837
2838        for i in 0..archive.len() {
2839            let entry = archive.by_index(i).unwrap();
2840            let name = entry.name().to_string();
2841            if name == "xl/worksheets/sheet1.xml" {
2842                writer.start_file(name, opts).unwrap();
2843                writer.write_all(bomb_sheet).unwrap();
2844            } else {
2845                // Copy every other entry's already-compressed bytes verbatim.
2846                writer.raw_copy_file(entry).unwrap();
2847            }
2848        }
2849        writer.finish().unwrap();
2850    }
2851
2852    /// A spreadsheet whose declared dense grid exceeds [`MAX_SPREADSHEET_CELLS`]
2853    /// is refused with a typed [`ExtractError::Parse`] BEFORE calamine allocates
2854    /// the dense matrix — never an OOM/abort. Pre-fix, `extract_spreadsheet`
2855    /// called `worksheet_range` directly and the process aborted on the
2856    /// allocation; this test would not return (it would kill the test runner),
2857    /// so it encodes the resource-exhaustion regression.
2858    #[test]
2859    fn regression_spreadsheet_dense_bomb_refused_not_oom() {
2860        let tmp = tempfile::TempDir::new().unwrap();
2861        let bomb = tmp.path().join("invoice.xlsx");
2862        write_dense_bomb_xlsx(&bomb);
2863
2864        // A few-hundred-byte file on disk — the whole point of the bomb.
2865        assert!(
2866            std::fs::metadata(&bomb).unwrap().len() < 10_000,
2867            "the bomb must be tiny on disk; the danger is the in-memory expansion"
2868        );
2869
2870        let err = extract(&bomb).unwrap_err();
2871        assert!(
2872            matches!(
2873                err,
2874                ExtractError::Parse {
2875                    format: "spreadsheet",
2876                    ..
2877                }
2878            ),
2879            "an over-cap dense grid must be a typed spreadsheet Parse refusal, got {err:?}"
2880        );
2881        assert_eq!(err.code(), "EXTRACT_PARSE_ERROR");
2882    }
2883
2884    /// The cap is a guard, not a wall: a normal spreadsheet still extracts. Locks
2885    /// down that the preflight bound does not regress the legitimate path (the
2886    /// corpus `sample.xlsx` is a 3×3 grid, far under the cap).
2887    #[test]
2888    fn regression_spreadsheet_cap_allows_real_workbook() {
2889        let got = extract(&fixture("sample.xlsx")).unwrap();
2890        assert_eq!(got.metadata["sheets"], MetaValue::Num(1));
2891        assert!(!got.text.is_empty());
2892    }
2893
2894    /// Build a minimal `.ods` (OpenDocument Spreadsheet) whose `content.xml`
2895    /// body is exactly `content_xml`, written to `dest`. Lets a test inject a
2896    /// truncated/unclosed document XML and drive it through the real
2897    /// `extract_spreadsheet` ODS path. The mimetype + manifest members make
2898    /// calamine's auto-detector recognize the package as ODS.
2899    fn write_ods_with_content(dest: &Path, content_xml: &str) {
2900        use std::io::Write;
2901        let manifest = "<?xml version=\"1.0\"?>\
2902<manifest:manifest xmlns:manifest=\"urn:oasis:names:tc:opendocument:xmlns:manifest:1.0\">\
2903<manifest:file-entry manifest:full-path=\"/\" \
2904manifest:media-type=\"application/vnd.oasis.opendocument.spreadsheet\"/></manifest:manifest>";
2905        let file = std::fs::File::create(dest).unwrap();
2906        let mut writer = zip::ZipWriter::new(file);
2907        let stored = zip::write::SimpleFileOptions::default()
2908            .compression_method(zip::CompressionMethod::Stored);
2909        // The mimetype member must be the first, STORED entry for OpenDocument.
2910        writer.start_file("mimetype", stored).unwrap();
2911        writer
2912            .write_all(b"application/vnd.oasis.opendocument.spreadsheet")
2913            .unwrap();
2914        writer.start_file("META-INF/manifest.xml", stored).unwrap();
2915        writer.write_all(manifest.as_bytes()).unwrap();
2916        writer.start_file("content.xml", stored).unwrap();
2917        writer.write_all(content_xml.as_bytes()).unwrap();
2918        writer.finish().unwrap();
2919    }
2920
2921    /// A truncated `.ods` — `content.xml` opens `<table:table>` then hits EOF
2922    /// before the matching `</table:table>` — must be REFUSED fast with a typed
2923    /// Parse error, not spin forever inside calamine's unbounded ODS reader
2924    /// (resource-exhaustion DoS on untrusted `sources/` input). Pre-fix this test
2925    /// hangs (calamine's `worksheet_range` never returns); post-fix the structural
2926    /// pre-scan refuses it in microseconds. A well-formed `.ods` still extracts.
2927    #[test]
2928    fn regression_truncated_ods_is_refused_not_hung() {
2929        let tmp = tempfile::TempDir::new().unwrap();
2930
2931        // Truncated: the spreadsheet opens `<table:table>` and the document ends
2932        // there — exactly the EOF-mid-table shape that hangs the ODS reader.
2933        let trunc = tmp.path().join("trunc.ods");
2934        let truncated_content = "<?xml version=\"1.0\"?>\
2935<office:document-content \
2936xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\" \
2937xmlns:table=\"urn:oasis:names:tc:opendocument:xmlns:table:1.0\">\
2938<office:body><office:spreadsheet><table:table table:name=\"S\">";
2939        write_ods_with_content(&trunc, truncated_content);
2940
2941        let start = std::time::Instant::now();
2942        let err = extract(&trunc).unwrap_err();
2943        let elapsed = start.elapsed();
2944        assert!(
2945            matches!(&err, ExtractError::Parse { format, .. } if *format == "spreadsheet"),
2946            "a truncated .ods must be a typed spreadsheet Parse refusal, got {err:?}"
2947        );
2948        assert_eq!(err.code(), "EXTRACT_PARSE_ERROR");
2949        assert!(
2950            elapsed < std::time::Duration::from_secs(1),
2951            "the truncated .ods must fail fast (<1s); took {elapsed:?} (would-be hang)"
2952        );
2953
2954        // A well-formed `.ods` with a single 1-row, 2-cell table still extracts
2955        // its cell text — the pre-scan must not regress valid spreadsheets.
2956        let ok = tmp.path().join("ok.ods");
2957        let valid_content = "<?xml version=\"1.0\"?>\
2958<office:document-content \
2959xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\" \
2960xmlns:table=\"urn:oasis:names:tc:opendocument:xmlns:table:1.0\" \
2961xmlns:text=\"urn:oasis:names:tc:opendocument:xmlns:text:1.0\">\
2962<office:body><office:spreadsheet>\
2963<table:table table:name=\"S\">\
2964<table:table-row>\
2965<table:table-cell office:value-type=\"string\"><text:p>Alpha</text:p></table:table-cell>\
2966<table:table-cell office:value-type=\"string\"><text:p>Beta</text:p></table:table-cell>\
2967</table:table-row>\
2968</table:table>\
2969</office:spreadsheet></office:body></office:document-content>";
2970        write_ods_with_content(&ok, valid_content);
2971        let got = extract(&ok).unwrap();
2972        assert!(
2973            got.text.contains("Alpha") && got.text.contains("Beta"),
2974            "a valid .ods must still extract its cell text, got {:?}",
2975            got.text
2976        );
2977    }
2978
2979    #[test]
2980    fn ods_repeat_attributes_are_bounded_before_calamine_allocates() {
2981        let tmp = tempfile::TempDir::new().unwrap();
2982        let hostile = tmp.path().join("repeat-bomb.ods");
2983        let content = format!(
2984            "<?xml version=\"1.0\"?>\
2985<office:document-content \
2986xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\" \
2987xmlns:table=\"urn:oasis:names:tc:opendocument:xmlns:table:1.0\">\
2988<office:body><office:spreadsheet><table:table table:name=\"S\">\
2989<table:table-row table:number-rows-repeated=\"{MAX_SPREADSHEET_CELLS}\">\
2990<table:table-cell table:number-columns-repeated=\"2\"/>\
2991</table:table-row></table:table></office:spreadsheet></office:body>\
2992</office:document-content>"
2993        );
2994        write_ods_with_content(&hostile, &content);
2995        let error = extract(&hostile)
2996            .expect_err("expanded ODS cells must be refused before dense materialization");
2997        assert!(
2998            matches!(&error, ExtractError::Parse { format, message }
2999                if *format == "spreadsheet" && message.contains("expanded cells")),
3000            "got {error:?}"
3001        );
3002    }
3003
3004    // ── regression: entity-ref / CDATA fidelity (findings #34, #1011) ──────────
3005
3006    /// Build a minimal valid `.docx` whose `word/document.xml` body is the given
3007    /// run XML, written to `dest`. Only the three OOXML members `extract_docx`
3008    /// touches need to be real; the rest of a Word package is optional for text
3009    /// extraction.
3010    fn write_docx(dest: &Path, body_runs: &str) {
3011        use std::io::Write;
3012        let document = format!(
3013            "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\
3014<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">\
3015<w:body>{body_runs}</w:body></w:document>"
3016        );
3017        let file = std::fs::File::create(dest).unwrap();
3018        let mut writer = zip::ZipWriter::new(file);
3019        let opts = zip::write::SimpleFileOptions::default()
3020            .compression_method(zip::CompressionMethod::Stored);
3021        writer.start_file("word/document.xml", opts).unwrap();
3022        writer.write_all(document.as_bytes()).unwrap();
3023        writer.finish().unwrap();
3024    }
3025
3026    #[test]
3027    fn regression_docx_resolves_entity_refs() {
3028        // quick-xml 0.40 surfaces `&amp;`/`&lt;`/`&gt;`/`&#8212;` as separate
3029        // GeneralRef events; pre-fix they were routed to `_ => {}` and dropped,
3030        // corrupting `Smith & Co invoice <final> total — 100`.
3031        let tmp = tempfile::TempDir::new().unwrap();
3032        let f = tmp.path().join("entity.docx");
3033        write_docx(
3034            &f,
3035            "<w:p><w:r><w:t>Smith &amp; Co invoice &lt;final&gt; total &#8212; 100</w:t></w:r></w:p>",
3036        );
3037        let got = extract(&f).unwrap();
3038        assert_eq!(got.text, "Smith & Co invoice <final> total — 100\n");
3039    }
3040
3041    #[test]
3042    fn regression_docx_preserves_cdata_run_text() {
3043        // CDATA inside `<w:t>` is valid and literal; pre-fix it fell through the
3044        // wildcard arm and the payload vanished.
3045        let tmp = tempfile::TempDir::new().unwrap();
3046        let f = tmp.path().join("cdata.docx");
3047        write_docx(
3048            &f,
3049            "<w:p><w:r><w:t>Line A.</w:t></w:r></w:p>\
3050<w:p><w:r><w:t><![CDATA[IMPORTANT CDATA CONTENT]]></w:t></w:r></w:p>\
3051<w:p><w:r><w:t>Line C.</w:t></w:r></w:p>",
3052        );
3053        let got = extract(&f).unwrap();
3054        assert_eq!(got.text, "Line A.\nIMPORTANT CDATA CONTENT\nLine C.\n");
3055    }
3056
3057    #[test]
3058    fn resolve_entity_ref_maps_named_and_numeric() {
3059        use quick_xml::events::BytesRef;
3060        let r = |s: &'static str| resolve_entity_ref(&BytesRef::new(s));
3061        assert_eq!(r("amp"), "&");
3062        assert_eq!(r("lt"), "<");
3063        assert_eq!(r("gt"), ">");
3064        assert_eq!(r("quot"), "\"");
3065        assert_eq!(r("apos"), "'");
3066        assert_eq!(r("#8212"), "—");
3067        assert_eq!(r("#x2014"), "—");
3068        // Unknown named entity → bare name (best-effort, never a panic).
3069        assert_eq!(r("nbsp"), "nbsp");
3070    }
3071
3072    // ── regression: EPUB OPF parsing (findings #35, #37, #1012) ────────────────
3073
3074    /// Build a minimal valid EPUB at `dest`. `opf_metadata` is spliced verbatim
3075    /// inside `<metadata>`; `manifest_href` is the chapter item's href; the
3076    /// chapter XHTML is stored under the literal zip entry `chapter_entry`. The
3077    /// mimetype member is written first and stored (per the EPUB OCF spec).
3078    fn write_epub(dest: &Path, opf_metadata: &str, manifest_href: &str, chapter_entry: &str) {
3079        use std::io::Write;
3080        let container = "<?xml version=\"1.0\"?>\
3081<container version=\"1.0\" xmlns=\"urn:oasis:names:tc:opendocument:xmlns:container\">\
3082<rootfiles><rootfile full-path=\"OEBPS/content.opf\" \
3083media-type=\"application/oebps-package+xml\"/></rootfiles></container>";
3084        let opf = format!(
3085            "<?xml version=\"1.0\" encoding=\"utf-8\"?>\
3086<package xmlns=\"http://www.idpf.org/2007/opf\" version=\"3.0\" unique-identifier=\"id\">\
3087<metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\">{opf_metadata}</metadata>\
3088<manifest><item id=\"c1\" href=\"{manifest_href}\" media-type=\"application/xhtml+xml\"/></manifest>\
3089<spine><itemref idref=\"c1\"/></spine></package>"
3090        );
3091        let chapter = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\
3092<html xmlns=\"http://www.w3.org/1999/xhtml\"><body>\
3093<p>Hello world body text.</p></body></html>";
3094
3095        let file = std::fs::File::create(dest).unwrap();
3096        let mut writer = zip::ZipWriter::new(file);
3097        let stored = zip::write::SimpleFileOptions::default()
3098            .compression_method(zip::CompressionMethod::Stored);
3099        // mimetype must be the first member and stored uncompressed.
3100        writer.start_file("mimetype", stored).unwrap();
3101        writer.write_all(b"application/epub+zip").unwrap();
3102        writer.start_file("META-INF/container.xml", stored).unwrap();
3103        writer.write_all(container.as_bytes()).unwrap();
3104        writer.start_file("OEBPS/content.opf", stored).unwrap();
3105        writer.write_all(opf.as_bytes()).unwrap();
3106        writer.start_file(chapter_entry, stored).unwrap();
3107        writer.write_all(chapter.as_bytes()).unwrap();
3108        writer.finish().unwrap();
3109    }
3110
3111    #[test]
3112    fn regression_epub_title_accumulates_entities_and_nested_events() {
3113        // Pre-fix the title was cut at the first Text node, so an entity or a
3114        // comment inside `<dc:title>` truncated it.
3115        let tmp = tempfile::TempDir::new().unwrap();
3116
3117        let f1 = tmp.path().join("entity.epub");
3118        write_epub(
3119            &f1,
3120            "<dc:title>Smith &amp; Jones: A &lt;Tale&gt;</dc:title>",
3121            "chapter.xhtml",
3122            "OEBPS/chapter.xhtml",
3123        );
3124        let got = extract(&f1).unwrap();
3125        assert_eq!(
3126            got.metadata["title"],
3127            MetaValue::Str("Smith & Jones: A <Tale>".into())
3128        );
3129
3130        let f2 = tmp.path().join("comment.epub");
3131        write_epub(
3132            &f2,
3133            "<dc:title>Part One<!-- editorial --> and Part Two</dc:title>",
3134            "chapter.xhtml",
3135            "OEBPS/chapter.xhtml",
3136        );
3137        let got = extract(&f2).unwrap();
3138        assert_eq!(
3139            got.metadata["title"],
3140            MetaValue::Str("Part One and Part Two".into())
3141        );
3142    }
3143
3144    #[test]
3145    fn regression_epub_self_closing_title_does_not_capture_author() {
3146        // A self-closing `<dc:title/>` (an untitled book) must NOT latch the next
3147        // text node (the author) as the title.
3148        let tmp = tempfile::TempDir::new().unwrap();
3149        let f = tmp.path().join("empty-title.epub");
3150        write_epub(
3151            &f,
3152            "<dc:title/><dc:creator>John Doe</dc:creator>",
3153            "chapter.xhtml",
3154            "OEBPS/chapter.xhtml",
3155        );
3156        let got = extract(&f).unwrap();
3157        // No (or empty) title — never the author. `put_str` omits empty values.
3158        assert!(
3159            !got.metadata.contains_key("title"),
3160            "self-closing title must not capture the author, got {:?}",
3161            got.metadata.get("title")
3162        );
3163        // The chapter still extracts.
3164        assert_eq!(got.metadata["chapters"], MetaValue::Num(1));
3165    }
3166
3167    /// Build an `.epub` whose spine references the single chapter `spine_count`
3168    /// times — the spine-amplification shape.
3169    fn write_epub_with_spine(dest: &Path, spine_count: usize) {
3170        use std::io::Write;
3171        let container = "<?xml version=\"1.0\"?>\
3172<container version=\"1.0\" xmlns=\"urn:oasis:names:tc:opendocument:xmlns:container\">\
3173<rootfiles><rootfile full-path=\"OEBPS/content.opf\" \
3174media-type=\"application/oebps-package+xml\"/></rootfiles></container>";
3175        let itemrefs = "<itemref idref=\"c1\"/>".repeat(spine_count);
3176        let opf = format!(
3177            "<?xml version=\"1.0\" encoding=\"utf-8\"?>\
3178<package xmlns=\"http://www.idpf.org/2007/opf\" version=\"3.0\" unique-identifier=\"id\">\
3179<metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\"><dc:title>Bomb</dc:title></metadata>\
3180<manifest><item id=\"c1\" href=\"chapter.xhtml\" media-type=\"application/xhtml+xml\"/></manifest>\
3181<spine>{itemrefs}</spine></package>"
3182        );
3183        let chapter = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\
3184<html xmlns=\"http://www.w3.org/1999/xhtml\"><body><p>Repeated chapter body.</p></body></html>";
3185        let file = std::fs::File::create(dest).unwrap();
3186        let mut writer = zip::ZipWriter::new(file);
3187        let stored = zip::write::SimpleFileOptions::default()
3188            .compression_method(zip::CompressionMethod::Stored);
3189        writer.start_file("mimetype", stored).unwrap();
3190        writer.write_all(b"application/epub+zip").unwrap();
3191        writer.start_file("META-INF/container.xml", stored).unwrap();
3192        writer.write_all(container.as_bytes()).unwrap();
3193        writer.start_file("OEBPS/content.opf", stored).unwrap();
3194        writer.write_all(opf.as_bytes()).unwrap();
3195        writer.start_file("OEBPS/chapter.xhtml", stored).unwrap();
3196        writer.write_all(chapter.as_bytes()).unwrap();
3197        writer.finish().unwrap();
3198    }
3199
3200    #[test]
3201    fn regression_epub_spine_amplification_is_bounded() {
3202        // Adversarial review #8: a tiny .epub whose spine references the same
3203        // chapter a huge number of times pegged a CPU core (re-decoding +
3204        // re-rendering the chapter each time) and ballooned output. The spine
3205        // length is now capped, so an over-cap spine is REFUSED — fast, never
3206        // hung.
3207        let tmp = tempfile::TempDir::new().unwrap();
3208        let bomb = tmp.path().join("bomb.epub");
3209        write_epub_with_spine(&bomb, MAX_EPUB_SPINE_ITEMS + 1);
3210        let err = extract(&bomb).unwrap_err();
3211        assert!(
3212            matches!(&err, ExtractError::Parse { message, .. } if message.contains("spine")),
3213            "an over-cap spine must be refused with a spine error; got {err:?}"
3214        );
3215
3216        // A legitimate small repeat-spine still extracts: memoization renders the
3217        // shared chapter once, but each reading-order reference is still counted.
3218        let ok = tmp.path().join("ok.epub");
3219        write_epub_with_spine(&ok, 5);
3220        let got = extract(&ok).unwrap();
3221        assert_eq!(got.metadata["chapters"], MetaValue::Num(5));
3222    }
3223
3224    #[test]
3225    fn regression_epub_percent_encoded_href_resolves() {
3226        // An href `my%20chapter.xhtml` must match the zip entry
3227        // `OEBPS/my chapter.xhtml`; pre-fix the lookup failed and the chapter was
3228        // silently dropped (empty text, 0 chapters).
3229        let tmp = tempfile::TempDir::new().unwrap();
3230        let f = tmp.path().join("spaced.epub");
3231        write_epub(
3232            &f,
3233            "<dc:title>Spaced</dc:title>",
3234            "my%20chapter.xhtml",
3235            "OEBPS/my chapter.xhtml",
3236        );
3237        let got = extract(&f).unwrap();
3238        assert_eq!(got.metadata["chapters"], MetaValue::Num(1));
3239        assert!(
3240            got.text.contains("Hello world body text."),
3241            "percent-encoded-href chapter must extract, got {:?}",
3242            got.text
3243        );
3244    }
3245
3246    #[test]
3247    fn percent_decode_handles_spaces_and_unicode_and_stray_percent() {
3248        assert_eq!(percent_decode("my%20chapter.xhtml"), "my chapter.xhtml");
3249        // `%C3%A9` is UTF-8 for `é`.
3250        assert_eq!(percent_decode("caf%C3%A9.xhtml"), "café.xhtml");
3251        // A stray `%` not followed by two hex digits is emitted verbatim.
3252        assert_eq!(percent_decode("100%done"), "100%done");
3253        assert_eq!(percent_decode("plain.xhtml"), "plain.xhtml");
3254    }
3255
3256    #[test]
3257    fn normalize_zip_path_resolves_dot_segments() {
3258        assert_eq!(
3259            normalize_zip_path("OEBPS/../text/ch1.xhtml"),
3260            "text/ch1.xhtml"
3261        );
3262        assert_eq!(normalize_zip_path("OEBPS/./ch1.xhtml"), "OEBPS/ch1.xhtml");
3263        assert_eq!(normalize_zip_path("OEBPS/ch1.xhtml"), "OEBPS/ch1.xhtml");
3264    }
3265
3266    // ── regression: spreadsheet date rendering (finding #1013) ─────────────────
3267
3268    #[test]
3269    fn render_excel_datetime_renders_iso_not_serial() {
3270        use calamine::{ExcelDateTime, ExcelDateTimeType};
3271        // 46188 → 2026-06-15 (date only, midnight → no time component).
3272        let date = ExcelDateTime::new(46188.0, ExcelDateTimeType::DateTime, false);
3273        assert_eq!(render_excel_datetime(&date), "2026-06-15");
3274        // 46143.5 → 2026-05-01 12:00:00 (has a time component).
3275        let dt = ExcelDateTime::new(46143.5, ExcelDateTimeType::DateTime, false);
3276        assert_eq!(render_excel_datetime(&dt), "2026-05-01 12:00:00");
3277        // A duration is elapsed time, not a calendar date → keep the serial form.
3278        let dur = ExcelDateTime::new(1.5, ExcelDateTimeType::TimeDelta, false);
3279        assert_eq!(render_excel_datetime(&dur), "1.5");
3280    }
3281
3282    #[test]
3283    fn render_cell_dates_are_iso() {
3284        use calamine::{Data, ExcelDateTime, ExcelDateTimeType};
3285        assert_eq!(
3286            render_cell(&Data::DateTime(ExcelDateTime::new(
3287                46188.0,
3288                ExcelDateTimeType::DateTime,
3289                false
3290            ))),
3291            "2026-06-15"
3292        );
3293        // The integer/float/string paths are unchanged by the date fix.
3294        assert_eq!(render_cell(&Data::Float(3450.0)), "3450");
3295        assert_eq!(render_cell(&Data::Int(7)), "7");
3296    }
3297
3298    // ── regression: HTML/EPUB literal-content fidelity (finding #36) ───────────
3299
3300    /// Render an HTML body string through the production extract path.
3301    fn html_text(body: &str) -> String {
3302        let tmp = tempfile::TempDir::new().unwrap();
3303        let f = tmp.path().join("doc.html");
3304        std::fs::write(&f, format!("<html><body>{body}</body></html>")).unwrap();
3305        extract(&f).unwrap().text
3306    }
3307
3308    #[test]
3309    fn regression_html_keeps_literal_brackets_and_hashes() {
3310        // Pre-fix every `[bracketed]` substring and every leading-`#` run was
3311        // stripped from real prose, fusing `total[net]` into `totalnet` and
3312        // deleting the `#` from `#1 in sales`.
3313        let out = html_text(
3314            "<p>#1 in sales this quarter</p>\
3315<p>see chart[3] for data, array[0] = total[net]</p>",
3316        );
3317        assert!(out.contains("#1 in sales this quarter"), "got {out:?}");
3318        assert!(
3319            out.contains("see chart[3] for data, array[0] = total[net]"),
3320            "got {out:?}"
3321        );
3322
3323        // Citation markers and subscripts survive intact.
3324        let out = html_text("<p>See note [1] and [sic] here.</p><p>x[i] + y[j]</p>");
3325        assert!(out.contains("See note [1] and [sic] here."), "got {out:?}");
3326        assert!(out.contains("x[i] + y[j]"), "got {out:?}");
3327    }
3328
3329    #[test]
3330    fn html_headings_render_as_plain_prose_no_hash() {
3331        // A real `<h1>` heading still renders WITHOUT a `#` marker (the renderer
3332        // emits no heading prefix now), so headings read as prose.
3333        let out = html_text("<h1>Launch Plan</h1><p>Body prose.</p>");
3334        assert!(out.contains("Launch Plan"), "got {out:?}");
3335        assert!(
3336            !out.contains('#'),
3337            "no heading marker expected, got {out:?}"
3338        );
3339    }
3340
3341    #[test]
3342    fn html_links_render_as_bare_text_no_brackets() {
3343        // Link display text renders bare; the surrounding `[...]` the stock plain
3344        // decorator would add is gone.
3345        let out = html_text("<p>See the <a href=\"https://x.example\">handbook</a>.</p>");
3346        assert!(out.contains("See the handbook."), "got {out:?}");
3347    }
3348}