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}