Skip to main content

agentmux_srv/backend/attachments/
extract.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Everything derived from a non-image attachment: a short text preview for
5//! the tile, a text version of Office documents (no agent reads those), the
6//! page count of a PDF, and whether an Office file carries macros.
7//! docs/specs/SPEC_AGENT_PANE_FILE_ATTACHMENTS_2026_09_26.md §6.
8//!
9//! Office files are ZIPs, so every read here is bounded: an entry-count cap,
10//! a declared compression-ratio cap, one decompressed-bytes budget shared by
11//! all parts (the zip crate never reads past an entry's declared size, so the
12//! declared sizes bound the work), quick-xml with predefined entities only,
13//! a cap on the text produced, and a wall-clock deadline.
14//!
15//! Parsing runs in a child process ([`CHILD_ARG`]): srv re-runs its own
16//! executable, reads the result from its stdout and kills it after
17//! [`DEADLINE`]. Whatever a hostile file does to a parser (panic, deep
18//! recursion, a sheet that loads for minutes, memory) happens to the child.
19
20#[cfg(windows)]
21use agentmux_common::win32::NoWindow;
22use std::io::Read;
23use std::path::Path;
24use std::time::{Duration, Instant};
25
26use quick_xml::events::Event;
27use quick_xml::Reader;
28
29use super::kind::FileKind;
30
31pub const MAX_ZIP_ENTRIES: usize = 10_000;
32/// Total bytes all parts of one document may decompress to.
33pub const MAX_DECOMPRESSED_BYTES: u64 = 200 * 1024 * 1024;
34/// An entry claiming more than this ratio of uncompressed to compressed
35/// size is treated as a bomb.
36pub const MAX_COMPRESSION_RATIO: u64 = 100;
37/// Text versions are cut here (Codex refuses prompts over ~1M characters).
38pub const MAX_TEXT_BYTES: usize = 2 * 1024 * 1024;
39pub const DEADLINE: Duration = Duration::from_secs(30);
40/// PDFs larger than this aren't opened for a page count.
41pub const MAX_PDF_BYTES_FOR_COUNT: u64 = 100 * 1024 * 1024;
42
43pub const PREVIEW_LINES: usize = 40;
44pub const PREVIEW_BYTES: usize = 2048;
45
46/// The first lines of a text file, cut at a UTF-8 boundary.
47pub fn text_preview(path: &Path) -> std::io::Result<String> {
48    let mut head = Vec::with_capacity(PREVIEW_BYTES);
49    std::fs::File::open(path)?
50        .take(PREVIEW_BYTES as u64)
51        .read_to_end(&mut head)?;
52    let text = match std::str::from_utf8(&head) {
53        Ok(s) => s.to_string(),
54        Err(e) => String::from_utf8_lossy(&head[..e.valid_up_to()]).into_owned(),
55    };
56    Ok(text
57        .lines()
58        .take(PREVIEW_LINES)
59        .collect::<Vec<_>>()
60        .join("\n"))
61}
62
63/// What can be read out of a PDF.
64#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
65pub struct PdfFacts {
66    pub pages: Option<u32>,
67    /// Extracted text with the AgentMux header, or `None` when the PDF has
68    /// no extractable text (scanned, encrypted, malformed).
69    pub text: Option<String>,
70}
71
72/// The hidden argument srv re-executes itself with to read a document in a
73/// separate process: `srv __extract <pdf|text> <kind> <path> <name>` (see
74/// [`run_child`] and `main.rs`).
75pub const CHILD_ARG: &str = "__extract";
76
77/// Page count and text of a PDF, in this process. PDF parsers can panic or
78/// recurse deeply on malformed input: production code calls
79/// [`pdf_facts_isolated`], which runs this in the child process.
80pub fn pdf_facts_in_process(path: &Path, name: &str) -> PdfFacts {
81    if std::fs::metadata(path).map(|m| m.len()).unwrap_or(u64::MAX) > MAX_PDF_BYTES_FOR_COUNT {
82        return PdfFacts::default();
83    }
84    let path = path.to_path_buf();
85    let name = name.to_string();
86    std::panic::catch_unwind(move || {
87        let Ok(doc) = lopdf::Document::load(&path) else { return PdfFacts::default() };
88        let pages: Vec<u32> = doc.get_pages().keys().copied().collect();
89        let count = (!pages.is_empty()).then_some(pages.len() as u32);
90        let text = doc
91            .extract_text(&pages)
92            .ok()
93            .filter(|t| t.chars().any(|c| !c.is_whitespace()))
94            .map(|t| {
95                cap_text(format!(
96                    "Text extracted from {name} by AgentMux. Layout, images and scanned pages are not included.\n\n{}\n",
97                    t.trim()
98                ))
99            });
100        PdfFacts { pages: count, text }
101    })
102    .unwrap_or_default()
103}
104
105/// How a child run ended.
106#[derive(Debug, PartialEq, Eq)]
107enum ChildRun {
108    Output(Vec<u8>),
109    TimedOut,
110    Failed,
111}
112
113/// Run `cmd` with stdout piped, killing it after `deadline`.
114fn run_with_deadline(mut cmd: std::process::Command, deadline: Duration) -> ChildRun {
115    use std::process::Stdio;
116    cmd.stdin(Stdio::null())
117        .stdout(Stdio::piped())
118        .stderr(Stdio::null());
119    #[cfg(windows)]
120    {
121        cmd.no_window();
122    }
123    let Ok(mut child) = cmd.spawn() else {
124        return ChildRun::Failed;
125    };
126    // Read stdout on a thread so a large text can't fill the pipe and stall
127    // the child while we wait on it.
128    let mut stdout = child.stdout.take();
129    let reader = std::thread::spawn(move || {
130        let mut out = Vec::new();
131        if let Some(s) = stdout.as_mut() {
132            let _ = s
133                .take((MAX_TEXT_BYTES as u64) * 2 + 4096)
134                .read_to_end(&mut out);
135        }
136        out
137    });
138    let started = Instant::now();
139    loop {
140        match child.try_wait() {
141            Ok(Some(status)) if status.success() => break,
142            Ok(Some(_)) => return ChildRun::Failed,
143            Ok(None) if started.elapsed() > deadline => {
144                let _ = child.kill();
145                let _ = child.wait();
146                return ChildRun::TimedOut;
147            }
148            Ok(None) => std::thread::sleep(Duration::from_millis(50)),
149            Err(_) => return ChildRun::Failed,
150        }
151    }
152    ChildRun::Output(reader.join().unwrap_or_default())
153}
154
155/// `srv __extract <mode> <kind> <path> <name>` with the deadline.
156#[cfg(not(test))]
157fn run_isolated(mode: &str, kind: FileKind, path: &Path, name: &str) -> ChildRun {
158    let Ok(exe) = std::env::current_exe() else {
159        return ChildRun::Failed;
160    };
161    let mut cmd = std::process::Command::new(exe);
162    // It only reads one file: none of this instance's identity goes with it.
163    crate::backend::pane_env::sanitize_external_std_command(&mut cmd);
164    cmd.arg(CHILD_ARG)
165        .arg(mode)
166        .arg(kind.as_str())
167        .arg(path)
168        .arg(name);
169    run_with_deadline(cmd, DEADLINE)
170}
171
172/// Under `cargo test` the current executable is the test harness, which
173/// would read `__extract …` as test filters: run the child's code in-process.
174#[cfg(test)]
175fn run_isolated(mode: &str, kind: FileKind, path: &Path, name: &str) -> ChildRun {
176    match child_output(mode, kind.as_str(), path, name) {
177        Some(json) => ChildRun::Output(json.into_bytes()),
178        None => ChildRun::Failed,
179    }
180}
181
182/// [`pdf_facts_in_process`] in the child process. A crash or timeout just
183/// means no facts.
184pub fn pdf_facts_isolated(path: &Path, name: &str) -> PdfFacts {
185    match run_isolated("pdf", FileKind::Pdf, path, name) {
186        ChildRun::Output(out) => serde_json::from_slice(&out).unwrap_or_default(),
187        _ => PdfFacts::default(),
188    }
189}
190
191/// What the child reports for a text version: the text, none for this
192/// kind, or why it failed.
193#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
194struct TextOutcome {
195    text: Option<String>,
196    error: Option<String>,
197}
198
199/// [`document_text`] in the child process.
200pub fn document_text_isolated(
201    path: &Path,
202    kind: FileKind,
203    name: &str,
204) -> Result<Option<String>, ExtractError> {
205    match run_isolated("text", kind, path, name) {
206        ChildRun::Output(out) => match serde_json::from_slice::<TextOutcome>(&out) {
207            Ok(TextOutcome { error: Some(e), .. }) => Err(err(e)),
208            Ok(TextOutcome { text, .. }) => Ok(text),
209            Err(_) => Err(err("the document couldn't be read")),
210        },
211        ChildRun::TimedOut => Err(err("extraction took too long")),
212        ChildRun::Failed => Err(err("the document couldn't be read")),
213    }
214}
215
216/// Text version of a Word/Excel/PowerPoint/RTF document, in this process.
217/// Production code calls [`document_text_isolated`].
218pub fn document_text(
219    path: &Path,
220    kind: FileKind,
221    name: &str,
222) -> Result<Option<String>, ExtractError> {
223    let is_rtf = std::fs::File::open(path)
224        .and_then(|mut f| {
225            let mut head = [0u8; 5];
226            f.read_exact(&mut head).map(|_| &head == b"{\\rtf")
227        })
228        .unwrap_or(false);
229    if is_rtf {
230        rtf_text(path, name).map(Some)
231    } else {
232        office_text(path, kind, name)
233    }
234}
235
236fn kind_from_str(s: &str) -> Option<FileKind> {
237    Some(match s {
238        "pdf" => FileKind::Pdf,
239        "word" => FileKind::Word,
240        "excel" => FileKind::Excel,
241        "powerpoint" => FileKind::PowerPoint,
242        _ => return None,
243    })
244}
245
246/// Entry point for the child process: `srv __extract <mode> <kind> <path>
247/// <name>`. Prints [`PdfFacts`] (`pdf`) or a text outcome (`text`) as JSON.
248pub fn run_child(args: &[String]) -> i32 {
249    let (Some(mode), Some(kind), Some(path), Some(name)) =
250        (args.first(), args.get(1), args.get(2), args.get(3))
251    else {
252        return 2;
253    };
254    match child_output(mode, kind, Path::new(path), name) {
255        Some(json) => {
256            println!("{json}");
257            0
258        }
259        None => 2,
260    }
261}
262
263/// What the child prints for one request; `None` for a malformed request.
264fn child_output(mode: &str, kind: &str, path: &Path, name: &str) -> Option<String> {
265    let json = match (mode, kind_from_str(kind)) {
266        ("pdf", _) => serde_json::to_string(&pdf_facts_in_process(path, name)),
267        ("text", Some(kind)) => {
268            let outcome = match std::panic::catch_unwind(|| document_text(path, kind, name)) {
269                Ok(Ok(text)) => TextOutcome { text, error: None },
270                Ok(Err(e)) => TextOutcome {
271                    text: None,
272                    error: Some(e.0),
273                },
274                Err(_) => TextOutcome {
275                    text: None,
276                    error: Some("the document couldn't be read".into()),
277                },
278            };
279            serde_json::to_string(&outcome)
280        }
281        _ => return None,
282    };
283    Some(json.unwrap_or_else(|_| "{}".into()))
284}
285
286/// Plain text of an RTF document: control words dropped, `{\*…}` and a few
287/// non-text destinations skipped, `\par`/`\line`/`\tab`, `\'hh` and `\uN`
288/// decoded.
289pub fn rtf_text(path: &Path, name: &str) -> Result<String, ExtractError> {
290    let mut raw = Vec::new();
291    std::fs::File::open(path)
292        .map_err(|e| err(e.to_string()))?
293        .take(MAX_DECOMPRESSED_BYTES)
294        .read_to_end(&mut raw)
295        .map_err(|e| err(e.to_string()))?;
296    let deadline = Instant::now() + DEADLINE;
297    // RTF is 7-bit: anything else arrives as `\'hh` or `\uN`. A stray high
298    // byte reads as Latin-1, like `\'hh` does. Byte-wise, so a big file
299    // costs its own size and nothing more.
300    let chars: &[u8] = &raw;
301    let mut out = String::new();
302    // Group depths at which a skipped destination started.
303    let mut skip_from: Option<usize> = None;
304    let mut depth = 0usize;
305    let mut uc_skip = 0usize;
306    let mut i = 0;
307    while i < chars.len() {
308        if i % 4096 == 0 && Instant::now() > deadline {
309            return Err(err("extraction took too long"));
310        }
311        let c = chars[i];
312        match c {
313            b'{' => {
314                depth += 1;
315                i += 1;
316                // `{\*\dest …}` and known non-text destinations are skipped whole.
317                let rest = &chars[i..chars.len().min(i + 12)];
318                if skip_from.is_none()
319                    && [
320                        "\\*",
321                        "\\fonttbl",
322                        "\\colortbl",
323                        "\\stylesheet",
324                        "\\info",
325                        "\\pict",
326                        "\\header",
327                        "\\footer",
328                    ]
329                    .iter()
330                    .any(|d| rest.starts_with(d.as_bytes()))
331                {
332                    skip_from = Some(depth);
333                }
334            }
335            b'}' => {
336                if skip_from == Some(depth) {
337                    skip_from = None;
338                }
339                depth = depth.saturating_sub(1);
340                i += 1;
341            }
342            b'\\' => {
343                i += 1;
344                let Some(&n) = chars.get(i) else { break };
345                if n == b'\'' {
346                    let hex = chars
347                        .get(i + 1..i + 3)
348                        .and_then(|s| std::str::from_utf8(s).ok())
349                        .unwrap_or_default();
350                    if skip_from.is_none() {
351                        if let Ok(b) = u8::from_str_radix(hex, 16) {
352                            if uc_skip > 0 {
353                                uc_skip -= 1;
354                            } else {
355                                out.push(b as char);
356                            }
357                        }
358                    }
359                    i += 3;
360                    continue;
361                }
362                if !n.is_ascii_alphabetic() {
363                    // `\\`, `\{`, `\}` are literals; other control symbols drop.
364                    if skip_from.is_none() && matches!(n, b'\\' | b'{' | b'}') {
365                        out.push(n as char);
366                    }
367                    i += 1;
368                    continue;
369                }
370                let start = i;
371                while chars.get(i).is_some_and(|c| c.is_ascii_alphabetic()) {
372                    i += 1;
373                }
374                let word = std::str::from_utf8(&chars[start..i]).unwrap_or_default();
375                let num_start = i;
376                if chars.get(i) == Some(&b'-') {
377                    i += 1;
378                }
379                while chars.get(i).is_some_and(|c| c.is_ascii_digit()) {
380                    i += 1;
381                }
382                let num: Option<i32> = std::str::from_utf8(&chars[num_start..i])
383                    .ok()
384                    .and_then(|n| n.parse().ok());
385                if chars.get(i) == Some(&b' ') {
386                    i += 1;
387                }
388                if skip_from.is_some() {
389                    continue;
390                }
391                match word {
392                    "par" | "line" | "sect" | "page" => out.push('\n'),
393                    "tab" => out.push('\t'),
394                    "u" => {
395                        if let Some(n) = num {
396                            let code = if n < 0 { (n + 65536) as u32 } else { n as u32 };
397                            if let Some(ch) = char::from_u32(code) {
398                                out.push(ch);
399                            }
400                            // The ANSI fallback character that follows.
401                            uc_skip = 1;
402                        }
403                    }
404                    _ => {}
405                }
406            }
407            b'\r' | b'\n' => i += 1,
408            _ => {
409                if skip_from.is_none() && depth > 0 {
410                    if uc_skip > 0 {
411                        uc_skip -= 1;
412                    } else {
413                        out.push(c as char);
414                    }
415                }
416                i += 1;
417            }
418        }
419        if out.len() > MAX_TEXT_BYTES {
420            break;
421        }
422    }
423    Ok(cap_text(format!(
424        "Text extracted from {name} by AgentMux. Formatting, images and embedded objects are not included.\n\n{}\n",
425        out.trim()
426    )))
427}
428
429/// Why no text version was made.
430#[derive(Debug, Clone, PartialEq, Eq)]
431pub struct ExtractError(pub String);
432
433impl std::fmt::Display for ExtractError {
434    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
435        f.write_str(&self.0)
436    }
437}
438
439fn err(msg: impl Into<String>) -> ExtractError {
440    ExtractError(msg.into())
441}
442
443/// Text version of an Office document (`kind` Word/Excel/PowerPoint), with
444/// a header saying where it came from. `Ok(None)` for kinds with no text
445/// version (legacy binary .doc/.ppt).
446pub fn office_text(
447    path: &Path,
448    kind: FileKind,
449    name: &str,
450) -> Result<Option<String>, ExtractError> {
451    let deadline = Instant::now() + DEADLINE;
452    let body = match kind {
453        FileKind::Excel => Some(spreadsheet_text(path, deadline)?),
454        FileKind::Word | FileKind::PowerPoint => {
455            let Some(mut zip) = open_checked_zip(path)? else {
456                return Ok(None);
457            };
458            let mut budget = MAX_DECOMPRESSED_BYTES;
459            let names: Vec<String> = zip.file_names().map(str::to_string).collect();
460            let text = if names.iter().any(|n| n == "word/document.xml") {
461                docx_text(&mut zip, &names, &mut budget, deadline)?
462            } else if names.iter().any(|n| n == "ppt/presentation.xml") {
463                pptx_text(&mut zip, &mut budget, deadline)?
464            } else if names.iter().any(|n| n == "content.xml") {
465                // ODF text or presentation: paragraphs in content.xml.
466                let xml = read_part(&mut zip, "content.xml", &mut budget)?;
467                xml_text(&xml, OdfOrOoxml::Odf, deadline)?
468            } else {
469                return Ok(None);
470            };
471            Some(text)
472        }
473        _ => None,
474    };
475    Ok(body.map(|b| {
476        let mut out = format!(
477            "Text extracted from {name} by AgentMux. Formatting, images and embedded objects are not included.\n\n"
478        );
479        out.push_str(b.trim());
480        out.push('\n');
481        cap_text(out)
482    }))
483}
484
485/// Whether an OOXML package carries a VBA project, whatever its extension.
486pub fn has_macros(path: &Path) -> bool {
487    let Ok(Some(mut zip)) = open_checked_zip(path) else {
488        return false;
489    };
490    if zip
491        .file_names()
492        .any(|n| n.to_ascii_lowercase().contains("vbaproject"))
493    {
494        return true;
495    }
496    let mut budget = 1024 * 1024;
497    read_part(&mut zip, "[Content_Types].xml", &mut budget)
498        .map(|ct| {
499            let ct = ct.to_ascii_lowercase();
500            ct.contains("vbaproject") || ct.contains("macroenabled")
501        })
502        .unwrap_or(false)
503}
504
505fn cap_text(mut s: String) -> String {
506    if s.len() <= MAX_TEXT_BYTES {
507        return s;
508    }
509    let mut cut = MAX_TEXT_BYTES;
510    while !s.is_char_boundary(cut) {
511        cut -= 1;
512    }
513    s.truncate(cut);
514    s.push_str("\n\n[truncated: the text version is limited to 2 MB]\n");
515    s
516}
517
518/// Open a ZIP after checking entry count and every entry's declared ratio.
519/// `Ok(None)` if it isn't a ZIP at all.
520fn open_checked_zip(path: &Path) -> Result<Option<zip::ZipArchive<std::fs::File>>, ExtractError> {
521    let f = std::fs::File::open(path).map_err(|e| err(e.to_string()))?;
522    let Ok(mut zip) = zip::ZipArchive::new(f) else {
523        return Ok(None);
524    };
525    if zip.len() > MAX_ZIP_ENTRIES {
526        return Err(err(format!(
527            "the document has too many parts ({})",
528            zip.len()
529        )));
530    }
531    let mut declared: u64 = 0;
532    for i in 0..zip.len() {
533        let e = zip.by_index_raw(i).map_err(|e| err(e.to_string()))?;
534        let (size, packed) = (e.size(), e.compressed_size().max(1));
535        if size / packed > MAX_COMPRESSION_RATIO && size > 1024 * 1024 {
536            return Err(err(
537                "the document is compressed suspiciously well (possible zip bomb)",
538            ));
539        }
540        declared = declared.saturating_add(size);
541    }
542    if declared > MAX_DECOMPRESSED_BYTES * 4 {
543        return Err(err("the document is too large once decompressed"));
544    }
545    Ok(Some(zip))
546}
547
548/// Read one part as UTF-8, charging its bytes to `budget`.
549fn read_part(
550    zip: &mut zip::ZipArchive<std::fs::File>,
551    name: &str,
552    budget: &mut u64,
553) -> Result<String, ExtractError> {
554    let entry = zip.by_name(name).map_err(|e| err(format!("{name}: {e}")))?;
555    let mut buf = Vec::new();
556    entry
557        .take(*budget + 1)
558        .read_to_end(&mut buf)
559        .map_err(|e| err(format!("{name}: {e}")))?;
560    if buf.len() as u64 > *budget {
561        return Err(err("the document is too large once decompressed"));
562    }
563    *budget -= buf.len() as u64;
564    Ok(String::from_utf8_lossy(&buf).into_owned())
565}
566
567#[derive(Clone, Copy)]
568enum OdfOrOoxml {
569    /// `<w:t>` / `<a:t>` runs, `<w:p>` / `<a:p>` paragraphs.
570    Ooxml,
571    /// `<text:p>` / `<text:h>` paragraphs holding text directly.
572    Odf,
573}
574
575/// Plain text of one WordprocessingML, DrawingML or ODF part.
576fn xml_text(xml: &str, dialect: OdfOrOoxml, deadline: Instant) -> Result<String, ExtractError> {
577    let mut reader = Reader::from_str(xml);
578    let mut out = String::new();
579    let mut in_run = false;
580    let mut para_depth = 0usize;
581    loop {
582        if Instant::now() > deadline {
583            return Err(err("extraction took too long"));
584        }
585        let ev = reader
586            .read_event()
587            .map_err(|e| err(format!("unreadable XML: {e}")))?;
588        match ev {
589            Event::Start(e) => match (dialect, e.local_name().as_ref()) {
590                (OdfOrOoxml::Ooxml, b"t") => in_run = true,
591                (OdfOrOoxml::Odf, b"p" | b"h") => para_depth += 1,
592                _ => {}
593            },
594            Event::Empty(e) => match e.local_name().as_ref() {
595                b"tab" => out.push('\t'),
596                b"br" | b"line-break" => out.push('\n'),
597                b"s" if matches!(dialect, OdfOrOoxml::Odf) => out.push(' '),
598                _ => {}
599            },
600            Event::End(e) => match (dialect, e.local_name().as_ref()) {
601                (OdfOrOoxml::Ooxml, b"t") => in_run = false,
602                (OdfOrOoxml::Ooxml, b"p") => out.push('\n'),
603                (OdfOrOoxml::Odf, b"p" | b"h") => {
604                    para_depth = para_depth.saturating_sub(1);
605                    out.push('\n');
606                }
607                _ => {}
608            },
609            Event::Text(t) => {
610                let take = match dialect {
611                    OdfOrOoxml::Ooxml => in_run,
612                    OdfOrOoxml::Odf => para_depth > 0,
613                };
614                if take {
615                    out.push_str(
616                        &t.decode()
617                            .map_err(|e| err(format!("unreadable XML text: {e}")))?,
618                    );
619                }
620            }
621            Event::GeneralRef(r) => {
622                let take = match dialect {
623                    OdfOrOoxml::Ooxml => in_run,
624                    OdfOrOoxml::Odf => para_depth > 0,
625                };
626                if take {
627                    // Only the predefined entities and character references;
628                    // anything else (custom entities) is dropped, never expanded.
629                    if let Ok(Some(c)) = r.resolve_char_ref() {
630                        out.push(c);
631                    } else {
632                        let name: &[u8] = &r;
633                        match name {
634                            b"amp" => out.push('&'),
635                            b"lt" => out.push('<'),
636                            b"gt" => out.push('>'),
637                            b"quot" => out.push('"'),
638                            b"apos" => out.push('\''),
639                            _ => {}
640                        }
641                    }
642                }
643            }
644            Event::Eof => break,
645            _ => {}
646        }
647        if out.len() > MAX_TEXT_BYTES {
648            break;
649        }
650    }
651    Ok(out)
652}
653
654fn docx_text(
655    zip: &mut zip::ZipArchive<std::fs::File>,
656    names: &[String],
657    budget: &mut u64,
658    deadline: Instant,
659) -> Result<String, ExtractError> {
660    let mut out = xml_text(
661        &read_part(zip, "word/document.xml", budget)?,
662        OdfOrOoxml::Ooxml,
663        deadline,
664    )?;
665    // Headers, footers and notes after the body, in name order.
666    let mut extra: Vec<&String> = names
667        .iter()
668        .filter(|n| {
669            let n = n.as_str();
670            n.starts_with("word/")
671                && n.ends_with(".xml")
672                && (n.starts_with("word/header")
673                    || n.starts_with("word/footer")
674                    || n == "word/footnotes.xml"
675                    || n == "word/endnotes.xml")
676        })
677        .collect();
678    extra.sort();
679    for name in extra {
680        let text = xml_text(&read_part(zip, name, budget)?, OdfOrOoxml::Ooxml, deadline)?;
681        if !text.trim().is_empty() {
682            out.push_str("\n\n");
683            out.push_str(text.trim());
684        }
685    }
686    Ok(out)
687}
688
689/// Slides in presentation order: `presentation.xml`'s `sldIdLst` gives
690/// relationship ids, `presentation.xml.rels` maps them to slide parts.
691fn pptx_text(
692    zip: &mut zip::ZipArchive<std::fs::File>,
693    budget: &mut u64,
694    deadline: Instant,
695) -> Result<String, ExtractError> {
696    let pres = read_part(zip, "ppt/presentation.xml", budget)?;
697    let rels = read_part(zip, "ppt/_rels/presentation.xml.rels", budget).unwrap_or_default();
698    let targets = relationship_targets(&rels);
699    let mut order: Vec<String> = Vec::new();
700    let mut reader = Reader::from_str(&pres);
701    loop {
702        match reader
703            .read_event()
704            .map_err(|e| err(format!("unreadable XML: {e}")))?
705        {
706            Event::Start(e) | Event::Empty(e) if e.local_name().as_ref() == b"sldId" => {
707                for a in e.attributes().flatten() {
708                    if a.key.local_name().as_ref() == b"id" && a.key.as_ref() != b"id" {
709                        let rid = String::from_utf8_lossy(&a.value).into_owned();
710                        if let Some(t) = targets
711                            .iter()
712                            .find(|(id, _)| *id == rid)
713                            .map(|(_, t)| t.clone())
714                        {
715                            order.push(format!(
716                                "ppt/{}",
717                                t.trim_start_matches("/ppt/").trim_start_matches("./")
718                            ));
719                        }
720                    }
721                }
722            }
723            Event::Eof => break,
724            _ => {}
725        }
726    }
727    let mut out = String::new();
728    for (i, part) in order.iter().enumerate() {
729        let xml = match read_part(zip, part, budget) {
730            Ok(x) => x,
731            Err(_) => continue,
732        };
733        out.push_str(&format!("## Slide {}\n", i + 1));
734        out.push_str(xml_text(&xml, OdfOrOoxml::Ooxml, deadline)?.trim());
735        out.push_str("\n\n");
736    }
737    Ok(out)
738}
739
740/// `(Id, Target)` pairs of a `.rels` part.
741fn relationship_targets(rels: &str) -> Vec<(String, String)> {
742    let mut out = Vec::new();
743    let mut reader = Reader::from_str(rels);
744    while let Ok(ev) = reader.read_event() {
745        match ev {
746            Event::Start(e) | Event::Empty(e) if e.local_name().as_ref() == b"Relationship" => {
747                let mut id = None;
748                let mut target = None;
749                for a in e.attributes().flatten() {
750                    match a.key.as_ref() {
751                        b"Id" => id = Some(String::from_utf8_lossy(&a.value).into_owned()),
752                        b"Target" => target = Some(String::from_utf8_lossy(&a.value).into_owned()),
753                        _ => {}
754                    }
755                }
756                if let (Some(i), Some(t)) = (id, target) {
757                    out.push((i, t));
758                }
759            }
760            Event::Eof => break,
761            _ => {}
762        }
763    }
764    out
765}
766
767/// Every sheet as tab-separated rows under a `## Sheet` heading. A ZIP
768/// workbook is checked with the same limits first; calamine then reads
769/// through the zip crate, which stops at each entry's declared size.
770/// Loading one sheet can't be interrupted, which is why this runs in the
771/// child process ([`document_text_isolated`]) that is killed at the deadline.
772fn spreadsheet_text(path: &Path, deadline: Instant) -> Result<String, ExtractError> {
773    use calamine::{open_workbook_auto, Reader as _};
774    open_checked_zip(path)?;
775    let mut wb =
776        open_workbook_auto(path).map_err(|e| err(format!("unreadable spreadsheet: {e}")))?;
777    let mut out = String::new();
778    for sheet in wb.sheet_names() {
779        if Instant::now() > deadline || out.len() > MAX_TEXT_BYTES {
780            break;
781        }
782        let Ok(range) = wb.worksheet_range(&sheet) else {
783            continue;
784        };
785        out.push_str(&format!("## {sheet}\n"));
786        for row in range.rows() {
787            let cells: Vec<String> = row.iter().map(|c| c.to_string()).collect();
788            out.push_str(cells.join("\t").trim_end());
789            out.push('\n');
790            if out.len() > MAX_TEXT_BYTES || Instant::now() > deadline {
791                break;
792            }
793        }
794        out.push('\n');
795    }
796    Ok(out)
797}
798
799#[cfg(test)]
800mod tests {
801    use super::*;
802    use std::io::Write;
803    use zip::write::SimpleFileOptions;
804
805    /// Run as a child by `slow_child_is_killed_at_the_deadline`.
806    #[test]
807    #[ignore]
808    fn sleeping_child() {
809        std::thread::sleep(Duration::from_secs(20));
810    }
811
812    #[test]
813    fn slow_child_is_killed_at_the_deadline() {
814        let harness = std::env::current_exe().unwrap();
815        let mut cmd = std::process::Command::new(harness);
816        cmd.args([
817            "--ignored",
818            "--exact",
819            "backend::attachments::extract::tests::sleeping_child",
820        ]);
821        let started = Instant::now();
822        assert_eq!(
823            run_with_deadline(cmd, Duration::from_millis(500)),
824            ChildRun::TimedOut
825        );
826        assert!(started.elapsed() < Duration::from_secs(10));
827    }
828
829    #[test]
830    fn child_output_reports_text_or_why_not() {
831        let d = tempfile::tempdir().unwrap();
832        let rtf = d.path().join("a.rtf");
833        std::fs::write(&rtf, r"{\rtf1 Hello\par}").unwrap();
834        let ok = document_text_isolated(&rtf, FileKind::Word, "a.rtf")
835            .unwrap()
836            .unwrap();
837        assert!(ok.contains("Hello"), "{ok}");
838        let broken = d.path().join("b.docx");
839        std::fs::write(&broken, b"PK\x03\x04 not really").unwrap();
840        assert!(
841            document_text_isolated(&broken, FileKind::Word, "b.docx").is_ok_and(|t| t.is_none())
842        );
843        assert_eq!(child_output("text", "archive", &rtf, "a.rtf"), None);
844    }
845
846    fn zip_file(dir: &Path, name: &str, parts: &[(&str, &str)]) -> std::path::PathBuf {
847        let p = dir.join(name);
848        let mut z = zip::ZipWriter::new(std::fs::File::create(&p).unwrap());
849        for (n, body) in parts {
850            z.start_file(*n, SimpleFileOptions::default()).unwrap();
851            z.write_all(body.as_bytes()).unwrap();
852        }
853        z.finish().unwrap();
854        p
855    }
856
857    const W: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#;
858
859    #[test]
860    fn docx_paragraphs_runs_tabs_and_entities() {
861        let d = tempfile::tempdir().unwrap();
862        let doc = format!(
863            r#"<w:document {W}><w:body>
864<w:p><w:r><w:t>Hello</w:t></w:r><w:r><w:t xml:space="preserve"> world</w:t></w:r></w:p>
865<w:p><w:r><w:t>a</w:t><w:tab/><w:t>b &amp; c</w:t></w:r></w:p>
866</w:body></w:document>"#
867        );
868        let hdr = format!(r#"<w:hdr {W}><w:p><w:r><w:t>Header text</w:t></w:r></w:p></w:hdr>"#);
869        let p = zip_file(
870            d.path(),
871            "r.docx",
872            &[("word/document.xml", &doc), ("word/header1.xml", &hdr)],
873        );
874        let text = office_text(&p, FileKind::Word, "r.docx").unwrap().unwrap();
875        assert!(text.starts_with("Text extracted from r.docx by AgentMux."));
876        assert!(text.contains("Hello world\na\tb & c"), "{text}");
877        assert!(text.contains("Header text"));
878    }
879
880    #[test]
881    fn pptx_follows_presentation_order() {
882        let d = tempfile::tempdir().unwrap();
883        let pres = r#"<p:presentation xmlns:p="p" xmlns:r="r"><p:sldIdLst><p:sldId id="257" r:id="rId3"/><p:sldId id="256" r:id="rId2"/></p:sldIdLst></p:presentation>"#;
884        let rels = r#"<Relationships><Relationship Id="rId2" Target="slides/slide1.xml"/><Relationship Id="rId3" Target="slides/slide2.xml"/></Relationships>"#;
885        let s1 = r#"<p:sld xmlns:p="p" xmlns:a="a"><a:p><a:r><a:t>First file, second slide</a:t></a:r></a:p></p:sld>"#;
886        let s2 = r#"<p:sld xmlns:p="p" xmlns:a="a"><a:p><a:r><a:t>Second file, first slide</a:t></a:r></a:p></p:sld>"#;
887        let p = zip_file(
888            d.path(),
889            "deck.pptx",
890            &[
891                ("ppt/presentation.xml", pres),
892                ("ppt/_rels/presentation.xml.rels", rels),
893                ("ppt/slides/slide1.xml", s1),
894                ("ppt/slides/slide2.xml", s2),
895            ],
896        );
897        let text = office_text(&p, FileKind::PowerPoint, "deck.pptx")
898            .unwrap()
899            .unwrap();
900        let first = text.find("Second file, first slide").unwrap();
901        let second = text.find("First file, second slide").unwrap();
902        assert!(first < second, "{text}");
903        assert!(text.contains("## Slide 1\nSecond file"));
904    }
905
906    #[test]
907    fn odt_paragraphs() {
908        let d = tempfile::tempdir().unwrap();
909        let content = r#"<office:document-content xmlns:office="o" xmlns:text="t"><office:body><office:text><text:h>Title</text:h><text:p>Body<text:s/>text</text:p></office:text></office:body></office:document-content>"#;
910        let p = zip_file(
911            d.path(),
912            "n.odt",
913            &[
914                ("mimetype", "application/vnd.oasis.opendocument.text"),
915                ("content.xml", content),
916            ],
917        );
918        let text = office_text(&p, FileKind::Word, "n.odt").unwrap().unwrap();
919        assert!(text.contains("Title\nBody text"), "{text}");
920    }
921
922    #[test]
923    fn custom_entities_are_never_expanded() {
924        let xml = r#"<!DOCTYPE x [<!ENTITY a "AAAAAAAAAA"><!ENTITY b "&a;&a;&a;">]><w:document xmlns:w="w"><w:p><w:r><w:t>x&b;y</w:t></w:r></w:p></w:document>"#;
925        let text = xml_text(xml, OdfOrOoxml::Ooxml, Instant::now() + DEADLINE).unwrap();
926        assert_eq!(text.trim(), "xy");
927    }
928
929    #[test]
930    fn a_zip_bomb_is_refused() {
931        let d = tempfile::tempdir().unwrap();
932        let p = d.path().join("bomb.docx");
933        let mut z = zip::ZipWriter::new(std::fs::File::create(&p).unwrap());
934        z.start_file("word/document.xml", SimpleFileOptions::default())
935            .unwrap();
936        // 20 MB of zeros compresses about 1000:1.
937        z.write_all(&vec![b'0'; 20 * 1024 * 1024]).unwrap();
938        z.finish().unwrap();
939        let e = office_text(&p, FileKind::Word, "bomb.docx").unwrap_err();
940        assert!(e.0.contains("zip bomb"), "{e}");
941    }
942
943    #[test]
944    fn macros_are_detected_from_the_package() {
945        let d = tempfile::tempdir().unwrap();
946        let plain = zip_file(
947            d.path(),
948            "a.docx",
949            &[
950                ("[Content_Types].xml", "<Types/>"),
951                ("word/document.xml", "<x/>"),
952            ],
953        );
954        let with = zip_file(
955            d.path(),
956            "b.docx",
957            &[
958                ("[Content_Types].xml", "<Types/>"),
959                ("word/document.xml", "<x/>"),
960                ("word/vbaProject.bin", "x"),
961            ],
962        );
963        assert!(!has_macros(&plain));
964        assert!(has_macros(&with), "a .docx can still carry a VBA project");
965    }
966
967    #[test]
968    fn preview_is_the_first_lines_cut_cleanly() {
969        let d = tempfile::tempdir().unwrap();
970        let p = d.path().join("log.txt");
971        let body: String = (0..100).map(|i| format!("line {i} é\n")).collect();
972        std::fs::write(&p, body).unwrap();
973        let prev = text_preview(&p).unwrap();
974        assert_eq!(prev.lines().count(), PREVIEW_LINES);
975        assert!(prev.starts_with("line 0 é"));
976    }
977
978    #[test]
979    fn rtf_text_skips_tables_and_decodes() {
980        let d = tempfile::tempdir().unwrap();
981        let p = d.path().join("n.rtf");
982        std::fs::write(
983            &p,
984            r"{\rtf1\ansi{\fonttbl{\f0 Arial;}}{\colortbl;\red0\green0\blue0;}{\*\generator Word;}\f0 Hello\tab world\par Caf\'e9 \u8364? done\par}",
985        )
986        .unwrap();
987        let t = rtf_text(&p, "n.rtf").unwrap();
988        assert!(t.contains("Hello\tworld\nCafé € done"), "{t}");
989        assert!(!t.contains("Arial") && !t.contains("Word;"), "{t}");
990    }
991
992    #[test]
993    fn pdf_page_count_and_garbage() {
994        let d = tempfile::tempdir().unwrap();
995        let bad = d.path().join("bad.pdf");
996        std::fs::write(&bad, b"%PDF-1.4\nnot really a pdf").unwrap();
997        assert_eq!(pdf_facts_in_process(&bad, "bad.pdf"), PdfFacts::default());
998
999        let good = d.path().join("two.pdf");
1000        let mut doc = lopdf::Document::with_version("1.5");
1001        let pages_id = doc.new_object_id();
1002        let mut kids = Vec::new();
1003        for _ in 0..2 {
1004            let page =
1005                doc.add_object(lopdf::dictionary! { "Type" => "Page", "Parent" => pages_id });
1006            kids.push(page.into());
1007        }
1008        doc.objects.insert(
1009            pages_id,
1010            lopdf::Object::Dictionary(
1011                lopdf::dictionary! { "Type" => "Pages", "Kids" => kids, "Count" => 2 },
1012            ),
1013        );
1014        let catalog =
1015            doc.add_object(lopdf::dictionary! { "Type" => "Catalog", "Pages" => pages_id });
1016        doc.trailer.set("Root", catalog);
1017        doc.save(&good).unwrap();
1018        let facts = pdf_facts_in_process(&good, "two.pdf");
1019        assert_eq!(facts.pages, Some(2));
1020        // Pages with no content have no text.
1021        assert_eq!(facts.text, None);
1022    }
1023}