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
69use std::collections::HashMap;
70use std::fmt;
71
72use serde::{Deserialize, Serialize};
73use unicode_normalization::UnicodeNormalization;
74
75use crate::detect::RESERVED_FORMAT_IDS;
76use crate::segments::Segment;
77use crate::{Error, Result};
78
79mod anydoc_log;
80pub mod container;
81mod differential;
82mod document;
83pub mod eml;
84pub mod html;
85pub mod pdf;
86// The parquet, avro, and sqlite readers. Compiled only for the records
87// worker, so a default build pulls none of the heavy parser trees.
88// Crate-private, so a library consumer cannot run the native parsers in
89// process and bypass the jail. The worker dispatch reaches it through a
90// crate path.
91#[cfg(all(unix, feature = "records-worker"))]
92pub(crate) mod records;
93pub mod subprocess;
94mod visibility;
95mod workbook;
96
97pub use container::{
98    CONTAINER_ZIP_ID, CONTAINER_ZIP_VERSION, ContainerLimits, Inflated, ZipContainer,
99    preflight_entry_count,
100};
101pub use differential::{
102    DiffMetrics, DiffVerdict, DifferentialOutcome, compare_texts, normalize_for_diff,
103};
104pub use document::{AnydocDocument, markdown_to_plain};
105pub use eml::{EmlExpansion, EmlMime, expand_eml};
106pub use html::HtmlStrip;
107pub use subprocess::{PdfSubprocess, RecordsSubprocess};
108pub use visibility::{IndexRange, SheetVisibility, WorkbookVisibility, read_visibility};
109pub use workbook::WorkbookIr;
110
111/// What every converter returns on success.
112#[derive(Debug, Clone)]
113pub struct Outcome {
114    /// Stable converter id from the registry.
115    pub converter_id: String,
116    /// Converter version from the registry.
117    pub converter_version: String,
118    /// Format id the converter was asked to handle.
119    pub detected_format: String,
120    /// The converted text, UTF-8, NFC, LF line endings.
121    pub text: String,
122    /// Non-fatal notes about the conversion.
123    pub warnings: Vec<String>,
124    /// Structure spans over `text` for the segments file.
125    pub segments: Vec<Segment>,
126}
127
128/// A conversion failure with a machine-readable reason.
129#[derive(Debug, Clone)]
130pub struct ConvertError {
131    /// Stable reason code, such as `invalid_utf8` or `empty_output`.
132    pub code: &'static str,
133    /// Detail for a human reading the manifest.
134    pub message: String,
135}
136
137impl fmt::Display for ConvertError {
138    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139        write!(f, "{}: {}", self.code, self.message)
140    }
141}
142
143impl std::error::Error for ConvertError {}
144
145/// A converter takes one source's bytes and returns text or a reason.
146///
147/// The caller supplies the bytes, so the hash it records and the bytes
148/// the converter consumed are one snapshot. Implementations must be
149/// deterministic. The same input bytes under the same rules produce
150/// byte-identical output.
151pub trait Converter {
152    /// Stable converter id.
153    fn id(&self) -> &'static str;
154    /// Converter version, part of the incremental checkpoint key.
155    fn version(&self) -> &'static str;
156    /// Converts one source from its bytes.
157    fn convert(
158        &self,
159        source: &[u8],
160        detected_format: &str,
161    ) -> std::result::Result<Outcome, ConvertError>;
162}
163
164/// Normalizes text to the output contract.
165///
166/// CRLF and lone CR become LF, then the text is normalized to NFC.
167/// NFC only. NFKC rewrites identifiers, so it is prohibited.
168pub fn normalize_text(input: &str) -> String {
169    let mut unified = String::with_capacity(input.len());
170    let mut chars = input.chars().peekable();
171    while let Some(c) = chars.next() {
172        if c == '\r' {
173            if chars.peek() == Some(&'\n') {
174                chars.next();
175            }
176            unified.push('\n');
177        } else {
178            unified.push(c);
179        }
180    }
181    unified.nfc().collect()
182}
183
184/// Passthrough for text-native formats.
185///
186/// Reads the source as plain text and applies [`normalize_text`]. It
187/// parses nothing and strips no markup. Input encoding is UTF-8, or
188/// UTF-16 behind a required byte order mark: a leading `FF FE`
189/// decodes as UTF-16LE and `FE FF` as UTF-16BE, the mark is consumed
190/// before decoding, and the outcome carries a transcoding warning. A
191/// UTF-32 mark fails closed with reason `unsupported-encoding`, an
192/// odd trailing byte or unpaired surrogate fails with `invalid_utf16`
193/// naming the byte offset, and a file with no mark must be valid
194/// UTF-8. A leading UTF-8 byte order mark is stripped with a warning.
195/// A NUL scalar anywhere in the decoded text fails closed with reason
196/// `nul_bytes`, because text-native content never holds one and the
197/// usual culprit is a BOM-less UTF-16 export.
198pub struct PlainTextPassthrough;
199
200/// The floor reason for an unsupported record whose detected format
201/// has no converter entry and no declared reason, including true
202/// unknowns.
203pub const NO_CONVERTER_REASON: &str = "no-converter";
204
205/// The reason a records format records on a build without the
206/// `records-worker` feature. The converter exists and the rules route
207/// to it, but this binary's worker has no records mode, so the format
208/// is a deliberate capability gap rather than a converter error.
209pub const RECORDS_NOT_BUILT_REASON: &str = "records-worker-not-built";
210
211/// Registry id of the passthrough converter.
212pub const PASSTHROUGH_ID: &str = "text-passthrough";
213/// Version of the passthrough converter.
214pub const PASSTHROUGH_VERSION: &str = "1.2.0";
215
216/// Decodes UTF-16 byte pairs after a consumed byte order mark.
217///
218/// `mark_len` is the consumed mark's length, so failure offsets name
219/// positions in the original source bytes.
220fn decode_utf16_pairs(
221    bytes: &[u8],
222    big_endian: bool,
223    mark_len: usize,
224) -> std::result::Result<String, ConvertError> {
225    if !bytes.len().is_multiple_of(2) {
226        return Err(ConvertError {
227            code: "invalid_utf16",
228            message: format!("odd trailing byte at byte {}", mark_len + bytes.len() - 1),
229        });
230    }
231    let units = bytes.chunks_exact(2).map(|pair| {
232        if big_endian {
233            u16::from_be_bytes([pair[0], pair[1]])
234        } else {
235            u16::from_le_bytes([pair[0], pair[1]])
236        }
237    });
238    let mut decoded = String::with_capacity(bytes.len() / 2);
239    let mut consumed_units = 0usize;
240    for result in char::decode_utf16(units) {
241        match result {
242            Ok(c) => {
243                decoded.push(c);
244                consumed_units += c.len_utf16();
245            }
246            Err(_) => {
247                return Err(ConvertError {
248                    code: "invalid_utf16",
249                    message: format!(
250                        "unpaired surrogate at byte {}",
251                        mark_len + consumed_units * 2
252                    ),
253                });
254            }
255        }
256    }
257    Ok(decoded)
258}
259
260impl Converter for PlainTextPassthrough {
261    fn id(&self) -> &'static str {
262        PASSTHROUGH_ID
263    }
264
265    fn version(&self) -> &'static str {
266        PASSTHROUGH_VERSION
267    }
268
269    fn convert(
270        &self,
271        source: &[u8],
272        detected_format: &str,
273    ) -> std::result::Result<Outcome, ConvertError> {
274        let source_len = source.len();
275        let mut warnings = Vec::new();
276        // The four-byte UTF-32 marks come first, because the UTF-32LE
277        // mark begins with the UTF-16LE one and would otherwise decode
278        // as UTF-16 with interleaved NULs.
279        let raw = if source.starts_with(&[0xFF, 0xFE, 0x00, 0x00])
280            || source.starts_with(&[0x00, 0x00, 0xFE, 0xFF])
281        {
282            return Err(ConvertError {
283                code: "unsupported-encoding",
284                message: "utf-32 byte order mark".to_string(),
285            });
286        } else if let Some(rest) = source.strip_prefix(&[0xFF, 0xFE][..]) {
287            warnings.push("transcoded from utf-16le".to_string());
288            decode_utf16_pairs(rest, false, 2)?
289        } else if let Some(rest) = source.strip_prefix(&[0xFE, 0xFF][..]) {
290            warnings.push("transcoded from utf-16be".to_string());
291            decode_utf16_pairs(rest, true, 2)?
292        } else {
293            let raw = String::from_utf8(source.to_vec()).map_err(|e| ConvertError {
294                code: "invalid_utf8",
295                message: format!("invalid UTF-8 at byte {}", e.utf8_error().valid_up_to()),
296            })?;
297            match raw.strip_prefix('\u{feff}') {
298                Some(stripped) => {
299                    warnings.push("stripped leading byte order mark".to_string());
300                    stripped.to_string()
301                }
302                None => raw,
303            }
304        };
305        // Legitimate text-native content carries no NUL, and the
306        // common way one appears is a BOM-less UTF-16 export whose
307        // ASCII half happens to be valid UTF-8. Refuse instead of
308        // emitting NUL-split tokens.
309        if let Some(offset) = raw.find('\0') {
310            return Err(ConvertError {
311                code: "nul_bytes",
312                message: format!(
313                    "NUL at byte {offset} of the decoded text, the bytes may be \
314                     BOM-less UTF-16, re-export as UTF-8"
315                ),
316            });
317        }
318        let text = normalize_text(&raw);
319        if source_len > 0 && text.is_empty() {
320            return Err(ConvertError {
321                code: "empty_output",
322                message: format!("source is {source_len} bytes but conversion produced no text"),
323            });
324        }
325        let segments = vec![Segment::span(0, text.len(), "document")];
326        Ok(Outcome {
327            converter_id: PASSTHROUGH_ID.to_string(),
328            converter_version: PASSTHROUGH_VERSION.to_string(),
329            detected_format: detected_format.to_string(),
330            text,
331            warnings,
332            segments,
333        })
334    }
335}
336
337#[derive(Debug, Deserialize)]
338#[serde(deny_unknown_fields)]
339struct RawRegistry {
340    version: String,
341    converters: Vec<RawEntry>,
342    #[serde(default)]
343    unsupported: Vec<RawUnsupported>,
344    #[serde(default)]
345    containers: Option<ContainerLimits>,
346    #[serde(default)]
347    records: Option<RecordsLimits>,
348}
349
350#[derive(Debug, Deserialize)]
351#[serde(deny_unknown_fields)]
352struct RawEntry {
353    id: String,
354    version: String,
355    formats: Vec<String>,
356}
357
358#[derive(Debug, Deserialize)]
359#[serde(deny_unknown_fields)]
360struct RawUnsupported {
361    reason: String,
362    formats: Vec<String>,
363}
364
365/// The converter registry from `rules/converters.toml`.
366///
367/// The registry is data. A rules bump is a visible versioned event,
368/// and the pinned entry version must match the implementation, so a
369/// code change cannot ship silently under an old version.
370pub struct Registry {
371    version: String,
372    converters: Vec<Box<dyn Converter>>,
373    by_format: HashMap<String, usize>,
374    unsupported_reasons: HashMap<String, String>,
375    container_limits: ContainerLimits,
376    records_limits: RecordsLimits,
377}
378
379impl Registry {
380    /// The registry compiled into the binary from `rules/converters.toml`.
381    pub fn builtin() -> Result<Self> {
382        Self::parse(
383            include_str!("../../rules/converters.toml"),
384            "converters.toml",
385        )
386    }
387
388    /// Parses a registry and binds entries to implementations.
389    pub fn parse(text: &str, name: &str) -> Result<Self> {
390        let raw: RawRegistry = toml::from_str(text).map_err(|e| Error::Rules {
391            name: name.to_string(),
392            message: e.to_string(),
393        })?;
394        let records_limits = raw.records.clone().unwrap_or_default();
395        let mut converters: Vec<Box<dyn Converter>> = Vec::new();
396        let mut by_format = HashMap::new();
397        let mut unsupported_reasons = HashMap::new();
398        for entry in &raw.converters {
399            // A build without the records-worker feature has no records
400            // mode in its worker, so route the records formats to a
401            // deliberate unsupported reason instead of a converter that
402            // would spawn a worker unable to serve them. The rules stay
403            // one shared file: the routing decision is made here at
404            // construction, not by forking the rules.
405            #[cfg(not(feature = "records-worker"))]
406            if entry.id == subprocess::RECORDS_SUBPROCESS_ID {
407                for format in &entry.formats {
408                    if RESERVED_FORMAT_IDS.contains(&format.as_str()) {
409                        return Err(Error::Rules {
410                            name: name.to_string(),
411                            message: format!("format id {format:?} is reserved"),
412                        });
413                    }
414                    if by_format.contains_key(format)
415                        || unsupported_reasons
416                            .insert(format.clone(), RECORDS_NOT_BUILT_REASON.to_string())
417                            .is_some()
418                    {
419                        return Err(Error::Rules {
420                            name: name.to_string(),
421                            message: format!("format {format:?} claimed twice"),
422                        });
423                    }
424                }
425                continue;
426            }
427            let converter: Box<dyn Converter> = match entry.id.as_str() {
428                PASSTHROUGH_ID => Box::new(PlainTextPassthrough),
429                html::HTML_STRIP_ID => Box::new(HtmlStrip),
430                eml::EML_ID => Box::new(EmlMime),
431                document::ANYDOC_ID => Box::new(AnydocDocument),
432                workbook::WORKBOOK_ID => Box::new(WorkbookIr),
433                subprocess::PDF_SUBPROCESS_ID => Box::new(PdfSubprocess),
434                subprocess::RECORDS_SUBPROCESS_ID => {
435                    Box::new(RecordsSubprocess::new(records_limits.clone()))
436                }
437                other => {
438                    return Err(Error::Rules {
439                        name: name.to_string(),
440                        message: format!("unknown converter id {other:?}"),
441                    });
442                }
443            };
444            if entry.version != converter.version() {
445                return Err(Error::Rules {
446                    name: name.to_string(),
447                    message: format!(
448                        "converter {:?} pins version {:?} but the implementation is {:?}",
449                        entry.id,
450                        entry.version,
451                        converter.version()
452                    ),
453                });
454            }
455            let index = converters.len();
456            converters.push(converter);
457            for format in &entry.formats {
458                if RESERVED_FORMAT_IDS.contains(&format.as_str()) {
459                    return Err(Error::Rules {
460                        name: name.to_string(),
461                        message: format!("format id {format:?} is reserved"),
462                    });
463                }
464                if by_format.insert(format.clone(), index).is_some() {
465                    return Err(Error::Rules {
466                        name: name.to_string(),
467                        message: format!("format {format:?} claimed twice"),
468                    });
469                }
470            }
471        }
472        for entry in &raw.unsupported {
473            for format in &entry.formats {
474                if RESERVED_FORMAT_IDS.contains(&format.as_str()) {
475                    return Err(Error::Rules {
476                        name: name.to_string(),
477                        message: format!("format id {format:?} is reserved"),
478                    });
479                }
480                if by_format.contains_key(format)
481                    || unsupported_reasons
482                        .insert(format.clone(), entry.reason.clone())
483                        .is_some()
484                {
485                    return Err(Error::Rules {
486                        name: name.to_string(),
487                        message: format!("format {format:?} claimed twice"),
488                    });
489                }
490            }
491        }
492        Ok(Registry {
493            version: raw.version,
494            converters,
495            by_format,
496            unsupported_reasons,
497            container_limits: raw.containers.unwrap_or_default(),
498            records_limits,
499        })
500    }
501
502    /// The registry version, part of the run's rules version.
503    pub fn version(&self) -> &str {
504        &self.version
505    }
506
507    /// The converter that claims a format id, if any.
508    pub fn converter_for(&self, format_id: &str) -> Option<&dyn Converter> {
509        self.by_format
510            .get(format_id)
511            .map(|index| self.converters[*index].as_ref())
512    }
513
514    /// The container expansion limits from the rules.
515    pub fn container_limits(&self) -> &ContainerLimits {
516        &self.container_limits
517    }
518
519    /// The records worker ceilings from the rules.
520    pub fn records_limits(&self) -> &RecordsLimits {
521        &self.records_limits
522    }
523
524    /// The reason a format is unsupported.
525    ///
526    /// A declared entry from the registry wins, such as a spreadsheet
527    /// format with no visibility reader yet. Every other format no
528    /// converter claims gets the generic floor
529    /// [`NO_CONVERTER_REASON`], so an unsupported record is never
530    /// reason-less. A format a converter claims returns `None`.
531    pub fn unsupported_reason(&self, format_id: &str) -> Option<&str> {
532        if self.by_format.contains_key(format_id) {
533            return None;
534        }
535        Some(
536            self.unsupported_reasons
537                .get(format_id)
538                .map(String::as_str)
539                .unwrap_or(NO_CONVERTER_REASON),
540        )
541    }
542
543    /// A registry built directly from converter instances, for tests
544    /// that need behavior no shipped converter exhibits, such as a
545    /// panicking converter.
546    #[cfg(test)]
547    pub(crate) fn for_tests(
548        version: &str,
549        entries: Vec<(Box<dyn Converter>, Vec<&str>)>,
550    ) -> Registry {
551        let mut converters = Vec::new();
552        let mut by_format = HashMap::new();
553        for (converter, formats) in entries {
554            let index = converters.len();
555            converters.push(converter);
556            for format in formats {
557                by_format.insert(format.to_string(), index);
558            }
559        }
560        Registry {
561            version: version.to_string(),
562            converters,
563            by_format,
564            unsupported_reasons: HashMap::new(),
565            container_limits: ContainerLimits::default(),
566            records_limits: RecordsLimits::default(),
567        }
568    }
569}
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574
575    #[test]
576    fn normalize_converts_crlf_to_lf() {
577        assert_eq!(normalize_text("a\r\nb\r\n"), "a\nb\n");
578    }
579
580    #[test]
581    fn normalize_converts_lone_cr_to_lf() {
582        assert_eq!(normalize_text("a\rb\r"), "a\nb\n");
583        assert_eq!(normalize_text("a\r\rb"), "a\n\nb");
584    }
585
586    #[test]
587    fn normalize_composes_to_nfc() {
588        assert_eq!(normalize_text("cafe\u{301}"), "caf\u{e9}");
589    }
590
591    #[test]
592    fn normalize_never_applies_nfkc() {
593        assert_eq!(normalize_text("\u{fb01}le \u{2460}"), "\u{fb01}le \u{2460}");
594    }
595
596    #[test]
597    fn passthrough_rejects_invalid_utf8() {
598        let err = PlainTextPassthrough
599            .convert(b"hello \xff world", "text")
600            .unwrap_err();
601        assert_eq!(err.code, "invalid_utf8");
602    }
603
604    #[test]
605    fn passthrough_fails_on_suspiciously_empty_output() {
606        let err = PlainTextPassthrough
607            .convert("\u{feff}".as_bytes(), "text")
608            .unwrap_err();
609        assert_eq!(err.code, "empty_output");
610    }
611
612    #[test]
613    fn passthrough_accepts_an_empty_source() {
614        let outcome = PlainTextPassthrough.convert(b"", "text").unwrap();
615        assert_eq!(outcome.text, "");
616    }
617
618    fn utf16_bytes(text: &str, big_endian: bool) -> Vec<u8> {
619        let mut bytes = if big_endian {
620            vec![0xFE, 0xFF]
621        } else {
622            vec![0xFF, 0xFE]
623        };
624        for unit in text.encode_utf16() {
625            bytes.extend_from_slice(&if big_endian {
626                unit.to_be_bytes()
627            } else {
628                unit.to_le_bytes()
629            });
630        }
631        bytes
632    }
633
634    #[test]
635    fn passthrough_transcodes_utf16le_behind_a_byte_order_mark() {
636        let source = utf16_bytes("a,b\r\n1,caf\u{e9}\r\n", false);
637        let outcome = PlainTextPassthrough.convert(&source, "csv").unwrap();
638        assert_eq!(outcome.text, "a,b\n1,caf\u{e9}\n");
639        assert_eq!(
640            outcome.warnings,
641            vec!["transcoded from utf-16le".to_string()]
642        );
643    }
644
645    #[test]
646    fn passthrough_transcodes_utf16be_behind_a_byte_order_mark() {
647        let source = utf16_bytes("total \u{5317}\u{4eac}\n", true);
648        let outcome = PlainTextPassthrough.convert(&source, "text").unwrap();
649        assert_eq!(outcome.text, "total \u{5317}\u{4eac}\n");
650        assert_eq!(
651            outcome.warnings,
652            vec!["transcoded from utf-16be".to_string()]
653        );
654    }
655
656    #[test]
657    fn passthrough_refuses_utf32_byte_order_marks() {
658        for source in [
659            b"\xFF\xFE\x00\x00A\x00\x00\x00".as_slice(),
660            b"\x00\x00\xFE\xFF\x00\x00\x00A".as_slice(),
661        ] {
662            let err = PlainTextPassthrough.convert(source, "text").unwrap_err();
663            assert_eq!(err.code, "unsupported-encoding");
664            assert!(err.to_string().starts_with("unsupported-encoding: utf-32"));
665        }
666    }
667
668    #[test]
669    fn passthrough_fails_an_odd_utf16_tail_at_its_offset() {
670        let err = PlainTextPassthrough
671            .convert(b"\xFF\xFEA\x00B", "text")
672            .unwrap_err();
673        assert_eq!(err.code, "invalid_utf16");
674        assert_eq!(err.message, "odd trailing byte at byte 4");
675    }
676
677    #[test]
678    fn passthrough_fails_an_unpaired_surrogate_at_its_offset() {
679        let err = PlainTextPassthrough
680            .convert(b"\xFF\xFEA\x00\x00\xD8", "text")
681            .unwrap_err();
682        assert_eq!(err.code, "invalid_utf16");
683        assert_eq!(err.message, "unpaired surrogate at byte 4");
684    }
685
686    #[test]
687    fn passthrough_refuses_nul_bytes_with_the_first_offset() {
688        let err = PlainTextPassthrough
689            .convert(b"ab\0cd\0", "text")
690            .unwrap_err();
691        assert_eq!(err.code, "nul_bytes");
692        assert!(err.message.contains("byte 2"), "{}", err.message);
693        assert!(err.message.contains("BOM-less UTF-16"), "{}", err.message);
694        // The decoded path gets the same check: a UTF-16 file whose
695        // decoded text holds a NUL fails too.
696        let err = PlainTextPassthrough
697            .convert(b"\xFF\xFEA\x00\x00\x00B\x00", "text")
698            .unwrap_err();
699        assert_eq!(err.code, "nul_bytes");
700        assert!(err.message.contains("byte 1"), "{}", err.message);
701    }
702
703    #[test]
704    fn passthrough_still_fails_bomless_utf16_as_invalid_utf8() {
705        let mut source = utf16_bytes("R\u{e9}sum\u{e9}\n", false);
706        source.drain(..2);
707        let err = PlainTextPassthrough.convert(&source, "text").unwrap_err();
708        assert_eq!(err.code, "invalid_utf8");
709    }
710
711    #[test]
712    fn builtin_registry_claims_the_text_family() {
713        let registry = Registry::builtin().unwrap();
714        assert_eq!(registry.version(), "7");
715        assert!(registry.converter_for("json").is_some());
716        assert!(registry.converter_for("yaml").is_some());
717        assert!(registry.converter_for("svg").is_some());
718        assert!(registry.converter_for("ipynb").is_some());
719        assert!(registry.converter_for("python").is_some());
720        assert!(registry.converter_for("pickle").is_none());
721        assert!(registry.converter_for("text").is_some());
722        assert!(registry.converter_for("markdown").is_some());
723        assert!(registry.converter_for("csv").is_some());
724        assert!(registry.converter_for("pdf").is_some());
725        assert_eq!(
726            registry.converter_for("pdf").map(|c| c.id()),
727            Some("pdf-subprocess")
728        );
729        assert!(registry.converter_for("docx").is_some());
730        assert!(registry.converter_for("xlsx").is_some());
731        assert_eq!(
732            registry.converter_for("html").map(|c| c.id()),
733            Some("html-strip")
734        );
735        assert_eq!(
736            registry.converter_for("eml").map(|c| c.id()),
737            Some("eml-mime")
738        );
739        assert!(registry.converter_for("msg").is_none());
740        assert!(registry.converter_for("xlsb").is_none());
741        assert_eq!(
742            registry.unsupported_reason("xlsb"),
743            Some("hidden-visibility-unresolved")
744        );
745        assert_eq!(
746            registry.unsupported_reason("pickle"),
747            Some("pickle-deserialization-unsafe")
748        );
749        assert_eq!(registry.unsupported_reason("webp"), Some("engine-unpinned"));
750        // Parquet, avro, and sqlite are claimed by the records worker
751        // now, so they carry no unsupported reason. arrow and the rest
752        // stay deferred.
753        for format in ["parquet", "avro", "sqlite"] {
754            assert_eq!(
755                registry.converter_for(format).map(|c| c.id()),
756                Some("records-worker"),
757                "{format}"
758            );
759            assert_eq!(registry.unsupported_reason(format), None, "{format}");
760        }
761        assert_eq!(
762            registry.unsupported_reason("arrow"),
763            Some("converter-deferred")
764        );
765        assert_eq!(
766            registry.unsupported_reason("tar"),
767            Some("container-deferred")
768        );
769        assert_eq!(
770            registry.unsupported_reason("psd"),
771            Some("proprietary-binary")
772        );
773        // The floor: a format nothing claims and nothing declares.
774        assert_eq!(
775            registry.unsupported_reason("unknown"),
776            Some(NO_CONVERTER_REASON)
777        );
778        // A claimed format has no unsupported reason.
779        assert_eq!(registry.unsupported_reason("text"), None);
780        assert_eq!(registry.unsupported_reason("png"), Some("engine-unpinned"));
781        assert_eq!(registry.unsupported_reason("mp4"), Some("engine-unpinned"));
782        // html is claimed now, and msg stays on the floor: it is a
783        // compound file, not an RFC 822 message.
784        assert_eq!(registry.unsupported_reason("html"), None);
785        assert_eq!(
786            registry.unsupported_reason("msg"),
787            Some(NO_CONVERTER_REASON)
788        );
789    }
790
791    #[test]
792    fn registry_rejects_reserved_format_ids() {
793        for (section, id) in [
794            ("converters", "symlink"),
795            ("converters", "unknown"),
796            ("unsupported", "symlink"),
797            ("unsupported", "unknown"),
798        ] {
799            let toml = if section == "converters" {
800                format!(
801                    "version = \"1\"\n[[converters]]\nid = \"text-passthrough\"\nversion = \"1.2.0\"\nformats = [\"{id}\"]\n"
802                )
803            } else {
804                format!(
805                    "version = \"1\"\nconverters = []\n[[unsupported]]\nreason = \"r\"\nformats = [\"{id}\"]\n"
806                )
807            };
808            let err = match Registry::parse(&toml, "converters.toml") {
809                Ok(_) => panic!("{section} {id}: reserved id was accepted"),
810                Err(e) => e,
811            };
812            assert!(
813                err.to_string().contains("reserved"),
814                "{section} {id}: {err}"
815            );
816        }
817    }
818
819    #[test]
820    fn registry_rejects_a_version_that_contradicts_the_implementation() {
821        let toml = r#"
822version = "1"
823
824[[converters]]
825id = "text-passthrough"
826version = "9.9.9"
827formats = ["text"]
828"#;
829        assert!(Registry::parse(toml, "converters.toml").is_err());
830    }
831}