Skip to main content

text_mirror/convert/
mod.rs

1//! The converter boundary and the plain text passthrough.
2//!
3//! Every converter returns the same outcome shape. The registry in
4//! `rules/converters.toml` maps format ids to converters. A format no
5//! converter claims is unsupported, with a declared reason or the
6//! `no-converter` floor. A converter error or invalid
7//! output is a failure with a machine-readable reason and no text
8//! artifact. There is no silent skip and no silently empty file. A
9//! non-empty source that converts to empty text fails with reason
10//! `empty_output` in every converter.
11//!
12//! In-process conversion runs under a panic boundary and explicit
13//! ceilings that apply before allocation in this crate's own code:
14//! the source size before read, the decompressed size of every
15//! archive part and compound stream, the cell extent of every sheet
16//! before the workbook parser runs, and the rendered output size. A
17//! caught panic or an exceeded ceiling becomes one failed record and
18//! the run continues. The panic boundary requires the default
19//! `panic = "unwind"` profile. A downstream `panic = "abort"` build
20//! turns any converter panic into process death, so keep unwinding
21//! enabled wherever this crate converts untrusted bytes.
22
23/// Ceiling on source bytes read for conversion, checked before read.
24pub const MAX_SOURCE_BYTES: u64 = 128 * 1024 * 1024;
25
26/// Ceiling on the decompressed bytes of one archive part or one
27/// compound file stream.
28pub const MAX_PART_BYTES: u64 = 64 * 1024 * 1024;
29
30/// Ceiling on rendered artifact text bytes.
31pub const MAX_OUTPUT_BYTES: usize = 64 * 1024 * 1024;
32
33/// Ceiling on 0-based row indexes in one sheet.
34pub const MAX_SHEET_ROWS: u32 = 1_048_576;
35
36/// Ceiling on 0-based column indexes in one sheet.
37pub const MAX_SHEET_COLUMNS: u32 = 16_384;
38
39/// Ceiling on the row-column product of one sheet, checked from the
40/// source's own references before the workbook parser allocates.
41pub const MAX_SHEET_CELLS: u64 = 10_000_000;
42
43/// Ceilings for the records worker, held as rules data beside the
44/// container limits so a deployment raises them in a visible versioned
45/// bump. The parent reads them from the registry and passes them into
46/// the jailed worker, which enforces them and fails a source over any
47/// ceiling with reason `record-limit-exceeded`.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49#[serde(deny_unknown_fields)]
50pub struct RecordsLimits {
51    /// Records per parquet or avro file, and per sqlite table.
52    pub max_records: u64,
53    /// Tables per sqlite database.
54    pub max_tables: u64,
55    /// Rendered output bytes. Under the runner's response cap.
56    pub max_output_bytes: u64,
57}
58
59impl Default for RecordsLimits {
60    fn default() -> RecordsLimits {
61        RecordsLimits {
62            max_records: 100_000,
63            max_tables: 256,
64            max_output_bytes: 64 * 1024 * 1024,
65        }
66    }
67}
68
69/// The image-OCR jail limit profile, held as rules data beside the
70/// records ceilings so a deployment raises it in a visible versioned
71/// bump. The default runner limits are tuned for the pure-Rust
72/// document worker; the pinned vision runtime maps a far larger
73/// working set, so the image-OCR converter carries its own envelope.
74/// The registry reads this into the converter, which maps it into the
75/// runner limits its jailed worker enforces.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77#[serde(deny_unknown_fields)]
78pub struct ImageOcrLimits {
79    /// Wall-clock ceiling for one image, in seconds.
80    pub wall_timeout_secs: u64,
81    /// Ceiling on the response frame payload, in bytes.
82    pub max_response_bytes: u32,
83    /// Ceiling on stderr bytes.
84    pub max_stderr_bytes: u64,
85    /// RLIMIT_AS for the child, in bytes. Linux only.
86    pub address_space_bytes: u64,
87    /// RLIMIT_CPU for the child, in seconds.
88    pub cpu_seconds: u64,
89    /// RLIMIT_FSIZE for the child, in bytes.
90    pub file_size_bytes: u64,
91    /// RLIMIT_NPROC for the child.
92    pub max_processes: u64,
93}
94
95impl Default for ImageOcrLimits {
96    fn default() -> ImageOcrLimits {
97        ImageOcrLimits {
98            wall_timeout_secs: 300,
99            max_response_bytes: 8 * 1024 * 1024,
100            max_stderr_bytes: 4 * 1024 * 1024,
101            address_space_bytes: 64 * 1024 * 1024 * 1024,
102            cpu_seconds: 600,
103            file_size_bytes: 64 * 1024 * 1024,
104            max_processes: 64,
105        }
106    }
107}
108
109/// The image-metadata parser ceilings, held as rules data beside the
110/// records ceilings so a deployment raises them in a visible versioned
111/// bump. The parent reads them from the registry and passes them into
112/// the jailed worker, which enforces them and fails a source over any
113/// ceiling with a stable `image-metadata-*` reason. The metadata worker
114/// runs behind the shared platform-default jail, so these are the
115/// parser-surface ceilings only, not the wall-clock and address-space
116/// envelope the runner already imposes.
117#[derive(Debug, Clone, Serialize, Deserialize)]
118#[serde(deny_unknown_fields)]
119pub struct ImageMetadataLimits {
120    /// Inflated-size ceiling for any single compressed text block, the
121    /// png `zTXt` and compressed `iTXt` streams above all. Checked
122    /// incrementally during inflation so a decompression bomb is stopped
123    /// before it lands.
124    pub max_decompressed_bytes: u64,
125    /// Nesting-depth ceiling for an xmp parse.
126    pub max_xml_depth: u32,
127    /// Event-count ceiling for an xmp parse.
128    pub max_xml_events: u64,
129    /// Box-count ceiling for an iso base media file format carrier.
130    pub max_boxes: u64,
131    /// Ceiling on emitted metadata rows across all surfaces.
132    pub max_rows: u64,
133    /// Ceiling on the rendered value bytes, under the runner response
134    /// cap.
135    pub max_output_bytes: u64,
136}
137
138impl Default for ImageMetadataLimits {
139    fn default() -> ImageMetadataLimits {
140        ImageMetadataLimits {
141            max_decompressed_bytes: 16 * 1024 * 1024,
142            max_xml_depth: 100,
143            max_xml_events: 1_000_000,
144            max_boxes: 10_000,
145            max_rows: 4096,
146            max_output_bytes: 16 * 1024 * 1024,
147        }
148    }
149}
150
151use std::collections::HashMap;
152use std::fmt;
153
154use serde::{Deserialize, Serialize};
155use unicode_normalization::UnicodeNormalization;
156
157use crate::detect::RESERVED_FORMAT_IDS;
158use crate::segments::Segment;
159use crate::{Error, Result};
160
161mod anydoc_log;
162pub mod container;
163mod differential;
164mod document;
165pub mod eml;
166pub mod html;
167pub mod pdf;
168// The parquet, avro, and sqlite readers. Compiled only for the records
169// worker, so a default build pulls none of the heavy parser trees.
170// Crate-private, so a library consumer cannot run the native parsers in
171// process and bypass the jail. The worker dispatch reaches it through a
172// crate path.
173#[cfg(all(unix, feature = "records-worker"))]
174pub(crate) mod records;
175// The in-jail raster decode and recognize path. Crate-private for the
176// same reason records is: a library consumer must not run the decode
177// and engine path in process and bypass the jail. The worker dispatch
178// reaches it through a crate path.
179#[cfg(all(unix, feature = "image-ocr"))]
180pub(crate) mod image_ocr;
181// The image-metadata derived-child leg. The id, version, and the
182// applies predicate are always compiled so the registry and pipeline can
183// name the leg; the parsers and the parent rendering compile only under
184// the feature, crate-private for the same reason records is: a library
185// consumer must not run the parsers in process and bypass the jail.
186pub mod image_metadata;
187pub mod subprocess;
188mod visibility;
189mod workbook;
190
191pub use container::{
192    CONTAINER_ZIP_ID, CONTAINER_ZIP_VERSION, ContainerLimits, Inflated, ZipContainer,
193    preflight_entry_count,
194};
195pub use differential::{
196    DiffMetrics, DiffVerdict, DifferentialOutcome, compare_texts, normalize_for_diff,
197};
198pub use document::{AnydocDocument, markdown_to_plain};
199pub use eml::{EmlExpansion, EmlMime, expand_eml};
200pub use html::HtmlStrip;
201pub use subprocess::{PdfSubprocess, RecordsSubprocess};
202pub use visibility::{IndexRange, SheetVisibility, WorkbookVisibility, read_visibility};
203pub use workbook::WorkbookIr;
204
205/// What every converter returns on success.
206#[derive(Debug, Clone)]
207pub struct Outcome {
208    /// Stable converter id from the registry.
209    pub converter_id: String,
210    /// Converter version from the registry.
211    pub converter_version: String,
212    /// Format id the converter was asked to handle.
213    pub detected_format: String,
214    /// The converted text, UTF-8, NFC, LF line endings.
215    pub text: String,
216    /// Non-fatal notes about the conversion.
217    pub warnings: Vec<String>,
218    /// Structure spans over `text` for the segments file.
219    pub segments: Vec<Segment>,
220    /// How the text was derived. Every text and structured converter
221    /// sets [`ArtifactKind::Text`]; the pixel-OCR converter sets
222    /// [`ArtifactKind::Ocr`], so the manifest records recognized text
223    /// as OCR rather than extraction.
224    pub artifact_kind: crate::manifest::ArtifactKind,
225}
226
227/// A conversion failure with a machine-readable reason.
228#[derive(Debug, Clone)]
229pub struct ConvertError {
230    /// Stable reason code, such as `invalid_utf8` or `empty_output`.
231    pub code: &'static str,
232    /// Detail for a human reading the manifest.
233    pub message: String,
234}
235
236impl fmt::Display for ConvertError {
237    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
238        write!(f, "{}: {}", self.code, self.message)
239    }
240}
241
242impl std::error::Error for ConvertError {}
243
244/// A converter takes one source's bytes and returns text or a reason.
245///
246/// The caller supplies the bytes, so the hash it records and the bytes
247/// the converter consumed are one snapshot. Implementations must be
248/// deterministic. The same input bytes under the same rules produce
249/// byte-identical output.
250pub trait Converter {
251    /// Stable converter id.
252    fn id(&self) -> &'static str;
253    /// Converter version, part of the incremental checkpoint key.
254    fn version(&self) -> &'static str;
255    /// Converts one source from its bytes.
256    fn convert(
257        &self,
258        source: &[u8],
259        detected_format: &str,
260    ) -> std::result::Result<Outcome, ConvertError>;
261}
262
263/// Normalizes text to the output contract.
264///
265/// CRLF and lone CR become LF, then the text is normalized to NFC.
266/// NFC only. NFKC rewrites identifiers, so it is prohibited.
267pub fn normalize_text(input: &str) -> String {
268    let mut unified = String::with_capacity(input.len());
269    let mut chars = input.chars().peekable();
270    while let Some(c) = chars.next() {
271        if c == '\r' {
272            if chars.peek() == Some(&'\n') {
273                chars.next();
274            }
275            unified.push('\n');
276        } else {
277            unified.push(c);
278        }
279    }
280    unified.nfc().collect()
281}
282
283/// Invisible format characters: code points that render nothing yet are
284/// neither whitespace nor control characters, so a bare emptiness check
285/// would let them through. The set is the soft hyphen, the zero-width
286/// and bidi marks (`U+200B`–`U+200F`), the bidi overrides and embeddings
287/// (`U+202A`–`U+202E`), the word joiner and invisible-operator block
288/// (`U+2060`–`U+2064`), and the byte-order mark (`U+FEFF`). Text whose
289/// only characters are these carries nothing a reader would see. The set
290/// is a documented minimum and is not narrowed; whitespace and control
291/// characters are handled separately by [`is_meaningful`], so the two
292/// together cover the empty-render cases.
293pub(crate) fn is_invisible_format(c: char) -> bool {
294    matches!(
295        c as u32,
296        0x00AD | 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2064 | 0xFEFF
297    )
298}
299
300/// Whether a character is meaningful content: something a reader would
301/// see on the page. Whitespace, control characters, and invisible format
302/// characters are not. This is the shared meaningful-text floor: the PDF
303/// recovery path and the image-OCR converter both gate on the presence
304/// of at least one such character, so a well-formed but content-free
305/// result fails closed rather than passing as a blank success.
306pub(crate) fn is_meaningful(c: char) -> bool {
307    !c.is_whitespace() && !c.is_control() && !is_invisible_format(c)
308}
309
310/// Passthrough for text-native formats.
311///
312/// Reads the source as plain text and applies [`normalize_text`]. It
313/// parses nothing and strips no markup. Input encoding is UTF-8, or
314/// UTF-16 behind a required byte order mark: a leading `FF FE`
315/// decodes as UTF-16LE and `FE FF` as UTF-16BE, the mark is consumed
316/// before decoding, and the outcome carries a transcoding warning. A
317/// UTF-32 mark fails closed with reason `unsupported-encoding`, an
318/// odd trailing byte or unpaired surrogate fails with `invalid_utf16`
319/// naming the byte offset, and a file with no mark must be valid
320/// UTF-8. A leading UTF-8 byte order mark is stripped with a warning.
321/// A NUL scalar anywhere in the decoded text fails closed with reason
322/// `nul_bytes`, because text-native content never holds one and the
323/// usual culprit is a BOM-less UTF-16 export.
324pub struct PlainTextPassthrough;
325
326/// The floor reason for an unsupported record whose detected format
327/// has no converter entry and no declared reason, including true
328/// unknowns.
329pub const NO_CONVERTER_REASON: &str = "no-converter";
330
331/// The reason a records format records on a build without the
332/// `records-worker` feature. The converter exists and the rules route
333/// to it, but this binary's worker has no records mode, so the format
334/// is a deliberate capability gap rather than a converter error.
335pub const RECORDS_NOT_BUILT_REASON: &str = "records-worker-not-built";
336
337/// The reason raster-image formats record on a build without the
338/// `image-ocr` feature, or on a platform with no jail backend. The
339/// converter exists and the rules route to it, but this binary has no
340/// in-jail decode-plus-recognize path, so the format is a deliberate
341/// capability gap rather than a converter error. The checkpoint key
342/// includes `rules_version`, so a later feature-carrying build
343/// reconverts every such record.
344pub const IMAGE_OCR_NOT_BUILT_REASON: &str = "image-ocr-not-built";
345
346/// Registry id of the passthrough converter.
347pub const PASSTHROUGH_ID: &str = "text-passthrough";
348/// Version of the passthrough converter.
349pub const PASSTHROUGH_VERSION: &str = "1.2.0";
350
351/// Decodes UTF-16 byte pairs after a consumed byte order mark.
352///
353/// `mark_len` is the consumed mark's length, so failure offsets name
354/// positions in the original source bytes.
355fn decode_utf16_pairs(
356    bytes: &[u8],
357    big_endian: bool,
358    mark_len: usize,
359) -> std::result::Result<String, ConvertError> {
360    if !bytes.len().is_multiple_of(2) {
361        return Err(ConvertError {
362            code: "invalid_utf16",
363            message: format!("odd trailing byte at byte {}", mark_len + bytes.len() - 1),
364        });
365    }
366    let units = bytes.chunks_exact(2).map(|pair| {
367        if big_endian {
368            u16::from_be_bytes([pair[0], pair[1]])
369        } else {
370            u16::from_le_bytes([pair[0], pair[1]])
371        }
372    });
373    let mut decoded = String::with_capacity(bytes.len() / 2);
374    let mut consumed_units = 0usize;
375    for result in char::decode_utf16(units) {
376        match result {
377            Ok(c) => {
378                decoded.push(c);
379                consumed_units += c.len_utf16();
380            }
381            Err(_) => {
382                return Err(ConvertError {
383                    code: "invalid_utf16",
384                    message: format!(
385                        "unpaired surrogate at byte {}",
386                        mark_len + consumed_units * 2
387                    ),
388                });
389            }
390        }
391    }
392    Ok(decoded)
393}
394
395impl Converter for PlainTextPassthrough {
396    fn id(&self) -> &'static str {
397        PASSTHROUGH_ID
398    }
399
400    fn version(&self) -> &'static str {
401        PASSTHROUGH_VERSION
402    }
403
404    fn convert(
405        &self,
406        source: &[u8],
407        detected_format: &str,
408    ) -> std::result::Result<Outcome, ConvertError> {
409        let source_len = source.len();
410        let mut warnings = Vec::new();
411        // The four-byte UTF-32 marks come first, because the UTF-32LE
412        // mark begins with the UTF-16LE one and would otherwise decode
413        // as UTF-16 with interleaved NULs.
414        let raw = if source.starts_with(&[0xFF, 0xFE, 0x00, 0x00])
415            || source.starts_with(&[0x00, 0x00, 0xFE, 0xFF])
416        {
417            return Err(ConvertError {
418                code: "unsupported-encoding",
419                message: "utf-32 byte order mark".to_string(),
420            });
421        } else if let Some(rest) = source.strip_prefix(&[0xFF, 0xFE][..]) {
422            warnings.push("transcoded from utf-16le".to_string());
423            decode_utf16_pairs(rest, false, 2)?
424        } else if let Some(rest) = source.strip_prefix(&[0xFE, 0xFF][..]) {
425            warnings.push("transcoded from utf-16be".to_string());
426            decode_utf16_pairs(rest, true, 2)?
427        } else {
428            let raw = String::from_utf8(source.to_vec()).map_err(|e| ConvertError {
429                code: "invalid_utf8",
430                message: format!("invalid UTF-8 at byte {}", e.utf8_error().valid_up_to()),
431            })?;
432            match raw.strip_prefix('\u{feff}') {
433                Some(stripped) => {
434                    warnings.push("stripped leading byte order mark".to_string());
435                    stripped.to_string()
436                }
437                None => raw,
438            }
439        };
440        // Legitimate text-native content carries no NUL, and the
441        // common way one appears is a BOM-less UTF-16 export whose
442        // ASCII half happens to be valid UTF-8. Refuse instead of
443        // emitting NUL-split tokens.
444        if let Some(offset) = raw.find('\0') {
445            return Err(ConvertError {
446                code: "nul_bytes",
447                message: format!(
448                    "NUL at byte {offset} of the decoded text, the bytes may be \
449                     BOM-less UTF-16, re-export as UTF-8"
450                ),
451            });
452        }
453        let text = normalize_text(&raw);
454        if source_len > 0 && text.is_empty() {
455            return Err(ConvertError {
456                code: "empty_output",
457                message: format!("source is {source_len} bytes but conversion produced no text"),
458            });
459        }
460        let segments = vec![Segment::span(0, text.len(), "document")];
461        Ok(Outcome {
462            converter_id: PASSTHROUGH_ID.to_string(),
463            converter_version: PASSTHROUGH_VERSION.to_string(),
464            detected_format: detected_format.to_string(),
465            text,
466            warnings,
467            segments,
468            artifact_kind: crate::manifest::ArtifactKind::Text,
469        })
470    }
471}
472
473#[derive(Debug, Deserialize)]
474#[serde(deny_unknown_fields)]
475struct RawRegistry {
476    version: String,
477    converters: Vec<RawEntry>,
478    #[serde(default)]
479    unsupported: Vec<RawUnsupported>,
480    #[serde(default)]
481    containers: Option<ContainerLimits>,
482    #[serde(default)]
483    records: Option<RecordsLimits>,
484    #[serde(default)]
485    image_ocr: Option<ImageOcrLimits>,
486    #[serde(default)]
487    image_metadata: Option<ImageMetadataLimits>,
488}
489
490#[derive(Debug, Deserialize)]
491#[serde(deny_unknown_fields)]
492struct RawEntry {
493    id: String,
494    version: String,
495    formats: Vec<String>,
496}
497
498#[derive(Debug, Deserialize)]
499#[serde(deny_unknown_fields)]
500struct RawUnsupported {
501    reason: String,
502    formats: Vec<String>,
503}
504
505/// The converter registry from `rules/converters.toml`.
506///
507/// The registry is data. A rules bump is a visible versioned event,
508/// and the pinned entry version must match the implementation, so a
509/// code change cannot ship silently under an old version.
510pub struct Registry {
511    version: String,
512    converters: Vec<Box<dyn Converter>>,
513    by_format: HashMap<String, usize>,
514    unsupported_reasons: HashMap<String, String>,
515    container_limits: ContainerLimits,
516    records_limits: RecordsLimits,
517    image_ocr_limits: ImageOcrLimits,
518    image_metadata_limits: ImageMetadataLimits,
519    /// Index into `converters` of the auxiliary image-metadata converter,
520    /// which never enters `by_format`. The pipeline reaches it by this
521    /// index to run the derived-child leg. `None` when the feature is
522    /// absent, so the leg simply does not run.
523    image_metadata_index: Option<usize>,
524}
525
526impl Registry {
527    /// The registry compiled into the binary from `rules/converters.toml`.
528    pub fn builtin() -> Result<Self> {
529        Self::parse(
530            include_str!("../../rules/converters.toml"),
531            "converters.toml",
532        )
533    }
534
535    /// Parses a registry and binds entries to implementations.
536    pub fn parse(text: &str, name: &str) -> Result<Self> {
537        let raw: RawRegistry = toml::from_str(text).map_err(|e| Error::Rules {
538            name: name.to_string(),
539            message: e.to_string(),
540        })?;
541        let records_limits = raw.records.clone().unwrap_or_default();
542        let image_ocr_limits = raw.image_ocr.clone().unwrap_or_default();
543        let image_metadata_limits = raw.image_metadata.clone().unwrap_or_default();
544        let mut converters: Vec<Box<dyn Converter>> = Vec::new();
545        let mut by_format = HashMap::new();
546        let mut unsupported_reasons = HashMap::new();
547        // Reassigned only under the feature; without it the auxiliary
548        // converter is never constructed and the index stays absent.
549        #[allow(unused_mut)]
550        let mut image_metadata_index: Option<usize> = None;
551        for entry in &raw.converters {
552            // A build without the records-worker feature has no records
553            // mode in its worker, so route the records formats to a
554            // deliberate unsupported reason instead of a converter that
555            // would spawn a worker unable to serve them. The rules stay
556            // one shared file: the routing decision is made here at
557            // construction, not by forking the rules.
558            #[cfg(not(feature = "records-worker"))]
559            if entry.id == subprocess::RECORDS_SUBPROCESS_ID {
560                for format in &entry.formats {
561                    if RESERVED_FORMAT_IDS.contains(&format.as_str()) {
562                        return Err(Error::Rules {
563                            name: name.to_string(),
564                            message: format!("format id {format:?} is reserved"),
565                        });
566                    }
567                    if by_format.contains_key(format)
568                        || unsupported_reasons
569                            .insert(format.clone(), RECORDS_NOT_BUILT_REASON.to_string())
570                            .is_some()
571                    {
572                        return Err(Error::Rules {
573                            name: name.to_string(),
574                            message: format!("format {format:?} claimed twice"),
575                        });
576                    }
577                }
578                continue;
579            }
580            // A build without the in-jail image decode path, or a
581            // platform with no jail backend, has no way to decode and
582            // recognize a raster, so route the image formats to a
583            // deliberate unsupported reason instead of a converter that
584            // cannot serve them. The rules stay one shared file: the
585            // routing decision is made here at construction.
586            #[cfg(not(all(unix, feature = "image-ocr")))]
587            if entry.id == subprocess::IMAGE_PIXEL_OCR_ID {
588                for format in &entry.formats {
589                    if RESERVED_FORMAT_IDS.contains(&format.as_str()) {
590                        return Err(Error::Rules {
591                            name: name.to_string(),
592                            message: format!("format id {format:?} is reserved"),
593                        });
594                    }
595                    if by_format.contains_key(format)
596                        || unsupported_reasons
597                            .insert(format.clone(), IMAGE_OCR_NOT_BUILT_REASON.to_string())
598                            .is_some()
599                    {
600                        return Err(Error::Rules {
601                            name: name.to_string(),
602                            message: format!("format {format:?} claimed twice"),
603                        });
604                    }
605                }
606                continue;
607            }
608            // The image-metadata converter is auxiliary and claims no
609            // formats. Without the in-jail metadata reader, or on a
610            // platform with no jail backend, the derived-child leg cannot
611            // run; there are no formats to route to a not-built reason, so
612            // the entry is simply skipped. The pipeline reads
613            // `image_metadata_converter()` as absent and never runs the
614            // leg.
615            #[cfg(not(all(unix, feature = "image-metadata")))]
616            if entry.id == image_metadata::IMAGE_METADATA_ID {
617                continue;
618            }
619            let converter: Box<dyn Converter> = match entry.id.as_str() {
620                PASSTHROUGH_ID => Box::new(PlainTextPassthrough),
621                html::HTML_STRIP_ID => Box::new(HtmlStrip),
622                eml::EML_ID => Box::new(EmlMime),
623                document::ANYDOC_ID => Box::new(AnydocDocument),
624                workbook::WORKBOOK_ID => Box::new(WorkbookIr),
625                subprocess::PDF_SUBPROCESS_ID => Box::new(PdfSubprocess),
626                subprocess::RECORDS_SUBPROCESS_ID => {
627                    Box::new(RecordsSubprocess::new(records_limits.clone()))
628                }
629                #[cfg(all(unix, feature = "image-ocr"))]
630                subprocess::IMAGE_PIXEL_OCR_ID => {
631                    Box::new(subprocess::ImagePixelOcr::new(image_ocr_limits.clone()))
632                }
633                #[cfg(all(unix, feature = "image-metadata"))]
634                image_metadata::IMAGE_METADATA_ID => Box::new(subprocess::ImageMetadata::new(
635                    image_metadata_limits.clone(),
636                )),
637                other => {
638                    return Err(Error::Rules {
639                        name: name.to_string(),
640                        message: format!("unknown converter id {other:?}"),
641                    });
642                }
643            };
644            if entry.version != converter.version() {
645                return Err(Error::Rules {
646                    name: name.to_string(),
647                    message: format!(
648                        "converter {:?} pins version {:?} but the implementation is {:?}",
649                        entry.id,
650                        entry.version,
651                        converter.version()
652                    ),
653                });
654            }
655            let index = converters.len();
656            #[cfg(all(unix, feature = "image-metadata"))]
657            if entry.id == image_metadata::IMAGE_METADATA_ID {
658                image_metadata_index = Some(index);
659            }
660            converters.push(converter);
661            for format in &entry.formats {
662                if RESERVED_FORMAT_IDS.contains(&format.as_str()) {
663                    return Err(Error::Rules {
664                        name: name.to_string(),
665                        message: format!("format id {format:?} is reserved"),
666                    });
667                }
668                if by_format.insert(format.clone(), index).is_some() {
669                    return Err(Error::Rules {
670                        name: name.to_string(),
671                        message: format!("format {format:?} claimed twice"),
672                    });
673                }
674            }
675        }
676        for entry in &raw.unsupported {
677            for format in &entry.formats {
678                if RESERVED_FORMAT_IDS.contains(&format.as_str()) {
679                    return Err(Error::Rules {
680                        name: name.to_string(),
681                        message: format!("format id {format:?} is reserved"),
682                    });
683                }
684                if by_format.contains_key(format)
685                    || unsupported_reasons
686                        .insert(format.clone(), entry.reason.clone())
687                        .is_some()
688                {
689                    return Err(Error::Rules {
690                        name: name.to_string(),
691                        message: format!("format {format:?} claimed twice"),
692                    });
693                }
694            }
695        }
696        Ok(Registry {
697            version: raw.version,
698            converters,
699            by_format,
700            unsupported_reasons,
701            container_limits: raw.containers.unwrap_or_default(),
702            records_limits,
703            image_ocr_limits,
704            image_metadata_limits,
705            image_metadata_index,
706        })
707    }
708
709    /// The registry version, part of the run's rules version.
710    pub fn version(&self) -> &str {
711        &self.version
712    }
713
714    /// The converter that claims a format id, if any.
715    pub fn converter_for(&self, format_id: &str) -> Option<&dyn Converter> {
716        self.by_format
717            .get(format_id)
718            .map(|index| self.converters[*index].as_ref())
719    }
720
721    /// The container expansion limits from the rules.
722    pub fn container_limits(&self) -> &ContainerLimits {
723        &self.container_limits
724    }
725
726    /// The records worker ceilings from the rules.
727    pub fn records_limits(&self) -> &RecordsLimits {
728        &self.records_limits
729    }
730
731    /// The image-OCR jail limit profile from the rules.
732    pub fn image_ocr_limits(&self) -> &ImageOcrLimits {
733        &self.image_ocr_limits
734    }
735
736    /// The image-metadata parser ceilings from the rules.
737    pub fn image_metadata_limits(&self) -> &ImageMetadataLimits {
738        &self.image_metadata_limits
739    }
740
741    /// Test-only: drives the image formats through the fake-engine
742    /// image-OCR adapter instead of the pinned-runtime one, so a pipeline
743    /// test can compose a succeeding primary leg with the metadata leg.
744    /// The shared decode, area guards, and outcome mapping are the
745    /// production ones; only the recognition stage is the fake. A release
746    /// build has no such hook.
747    #[cfg(all(unix, feature = "image-ocr", feature = "test-adapters"))]
748    pub fn use_fake_image_ocr(&mut self) {
749        if let Some(index) = self
750            .converters
751            .iter()
752            .position(|c| c.id() == subprocess::IMAGE_PIXEL_OCR_ID)
753        {
754            self.converters[index] = Box::new(subprocess::ImagePixelOcr::new_fake(
755                self.image_ocr_limits.clone(),
756            ));
757        }
758    }
759
760    /// The auxiliary image-metadata converter, if the feature built one.
761    /// It never enters `by_format`, so the pipeline reaches it here to
762    /// run the derived-child leg. `None` means the leg does not run.
763    pub fn image_metadata_converter(&self) -> Option<&dyn Converter> {
764        self.image_metadata_index
765            .map(|index| self.converters[index].as_ref())
766    }
767
768    /// The reason a format is unsupported.
769    ///
770    /// A declared entry from the registry wins, such as a spreadsheet
771    /// format with no visibility reader yet. Every other format no
772    /// converter claims gets the generic floor
773    /// [`NO_CONVERTER_REASON`], so an unsupported record is never
774    /// reason-less. A format a converter claims returns `None`.
775    pub fn unsupported_reason(&self, format_id: &str) -> Option<&str> {
776        if self.by_format.contains_key(format_id) {
777            return None;
778        }
779        Some(
780            self.unsupported_reasons
781                .get(format_id)
782                .map(String::as_str)
783                .unwrap_or(NO_CONVERTER_REASON),
784        )
785    }
786
787    /// A registry built directly from converter instances, for tests
788    /// that need behavior no shipped converter exhibits, such as a
789    /// panicking converter.
790    #[cfg(test)]
791    pub(crate) fn for_tests(
792        version: &str,
793        entries: Vec<(Box<dyn Converter>, Vec<&str>)>,
794    ) -> Registry {
795        let mut converters = Vec::new();
796        let mut by_format = HashMap::new();
797        for (converter, formats) in entries {
798            let index = converters.len();
799            converters.push(converter);
800            for format in formats {
801                by_format.insert(format.to_string(), index);
802            }
803        }
804        Registry {
805            version: version.to_string(),
806            converters,
807            by_format,
808            unsupported_reasons: HashMap::new(),
809            container_limits: ContainerLimits::default(),
810            records_limits: RecordsLimits::default(),
811            image_ocr_limits: ImageOcrLimits::default(),
812            image_metadata_limits: ImageMetadataLimits::default(),
813            image_metadata_index: None,
814        }
815    }
816}
817
818#[cfg(test)]
819mod tests {
820    use super::*;
821
822    #[test]
823    fn normalize_converts_crlf_to_lf() {
824        assert_eq!(normalize_text("a\r\nb\r\n"), "a\nb\n");
825    }
826
827    #[test]
828    fn normalize_converts_lone_cr_to_lf() {
829        assert_eq!(normalize_text("a\rb\r"), "a\nb\n");
830        assert_eq!(normalize_text("a\r\rb"), "a\n\nb");
831    }
832
833    #[test]
834    fn normalize_composes_to_nfc() {
835        assert_eq!(normalize_text("cafe\u{301}"), "caf\u{e9}");
836    }
837
838    #[test]
839    fn normalize_never_applies_nfkc() {
840        assert_eq!(normalize_text("\u{fb01}le \u{2460}"), "\u{fb01}le \u{2460}");
841    }
842
843    #[test]
844    fn passthrough_rejects_invalid_utf8() {
845        let err = PlainTextPassthrough
846            .convert(b"hello \xff world", "text")
847            .unwrap_err();
848        assert_eq!(err.code, "invalid_utf8");
849    }
850
851    #[test]
852    fn passthrough_fails_on_suspiciously_empty_output() {
853        let err = PlainTextPassthrough
854            .convert("\u{feff}".as_bytes(), "text")
855            .unwrap_err();
856        assert_eq!(err.code, "empty_output");
857    }
858
859    #[test]
860    fn passthrough_accepts_an_empty_source() {
861        let outcome = PlainTextPassthrough.convert(b"", "text").unwrap();
862        assert_eq!(outcome.text, "");
863    }
864
865    fn utf16_bytes(text: &str, big_endian: bool) -> Vec<u8> {
866        let mut bytes = if big_endian {
867            vec![0xFE, 0xFF]
868        } else {
869            vec![0xFF, 0xFE]
870        };
871        for unit in text.encode_utf16() {
872            bytes.extend_from_slice(&if big_endian {
873                unit.to_be_bytes()
874            } else {
875                unit.to_le_bytes()
876            });
877        }
878        bytes
879    }
880
881    #[test]
882    fn passthrough_transcodes_utf16le_behind_a_byte_order_mark() {
883        let source = utf16_bytes("a,b\r\n1,caf\u{e9}\r\n", false);
884        let outcome = PlainTextPassthrough.convert(&source, "csv").unwrap();
885        assert_eq!(outcome.text, "a,b\n1,caf\u{e9}\n");
886        assert_eq!(
887            outcome.warnings,
888            vec!["transcoded from utf-16le".to_string()]
889        );
890    }
891
892    #[test]
893    fn passthrough_transcodes_utf16be_behind_a_byte_order_mark() {
894        let source = utf16_bytes("total \u{5317}\u{4eac}\n", true);
895        let outcome = PlainTextPassthrough.convert(&source, "text").unwrap();
896        assert_eq!(outcome.text, "total \u{5317}\u{4eac}\n");
897        assert_eq!(
898            outcome.warnings,
899            vec!["transcoded from utf-16be".to_string()]
900        );
901    }
902
903    #[test]
904    fn passthrough_refuses_utf32_byte_order_marks() {
905        for source in [
906            b"\xFF\xFE\x00\x00A\x00\x00\x00".as_slice(),
907            b"\x00\x00\xFE\xFF\x00\x00\x00A".as_slice(),
908        ] {
909            let err = PlainTextPassthrough.convert(source, "text").unwrap_err();
910            assert_eq!(err.code, "unsupported-encoding");
911            assert!(err.to_string().starts_with("unsupported-encoding: utf-32"));
912        }
913    }
914
915    #[test]
916    fn passthrough_fails_an_odd_utf16_tail_at_its_offset() {
917        let err = PlainTextPassthrough
918            .convert(b"\xFF\xFEA\x00B", "text")
919            .unwrap_err();
920        assert_eq!(err.code, "invalid_utf16");
921        assert_eq!(err.message, "odd trailing byte at byte 4");
922    }
923
924    #[test]
925    fn passthrough_fails_an_unpaired_surrogate_at_its_offset() {
926        let err = PlainTextPassthrough
927            .convert(b"\xFF\xFEA\x00\x00\xD8", "text")
928            .unwrap_err();
929        assert_eq!(err.code, "invalid_utf16");
930        assert_eq!(err.message, "unpaired surrogate at byte 4");
931    }
932
933    #[test]
934    fn passthrough_refuses_nul_bytes_with_the_first_offset() {
935        let err = PlainTextPassthrough
936            .convert(b"ab\0cd\0", "text")
937            .unwrap_err();
938        assert_eq!(err.code, "nul_bytes");
939        assert!(err.message.contains("byte 2"), "{}", err.message);
940        assert!(err.message.contains("BOM-less UTF-16"), "{}", err.message);
941        // The decoded path gets the same check: a UTF-16 file whose
942        // decoded text holds a NUL fails too.
943        let err = PlainTextPassthrough
944            .convert(b"\xFF\xFEA\x00\x00\x00B\x00", "text")
945            .unwrap_err();
946        assert_eq!(err.code, "nul_bytes");
947        assert!(err.message.contains("byte 1"), "{}", err.message);
948    }
949
950    #[test]
951    fn passthrough_still_fails_bomless_utf16_as_invalid_utf8() {
952        let mut source = utf16_bytes("R\u{e9}sum\u{e9}\n", false);
953        source.drain(..2);
954        let err = PlainTextPassthrough.convert(&source, "text").unwrap_err();
955        assert_eq!(err.code, "invalid_utf8");
956    }
957
958    #[test]
959    fn builtin_registry_claims_the_text_family() {
960        let registry = Registry::builtin().unwrap();
961        assert_eq!(registry.version(), "9");
962        assert!(registry.converter_for("json").is_some());
963        assert!(registry.converter_for("yaml").is_some());
964        assert!(registry.converter_for("svg").is_some());
965        assert!(registry.converter_for("ipynb").is_some());
966        assert!(registry.converter_for("python").is_some());
967        assert!(registry.converter_for("pickle").is_none());
968        assert!(registry.converter_for("text").is_some());
969        assert!(registry.converter_for("markdown").is_some());
970        assert!(registry.converter_for("csv").is_some());
971        assert!(registry.converter_for("pdf").is_some());
972        assert_eq!(
973            registry.converter_for("pdf").map(|c| c.id()),
974            Some("pdf-subprocess")
975        );
976        assert!(registry.converter_for("docx").is_some());
977        assert!(registry.converter_for("xlsx").is_some());
978        assert_eq!(
979            registry.converter_for("html").map(|c| c.id()),
980            Some("html-strip")
981        );
982        assert_eq!(
983            registry.converter_for("eml").map(|c| c.id()),
984            Some("eml-mime")
985        );
986        assert!(registry.converter_for("msg").is_none());
987        assert!(registry.converter_for("xlsb").is_none());
988        assert_eq!(
989            registry.unsupported_reason("xlsb"),
990            Some("hidden-visibility-unresolved")
991        );
992        assert_eq!(
993            registry.unsupported_reason("pickle"),
994            Some("pickle-deserialization-unsafe")
995        );
996        // png, jpeg, and webp are claimed by the image-pixel-ocr
997        // converter now (the image-ocr feature is on under test), so
998        // they carry no unsupported reason. tiff stays engine-unpinned:
999        // the in-jail decoder deliberately excludes it.
1000        for format in ["png", "jpeg", "webp"] {
1001            assert_eq!(
1002                registry.converter_for(format).map(|c| c.id()),
1003                Some("image-pixel-ocr"),
1004                "{format}"
1005            );
1006            assert_eq!(registry.unsupported_reason(format), None, "{format}");
1007        }
1008        assert_eq!(registry.unsupported_reason("tiff"), Some("engine-unpinned"));
1009        // heic cannot be decoded by the pure-Rust jail path and fails
1010        // closed until the opt-in external provider is built.
1011        assert_eq!(
1012            registry.unsupported_reason("heic"),
1013            Some("no-jailed-rasterizer")
1014        );
1015        // ai routes to the pdf path: a PDF-backed .ai keeps its text
1016        // layer, and a legacy PostScript-backed .ai fails closed with a
1017        // pdf-family reason rather than as an unknown format.
1018        assert_eq!(
1019            registry.converter_for("ai").map(|c| c.id()),
1020            Some("pdf-subprocess")
1021        );
1022        // Parquet, avro, and sqlite are claimed by the records worker
1023        // now, so they carry no unsupported reason. arrow and the rest
1024        // stay deferred.
1025        for format in ["parquet", "avro", "sqlite"] {
1026            assert_eq!(
1027                registry.converter_for(format).map(|c| c.id()),
1028                Some("records-worker"),
1029                "{format}"
1030            );
1031            assert_eq!(registry.unsupported_reason(format), None, "{format}");
1032        }
1033        assert_eq!(
1034            registry.unsupported_reason("arrow"),
1035            Some("converter-deferred")
1036        );
1037        assert_eq!(
1038            registry.unsupported_reason("tar"),
1039            Some("container-deferred")
1040        );
1041        assert_eq!(
1042            registry.unsupported_reason("psd"),
1043            Some("proprietary-binary")
1044        );
1045        // The floor: a format nothing claims and nothing declares.
1046        assert_eq!(
1047            registry.unsupported_reason("unknown"),
1048            Some(NO_CONVERTER_REASON)
1049        );
1050        // A claimed format has no unsupported reason.
1051        assert_eq!(registry.unsupported_reason("text"), None);
1052        assert_eq!(registry.unsupported_reason("mp4"), Some("engine-unpinned"));
1053        // html is claimed now, and msg stays on the floor: it is a
1054        // compound file, not an RFC 822 message.
1055        assert_eq!(registry.unsupported_reason("html"), None);
1056        assert_eq!(
1057            registry.unsupported_reason("msg"),
1058            Some(NO_CONVERTER_REASON)
1059        );
1060    }
1061
1062    // The raster family routes to a live converter when the `image-ocr`
1063    // feature is built with a jail backend, and to a deliberate
1064    // `image-ocr-not-built` capability gap otherwise. Both arms are
1065    // asserted here through a runtime branch, so both compile in every
1066    // configuration and neither rots. The self dev-dependency turns the
1067    // feature on for the normal suite, so `cargo test` runs the feature-
1068    // present arm; the feature-absent arm runs whenever the crate is
1069    // built without the feature, which is a documented CI step:
1070    //
1071    //   cargo test --no-default-features -p text-mirror   # crate built
1072    //   without the self dev-dependency, so the feature is genuinely off
1073    //
1074    // The unsupported reason itself is asserted directly regardless of
1075    // configuration so the not-built plumbing never goes untested.
1076    #[test]
1077    fn the_raster_family_routes_by_the_image_ocr_feature() {
1078        let registry = Registry::builtin().unwrap();
1079        let feature_present = cfg!(all(unix, feature = "image-ocr"));
1080        for format in ["png", "jpeg", "webp"] {
1081            if feature_present {
1082                // A live converter claims the format, so it carries no
1083                // unsupported reason.
1084                assert!(registry.converter_for(format).is_some(), "{format}");
1085                assert_eq!(registry.unsupported_reason(format), None, "{format}");
1086            } else {
1087                // A deliberate capability gap: no converter, and the
1088                // not-built reason rather than a converter error.
1089                assert!(registry.converter_for(format).is_none(), "{format}");
1090                assert_eq!(
1091                    registry.unsupported_reason(format),
1092                    Some(IMAGE_OCR_NOT_BUILT_REASON),
1093                    "{format}"
1094                );
1095            }
1096        }
1097    }
1098
1099    #[test]
1100    fn registry_rejects_reserved_format_ids() {
1101        for (section, id) in [
1102            ("converters", "symlink"),
1103            ("converters", "unknown"),
1104            ("unsupported", "symlink"),
1105            ("unsupported", "unknown"),
1106        ] {
1107            let toml = if section == "converters" {
1108                format!(
1109                    "version = \"1\"\n[[converters]]\nid = \"text-passthrough\"\nversion = \"1.2.0\"\nformats = [\"{id}\"]\n"
1110                )
1111            } else {
1112                format!(
1113                    "version = \"1\"\nconverters = []\n[[unsupported]]\nreason = \"r\"\nformats = [\"{id}\"]\n"
1114                )
1115            };
1116            let err = match Registry::parse(&toml, "converters.toml") {
1117                Ok(_) => panic!("{section} {id}: reserved id was accepted"),
1118                Err(e) => e,
1119            };
1120            assert!(
1121                err.to_string().contains("reserved"),
1122                "{section} {id}: {err}"
1123            );
1124        }
1125    }
1126
1127    #[test]
1128    fn registry_rejects_a_version_that_contradicts_the_implementation() {
1129        let toml = r#"
1130version = "1"
1131
1132[[converters]]
1133id = "text-passthrough"
1134version = "9.9.9"
1135formats = ["text"]
1136"#;
1137        assert!(Registry::parse(toml, "converters.toml").is_err());
1138    }
1139}