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    /// Expected BLAKE3 per pinned runtime role, keyed by the generic
94    /// role label. The deployment supplies the paths through the
95    /// runtime-inventory config; the cold worker re-hashes every file
96    /// against these values immediately before use. Empty when no
97    /// runtime is pinned in the rules, which leaves recognition on its
98    /// fail-closed runtime-missing path.
99    #[serde(default)]
100    pub inventory: std::collections::BTreeMap<String, String>,
101    /// The svg provider pins, keyed by the two provider role labels:
102    /// the expected BLAKE3, the exact version, and, for the rasterizer,
103    /// the aggregate digest over its enumerated closure. Rules data,
104    /// like every other pinned expectation; the deployment supplies only
105    /// paths and jail parameters through the provider configuration.
106    /// Empty when the rules pin no provider, which leaves the svg leg
107    /// unconfigurable.
108    #[serde(default)]
109    pub svg_provider: std::collections::BTreeMap<String, provider::ProviderPin>,
110}
111
112impl Default for ImageOcrLimits {
113    fn default() -> ImageOcrLimits {
114        ImageOcrLimits {
115            wall_timeout_secs: 300,
116            max_response_bytes: 8 * 1024 * 1024,
117            max_stderr_bytes: 4 * 1024 * 1024,
118            address_space_bytes: 64 * 1024 * 1024 * 1024,
119            cpu_seconds: 600,
120            file_size_bytes: 64 * 1024 * 1024,
121            max_processes: 64,
122            inventory: std::collections::BTreeMap::new(),
123            svg_provider: std::collections::BTreeMap::new(),
124        }
125    }
126}
127
128/// The ruled encoder-input area cap: 1536 * 1536 = 2.36 MP, the
129/// validated-safe ceiling. Applied uniformly to every image fed to the
130/// engine, because the hazard is encoder-side and degenerates on
131/// oversized area, not on any single dimension.
132pub const IMAGE_OCR_MAX_AREA_PX: u64 = 2_359_296;
133
134/// The largest single edge the engine was validated against. An input
135/// under the area cap but with a longer edge is legal by area and must
136/// not fail; it logs one note for validation and proceeds.
137pub const IMAGE_OCR_VALIDATED_LONG_EDGE: u32 = 1600;
138
139/// Decode-time allocation guard: one layer of a layered containment,
140/// not a single invariant. Three independent limits bound this path.
141/// The parent's source-bytes ceiling bounds the bytes that stage into
142/// the jail; this guard bounds the decoder's working pixel buffers; and
143/// the area cap bounds the encoder input. This guard's job is only the
144/// middle layer: it caps the decoder against a decompression bomb whose
145/// declared dimensions the area preflight has not yet seen. The largest
146/// in-scope raster is the area cap at 4 bytes per pixel, about 9 MiB, so
147/// this value leaves room for the decoded buffer plus codec scratch
148/// while refusing a bomb demanding hundreds of megabytes. It is not the
149/// jail's address-space limit and not the area cap; each of the three
150/// fails closed on its own.
151pub const IMAGE_OCR_DECODE_ALLOC: u64 = 64 * 1024 * 1024;
152
153/// The pinned-runtime role labels of the image-OCR path: the engine
154/// binary, its weights, the projector, the fixed prompt, and the
155/// serialized limit policy. Generic labels only; the identities behind
156/// them are deployment data bound by hash.
157pub(crate) const IMAGE_OCR_ROLE_LABELS: [&str; 5] = [
158    "engine-cli",
159    "vision-weights",
160    "vision-projector",
161    "ocr-prompt",
162    "ocr-limit-policy",
163];
164
165/// The speech-transcription jail limit profile and pinned runtime
166/// hashes, held as rules data beside the image-OCR profile so a
167/// deployment changes them only in a visible versioned bump. Every
168/// number is a containment ceiling measured for the pinned engine on
169/// the pinned machine class. The registry reads this into the audio
170/// converter, which maps it into the runner limits its jailed worker
171/// enforces and sends the decoded-duration ceiling into the jail.
172#[derive(Debug, Clone, Serialize, Deserialize)]
173#[serde(deny_unknown_fields)]
174pub struct AsrProfile {
175    /// Decoded-duration ceiling in seconds, asserted in-jail from the
176    /// decoded frame count with a small priming allowance. Over the
177    /// ceiling fails closed with `asr-duration-exceeded`, never
178    /// truncation.
179    pub max_duration_seconds: u64,
180    /// Wall-clock ceiling for one source, in seconds.
181    pub wall_timeout_secs: u64,
182    /// RLIMIT_CPU for the child, in seconds. The second backstop
183    /// behind the accelerator preflight: a run that fell back to CPU
184    /// burns through it long before the wall clock.
185    pub cpu_seconds: u64,
186    /// Parent-side ceiling on the aggregate resident bytes of the
187    /// worker's whole process group, engine child included.
188    pub max_resident_bytes: u64,
189    /// Ceiling on the response frame payload, in bytes.
190    pub max_response_bytes: u32,
191    /// Ceiling on stderr bytes.
192    pub max_stderr_bytes: u64,
193    /// RLIMIT_FSIZE for the child, in bytes. Sits above the decoded
194    /// size preflight, so the preflight is the arbiter and this only
195    /// backstops it.
196    pub file_size_bytes: u64,
197    /// RLIMIT_NPROC for the child.
198    pub max_processes: u64,
199    /// The pinned transcription language. The built-in decode policy
200    /// pins `en`, and rules parsing refuses any other value.
201    pub language: String,
202    /// Expected BLAKE3 per pinned runtime role, keyed by the generic
203    /// role label. `asr-cli`, `asr-weights`, and `asr-decode-policy`
204    /// must be present; `asr-probe` is accepted and pinned in the
205    /// built-in rules, and a required role whose pin a rules file
206    /// omits fails the engine path closed in the worker as
207    /// runtime-missing rather than parsing as an invented identity.
208    /// The deployment supplies the file paths through the runtime
209    /// inventory config; the cold worker re-hashes every file against
210    /// these values immediately before preflight and execution.
211    pub inventory: std::collections::BTreeMap<String, String>,
212}
213
214/// Whether a string is a lowercase hex BLAKE3.
215fn is_lower_hex_256(value: &str) -> bool {
216    value.len() == 64
217        && value
218            .chars()
219            .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
220}
221
222/// Validates the `[asr]` rules section: the pinned language, the
223/// known roles only with the three pinned ones present, lowercase hex
224/// hashes, and an `asr-decode-policy` value that matches the
225/// serialization built into the adapter, so the rules cannot silently
226/// disagree with the code. The probe role is allowed but not
227/// required: the built-in rules pin it, a custom profile may omit
228/// it, and a required runtime role with no pinned hash fails closed
229/// in the worker as runtime-missing rather than parsing as an
230/// invented identity here.
231fn validate_asr_profile(profile: &AsrProfile, name: &str) -> Result<()> {
232    let rules_error = |message: String| Error::Rules {
233        name: name.to_string(),
234        message,
235    };
236    if profile.language != "en" {
237        return Err(rules_error(format!(
238            "[asr] language {:?} is not supported: the built-in decode policy pins \"en\"",
239            profile.language
240        )));
241    }
242    let required_roles = [
243        subprocess::ASR_ROLE_CLI,
244        subprocess::ASR_ROLE_WEIGHTS,
245        subprocess::ASR_ROLE_DECODE_POLICY,
246    ];
247    let allowed_roles = [
248        subprocess::ASR_ROLE_CLI,
249        subprocess::ASR_ROLE_WEIGHTS,
250        subprocess::ASR_ROLE_DECODE_POLICY,
251        subprocess::ASR_ROLE_PROBE,
252    ];
253    for role in required_roles {
254        if !profile.inventory.contains_key(role) {
255            return Err(rules_error(format!(
256                "[asr.inventory] is missing role {role:?}"
257            )));
258        }
259    }
260    for (role, hash) in &profile.inventory {
261        if !allowed_roles.contains(&role.as_str()) {
262            return Err(rules_error(format!(
263                "[asr.inventory] names unknown role {role:?}"
264            )));
265        }
266        if !is_lower_hex_256(hash) {
267            return Err(rules_error(format!(
268                "[asr.inventory] role {role:?} is not a lowercase hex BLAKE3"
269            )));
270        }
271    }
272    let built_in = crate::hash::hash_bytes(subprocess::ASR_DECODE_POLICY.as_bytes());
273    if profile.inventory[subprocess::ASR_ROLE_DECODE_POLICY] != built_in {
274        return Err(rules_error(
275            "[asr.inventory] asr-decode-policy does not match the built-in decode \
276             serialization"
277                .to_string(),
278        ));
279    }
280    Ok(())
281}
282
283/// Validates the `[image_ocr]` inventory keys against the known role
284/// labels and requires lowercase hex hashes.
285fn validate_image_ocr_limits(limits: &ImageOcrLimits, name: &str) -> Result<()> {
286    provider::validate_pins(&limits.svg_provider).map_err(|message| Error::Rules {
287        name: name.to_string(),
288        message,
289    })?;
290    for (role, hash) in &limits.inventory {
291        if !IMAGE_OCR_ROLE_LABELS.contains(&role.as_str()) {
292            return Err(Error::Rules {
293                name: name.to_string(),
294                message: format!("[image_ocr.inventory] names unknown role {role:?}"),
295            });
296        }
297        if !is_lower_hex_256(hash) {
298            return Err(Error::Rules {
299                name: name.to_string(),
300                message: format!(
301                    "[image_ocr.inventory] role {role:?} is not a lowercase hex BLAKE3"
302                ),
303            });
304        }
305    }
306    Ok(())
307}
308
309/// Deployment-side mapping from a pinned runtime role label to the
310/// file that fills it. Paths are deployment data and never enter the
311/// versioned rules: the expected hashes live in the rules, and the
312/// jailed worker re-hashes every supplied file against them
313/// immediately before use, so a swap after parent-side validation
314/// still fails closed. Roles are validated against the known label
315/// set, so a typo fails loudly instead of silently leaving a role
316/// unfilled.
317#[derive(Debug, Clone, Default)]
318pub struct RuntimeInventory {
319    paths: std::collections::BTreeMap<String, std::path::PathBuf>,
320}
321
322impl RuntimeInventory {
323    /// An inventory with no roles filled: every engine path stays on
324    /// its fail-closed runtime-missing seam.
325    pub fn empty() -> RuntimeInventory {
326        RuntimeInventory::default()
327    }
328
329    /// Every role label a deployment may fill.
330    pub fn known_roles() -> impl Iterator<Item = &'static str> {
331        IMAGE_OCR_ROLE_LABELS
332            .into_iter()
333            .chain(subprocess::ASR_FILE_ROLE_LABELS)
334    }
335
336    /// Fills one role with the absolute path of a literal regular
337    /// file. A directory or a symlink is refused here, before it can
338    /// become a broader jail capability, and the spawn path re-asserts
339    /// the same rule right before every run.
340    pub fn set(&mut self, role: &str, path: std::path::PathBuf) -> Result<()> {
341        let rules_error = |message: String| Error::Rules {
342            name: "runtime-inventory".to_string(),
343            message,
344        };
345        if !Self::known_roles().any(|known| known == role) {
346            return Err(rules_error(format!("unknown runtime role {role:?}")));
347        }
348        if !path.is_absolute() {
349            return Err(rules_error(format!(
350                "runtime role {role:?} needs an absolute path"
351            )));
352        }
353        let metadata = std::fs::symlink_metadata(&path)
354            .map_err(|e| rules_error(format!("runtime role {role:?} cannot be inspected: {e}")))?;
355        if !metadata.file_type().is_file() {
356            return Err(rules_error(format!(
357                "runtime role {role:?} is not a literal regular file"
358            )));
359        }
360        self.paths.insert(role.to_string(), path);
361        Ok(())
362    }
363
364    /// The path filling a role, when the deployment supplied one.
365    pub fn path(&self, role: &str) -> Option<&std::path::Path> {
366        self.paths.get(role).map(std::path::PathBuf::as_path)
367    }
368
369    /// Parses an inventory from TOML text: one flat table mapping each
370    /// role label to an absolute path.
371    pub fn parse(text: &str, name: &str) -> Result<RuntimeInventory> {
372        let raw: std::collections::BTreeMap<String, String> =
373            toml::from_str(text).map_err(|e| Error::Rules {
374                name: name.to_string(),
375                message: e.to_string(),
376            })?;
377        let mut inventory = RuntimeInventory::empty();
378        for (role, path) in raw {
379            inventory.set(&role, std::path::PathBuf::from(path))?;
380        }
381        Ok(inventory)
382    }
383
384    /// Loads an inventory from a TOML file.
385    pub fn load(path: &std::path::Path) -> Result<RuntimeInventory> {
386        let text = std::fs::read_to_string(path).map_err(|e| Error::io("read", path, e))?;
387        RuntimeInventory::parse(&text, &path.display().to_string())
388    }
389}
390
391/// The image-metadata parser ceilings, held as rules data beside the
392/// records ceilings so a deployment raises them in a visible versioned
393/// bump. The parent reads them from the registry and passes them into
394/// the jailed worker, which enforces them and fails a source over any
395/// ceiling with a stable `image-metadata-*` reason. The metadata worker
396/// runs behind the shared platform-default jail, so these are the
397/// parser-surface ceilings only, not the wall-clock and address-space
398/// envelope the runner already imposes.
399#[derive(Debug, Clone, Serialize, Deserialize)]
400#[serde(deny_unknown_fields)]
401pub struct ImageMetadataLimits {
402    /// Inflated-size ceiling for any single compressed text block, the
403    /// png `zTXt` and compressed `iTXt` streams above all. Checked
404    /// incrementally during inflation so a decompression bomb is stopped
405    /// before it lands.
406    pub max_decompressed_bytes: u64,
407    /// Nesting-depth ceiling for an xmp parse.
408    pub max_xml_depth: u32,
409    /// Event-count ceiling for an xmp parse.
410    pub max_xml_events: u64,
411    /// Box-count ceiling for an iso base media file format carrier.
412    pub max_boxes: u64,
413    /// Ceiling on emitted metadata rows across all surfaces.
414    pub max_rows: u64,
415    /// Ceiling on the rendered value bytes, under the runner response
416    /// cap.
417    pub max_output_bytes: u64,
418}
419
420impl Default for ImageMetadataLimits {
421    fn default() -> ImageMetadataLimits {
422        ImageMetadataLimits {
423            max_decompressed_bytes: 16 * 1024 * 1024,
424            max_xml_depth: 100,
425            max_xml_events: 1_000_000,
426            max_boxes: 10_000,
427            max_rows: 4096,
428            max_output_bytes: 16 * 1024 * 1024,
429        }
430    }
431}
432
433use std::collections::HashMap;
434use std::fmt;
435
436use serde::{Deserialize, Serialize};
437use unicode_normalization::UnicodeNormalization;
438
439use crate::detect::RESERVED_FORMAT_IDS;
440use crate::segments::Segment;
441use crate::{Error, Result};
442
443mod anydoc_log;
444pub mod container;
445mod differential;
446mod document;
447pub mod eml;
448pub mod html;
449pub mod pdf;
450// The parquet, avro, and sqlite readers. Compiled only for the records
451// worker, so a default build pulls none of the heavy parser trees.
452// Crate-private, so a library consumer cannot run the native parsers in
453// process and bypass the jail. The worker dispatch reaches it through a
454// crate path.
455#[cfg(all(unix, feature = "records-worker"))]
456pub(crate) mod records;
457// The in-jail raster decode and recognize path. Crate-private for the
458// same reason records is: a library consumer must not run the decode
459// and engine path in process and bypass the jail. The worker dispatch
460// reaches it through a crate path.
461#[cfg(all(unix, feature = "image-ocr"))]
462pub(crate) mod image_ocr;
463// The in-jail audio decode and transcribe path. Crate-private for the
464// same reason image_ocr is: a library consumer must not run the
465// decode and engine path in process and bypass the jail. The worker
466// dispatch reaches it through a crate path.
467#[cfg(all(unix, feature = "audio-asr"))]
468pub(crate) mod audio_asr;
469// The image-metadata derived-child leg. The id, version, and the
470// applies predicate are always compiled so the registry and pipeline can
471// name the leg; the parsers and the parent rendering compile only under
472// the feature, crate-private for the same reason records is: a library
473// consumer must not run the parsers in process and bypass the jail.
474pub mod image_metadata;
475// The deployment-owned provider configuration. Always compiled, so a
476// build without the provider feature still parses and validates a
477// configuration it was handed rather than ignoring it silently.
478pub mod provider;
479pub mod subprocess;
480// The in-jail svg raster path. Crate-private for the same reason
481// image_ocr is: a library consumer must not run the external
482// components in process and bypass the provider jail. The worker
483// dispatch reaches it through a crate path.
484#[cfg(all(unix, feature = "svg-provider"))]
485pub(crate) mod svg_raster;
486mod visibility;
487mod workbook;
488
489pub use container::{
490    CONTAINER_ZIP_ID, CONTAINER_ZIP_VERSION, ContainerLimits, Inflated, ZipContainer,
491    preflight_entry_count,
492};
493pub use differential::{
494    DiffMetrics, DiffVerdict, DifferentialOutcome, compare_texts, normalize_for_diff,
495};
496pub use document::{AnydocDocument, markdown_to_plain};
497pub use eml::{EmlExpansion, EmlMime, expand_eml};
498pub use html::HtmlStrip;
499pub use subprocess::{PdfSubprocess, RecordsSubprocess};
500pub use visibility::{IndexRange, SheetVisibility, WorkbookVisibility, read_visibility};
501pub use workbook::WorkbookIr;
502
503/// What every converter returns on success.
504#[derive(Debug, Clone)]
505pub struct Outcome {
506    /// Stable converter id from the registry.
507    pub converter_id: String,
508    /// Converter version from the registry.
509    pub converter_version: String,
510    /// Format id the converter was asked to handle.
511    pub detected_format: String,
512    /// The converted text, UTF-8, NFC, LF line endings.
513    pub text: String,
514    /// Non-fatal notes about the conversion.
515    pub warnings: Vec<String>,
516    /// Structure spans over `text` for the segments file.
517    pub segments: Vec<Segment>,
518    /// How the text was derived. Every text and structured converter
519    /// sets [`ArtifactKind::Text`]; the pixel-OCR converter sets
520    /// [`ArtifactKind::Ocr`], so the manifest records recognized text
521    /// as OCR rather than extraction.
522    pub artifact_kind: crate::manifest::ArtifactKind,
523    /// Media provenance for a transcript artifact: source duration,
524    /// language, and the pinned engine identity as a role label and
525    /// hashes. Every text and image converter returns `None`; the
526    /// audio converter populates it, and the pipeline copies it onto
527    /// the manifest record and through dedup.
528    pub media: Option<crate::manifest::Media>,
529}
530
531/// A conversion failure with a machine-readable reason.
532#[derive(Debug, Clone)]
533pub struct ConvertError {
534    /// Stable reason code, such as `invalid_utf8` or `empty_output`.
535    pub code: &'static str,
536    /// Detail for a human reading the manifest.
537    pub message: String,
538}
539
540impl fmt::Display for ConvertError {
541    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
542        write!(f, "{}: {}", self.code, self.message)
543    }
544}
545
546impl std::error::Error for ConvertError {}
547
548/// A converter takes one source's bytes and returns text or a reason.
549///
550/// The caller supplies the bytes, so the hash it records and the bytes
551/// the converter consumed are one snapshot. Implementations must be
552/// deterministic. The same input bytes under the same rules produce
553/// byte-identical output.
554pub trait Converter {
555    /// Stable converter id.
556    fn id(&self) -> &'static str;
557    /// Converter version, part of the incremental checkpoint key.
558    fn version(&self) -> &'static str;
559    /// Converts one source from its bytes.
560    fn convert(
561        &self,
562        source: &[u8],
563        detected_format: &str,
564    ) -> std::result::Result<Outcome, ConvertError>;
565}
566
567/// Normalizes text to the output contract.
568///
569/// CRLF and lone CR become LF, then the text is normalized to NFC.
570/// NFC only. NFKC rewrites identifiers, so it is prohibited.
571pub fn normalize_text(input: &str) -> String {
572    let mut unified = String::with_capacity(input.len());
573    let mut chars = input.chars().peekable();
574    while let Some(c) = chars.next() {
575        if c == '\r' {
576            if chars.peek() == Some(&'\n') {
577                chars.next();
578            }
579            unified.push('\n');
580        } else {
581            unified.push(c);
582        }
583    }
584    unified.nfc().collect()
585}
586
587/// Invisible format characters: code points that render nothing yet are
588/// neither whitespace nor control characters, so a bare emptiness check
589/// would let them through. The set is the soft hyphen, the zero-width
590/// and bidi marks (`U+200B`–`U+200F`), the bidi overrides and embeddings
591/// (`U+202A`–`U+202E`), the word joiner and invisible-operator block
592/// (`U+2060`–`U+2064`), and the byte-order mark (`U+FEFF`). Text whose
593/// only characters are these carries nothing a reader would see. The set
594/// is a documented minimum and is not narrowed; whitespace and control
595/// characters are handled separately by [`is_meaningful`], so the two
596/// together cover the empty-render cases.
597pub(crate) fn is_invisible_format(c: char) -> bool {
598    matches!(
599        c as u32,
600        0x00AD | 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2064 | 0xFEFF
601    )
602}
603
604/// Whether a character is meaningful content: something a reader would
605/// see on the page. Whitespace, control characters, and invisible format
606/// characters are not. This is the shared meaningful-text floor: the PDF
607/// recovery path and the image-OCR converter both gate on the presence
608/// of at least one such character, so a well-formed but content-free
609/// result fails closed rather than passing as a blank success.
610pub(crate) fn is_meaningful(c: char) -> bool {
611    !c.is_whitespace() && !c.is_control() && !is_invisible_format(c)
612}
613
614/// Passthrough for text-native formats.
615///
616/// Reads the source as plain text and applies [`normalize_text`]. It
617/// parses nothing and strips no markup. Input encoding is UTF-8, or
618/// UTF-16 behind a required byte order mark: a leading `FF FE`
619/// decodes as UTF-16LE and `FE FF` as UTF-16BE, the mark is consumed
620/// before decoding, and the outcome carries a transcoding warning. A
621/// UTF-32 mark fails closed with reason `unsupported-encoding`, an
622/// odd trailing byte or unpaired surrogate fails with `invalid_utf16`
623/// naming the byte offset, and a file with no mark must be valid
624/// UTF-8. A leading UTF-8 byte order mark is stripped with a warning.
625/// A NUL scalar anywhere in the decoded text fails closed with reason
626/// `nul_bytes`, because text-native content never holds one and the
627/// usual culprit is a BOM-less UTF-16 export.
628pub struct PlainTextPassthrough;
629
630/// The floor reason for an unsupported record whose detected format
631/// has no converter entry and no declared reason, including true
632/// unknowns.
633pub const NO_CONVERTER_REASON: &str = "no-converter";
634
635/// The reason a records format records on a build without the
636/// `records-worker` feature. The converter exists and the rules route
637/// to it, but this binary's worker has no records mode, so the format
638/// is a deliberate capability gap rather than a converter error.
639pub const RECORDS_NOT_BUILT_REASON: &str = "records-worker-not-built";
640
641/// The reason raster-image formats record on a build without the
642/// `image-ocr` feature, or on a platform with no jail backend. The
643/// converter exists and the rules route to it, but this binary has no
644/// in-jail decode-plus-recognize path, so the format is a deliberate
645/// capability gap rather than a converter error. The checkpoint key
646/// includes `rules_version`, so a later feature-carrying build
647/// reconverts every such record.
648pub const IMAGE_OCR_NOT_BUILT_REASON: &str = "image-ocr-not-built";
649
650/// The warning an svg record carries when a deployment configured the
651/// external provider but this binary was built without the
652/// `svg-provider` feature. The configuration is valid and the operator
653/// asked for the leg, so the record says the leg did not run rather
654/// than looking like a provider-free run. A later build that carries
655/// the feature is not allowed to skip such a record as unchanged, so
656/// the child is minted on the first build that can produce it.
657pub const SVG_PROVIDER_NOT_BUILT: &str = "svg-provider-not-built";
658
659/// The reason audio formats record on a build without the `audio-asr`
660/// feature. The converter exists and the rules route to it, but this
661/// binary has no in-jail decode-plus-transcribe path, so the formats
662/// are a deliberate capability gap rather than a converter error. The
663/// checkpoint key includes `rules_version`, so a later feature-carrying
664/// build reconverts every such record.
665pub const AUDIO_ASR_NOT_BUILT_REASON: &str = "audio-asr-not-built";
666
667/// The reason a media format records when its adapter's engine has no
668/// pinned identity for the running platform. Declared entries in the
669/// rules use the same string; the registry also applies it at
670/// construction to the audio formats on any platform outside the
671/// pinned machine class, because an engine pin is per machine class
672/// and a second class needs its own measured pin before the route
673/// opens there.
674pub const ENGINE_UNPINNED_REASON: &str = "engine-unpinned";
675
676/// Registry id of the passthrough converter.
677pub const PASSTHROUGH_ID: &str = "text-passthrough";
678/// Version of the passthrough converter.
679pub const PASSTHROUGH_VERSION: &str = "1.2.0";
680
681/// Decodes UTF-16 byte pairs after a consumed byte order mark.
682///
683/// `mark_len` is the consumed mark's length, so failure offsets name
684/// positions in the original source bytes.
685fn decode_utf16_pairs(
686    bytes: &[u8],
687    big_endian: bool,
688    mark_len: usize,
689) -> std::result::Result<String, ConvertError> {
690    if !bytes.len().is_multiple_of(2) {
691        return Err(ConvertError {
692            code: "invalid_utf16",
693            message: format!("odd trailing byte at byte {}", mark_len + bytes.len() - 1),
694        });
695    }
696    let units = bytes.chunks_exact(2).map(|pair| {
697        if big_endian {
698            u16::from_be_bytes([pair[0], pair[1]])
699        } else {
700            u16::from_le_bytes([pair[0], pair[1]])
701        }
702    });
703    let mut decoded = String::with_capacity(bytes.len() / 2);
704    let mut consumed_units = 0usize;
705    for result in char::decode_utf16(units) {
706        match result {
707            Ok(c) => {
708                decoded.push(c);
709                consumed_units += c.len_utf16();
710            }
711            Err(_) => {
712                return Err(ConvertError {
713                    code: "invalid_utf16",
714                    message: format!(
715                        "unpaired surrogate at byte {}",
716                        mark_len + consumed_units * 2
717                    ),
718                });
719            }
720        }
721    }
722    Ok(decoded)
723}
724
725impl Converter for PlainTextPassthrough {
726    fn id(&self) -> &'static str {
727        PASSTHROUGH_ID
728    }
729
730    fn version(&self) -> &'static str {
731        PASSTHROUGH_VERSION
732    }
733
734    fn convert(
735        &self,
736        source: &[u8],
737        detected_format: &str,
738    ) -> std::result::Result<Outcome, ConvertError> {
739        let source_len = source.len();
740        let mut warnings = Vec::new();
741        // The four-byte UTF-32 marks come first, because the UTF-32LE
742        // mark begins with the UTF-16LE one and would otherwise decode
743        // as UTF-16 with interleaved NULs.
744        let raw = if source.starts_with(&[0xFF, 0xFE, 0x00, 0x00])
745            || source.starts_with(&[0x00, 0x00, 0xFE, 0xFF])
746        {
747            return Err(ConvertError {
748                code: "unsupported-encoding",
749                message: "utf-32 byte order mark".to_string(),
750            });
751        } else if let Some(rest) = source.strip_prefix(&[0xFF, 0xFE][..]) {
752            warnings.push("transcoded from utf-16le".to_string());
753            decode_utf16_pairs(rest, false, 2)?
754        } else if let Some(rest) = source.strip_prefix(&[0xFE, 0xFF][..]) {
755            warnings.push("transcoded from utf-16be".to_string());
756            decode_utf16_pairs(rest, true, 2)?
757        } else {
758            let raw = String::from_utf8(source.to_vec()).map_err(|e| ConvertError {
759                code: "invalid_utf8",
760                message: format!("invalid UTF-8 at byte {}", e.utf8_error().valid_up_to()),
761            })?;
762            match raw.strip_prefix('\u{feff}') {
763                Some(stripped) => {
764                    warnings.push("stripped leading byte order mark".to_string());
765                    stripped.to_string()
766                }
767                None => raw,
768            }
769        };
770        // Legitimate text-native content carries no NUL, and the
771        // common way one appears is a BOM-less UTF-16 export whose
772        // ASCII half happens to be valid UTF-8. Refuse instead of
773        // emitting NUL-split tokens.
774        if let Some(offset) = raw.find('\0') {
775            return Err(ConvertError {
776                code: "nul_bytes",
777                message: format!(
778                    "NUL at byte {offset} of the decoded text, the bytes may be \
779                     BOM-less UTF-16, re-export as UTF-8"
780                ),
781            });
782        }
783        let text = normalize_text(&raw);
784        if source_len > 0 && text.is_empty() {
785            return Err(ConvertError {
786                code: "empty_output",
787                message: format!("source is {source_len} bytes but conversion produced no text"),
788            });
789        }
790        let segments = vec![Segment::span(0, text.len(), "document")];
791        Ok(Outcome {
792            converter_id: PASSTHROUGH_ID.to_string(),
793            converter_version: PASSTHROUGH_VERSION.to_string(),
794            detected_format: detected_format.to_string(),
795            text,
796            warnings,
797            segments,
798            artifact_kind: crate::manifest::ArtifactKind::Text,
799            media: None,
800        })
801    }
802}
803
804#[derive(Debug, Deserialize)]
805#[serde(deny_unknown_fields)]
806struct RawRegistry {
807    version: String,
808    converters: Vec<RawEntry>,
809    #[serde(default)]
810    unsupported: Vec<RawUnsupported>,
811    #[serde(default)]
812    containers: Option<ContainerLimits>,
813    #[serde(default)]
814    records: Option<RecordsLimits>,
815    #[serde(default)]
816    image_ocr: Option<ImageOcrLimits>,
817    #[serde(default)]
818    image_metadata: Option<ImageMetadataLimits>,
819    #[serde(default)]
820    asr: Option<AsrProfile>,
821}
822
823#[derive(Debug, Deserialize)]
824#[serde(deny_unknown_fields)]
825struct RawEntry {
826    id: String,
827    version: String,
828    formats: Vec<String>,
829}
830
831#[derive(Debug, Deserialize)]
832#[serde(deny_unknown_fields)]
833struct RawUnsupported {
834    reason: String,
835    formats: Vec<String>,
836}
837
838/// The converter registry from `rules/converters.toml`.
839///
840/// The registry is data. A rules bump is a visible versioned event,
841/// and the pinned entry version must match the implementation, so a
842/// code change cannot ship silently under an old version.
843pub struct Registry {
844    version: String,
845    converters: Vec<Box<dyn Converter>>,
846    by_format: HashMap<String, usize>,
847    unsupported_reasons: HashMap<String, String>,
848    container_limits: ContainerLimits,
849    records_limits: RecordsLimits,
850    image_ocr_limits: ImageOcrLimits,
851    image_metadata_limits: ImageMetadataLimits,
852    asr_profile: Option<AsrProfile>,
853    /// Index into `converters` of the converter that runs the svg
854    /// provider leg. It is the same pixel-OCR converter the raster
855    /// formats route to, constructed with the provider wired, and it is
856    /// reached by this index alone: svg never enters `by_format`, so
857    /// its primary stays the passthrough. `None` whenever no complete
858    /// provider is configured, which is every default run.
859    svg_ocr_index: Option<usize>,
860    /// The configured svg provider, kept so the test-only fake hook can
861    /// rebuild the converter with the same provider wired. A release
862    /// build has no such hook and carries no such field.
863    #[cfg(all(
864        unix,
865        feature = "image-ocr",
866        feature = "svg-provider",
867        feature = "test-adapters"
868    ))]
869    svg_provider: Option<provider::SvgProvider>,
870    /// Index into `converters` of the auxiliary image-metadata converter,
871    /// which never enters `by_format`. The pipeline reaches it by this
872    /// index to run the derived-child leg. `None` when the feature is
873    /// absent, so the leg simply does not run.
874    image_metadata_index: Option<usize>,
875}
876
877impl Registry {
878    /// The registry compiled into the binary from `rules/converters.toml`.
879    pub fn builtin() -> Result<Self> {
880        Self::parse(
881            include_str!("../../rules/converters.toml"),
882            "converters.toml",
883        )
884    }
885
886    /// The built-in registry with the deployment's runtime-inventory
887    /// paths wired into the pinned-engine converters.
888    pub fn builtin_with_inventory(inventory: &RuntimeInventory) -> Result<Self> {
889        Self::parse_with_inventory(
890            include_str!("../../rules/converters.toml"),
891            "converters.toml",
892            inventory,
893        )
894    }
895
896    /// The built-in registry with the deployment's runtime-inventory
897    /// paths and its external provider configuration wired in.
898    pub fn builtin_with_runtime(
899        inventory: &RuntimeInventory,
900        provider: Option<&provider::ProviderConfig>,
901    ) -> Result<Self> {
902        Self::parse_with_runtime(
903            include_str!("../../rules/converters.toml"),
904            "converters.toml",
905            inventory,
906            provider,
907        )
908    }
909
910    /// Parses a registry and binds entries to implementations, with no
911    /// runtime inventory: every pinned-engine path stays on its
912    /// fail-closed runtime-missing seam.
913    pub fn parse(text: &str, name: &str) -> Result<Self> {
914        Self::parse_with_inventory(text, name, &RuntimeInventory::empty())
915    }
916
917    /// Parses a registry and binds entries to implementations, wiring
918    /// the deployment's runtime-inventory paths into the pinned-engine
919    /// converters. The expected hashes stay in the rules text; the
920    /// inventory supplies only paths.
921    #[cfg_attr(
922        not(all(unix, any(feature = "image-ocr", feature = "audio-asr"))),
923        allow(unused_variables)
924    )]
925    pub fn parse_with_inventory(
926        text: &str,
927        name: &str,
928        inventory: &RuntimeInventory,
929    ) -> Result<Self> {
930        Self::parse_with_runtime(text, name, inventory, None)
931    }
932
933    /// Parses a registry, wiring both the deployment's runtime
934    /// inventory and, when one is configured, the external svg
935    /// provider. The provider is opt-in twice over: the feature must be
936    /// built and a configuration must name both roles.
937    #[cfg_attr(
938        not(all(unix, any(feature = "image-ocr", feature = "audio-asr"))),
939        allow(unused_variables)
940    )]
941    pub fn parse_with_runtime(
942        text: &str,
943        name: &str,
944        inventory: &RuntimeInventory,
945        provider: Option<&provider::ProviderConfig>,
946    ) -> Result<Self> {
947        let raw: RawRegistry = toml::from_str(text).map_err(|e| Error::Rules {
948            name: name.to_string(),
949            message: e.to_string(),
950        })?;
951        let records_limits = raw.records.clone().unwrap_or_default();
952        let image_ocr_limits = raw.image_ocr.clone().unwrap_or_default();
953        let image_metadata_limits = raw.image_metadata.clone().unwrap_or_default();
954        validate_image_ocr_limits(&image_ocr_limits, name)?;
955        let asr_profile = raw.asr.clone();
956        if let Some(profile) = &asr_profile {
957            validate_asr_profile(profile, name)?;
958        }
959        let mut converters: Vec<Box<dyn Converter>> = Vec::new();
960        let mut by_format = HashMap::new();
961        let mut unsupported_reasons = HashMap::new();
962        // Reassigned only under the feature; without it the auxiliary
963        // converter is never constructed and the index stays absent.
964        #[allow(unused_mut)]
965        let mut image_metadata_index: Option<usize> = None;
966        // Reassigned only when the feature is built and a complete
967        // provider is configured; otherwise the svg leg does not exist.
968        #[allow(unused_mut)]
969        let mut svg_ocr_index: Option<usize> = None;
970        for entry in &raw.converters {
971            // A build without the records-worker feature has no records
972            // mode in its worker, so route the records formats to a
973            // deliberate unsupported reason instead of a converter that
974            // would spawn a worker unable to serve them. The rules stay
975            // one shared file: the routing decision is made here at
976            // construction, not by forking the rules.
977            #[cfg(not(feature = "records-worker"))]
978            if entry.id == subprocess::RECORDS_SUBPROCESS_ID {
979                for format in &entry.formats {
980                    if RESERVED_FORMAT_IDS.contains(&format.as_str()) {
981                        return Err(Error::Rules {
982                            name: name.to_string(),
983                            message: format!("format id {format:?} is reserved"),
984                        });
985                    }
986                    if by_format.contains_key(format)
987                        || unsupported_reasons
988                            .insert(format.clone(), RECORDS_NOT_BUILT_REASON.to_string())
989                            .is_some()
990                    {
991                        return Err(Error::Rules {
992                            name: name.to_string(),
993                            message: format!("format {format:?} claimed twice"),
994                        });
995                    }
996                }
997                continue;
998            }
999            // A build without the in-jail image decode path, or a
1000            // platform with no jail backend, has no way to decode and
1001            // recognize a raster, so route the image formats to a
1002            // deliberate unsupported reason instead of a converter that
1003            // cannot serve them. The rules stay one shared file: the
1004            // routing decision is made here at construction.
1005            #[cfg(not(all(unix, feature = "image-ocr")))]
1006            if entry.id == subprocess::IMAGE_PIXEL_OCR_ID {
1007                for format in &entry.formats {
1008                    if RESERVED_FORMAT_IDS.contains(&format.as_str()) {
1009                        return Err(Error::Rules {
1010                            name: name.to_string(),
1011                            message: format!("format id {format:?} is reserved"),
1012                        });
1013                    }
1014                    if by_format.contains_key(format)
1015                        || unsupported_reasons
1016                            .insert(format.clone(), IMAGE_OCR_NOT_BUILT_REASON.to_string())
1017                            .is_some()
1018                    {
1019                        return Err(Error::Rules {
1020                            name: name.to_string(),
1021                            message: format!("format {format:?} claimed twice"),
1022                        });
1023                    }
1024                }
1025                continue;
1026            }
1027            // A build without the audio-asr feature has no in-jail
1028            // decode-plus-transcribe path, so route the audio formats
1029            // to a deliberate unsupported reason instead of a
1030            // converter that cannot serve them. The rules stay one
1031            // shared file: the routing decision is made here at
1032            // construction.
1033            #[cfg(not(all(unix, feature = "audio-asr")))]
1034            if entry.id == subprocess::ASR_ADAPTER_ID {
1035                for format in &entry.formats {
1036                    if RESERVED_FORMAT_IDS.contains(&format.as_str()) {
1037                        return Err(Error::Rules {
1038                            name: name.to_string(),
1039                            message: format!("format id {format:?} is reserved"),
1040                        });
1041                    }
1042                    if by_format.contains_key(format)
1043                        || unsupported_reasons
1044                            .insert(format.clone(), AUDIO_ASR_NOT_BUILT_REASON.to_string())
1045                            .is_some()
1046                    {
1047                        return Err(Error::Rules {
1048                            name: name.to_string(),
1049                            message: format!("format {format:?} claimed twice"),
1050                        });
1051                    }
1052                }
1053                continue;
1054            }
1055            // The pinned speech engine identity exists for one machine
1056            // class. A feature-carrying build on any other platform
1057            // routes the audio formats back to engine-unpinned, the
1058            // same reason they carried before the pin, until that
1059            // platform gains its own measured engine identity in a
1060            // rules bump.
1061            #[cfg(all(
1062                unix,
1063                feature = "audio-asr",
1064                not(all(target_os = "macos", target_arch = "aarch64"))
1065            ))]
1066            if entry.id == subprocess::ASR_ADAPTER_ID {
1067                for format in &entry.formats {
1068                    if RESERVED_FORMAT_IDS.contains(&format.as_str()) {
1069                        return Err(Error::Rules {
1070                            name: name.to_string(),
1071                            message: format!("format id {format:?} is reserved"),
1072                        });
1073                    }
1074                    if by_format.contains_key(format)
1075                        || unsupported_reasons
1076                            .insert(format.clone(), ENGINE_UNPINNED_REASON.to_string())
1077                            .is_some()
1078                    {
1079                        return Err(Error::Rules {
1080                            name: name.to_string(),
1081                            message: format!("format {format:?} claimed twice"),
1082                        });
1083                    }
1084                }
1085                continue;
1086            }
1087            // The image-metadata converter is auxiliary and claims no
1088            // formats. Without the in-jail metadata reader, or on a
1089            // platform with no jail backend, the derived-child leg cannot
1090            // run; there are no formats to route to a not-built reason, so
1091            // the entry is simply skipped. The pipeline reads
1092            // `image_metadata_converter()` as absent and never runs the
1093            // leg.
1094            #[cfg(not(all(unix, feature = "image-metadata")))]
1095            if entry.id == image_metadata::IMAGE_METADATA_ID {
1096                continue;
1097            }
1098            let converter: Box<dyn Converter> = match entry.id.as_str() {
1099                PASSTHROUGH_ID => Box::new(PlainTextPassthrough),
1100                html::HTML_STRIP_ID => Box::new(HtmlStrip),
1101                eml::EML_ID => Box::new(EmlMime),
1102                document::ANYDOC_ID => Box::new(AnydocDocument),
1103                workbook::WORKBOOK_ID => Box::new(WorkbookIr),
1104                subprocess::PDF_SUBPROCESS_ID => Box::new(PdfSubprocess),
1105                subprocess::RECORDS_SUBPROCESS_ID => {
1106                    Box::new(RecordsSubprocess::new(records_limits.clone()))
1107                }
1108                #[cfg(all(unix, feature = "image-ocr"))]
1109                subprocess::IMAGE_PIXEL_OCR_ID => {
1110                    // With a provider configured the same converter
1111                    // additionally carries the svg leg. Without one it
1112                    // is the direct raster converter it has always
1113                    // been, and svg never reaches it.
1114                    #[cfg(feature = "svg-provider")]
1115                    let converter = match provider.and_then(provider::ProviderConfig::svg) {
1116                        Some(svg) => {
1117                            // A configured provider needs pinned
1118                            // expectations to be verified against. Rules
1119                            // that pin none cannot run one, and saying
1120                            // so beats running an unverified executable.
1121                            if image_ocr_limits.svg_provider.is_empty() {
1122                                return Err(Error::Rules {
1123                                    name: name.to_string(),
1124                                    message: "a provider is configured but [image_ocr.svg_provider] pins no expectations for it".to_string(),
1125                                });
1126                            }
1127                            // A role that enumerates a closure must
1128                            // have that closure pinned. Without the
1129                            // digest the jail would grant a subtree
1130                            // nothing asserts the contents of, which is
1131                            // the one thing the aggregate digest exists
1132                            // to prevent.
1133                            for (label, role) in [
1134                                (provider::PROVIDER_ROLE_RASTER, &svg.raster),
1135                                (provider::PROVIDER_ROLE_ENCODER, &svg.encoder),
1136                            ] {
1137                                provider::check_closure_is_pinned(
1138                                    label,
1139                                    role,
1140                                    image_ocr_limits.svg_provider.get(label),
1141                                )
1142                                .map_err(|message| {
1143                                    Error::Rules {
1144                                        name: name.to_string(),
1145                                        message,
1146                                    }
1147                                })?;
1148                            }
1149                            Box::new(subprocess::ImagePixelOcr::with_svg_provider(
1150                                image_ocr_limits.clone(),
1151                                inventory,
1152                                svg,
1153                            ))
1154                        }
1155                        None => Box::new(subprocess::ImagePixelOcr::new(
1156                            image_ocr_limits.clone(),
1157                            inventory,
1158                        )),
1159                    };
1160                    #[cfg(not(feature = "svg-provider"))]
1161                    let converter = Box::new(subprocess::ImagePixelOcr::new(
1162                        image_ocr_limits.clone(),
1163                        inventory,
1164                    ));
1165                    converter
1166                }
1167                #[cfg(all(
1168                    unix,
1169                    feature = "audio-asr",
1170                    target_os = "macos",
1171                    target_arch = "aarch64"
1172                ))]
1173                subprocess::ASR_ADAPTER_ID => {
1174                    let profile = asr_profile.clone().ok_or_else(|| Error::Rules {
1175                        name: name.to_string(),
1176                        message: "the asr-adapter entry requires an [asr] section".to_string(),
1177                    })?;
1178                    Box::new(subprocess::AsrAdapter::new(profile, inventory))
1179                }
1180                #[cfg(all(unix, feature = "image-metadata"))]
1181                image_metadata::IMAGE_METADATA_ID => Box::new(subprocess::ImageMetadata::new(
1182                    image_metadata_limits.clone(),
1183                )),
1184                other => {
1185                    return Err(Error::Rules {
1186                        name: name.to_string(),
1187                        message: format!("unknown converter id {other:?}"),
1188                    });
1189                }
1190            };
1191            if entry.version != converter.version() {
1192                return Err(Error::Rules {
1193                    name: name.to_string(),
1194                    message: format!(
1195                        "converter {:?} pins version {:?} but the implementation is {:?}",
1196                        entry.id,
1197                        entry.version,
1198                        converter.version()
1199                    ),
1200                });
1201            }
1202            let index = converters.len();
1203            #[cfg(all(unix, feature = "image-ocr", feature = "svg-provider"))]
1204            if entry.id == subprocess::IMAGE_PIXEL_OCR_ID
1205                && provider.and_then(provider::ProviderConfig::svg).is_some()
1206            {
1207                svg_ocr_index = Some(index);
1208            }
1209            #[cfg(all(unix, feature = "image-metadata"))]
1210            if entry.id == image_metadata::IMAGE_METADATA_ID {
1211                image_metadata_index = Some(index);
1212            }
1213            converters.push(converter);
1214            for format in &entry.formats {
1215                if RESERVED_FORMAT_IDS.contains(&format.as_str()) {
1216                    return Err(Error::Rules {
1217                        name: name.to_string(),
1218                        message: format!("format id {format:?} is reserved"),
1219                    });
1220                }
1221                if by_format.insert(format.clone(), index).is_some() {
1222                    return Err(Error::Rules {
1223                        name: name.to_string(),
1224                        message: format!("format {format:?} claimed twice"),
1225                    });
1226                }
1227            }
1228        }
1229        for entry in &raw.unsupported {
1230            for format in &entry.formats {
1231                if RESERVED_FORMAT_IDS.contains(&format.as_str()) {
1232                    return Err(Error::Rules {
1233                        name: name.to_string(),
1234                        message: format!("format id {format:?} is reserved"),
1235                    });
1236                }
1237                if by_format.contains_key(format)
1238                    || unsupported_reasons
1239                        .insert(format.clone(), entry.reason.clone())
1240                        .is_some()
1241                {
1242                    return Err(Error::Rules {
1243                        name: name.to_string(),
1244                        message: format!("format {format:?} claimed twice"),
1245                    });
1246                }
1247            }
1248        }
1249        Ok(Registry {
1250            version: raw.version,
1251            converters,
1252            by_format,
1253            unsupported_reasons,
1254            container_limits: raw.containers.unwrap_or_default(),
1255            records_limits,
1256            image_ocr_limits,
1257            image_metadata_limits,
1258            asr_profile,
1259            image_metadata_index,
1260            svg_ocr_index,
1261            #[cfg(all(
1262                unix,
1263                feature = "image-ocr",
1264                feature = "svg-provider",
1265                feature = "test-adapters"
1266            ))]
1267            svg_provider: provider.and_then(provider::ProviderConfig::svg).cloned(),
1268        })
1269    }
1270
1271    /// The registry version, part of the run's rules version.
1272    pub fn version(&self) -> &str {
1273        &self.version
1274    }
1275
1276    /// The converter that claims a format id, if any.
1277    pub fn converter_for(&self, format_id: &str) -> Option<&dyn Converter> {
1278        self.by_format
1279            .get(format_id)
1280            .map(|index| self.converters[*index].as_ref())
1281    }
1282
1283    /// The container expansion limits from the rules.
1284    pub fn container_limits(&self) -> &ContainerLimits {
1285        &self.container_limits
1286    }
1287
1288    /// The records worker ceilings from the rules.
1289    pub fn records_limits(&self) -> &RecordsLimits {
1290        &self.records_limits
1291    }
1292
1293    /// The image-OCR jail limit profile from the rules.
1294    pub fn image_ocr_limits(&self) -> &ImageOcrLimits {
1295        &self.image_ocr_limits
1296    }
1297
1298    /// The image-metadata parser ceilings from the rules.
1299    pub fn image_metadata_limits(&self) -> &ImageMetadataLimits {
1300        &self.image_metadata_limits
1301    }
1302
1303    /// The speech-transcription jail profile from the rules, when the
1304    /// rules carry one.
1305    pub fn asr_profile(&self) -> Option<&AsrProfile> {
1306        self.asr_profile.as_ref()
1307    }
1308
1309    /// Test-only: drives the audio formats through the fake-engine
1310    /// speech adapter instead of the pinned-runtime one, so a pipeline
1311    /// test can exercise the decode, preflight, rendering, and media
1312    /// propagation without a wired engine. The shared decode,
1313    /// duration, size, and silence layers are the production ones;
1314    /// only the engine stage is the fake. A release build has no such
1315    /// hook, and the registry never constructs the fake itself.
1316    #[cfg(all(
1317        unix,
1318        feature = "audio-asr",
1319        feature = "test-adapters",
1320        target_os = "macos",
1321        target_arch = "aarch64"
1322    ))]
1323    pub fn use_fake_asr(&mut self) {
1324        if let Some(index) = self
1325            .converters
1326            .iter()
1327            .position(|c| c.id() == subprocess::ASR_ADAPTER_ID)
1328            && let Some(profile) = self.asr_profile.clone()
1329        {
1330            self.converters[index] = Box::new(subprocess::AsrAdapter::new_fake(profile));
1331        }
1332    }
1333
1334    /// Test-only: drives the image formats through the fake-engine
1335    /// image-OCR adapter instead of the pinned-runtime one, so a pipeline
1336    /// test can compose a succeeding primary leg with the metadata leg.
1337    /// The shared decode, area guards, and outcome mapping are the
1338    /// production ones; only the recognition stage is the fake. A release
1339    /// build has no such hook.
1340    #[cfg(all(unix, feature = "image-ocr", feature = "test-adapters"))]
1341    pub fn use_fake_image_ocr(&mut self) {
1342        if let Some(index) = self
1343            .converters
1344            .iter()
1345            .position(|c| c.id() == subprocess::IMAGE_PIXEL_OCR_ID)
1346        {
1347            // A configured provider stays wired: only the recognition
1348            // stage is faked, so the provider half of the svg leg runs
1349            // for real, jail and all.
1350            #[cfg(feature = "svg-provider")]
1351            {
1352                self.converters[index] = match &self.svg_provider {
1353                    Some(svg) => Box::new(subprocess::ImagePixelOcr::new_fake_with_svg_provider(
1354                        self.image_ocr_limits.clone(),
1355                        svg,
1356                    )),
1357                    None => Box::new(subprocess::ImagePixelOcr::new_fake(
1358                        self.image_ocr_limits.clone(),
1359                    )),
1360                };
1361            }
1362            #[cfg(not(feature = "svg-provider"))]
1363            {
1364                self.converters[index] = Box::new(subprocess::ImagePixelOcr::new_fake(
1365                    self.image_ocr_limits.clone(),
1366                ));
1367            }
1368        }
1369    }
1370
1371    /// Test seam for the provider runner's spawn-time grant check.
1372    /// The converter performs its normal parent presence preflight,
1373    /// replaces `path` with a directory, then enters `Runner::run`.
1374    /// This method exists only with `test-adapters` and keeps the fake
1375    /// recognition stage used by provider pipeline tests.
1376    #[cfg(all(
1377        unix,
1378        feature = "image-ocr",
1379        feature = "svg-provider",
1380        feature = "test-adapters"
1381    ))]
1382    pub fn use_provider_spawn_recheck_test_seam(&mut self, path: std::path::PathBuf) {
1383        if let (Some(index), Some(svg)) = (self.svg_ocr_index, &self.svg_provider) {
1384            self.converters[index] = Box::new(
1385                subprocess::ImagePixelOcr::new_fake_with_svg_provider_spawn_recheck(
1386                    self.image_ocr_limits.clone(),
1387                    svg,
1388                    path,
1389                ),
1390            );
1391        }
1392    }
1393
1394    /// The converter that runs the svg provider leg, when a complete
1395    /// provider is configured and this build carries the feature. It
1396    /// never enters `by_format`, so the pipeline reaches it here to run
1397    /// the derived-child leg, exactly as it reaches the metadata
1398    /// converter. `None` means the leg does not run and svg is a
1399    /// passthrough primary with no child.
1400    pub fn svg_ocr_converter(&self) -> Option<&dyn Converter> {
1401        self.svg_ocr_index
1402            .map(|index| self.converters[index].as_ref())
1403    }
1404
1405    /// The auxiliary image-metadata converter, if the feature built one.
1406    /// It never enters `by_format`, so the pipeline reaches it here to
1407    /// run the derived-child leg. `None` means the leg does not run.
1408    pub fn image_metadata_converter(&self) -> Option<&dyn Converter> {
1409        self.image_metadata_index
1410            .map(|index| self.converters[index].as_ref())
1411    }
1412
1413    /// The reason a format is unsupported.
1414    ///
1415    /// A declared entry from the registry wins, such as a spreadsheet
1416    /// format with no visibility reader yet. Every other format no
1417    /// converter claims gets the generic floor
1418    /// [`NO_CONVERTER_REASON`], so an unsupported record is never
1419    /// reason-less. A format a converter claims returns `None`.
1420    pub fn unsupported_reason(&self, format_id: &str) -> Option<&str> {
1421        if self.by_format.contains_key(format_id) {
1422            return None;
1423        }
1424        Some(
1425            self.unsupported_reasons
1426                .get(format_id)
1427                .map(String::as_str)
1428                .unwrap_or(NO_CONVERTER_REASON),
1429        )
1430    }
1431
1432    /// A registry built directly from converter instances, for tests
1433    /// that need behavior no shipped converter exhibits, such as a
1434    /// panicking converter.
1435    #[cfg(test)]
1436    pub(crate) fn for_tests(
1437        version: &str,
1438        entries: Vec<(Box<dyn Converter>, Vec<&str>)>,
1439    ) -> Registry {
1440        let mut converters = Vec::new();
1441        let mut by_format = HashMap::new();
1442        for (converter, formats) in entries {
1443            let index = converters.len();
1444            converters.push(converter);
1445            for format in formats {
1446                by_format.insert(format.to_string(), index);
1447            }
1448        }
1449        Registry {
1450            version: version.to_string(),
1451            converters,
1452            by_format,
1453            unsupported_reasons: HashMap::new(),
1454            container_limits: ContainerLimits::default(),
1455            records_limits: RecordsLimits::default(),
1456            image_ocr_limits: ImageOcrLimits::default(),
1457            image_metadata_limits: ImageMetadataLimits::default(),
1458            asr_profile: None,
1459            image_metadata_index: None,
1460            svg_ocr_index: None,
1461            #[cfg(all(
1462                unix,
1463                feature = "image-ocr",
1464                feature = "svg-provider",
1465                feature = "test-adapters"
1466            ))]
1467            svg_provider: None,
1468        }
1469    }
1470}
1471
1472#[cfg(test)]
1473mod tests {
1474    use super::*;
1475
1476    #[test]
1477    fn normalize_converts_crlf_to_lf() {
1478        assert_eq!(normalize_text("a\r\nb\r\n"), "a\nb\n");
1479    }
1480
1481    #[test]
1482    fn normalize_converts_lone_cr_to_lf() {
1483        assert_eq!(normalize_text("a\rb\r"), "a\nb\n");
1484        assert_eq!(normalize_text("a\r\rb"), "a\n\nb");
1485    }
1486
1487    #[test]
1488    fn normalize_composes_to_nfc() {
1489        assert_eq!(normalize_text("cafe\u{301}"), "caf\u{e9}");
1490    }
1491
1492    #[test]
1493    fn normalize_never_applies_nfkc() {
1494        assert_eq!(normalize_text("\u{fb01}le \u{2460}"), "\u{fb01}le \u{2460}");
1495    }
1496
1497    #[test]
1498    fn passthrough_rejects_invalid_utf8() {
1499        let err = PlainTextPassthrough
1500            .convert(b"hello \xff world", "text")
1501            .unwrap_err();
1502        assert_eq!(err.code, "invalid_utf8");
1503    }
1504
1505    #[test]
1506    fn passthrough_fails_on_suspiciously_empty_output() {
1507        let err = PlainTextPassthrough
1508            .convert("\u{feff}".as_bytes(), "text")
1509            .unwrap_err();
1510        assert_eq!(err.code, "empty_output");
1511    }
1512
1513    #[test]
1514    fn passthrough_accepts_an_empty_source() {
1515        let outcome = PlainTextPassthrough.convert(b"", "text").unwrap();
1516        assert_eq!(outcome.text, "");
1517    }
1518
1519    fn utf16_bytes(text: &str, big_endian: bool) -> Vec<u8> {
1520        let mut bytes = if big_endian {
1521            vec![0xFE, 0xFF]
1522        } else {
1523            vec![0xFF, 0xFE]
1524        };
1525        for unit in text.encode_utf16() {
1526            bytes.extend_from_slice(&if big_endian {
1527                unit.to_be_bytes()
1528            } else {
1529                unit.to_le_bytes()
1530            });
1531        }
1532        bytes
1533    }
1534
1535    #[test]
1536    fn passthrough_transcodes_utf16le_behind_a_byte_order_mark() {
1537        let source = utf16_bytes("a,b\r\n1,caf\u{e9}\r\n", false);
1538        let outcome = PlainTextPassthrough.convert(&source, "csv").unwrap();
1539        assert_eq!(outcome.text, "a,b\n1,caf\u{e9}\n");
1540        assert_eq!(
1541            outcome.warnings,
1542            vec!["transcoded from utf-16le".to_string()]
1543        );
1544    }
1545
1546    #[test]
1547    fn passthrough_transcodes_utf16be_behind_a_byte_order_mark() {
1548        let source = utf16_bytes("total \u{5317}\u{4eac}\n", true);
1549        let outcome = PlainTextPassthrough.convert(&source, "text").unwrap();
1550        assert_eq!(outcome.text, "total \u{5317}\u{4eac}\n");
1551        assert_eq!(
1552            outcome.warnings,
1553            vec!["transcoded from utf-16be".to_string()]
1554        );
1555    }
1556
1557    #[test]
1558    fn passthrough_refuses_utf32_byte_order_marks() {
1559        for source in [
1560            b"\xFF\xFE\x00\x00A\x00\x00\x00".as_slice(),
1561            b"\x00\x00\xFE\xFF\x00\x00\x00A".as_slice(),
1562        ] {
1563            let err = PlainTextPassthrough.convert(source, "text").unwrap_err();
1564            assert_eq!(err.code, "unsupported-encoding");
1565            assert!(err.to_string().starts_with("unsupported-encoding: utf-32"));
1566        }
1567    }
1568
1569    #[test]
1570    fn passthrough_fails_an_odd_utf16_tail_at_its_offset() {
1571        let err = PlainTextPassthrough
1572            .convert(b"\xFF\xFEA\x00B", "text")
1573            .unwrap_err();
1574        assert_eq!(err.code, "invalid_utf16");
1575        assert_eq!(err.message, "odd trailing byte at byte 4");
1576    }
1577
1578    #[test]
1579    fn passthrough_fails_an_unpaired_surrogate_at_its_offset() {
1580        let err = PlainTextPassthrough
1581            .convert(b"\xFF\xFEA\x00\x00\xD8", "text")
1582            .unwrap_err();
1583        assert_eq!(err.code, "invalid_utf16");
1584        assert_eq!(err.message, "unpaired surrogate at byte 4");
1585    }
1586
1587    #[test]
1588    fn passthrough_refuses_nul_bytes_with_the_first_offset() {
1589        let err = PlainTextPassthrough
1590            .convert(b"ab\0cd\0", "text")
1591            .unwrap_err();
1592        assert_eq!(err.code, "nul_bytes");
1593        assert!(err.message.contains("byte 2"), "{}", err.message);
1594        assert!(err.message.contains("BOM-less UTF-16"), "{}", err.message);
1595        // The decoded path gets the same check: a UTF-16 file whose
1596        // decoded text holds a NUL fails too.
1597        let err = PlainTextPassthrough
1598            .convert(b"\xFF\xFEA\x00\x00\x00B\x00", "text")
1599            .unwrap_err();
1600        assert_eq!(err.code, "nul_bytes");
1601        assert!(err.message.contains("byte 1"), "{}", err.message);
1602    }
1603
1604    #[test]
1605    fn passthrough_still_fails_bomless_utf16_as_invalid_utf8() {
1606        let mut source = utf16_bytes("R\u{e9}sum\u{e9}\n", false);
1607        source.drain(..2);
1608        let err = PlainTextPassthrough.convert(&source, "text").unwrap_err();
1609        assert_eq!(err.code, "invalid_utf8");
1610    }
1611
1612    #[test]
1613    fn builtin_registry_claims_the_text_family() {
1614        let registry = Registry::builtin().unwrap();
1615        assert_eq!(registry.version(), "10");
1616        assert!(registry.converter_for("json").is_some());
1617        assert!(registry.converter_for("yaml").is_some());
1618        assert!(registry.converter_for("svg").is_some());
1619        assert!(registry.converter_for("ipynb").is_some());
1620        assert!(registry.converter_for("python").is_some());
1621        assert!(registry.converter_for("pickle").is_none());
1622        assert!(registry.converter_for("text").is_some());
1623        assert!(registry.converter_for("markdown").is_some());
1624        assert!(registry.converter_for("csv").is_some());
1625        assert!(registry.converter_for("pdf").is_some());
1626        assert_eq!(
1627            registry.converter_for("pdf").map(|c| c.id()),
1628            Some("pdf-subprocess")
1629        );
1630        assert!(registry.converter_for("docx").is_some());
1631        assert!(registry.converter_for("xlsx").is_some());
1632        assert_eq!(
1633            registry.converter_for("html").map(|c| c.id()),
1634            Some("html-strip")
1635        );
1636        assert_eq!(
1637            registry.converter_for("eml").map(|c| c.id()),
1638            Some("eml-mime")
1639        );
1640        assert!(registry.converter_for("msg").is_none());
1641        assert!(registry.converter_for("xlsb").is_none());
1642        assert_eq!(
1643            registry.unsupported_reason("xlsb"),
1644            Some("hidden-visibility-unresolved")
1645        );
1646        assert_eq!(
1647            registry.unsupported_reason("pickle"),
1648            Some("pickle-deserialization-unsafe")
1649        );
1650        // png, jpeg, and webp are claimed by the image-pixel-ocr
1651        // converter now (the image-ocr feature is on under test), so
1652        // they carry no unsupported reason. tiff stays engine-unpinned:
1653        // the in-jail decoder deliberately excludes it.
1654        for format in ["png", "jpeg", "webp"] {
1655            assert_eq!(
1656                registry.converter_for(format).map(|c| c.id()),
1657                Some("image-pixel-ocr"),
1658                "{format}"
1659            );
1660            assert_eq!(registry.unsupported_reason(format), None, "{format}");
1661        }
1662        assert_eq!(registry.unsupported_reason("tiff"), Some("engine-unpinned"));
1663        // heic cannot be decoded by the pure-Rust jail path and fails
1664        // closed until the opt-in external provider is built.
1665        assert_eq!(
1666            registry.unsupported_reason("heic"),
1667            Some("no-jailed-rasterizer")
1668        );
1669        // ai routes to the pdf path: a PDF-backed .ai keeps its text
1670        // layer, and a legacy PostScript-backed .ai fails closed with a
1671        // pdf-family reason rather than as an unknown format.
1672        assert_eq!(
1673            registry.converter_for("ai").map(|c| c.id()),
1674            Some("pdf-subprocess")
1675        );
1676        // Parquet, avro, and sqlite are claimed by the records worker
1677        // now, so they carry no unsupported reason. arrow and the rest
1678        // stay deferred.
1679        for format in ["parquet", "avro", "sqlite"] {
1680            assert_eq!(
1681                registry.converter_for(format).map(|c| c.id()),
1682                Some("records-worker"),
1683                "{format}"
1684            );
1685            assert_eq!(registry.unsupported_reason(format), None, "{format}");
1686        }
1687        assert_eq!(
1688            registry.unsupported_reason("arrow"),
1689            Some("converter-deferred")
1690        );
1691        assert_eq!(
1692            registry.unsupported_reason("tar"),
1693            Some("container-deferred")
1694        );
1695        assert_eq!(
1696            registry.unsupported_reason("psd"),
1697            Some("proprietary-binary")
1698        );
1699        // The floor: a format nothing claims and nothing declares.
1700        assert_eq!(
1701            registry.unsupported_reason("unknown"),
1702            Some(NO_CONVERTER_REASON)
1703        );
1704        // A claimed format has no unsupported reason.
1705        assert_eq!(registry.unsupported_reason("text"), None);
1706        assert_eq!(registry.unsupported_reason("mp4"), Some("engine-unpinned"));
1707        // html is claimed now, and msg stays on the floor: it is a
1708        // compound file, not an RFC 822 message.
1709        assert_eq!(registry.unsupported_reason("html"), None);
1710        assert_eq!(
1711            registry.unsupported_reason("msg"),
1712            Some(NO_CONVERTER_REASON)
1713        );
1714    }
1715
1716    // The raster family routes to a live converter when the `image-ocr`
1717    // feature is built with a jail backend, and to a deliberate
1718    // `image-ocr-not-built` capability gap otherwise. Both arms are
1719    // asserted here through a runtime branch, so both compile in every
1720    // configuration and neither rots. The self dev-dependency turns the
1721    // feature on for the normal suite, so `cargo test` runs the feature-
1722    // present arm; the feature-absent arm runs whenever the crate is
1723    // built without the feature, which is a documented CI step:
1724    //
1725    //   cargo test --no-default-features -p text-mirror   # crate built
1726    //   without the self dev-dependency, so the feature is genuinely off
1727    //
1728    // The unsupported reason itself is asserted directly regardless of
1729    // configuration so the not-built plumbing never goes untested.
1730    #[test]
1731    fn the_raster_family_routes_by_the_image_ocr_feature() {
1732        let registry = Registry::builtin().unwrap();
1733        let feature_present = cfg!(all(unix, feature = "image-ocr"));
1734        for format in ["png", "jpeg", "webp"] {
1735            if feature_present {
1736                // A live converter claims the format, so it carries no
1737                // unsupported reason.
1738                assert!(registry.converter_for(format).is_some(), "{format}");
1739                assert_eq!(registry.unsupported_reason(format), None, "{format}");
1740            } else {
1741                // A deliberate capability gap: no converter, and the
1742                // not-built reason rather than a converter error.
1743                assert!(registry.converter_for(format).is_none(), "{format}");
1744                assert_eq!(
1745                    registry.unsupported_reason(format),
1746                    Some(IMAGE_OCR_NOT_BUILT_REASON),
1747                    "{format}"
1748                );
1749            }
1750        }
1751    }
1752
1753    // The audio family's route depends on the feature and the pinned
1754    // machine class. All three arms are asserted through runtime
1755    // branches, so each compiles in every configuration and none rots:
1756    // with the feature on the pinned class the live converter claims
1757    // the formats, with the feature elsewhere they stay engine-unpinned
1758    // because the engine identity is per machine class, and without
1759    // the feature they carry the deliberate not-built gap.
1760    #[test]
1761    fn the_audio_family_routes_by_the_feature_and_platform() {
1762        let registry = Registry::builtin().unwrap();
1763        let pinned_class = cfg!(all(
1764            unix,
1765            feature = "audio-asr",
1766            target_os = "macos",
1767            target_arch = "aarch64"
1768        ));
1769        let feature_present = cfg!(all(unix, feature = "audio-asr"));
1770        for format in ["wav", "mp3", "flac", "m4a"] {
1771            if pinned_class {
1772                assert_eq!(
1773                    registry.converter_for(format).map(|c| c.id()),
1774                    Some("asr-adapter"),
1775                    "{format}"
1776                );
1777                assert_eq!(registry.unsupported_reason(format), None, "{format}");
1778            } else if feature_present {
1779                assert!(registry.converter_for(format).is_none(), "{format}");
1780                assert_eq!(
1781                    registry.unsupported_reason(format),
1782                    Some(ENGINE_UNPINNED_REASON),
1783                    "{format}"
1784                );
1785            } else {
1786                assert!(registry.converter_for(format).is_none(), "{format}");
1787                assert_eq!(
1788                    registry.unsupported_reason(format),
1789                    Some(AUDIO_ASR_NOT_BUILT_REASON),
1790                    "{format}"
1791                );
1792            }
1793        }
1794        // Video containers stay engine-unpinned everywhere: their
1795        // audio-track extraction is a later, separately ruled path.
1796        assert_eq!(registry.unsupported_reason("mp4"), Some("engine-unpinned"));
1797        assert_eq!(registry.unsupported_reason("mov"), Some("engine-unpinned"));
1798        // The [asr] profile parses with the ruled ceilings and exactly
1799        // the four pinned roles, whatever the routing arm.
1800        let profile = registry
1801            .asr_profile()
1802            .expect("the built-in rules carry an [asr] section");
1803        assert_eq!(profile.max_duration_seconds, 3600);
1804        assert_eq!(profile.wall_timeout_secs, 900);
1805        assert_eq!(profile.cpu_seconds, 600);
1806        assert_eq!(profile.max_resident_bytes, 6_442_450_944);
1807        assert_eq!(profile.max_response_bytes, 4_194_304);
1808        assert_eq!(profile.max_stderr_bytes, 4_194_304);
1809        assert_eq!(profile.file_size_bytes, 1_207_959_552);
1810        assert_eq!(profile.max_processes, 64);
1811        assert_eq!(profile.language, "en");
1812        // All four runtime roles ship pinned, the probe included.
1813        assert_eq!(profile.inventory.len(), 4);
1814        assert!(profile.inventory.contains_key("asr-probe"));
1815    }
1816
1817    // The [asr] section is validated at parse: a wrong language, a
1818    // missing or unknown role, a malformed hash, and a decode-policy
1819    // hash that disagrees with the built-in serialization are all
1820    // rules errors, not silent divergences.
1821    #[test]
1822    fn the_asr_profile_is_validated_at_parse() {
1823        let policy_hash = crate::hash::hash_bytes(subprocess::ASR_DECODE_POLICY.as_bytes());
1824        let zeros = "0".repeat(64);
1825        let base = |language: &str, extra_row: &str, policy: &str| {
1826            format!(
1827                r#"version = "1"
1828converters = []
1829
1830[asr]
1831max_duration_seconds = 3600
1832wall_timeout_secs = 900
1833cpu_seconds = 600
1834max_resident_bytes = 6442450944
1835max_response_bytes = 4194304
1836max_stderr_bytes = 4194304
1837file_size_bytes = 1207959552
1838max_processes = 64
1839language = "{language}"
1840
1841[asr.inventory]
1842asr-cli = "{zeros}"
1843asr-weights = "{zeros}"
1844asr-decode-policy = "{policy}"
1845{extra_row}"#,
1846                zeros = zeros.as_str(),
1847            )
1848        };
1849        // A custom profile that omits the optional probe row parses.
1850        let good = base("en", "", &policy_hash);
1851        assert!(Registry::parse(&good, "converters.toml").is_ok());
1852        // The probe-bearing shape the built-in rules ship parses the
1853        // same way.
1854        let with_probe = base("en", &format!("asr-probe = \"{zeros}\"\n"), &policy_hash);
1855        assert!(Registry::parse(&with_probe, "converters.toml").is_ok());
1856        // A non-pinned language is refused.
1857        let err = Registry::parse(&base("fr", "", &policy_hash), "converters.toml")
1858            .err()
1859            .expect("a non-pinned language must be refused");
1860        assert!(err.to_string().contains("language"), "{err}");
1861        // An unknown role is refused.
1862        let unknown = base("en", &format!("asr-extra = \"{zeros}\"\n"), &policy_hash);
1863        let err = Registry::parse(&unknown, "converters.toml")
1864            .err()
1865            .expect("an unknown role must be refused");
1866        assert!(err.to_string().contains("role"), "{err}");
1867        // A missing pinned role is refused.
1868        let missing: String = good
1869            .lines()
1870            .filter(|line| !line.starts_with("asr-weights"))
1871            .collect::<Vec<_>>()
1872            .join("\n");
1873        let err = Registry::parse(&missing, "converters.toml")
1874            .err()
1875            .expect("a missing pinned role must be refused");
1876        assert!(err.to_string().contains("missing"), "{err}");
1877        // A decode-policy hash that disagrees with the built-in
1878        // serialization is refused.
1879        let err = Registry::parse(&base("en", "", &"a".repeat(64)), "converters.toml")
1880            .err()
1881            .expect("a divergent decode-policy hash must be refused");
1882        assert!(err.to_string().contains("decode"), "{err}");
1883        // A malformed hash is refused.
1884        let err = Registry::parse(&base("en", "", "SHOUTING"), "converters.toml")
1885            .err()
1886            .expect("a malformed hash must be refused");
1887        assert!(err.to_string().contains("hex"), "{err}");
1888    }
1889
1890    #[test]
1891    #[cfg(unix)]
1892    fn the_runtime_inventory_validates_roles_and_paths() {
1893        let dir = tempfile::tempdir().unwrap();
1894        let file = dir.path().join("engine");
1895        std::fs::write(&file, b"pinned bytes").unwrap();
1896        let mut inventory = RuntimeInventory::empty();
1897        assert!(inventory.set("asr-cli", file.clone()).is_ok());
1898        assert_eq!(inventory.path("asr-cli"), Some(file.as_path()));
1899        assert!(inventory.path("asr-weights").is_none());
1900        // An unknown role and a relative path both fail loudly. The
1901        // decode-policy role is deliberately unknown here: the crate
1902        // owns that serialization as a constant, so it is never a
1903        // deployment inventory key.
1904        assert!(inventory.set("mystery-role", file.clone()).is_err());
1905        assert!(inventory.set("asr-decode-policy", file.clone()).is_err());
1906        let policy_row = format!("asr-decode-policy = \"{}\"\n", file.display());
1907        assert!(RuntimeInventory::parse(&policy_row, "inventory.toml").is_err());
1908        assert!(
1909            inventory
1910                .set("asr-weights", std::path::PathBuf::from("relative"))
1911                .is_err()
1912        );
1913        // A directory, a symlink, and a missing path are all refused:
1914        // the jail grants literal regular files only, and the spawn
1915        // path re-asserts the same rule.
1916        assert!(
1917            inventory
1918                .set("asr-weights", dir.path().to_path_buf())
1919                .is_err()
1920        );
1921        let link = dir.path().join("link");
1922        std::os::unix::fs::symlink(&file, &link).unwrap();
1923        assert!(inventory.set("asr-weights", link).is_err());
1924        assert!(
1925            inventory
1926                .set("asr-weights", dir.path().join("absent"))
1927                .is_err()
1928        );
1929        // The TOML form parses the same way.
1930        let toml_text = format!("asr-weights = \"{}\"\n", file.display());
1931        let parsed = RuntimeInventory::parse(&toml_text, "inventory.toml").unwrap();
1932        assert_eq!(parsed.path("asr-weights"), Some(file.as_path()));
1933        assert!(RuntimeInventory::parse("nope = \"/x\"\n", "inventory.toml").is_err());
1934    }
1935
1936    #[test]
1937    fn registry_rejects_reserved_format_ids() {
1938        for (section, id) in [
1939            ("converters", "symlink"),
1940            ("converters", "unknown"),
1941            ("unsupported", "symlink"),
1942            ("unsupported", "unknown"),
1943        ] {
1944            let toml = if section == "converters" {
1945                format!(
1946                    "version = \"1\"\n[[converters]]\nid = \"text-passthrough\"\nversion = \"1.2.0\"\nformats = [\"{id}\"]\n"
1947                )
1948            } else {
1949                format!(
1950                    "version = \"1\"\nconverters = []\n[[unsupported]]\nreason = \"r\"\nformats = [\"{id}\"]\n"
1951                )
1952            };
1953            let err = match Registry::parse(&toml, "converters.toml") {
1954                Ok(_) => panic!("{section} {id}: reserved id was accepted"),
1955                Err(e) => e,
1956            };
1957            assert!(
1958                err.to_string().contains("reserved"),
1959                "{section} {id}: {err}"
1960            );
1961        }
1962    }
1963
1964    #[test]
1965    fn registry_rejects_a_version_that_contradicts_the_implementation() {
1966        let toml = r#"
1967version = "1"
1968
1969[[converters]]
1970id = "text-passthrough"
1971version = "9.9.9"
1972formats = ["text"]
1973"#;
1974        assert!(Registry::parse(toml, "converters.toml").is_err());
1975    }
1976}