1#[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;
32pub const MAX_DECOMPRESSED_BYTES: u64 = 200 * 1024 * 1024;
34pub const MAX_COMPRESSION_RATIO: u64 = 100;
37pub const MAX_TEXT_BYTES: usize = 2 * 1024 * 1024;
39pub const DEADLINE: Duration = Duration::from_secs(30);
40pub const MAX_PDF_BYTES_FOR_COUNT: u64 = 100 * 1024 * 1024;
42
43pub const PREVIEW_LINES: usize = 40;
44pub const PREVIEW_BYTES: usize = 2048;
45
46pub 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#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
65pub struct PdfFacts {
66 pub pages: Option<u32>,
67 pub text: Option<String>,
70}
71
72pub const CHILD_ARG: &str = "__extract";
76
77pub 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#[derive(Debug, PartialEq, Eq)]
107enum ChildRun {
108 Output(Vec<u8>),
109 TimedOut,
110 Failed,
111}
112
113fn 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 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#[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 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#[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
182pub 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#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
194struct TextOutcome {
195 text: Option<String>,
196 error: Option<String>,
197}
198
199pub 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
216pub 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
246pub 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
263fn 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
286pub 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 let chars: &[u8] = &raw;
301 let mut out = String::new();
302 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 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 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 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#[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
443pub 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 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
485pub 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
518fn 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
548fn 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 Ooxml,
571 Odf,
573}
574
575fn 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 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 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
689fn 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
740fn 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
767fn 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 #[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 & 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 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 assert_eq!(facts.text, None);
1022 }
1023}