1use 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
45const MAX_DOCUMENT_INPUT_BYTES: u64 = 128 * 1024 * 1024;
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct Extracted {
60 pub text: String,
65
66 pub metadata: BTreeMap<String, MetaValue>,
71}
72
73impl Extracted {
74 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 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 fn put_num(&mut self, key: &str, value: u64) {
100 self.metadata.insert(key.to_string(), MetaValue::Num(value));
101 }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(untagged)]
109pub enum MetaValue {
110 Str(String),
112 Num(u64),
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum Format {
120 Pdf,
122 Docx,
124 Spreadsheet,
126 Epub,
128 Html,
130}
131
132impl Format {
133 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 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#[derive(Debug, thiserror::Error)]
166pub enum ExtractError {
167 #[error("unsupported document format: {0:?} (supported: pdf, docx, xlsx/xlsm/xlsb/ods, epub, html/htm/xhtml)")]
170 UnsupportedFormat(String),
171
172 #[error("document is encrypted or password-protected: {0}")]
176 Encrypted(String),
177
178 #[error("failed to parse {format} document: {message}")]
181 Parse {
182 format: &'static str,
184 message: String,
186 },
187
188 #[error(transparent)]
190 Io(#[from] std::io::Error),
191}
192
193impl ExtractError {
194 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
206pub type Result<T> = std::result::Result<T, ExtractError>;
208
209pub 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
264pub 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 let Some(first) = lines.iter().position(|l| !l.is_empty()) else {
290 return String::new();
291 };
292 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 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
317fn 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 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
353fn 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
366fn 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
382fn 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
404fn 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 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 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 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 Ok(Event::GeneralRef(r)) => {
479 if in_text_run {
480 out.push_str(&resolve_entity_ref(&r));
481 bound_output!();
482 }
483 }
484 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
507fn 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
517fn resolve_entity_ref(reference: &quick_xml::events::BytesRef<'_>) -> String {
527 if let Ok(Some(ch)) = reference.resolve_char_ref() {
529 return ch.to_string();
530 }
531 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
544const MAX_SPREADSHEET_CELLS: u64 = 2_000_000;
560
561fn extract_spreadsheet(bytes: &[u8], is_ods: bool) -> Result<Extracted> {
576 use calamine::{open_workbook_auto_from_rs, Reader};
577
578 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'); }
607
608 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
654fn 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 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 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
845fn 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 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 Sheets::Xls(_) | Sheets::Ods(_) => Ok(None),
933 }
934}
935
936fn 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 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
971fn render_excel_datetime(dt: &calamine::ExcelDateTime) -> String {
977 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
998const MAX_EPUB_SPINE_ITEMS: usize = 10_000;
1013const MAX_EPUB_MANIFEST_ITEMS: usize = 20_000;
1014const MAX_XML_EVENTS: usize = 1_000_000;
1015
1016const MAX_EXTRACT_OUTPUT_BYTES: usize = 64 * 1024 * 1024;
1026
1027fn extract_epub(bytes: &[u8]) -> Result<Extracted> {
1039 let mut archive = open_zip(Cursor::new(bytes), "epub")?;
1040 let mut budget = ExtractionBudget::default();
1041
1042 let container = read_zip_entry(&mut archive, "META-INF/container.xml", "epub", &mut budget)?;
1044 let opf_path = epub_opf_path(&container)?;
1045
1046 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 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 let mut text = String::new();
1069 let mut chapters = 0u64;
1070 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; };
1079 let entry = join_zip_path(&base, href);
1080 let chapter_text = match rendered.get(&entry) {
1081 Some(cached) => cached.clone(),
1082 None => {
1083 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 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
1122fn 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
1156struct OpfParsed {
1158 manifest: BTreeMap<String, String>,
1160 spine: Vec<String>,
1162 title: Option<String>,
1164}
1165
1166fn 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 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 b"title" if title.is_none() => in_title = true,
1228 _ => {}
1229 },
1230 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 Ok(Event::GeneralRef(r)) => {
1286 if in_title {
1287 title_buf.push_str(&resolve_entity_ref(&r));
1288 }
1289 }
1290 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
1315fn 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
1325fn 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
1344fn 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
1368fn 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
1386fn extract_html(bytes: &[u8]) -> Result<Extracted> {
1392 let text = html_to_text(bytes)?;
1393 Ok(Extracted::new(text, Format::Html))
1394}
1395
1396fn html_to_text(html: &[u8]) -> Result<String> {
1412 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 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 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
1484const MAX_HTML_NESTING_DEPTH: usize = 4_096;
1489
1490const MAX_HTML_TABLE_ROW_CELLS: usize = 4_096;
1504
1505const MAX_HTML_TABLE_CELLS: usize = 200_000;
1516
1517const HTML_VOID_ELEMENTS: &[&str] = &[
1521 "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source",
1522 "track", "wbr",
1523];
1524
1525fn 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 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
1570enum TableBomb {
1574 RowTooWide(usize),
1577 TooManyCells(usize),
1581}
1582
1583fn 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 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
1634fn 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 *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
1716fn 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#[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 (title.to_string(), ())
1804 }
1805 fn header_prefix(&self, _level: usize) -> String {
1806 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 (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#[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 let trimmed = line.trim_start();
1845 let after_hashes = trimmed.trim_start_matches('#');
1846 let line = if after_hashes.len() != trimmed.len() {
1847 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#[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 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
1896fn 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
1919const MAX_ZIP_ENTRIES: u16 = 20_000;
1925const MAX_ZIP_CENTRAL_DIRECTORY_BYTES: u32 = 32 * 1024 * 1024;
1926
1927fn 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
2040const 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
2073fn 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 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 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 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
2126fn 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()); bytes.extend_from_slice(&0u16.to_le_bytes()); bytes.extend_from_slice(&entries.to_le_bytes());
2181 bytes.extend_from_slice(&entries.to_le_bytes());
2182 bytes.extend_from_slice(¢ral_size.to_le_bytes());
2183 bytes.extend_from_slice(¢ral_offset.to_le_bytes());
2184 bytes.extend_from_slice(&0u16.to_le_bytes()); 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 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 fn expected(name: &str) -> String {
2239 std::fs::read_to_string(fixture(&format!("{name}.txt"))).unwrap()
2240 }
2241
2242 fn tokens(s: &str) -> String {
2246 s.split_whitespace().collect::<Vec<_>>().join(" ")
2247 }
2248
2249 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 #[test]
2265 fn excel_datetime_out_of_range_serial_stays_raw_and_never_panics() {
2266 use calamine::{ExcelDateTime, ExcelDateTimeType};
2267 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 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 #[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 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 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 #[test]
2361 fn html_table_scanner_counts_cells_and_classifies_shape() {
2362 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 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 assert!(
2376 html_table_amplification(one_row, 100, 100).is_none(),
2377 "5 cells under both caps must not fire"
2378 );
2379
2380 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 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 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 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 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 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 #[test]
2442 fn regression_html_wide_table_bomb_is_refused_small_table_ok() {
2443 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 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 let rows = MAX_HTML_TABLE_CELLS / 2 + 5; 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 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 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 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 #[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 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 #[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 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 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 #[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 #[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 #[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 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 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 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 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 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 #[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 assert_eq!(unwrap_brackets("a [b c"), "a [b c");
2757 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 assert!(json["metadata"]["sheets"].is_number());
2784 assert!(json["metadata"]["format"].is_string());
2785 }
2786
2787 #[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 assert_eq!(normalize_text(&raw), "only real line\n");
2805
2806 assert_eq!(normalize_text(&" \n".repeat(500_000)), "");
2808 }
2809
2810 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 writer.raw_copy_file(entry).unwrap();
2847 }
2848 }
2849 writer.finish().unwrap();
2850 }
2851
2852 #[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 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 #[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 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 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 #[test]
2928 fn regression_truncated_ods_is_refused_not_hung() {
2929 let tmp = tempfile::TempDir::new().unwrap();
2930
2931 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 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 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 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 & Co invoice <final> total — 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 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 assert_eq!(r("nbsp"), "nbsp");
3070 }
3071
3072 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 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 let tmp = tempfile::TempDir::new().unwrap();
3116
3117 let f1 = tmp.path().join("entity.epub");
3118 write_epub(
3119 &f1,
3120 "<dc:title>Smith & Jones: A <Tale></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 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 assert!(
3159 !got.metadata.contains_key("title"),
3160 "self-closing title must not capture the author, got {:?}",
3161 got.metadata.get("title")
3162 );
3163 assert_eq!(got.metadata["chapters"], MetaValue::Num(1));
3165 }
3166
3167 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 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 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 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 assert_eq!(percent_decode("caf%C3%A9.xhtml"), "café.xhtml");
3251 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 #[test]
3269 fn render_excel_datetime_renders_iso_not_serial() {
3270 use calamine::{ExcelDateTime, ExcelDateTimeType};
3271 let date = ExcelDateTime::new(46188.0, ExcelDateTimeType::DateTime, false);
3273 assert_eq!(render_excel_datetime(&date), "2026-06-15");
3274 let dt = ExcelDateTime::new(46143.5, ExcelDateTimeType::DateTime, false);
3276 assert_eq!(render_excel_datetime(&dt), "2026-05-01 12:00:00");
3277 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 assert_eq!(render_cell(&Data::Float(3450.0)), "3450");
3295 assert_eq!(render_cell(&Data::Int(7)), "7");
3296 }
3297
3298 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 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 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 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 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}