Skip to main content

text_mirror/
pipeline.rs

1//! The end-to-end pipeline behind the CLI verbs.
2//!
3//! `run` is incremental and idempotent. The manifest is the
4//! checkpoint. A prior terminal record whose key of source path,
5//! source hash, converter version, and rules version matches the
6//! current pass, and whose artifact still hashes to the recorded
7//! text hash, becomes a `skipped_unchanged` record with no work done.
8//! Failed and unsupported sources are re-evaluated every run, so their
9//! terminal records keep the current reason and the enumerated
10//! remainder stays visible.
11//!
12//! Per-source problems, meaning IO errors, converter errors, and
13//! mirror write failures, become `failed` records and the run
14//! continues. Only run-level problems abort: a walk failure, a rules
15//! failure, an invalid layout, or a manifest open or append failure.
16
17use std::collections::{BTreeMap, HashMap};
18use std::fs;
19use std::path::{Path, PathBuf};
20use std::time::Instant;
21
22use crate::convert::{self, Registry};
23use crate::detect::{self, Detection, FormatTable, UNKNOWN_FORMAT};
24use crate::hash::{self, CanonicalArtifact, DedupIndex};
25use crate::manifest::{self, ArtifactKind, ManifestSchema, ManifestWriter, Record, Status};
26use crate::mirror;
27use crate::report::{
28    DivisionStatus, ExplainEntry, FormatCount, RunReport, ScanReport, SizeOutlier, StatusCounts,
29    StatusReport,
30};
31use crate::segments;
32use crate::walk::{self, EntryKind, WalkOptions};
33use crate::{Error, Result};
34
35/// The detected format recorded for a symlink entry.
36pub const SYMLINK_FORMAT: &str = "symlink";
37
38/// The detected format recorded in scan output for FIFOs and sockets.
39pub const SPECIAL_FORMAT: &str = "special";
40
41/// The rules files loaded for one run.
42///
43/// The format table and the converter registry must declare the same
44/// version. That shared version is recorded in every record as
45/// `rules_version`.
46pub struct Rules {
47    /// The format table from `rules/formats.toml`.
48    pub table: FormatTable,
49    /// The converter registry from `rules/converters.toml`.
50    pub registry: Registry,
51}
52
53impl Rules {
54    /// The rules compiled into the binary.
55    pub fn builtin() -> Result<Self> {
56        let table = FormatTable::builtin()?;
57        let registry = Registry::builtin()?;
58        if table.version() != registry.version() {
59            return Err(Error::Rules {
60                name: "rules".to_string(),
61                message: format!(
62                    "formats.toml version {:?} does not match converters.toml version {:?}",
63                    table.version(),
64                    registry.version()
65                ),
66            });
67        }
68        Ok(Rules { table, registry })
69    }
70
71    /// The shared rules version.
72    pub fn version(&self) -> &str {
73        self.table.version()
74    }
75}
76
77/// Options for one conversion run.
78pub struct RunOptions<'a> {
79    /// The division root to walk.
80    pub root: &'a Path,
81    /// The mirror tree root.
82    pub mirror_root: &'a Path,
83    /// The directory holding manifest shards.
84    pub manifest_dir: &'a Path,
85    /// The division name, also the shard file stem.
86    pub division: &'a str,
87    /// Traversal options.
88    pub walk: WalkOptions,
89}
90
91/// Inventories a division root without converting anything.
92///
93/// A file is a size outlier when it is at least 1 MiB and at least ten
94/// times the mean file size of the tree. Symlinks appear under the
95/// `symlink` format and FIFOs and sockets under `special`.
96pub fn scan(root: &Path, rules: &Rules, options: &WalkOptions) -> Result<ScanReport> {
97    let entries = walk::walk_division(root, options)?;
98    let mut inventory: Vec<(String, u64, String)> = Vec::new();
99    for entry in &entries {
100        let absolute = root.join(&entry.path);
101        let (size, format) = match entry.kind {
102            EntryKind::File => {
103                let size = absolute
104                    .metadata()
105                    .map_err(|e| Error::io("stat", &absolute, e))?
106                    .len();
107                let detection = detect::detect_file(&absolute, &rules.table)?;
108                (size, detection.detected)
109            }
110            EntryKind::Symlink => {
111                let size = fs::symlink_metadata(&absolute)
112                    .map_err(|e| Error::io("stat", &absolute, e))?
113                    .len();
114                (size, SYMLINK_FORMAT.to_string())
115            }
116            EntryKind::Other => (0, SPECIAL_FORMAT.to_string()),
117        };
118        inventory.push((entry.path.to_string_lossy().into_owned(), size, format));
119    }
120
121    let mut by_format: BTreeMap<String, (u64, u64)> = BTreeMap::new();
122    let mut total_bytes = 0u64;
123    for (_, size, format) in &inventory {
124        let entry = by_format.entry(format.clone()).or_insert((0, 0));
125        entry.0 += 1;
126        entry.1 += size;
127        total_bytes += size;
128    }
129
130    let mean = if inventory.is_empty() {
131        0.0
132    } else {
133        total_bytes as f64 / inventory.len() as f64
134    };
135    let mut outliers: Vec<SizeOutlier> = inventory
136        .iter()
137        .filter(|(_, size, _)| *size >= 1_048_576 && *size as f64 >= 10.0 * mean)
138        .map(|(path, size, format)| SizeOutlier {
139            path: path.clone(),
140            bytes: *size,
141            format: format.clone(),
142        })
143        .collect();
144    outliers.sort_by(|a, b| b.bytes.cmp(&a.bytes).then(a.path.cmp(&b.path)));
145
146    Ok(ScanReport {
147        root: root.display().to_string(),
148        rules_version: rules.version().to_string(),
149        files: inventory.len() as u64,
150        bytes: total_bytes,
151        formats: by_format
152            .into_iter()
153            .map(|(format, (files, bytes))| FormatCount {
154                format,
155                files,
156                bytes,
157            })
158            .collect(),
159        outliers,
160    })
161}
162
163pub(crate) fn validate_division(name: &str) -> Result<()> {
164    let usable = !name.is_empty()
165        && name
166            .chars()
167            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
168    if usable {
169        Ok(())
170    } else {
171        Err(Error::Division {
172            name: name.to_string(),
173        })
174    }
175}
176
177/// Resolves a path for containment checks without creating anything.
178///
179/// Existing prefixes canonicalize, so symlinks resolve. Components
180/// past the first missing one cannot be symlinks, so they resolve
181/// lexically, and a parent component pops a base that is already
182/// symlink-free.
183fn resolve_for_check(path: &Path) -> Result<PathBuf> {
184    use std::path::Component;
185    let absolute = std::path::absolute(path).map_err(|e| Error::io("absolutize", path, e))?;
186    let mut resolved = PathBuf::new();
187    for component in absolute.components() {
188        match component {
189            Component::Prefix(_) | Component::RootDir => resolved.push(component),
190            Component::CurDir => {}
191            Component::ParentDir => {
192                resolved.pop();
193            }
194            Component::Normal(name) => {
195                resolved.push(name);
196                if let Ok(canonical) = resolved.canonicalize() {
197                    resolved = canonical;
198                }
199            }
200        }
201    }
202    Ok(resolved)
203}
204
205/// Refuses a layout that nests the division root and an output root
206/// either way, so outputs can never become next-run sources and
207/// artifacts can never land inside the source tree.
208///
209/// Every path resolves without touching the filesystem state, and the
210/// output directories are created only after the layout is approved,
211/// so a refusal leaves the division root untouched.
212fn check_layout(root: &Path, mirror_root: &Path, manifest_dir: &Path) -> Result<()> {
213    let root_resolved = resolve_for_check(root)?;
214    for (name, dir) in [
215        ("mirror root", mirror_root),
216        ("manifest directory", manifest_dir),
217    ] {
218        let dir_resolved = resolve_for_check(dir)?;
219        if dir_resolved.starts_with(&root_resolved) {
220            return Err(Error::Layout {
221                message: format!(
222                    "{name} {} lies inside the division root {}",
223                    dir.display(),
224                    root.display()
225                ),
226            });
227        }
228        if root_resolved.starts_with(&dir_resolved) {
229            return Err(Error::Layout {
230                message: format!(
231                    "division root {} lies inside the {name} {}",
232                    root.display(),
233                    dir.display()
234                ),
235            });
236        }
237    }
238    fs::create_dir_all(mirror_root).map_err(|e| Error::io("create_dir", mirror_root, e))?;
239    fs::create_dir_all(manifest_dir).map_err(|e| Error::io("create_dir", manifest_dir, e))?;
240    Ok(())
241}
242
243fn new_record(
244    source_path: &str,
245    source_hash: &str,
246    source_size: u64,
247    detection: &Detection,
248    rules_version: &str,
249    status: Status,
250) -> Record {
251    Record {
252        schema: ManifestSchema,
253        source_path: source_path.to_string(),
254        source_hash: source_hash.to_string(),
255        source_size,
256        declared_format: detection.declared.clone(),
257        detected_format: detection.detected.clone(),
258        format_mismatch: detection.mismatch,
259        status,
260        text_path: None,
261        text_hash: None,
262        artifact_kind: None,
263        converter_id: None,
264        converter_version: None,
265        tool_version: None,
266        rules_version: rules_version.to_string(),
267        media: None,
268        parent_source: None,
269        dedup_of: None,
270        warnings: Vec::new(),
271        error: None,
272        duration_ms: None,
273    }
274}
275
276fn elapsed_ms(started: Instant) -> u64 {
277    u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
278}
279
280fn terminal_by_path(records: Vec<Record>) -> HashMap<String, Record> {
281    let mut terminal = HashMap::new();
282    for record in records {
283        terminal.insert(record.source_path.clone(), record);
284    }
285    terminal
286}
287
288/// Seeds the dedup index from prior terminal records.
289///
290/// Only records whose converter version and rules version match the
291/// current run are eligible, so a rules bump never propagates stale
292/// artifacts. Candidates are visited sorted by source path, and the
293/// first insert per hash wins, so the canonical choice is
294/// deterministic.
295fn seed_dedup(terminal: &HashMap<String, Record>, rules: &Rules, dedup: &mut DedupIndex) {
296    let mut candidates: Vec<&Record> = terminal.values().collect();
297    candidates.sort_by(|a, b| a.source_path.cmp(&b.source_path));
298    for record in candidates {
299        let canonical_status = record.status == Status::Converted
300            || (record.status == Status::SkippedUnchanged && record.dedup_of.is_none());
301        if !canonical_status || record.rules_version != rules.version() {
302            continue;
303        }
304        let (Some(text_path), Some(text_hash), Some(converter_id), Some(converter_version)) = (
305            &record.text_path,
306            &record.text_hash,
307            &record.converter_id,
308            &record.converter_version,
309        ) else {
310            continue;
311        };
312        let current = rules.registry.converter_for(&record.detected_format);
313        let versions_match =
314            current.is_some_and(|c| c.id() == converter_id && c.version() == converter_version);
315        if !versions_match {
316            continue;
317        }
318        dedup.insert(
319            &record.source_hash,
320            CanonicalArtifact {
321                source_path: record.source_path.clone(),
322                text_path: text_path.clone(),
323                text_hash: text_hash.clone(),
324                converter_id: converter_id.clone(),
325                converter_version: converter_version.clone(),
326                artifact_kind: record.artifact_kind,
327                warnings: record.warnings.clone(),
328            },
329        );
330    }
331}
332
333fn record_outcome(
334    writer: &mut ManifestWriter,
335    terminal: &mut HashMap<String, Record>,
336    counts: &mut StatusCounts,
337    record: Record,
338) -> Result<()> {
339    writer.append(&record)?;
340    match record.status {
341        Status::Converted => counts.converted += 1,
342        Status::Failed => counts.failed += 1,
343        Status::Unsupported => counts.unsupported += 1,
344        Status::SkippedUnchanged => counts.skipped_unchanged += 1,
345        Status::Dedup => counts.dedup += 1,
346    }
347    terminal.insert(record.source_path.clone(), record);
348    Ok(())
349}
350
351/// Removes any artifact and segments file left behind for a source
352/// whose terminal outcome carries no text, so the mirror never
353/// contradicts the manifest.
354fn remove_stale_artifact(text_absolute: &Path, segments_absolute: &Path, record: &mut Record) {
355    for stale in [text_absolute, segments_absolute] {
356        match fs::remove_file(stale) {
357            Ok(()) => {}
358            Err(e)
359                if e.kind() == std::io::ErrorKind::NotFound
360                    || e.kind() == std::io::ErrorKind::NotADirectory => {}
361            Err(e) => record
362                .warnings
363                .push(format!("stale_artifact_not_removed: {e}")),
364        }
365    }
366}
367
368/// True when an artifact and its sidecar still match the recorded
369/// hash: the text bytes hash to `text_hash` and the sidecar parses as
370/// segments@1 and validates against the text.
371fn envelope_intact(artifact: &Path, text_hash: &str, segments_path: &Path) -> bool {
372    let Ok(text) = fs::read_to_string(artifact) else {
373        return false;
374    };
375    if hash::hash_bytes(text.as_bytes()) != text_hash {
376        return false;
377    }
378    let Ok(sidecar) = fs::read_to_string(segments_path) else {
379        return false;
380    };
381    segments::parse_jsonl(&sidecar).is_ok_and(|segs| segments::validate(&segs, &text).is_ok())
382}
383
384fn source_front(
385    absolute: &Path,
386    table: &FormatTable,
387) -> Result<(String, u64, Detection, Vec<String>)> {
388    let source_hash = hash::hash_file(absolute)?;
389    let source_size = absolute
390        .metadata()
391        .map_err(|e| Error::io("stat", absolute, e))?
392        .len();
393    let (detection, warnings) = detect::detect_file_with_warnings(absolute, table)?;
394    Ok((source_hash, source_size, detection, warnings))
395}
396
397/// Identity of one source, container child or top-level file alike.
398struct Meta {
399    source_path: String,
400    parent_source: Option<String>,
401    source_hash: String,
402    source_size: u64,
403    detection: Detection,
404    detect_warnings: Vec<String>,
405}
406
407/// Where a unit's bytes come from. A top-level file reads from disk
408/// after the skip and dedup decisions, so an unchanged file never
409/// pays a second read. A container member already holds its bytes.
410enum UnitBytes {
411    Disk(PathBuf),
412    Mem(Vec<u8>),
413}
414
415impl UnitBytes {
416    fn load(self, expected_hash: &str) -> std::result::Result<Vec<u8>, String> {
417        match self {
418            UnitBytes::Mem(bytes) => Ok(bytes),
419            UnitBytes::Disk(path) => {
420                let bytes = fs::read(&path).map_err(|e| format!("source_io_error: read: {e}"))?;
421                if hash::hash_bytes(&bytes) != expected_hash {
422                    return Err(
423                        "source_changed_during_run: bytes changed between hash and conversion"
424                            .to_string(),
425                    );
426                }
427                Ok(bytes)
428            }
429        }
430    }
431}
432
433/// One unit of conversion work: a top-level source or a container
434/// member.
435struct Unit {
436    meta: Meta,
437    bytes: UnitBytes,
438    depth: usize,
439    /// Container hashes on this unit's chain, for the quine guard.
440    ancestors: Vec<String>,
441}
442
443/// Which container family a detected format belongs to, if any.
444enum ContainerKind {
445    Zip,
446    Eml,
447}
448
449/// True when a container member's hash repeats a container already on
450/// its chain, which is the zip-quine signature. Kills a quine before
451/// it spends the depth budget.
452fn hash_on_chain(hash: &str, ancestors: &[String]) -> bool {
453    ancestors.iter().any(|ancestor| ancestor == hash)
454}
455
456/// Case-folded, NFC-normalized key for a child source path, so two
457/// members whose paths differ only by case or Unicode form collide.
458fn collision_key(source_path: &str) -> String {
459    use unicode_normalization::UnicodeNormalization;
460    source_path.nfc().collect::<String>().to_lowercase()
461}
462
463/// The synthetic derived-child path an image's metadata leg writes to.
464fn metadata_child_path(source_path: &str) -> String {
465    format!("{source_path}.d/#image-metadata")
466}
467
468/// The claimed-path state for one top-level container's whole
469/// expansion, so members cannot alias each other's paths and a
470/// re-expansion can retire the descendants a shrunken member set
471/// dropped.
472struct ClaimState {
473    /// `<container>.d/`, the namespace every descendant path begins
474    /// with.
475    prefix: String,
476    /// Collision keys already claimed this expansion.
477    claimed: std::collections::HashSet<String>,
478    /// Actual descendant source paths recorded this expansion.
479    emitted: std::collections::HashSet<String>,
480    /// Counter for synthetic collision-child paths.
481    collisions: usize,
482}
483
484/// Reason recorded when a re-expanded container no longer holds a
485/// member its prior manifest still named.
486const REMOVED_MEMBER_REASON: &str = "container-member-removed";
487
488fn container_kind(detected: &str) -> Option<ContainerKind> {
489    match detected {
490        "zip" => Some(ContainerKind::Zip),
491        "eml" => Some(ContainerKind::Eml),
492        _ => None,
493    }
494}
495
496/// Shared run state for the recursive conversion of one division.
497struct Expander<'a> {
498    rules: &'a Rules,
499    mirror_root: &'a Path,
500    division: &'a str,
501    walked: &'a std::collections::HashSet<String>,
502    limits: &'a convert::ContainerLimits,
503    writer: &'a mut ManifestWriter,
504    terminal: &'a mut HashMap<String, Record>,
505    counts: &'a mut StatusCounts,
506    dedup: &'a mut DedupIndex,
507    /// Set while a top-level container expands, tracking claimed and
508    /// emitted descendant paths for aliasing and reconciliation.
509    claim: Option<ClaimState>,
510}
511
512impl Expander<'_> {
513    fn artifact_paths(&self, source_path: &str) -> (PathBuf, PathBuf, String) {
514        let relative = Path::new(source_path);
515        (
516            mirror::mirror_path(self.mirror_root, self.division, relative),
517            segments::segments_path(self.mirror_root, self.division, relative),
518            mirror::recorded_text_path(self.division, source_path),
519        )
520    }
521
522    fn emit(&mut self, record: Record) -> Result<()> {
523        if let Some(claim) = &mut self.claim
524            && record.source_path.starts_with(&claim.prefix)
525        {
526            claim.emitted.insert(record.source_path.clone());
527        }
528        record_outcome(self.writer, self.terminal, self.counts, record)
529    }
530
531    fn base_record(&self, meta: &Meta, status: Status) -> Record {
532        let mut record = new_record(
533            &meta.source_path,
534            &meta.source_hash,
535            meta.source_size,
536            &meta.detection,
537            self.rules.version(),
538            status,
539        );
540        record.parent_source.clone_from(&meta.parent_source);
541        record.warnings.clone_from(&meta.detect_warnings);
542        record
543    }
544
545    /// Emits a failed record for a whole unit and clears any stale
546    /// artifact.
547    fn fail(&mut self, meta: &Meta, reason: String, started: Instant) -> Result<()> {
548        let (text_absolute, segments_absolute, _) = self.artifact_paths(&meta.source_path);
549        let mut record = self.base_record(meta, Status::Failed);
550        record.error = Some(reason);
551        record.duration_ms = Some(elapsed_ms(started));
552        remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
553        self.emit(record)
554    }
555
556    /// True when a real walked source already occupies this
557    /// container's expansion namespace, which would collide two
558    /// artifacts on one mirror path.
559    fn expansion_collides(&self, source_path: &str) -> bool {
560        let prefix = format!("{source_path}.d/");
561        self.walked.iter().any(|walked| walked.starts_with(&prefix))
562    }
563
564    /// Processes one unit: checkpoint, dedup, then conversion or
565    /// container expansion, appending one record per source.
566    fn process(&mut self, unit: Unit, started: Instant, expanded: &mut u64) -> Result<()> {
567        let Unit {
568            meta,
569            bytes,
570            depth,
571            ancestors,
572        } = unit;
573        let (text_absolute, segments_absolute, text_relative) =
574            self.artifact_paths(&meta.source_path);
575        let kind = container_kind(&meta.detection.detected);
576        let converter = self.rules.registry.converter_for(&meta.detection.detected);
577        let current_version = match kind {
578            Some(ContainerKind::Zip) => Some(convert::CONTAINER_ZIP_VERSION),
579            _ => converter.map(|c| c.version()),
580        };
581
582        // An image carrier whose metadata leg runs owns a derived child
583        // the way a container owns members: the checkpoint and dedup
584        // decisions below account for that child.
585        let metadata_leg = self.metadata_leg_applies(&meta.detection.detected);
586        let has_children = kind.is_some() || metadata_leg;
587
588        // Checkpoint: an unchanged source with an intact artifact
589        // skips, containers included. A skipped container leaves its
590        // children untouched, and their prior records stay terminal.
591        if let Some(previous) = self.terminal.get(&meta.source_path) {
592            let key_matches = previous.source_hash == meta.source_hash
593                && previous.rules_version == self.rules.version()
594                && previous.converter_version.as_deref() == current_version;
595            let has_text = matches!(
596                previous.status,
597                Status::Converted | Status::Dedup | Status::SkippedUnchanged
598            );
599            // A prior record from a build without the metadata converter
600            // never had its metadata leg run. Once the converter is
601            // present that record is not skippable, so the child is
602            // minted on the first build that can produce it.
603            let leg_never_ran = metadata_leg
604                && previous
605                    .warnings
606                    .iter()
607                    .any(|w| w == convert::image_metadata::IMAGE_METADATA_NOT_BUILT);
608            if key_matches && has_text && !leg_never_ran {
609                let intact = match (&previous.text_path, &previous.text_hash) {
610                    (Some(text_path), Some(text_hash)) => {
611                        mirror::resolve_recorded_path(self.mirror_root, text_path).is_ok_and(
612                            |artifact| envelope_intact(&artifact, text_hash, &segments_absolute),
613                        )
614                    }
615                    _ => false,
616                };
617                // A container's members, and an image's metadata child,
618                // are reachable only through their parent, so an intact
619                // parent envelope is not enough: every text-bearing
620                // descendant must still hash to its record, or the
621                // parent re-runs.
622                let descendants_ok = !has_children || self.descendants_intact(&meta.source_path);
623                if intact && descendants_ok {
624                    // A nested container, or an image carrier, that
625                    // skips inside a re-expanding parent just validated
626                    // its whole subtree intact, so every prior descendant
627                    // under it is kept: the owner's reconciliation must
628                    // not retire them as removed. A kept metadata child
629                    // also keeps its claim on its path, so a later
630                    // literal member cannot write over it.
631                    if has_children && self.claim.is_some() {
632                        let prefix = format!("{}.d/", meta.source_path);
633                        let kept: Vec<String> = self
634                            .terminal
635                            .keys()
636                            .filter(|path| path.starts_with(&prefix))
637                            .cloned()
638                            .collect();
639                        if let Some(claim) = &mut self.claim {
640                            if metadata_leg {
641                                for path in &kept {
642                                    claim.claimed.insert(collision_key(path));
643                                }
644                            }
645                            claim.emitted.extend(kept);
646                        }
647                    }
648                    let mut record = previous.clone();
649                    record.status = Status::SkippedUnchanged;
650                    record.error = None;
651                    record.duration_ms = Some(elapsed_ms(started));
652                    return self.emit(record);
653                }
654            }
655        }
656
657        // Container guards, before any expansion. A container past the
658        // depth cap or one whose bytes repeat an ancestor container is
659        // a failed child, unexpanded.
660        if kind.is_some() {
661            if depth > self.limits.max_depth {
662                return self.fail(&meta, "container-depth-exceeded".to_string(), started);
663            }
664            if hash_on_chain(&meta.source_hash, &ancestors) {
665                return self.fail(&meta, "container-recursive".to_string(), started);
666            }
667        }
668
669        // The source ceiling precedes every parent-side load. An image
670        // whose metadata leg would load its bytes for the dedup borrow
671        // is over the ceiling here, so it skips the borrow and falls
672        // through to the leaf path, which records the resource-limit
673        // failure with no child, exactly as an unborrowed image would.
674        let over_ceiling = meta.source_size > convert::MAX_SOURCE_BYTES;
675
676        // Dedup borrow, non-containers only. A container never dedups,
677        // because borrowing its artifact would skip re-expanding its
678        // members under their own paths.
679        if kind.is_none()
680            && !(metadata_leg && over_ceiling)
681            && let Some(canonical) = self.dedup.get(&meta.source_hash)
682            && canonical.source_path != meta.source_path
683            && converter.is_some_and(|c| {
684                c.id() == canonical.converter_id && c.version() == canonical.converter_version
685            })
686        {
687            let canonical = canonical.clone();
688            let canonical_segments = segments::segments_path(
689                self.mirror_root,
690                self.division,
691                Path::new(&canonical.source_path),
692            );
693            if let Ok(canonical_text) =
694                mirror::resolve_recorded_path(self.mirror_root, &canonical.text_path)
695                && let (Ok(text), Ok(segment_lines)) = (
696                    fs::read_to_string(&canonical_text),
697                    fs::read_to_string(&canonical_segments),
698                )
699                && hash::hash_bytes(text.as_bytes()) == canonical.text_hash
700                && segments::parse_jsonl(&segment_lines)
701                    .is_ok_and(|segs| segments::validate(&segs, &text).is_ok())
702            {
703                // The metadata leg still runs for a borrowed primary:
704                // the bytes are identical by hash, the parse is cheap,
705                // and the child belongs to this path. The bytes are
706                // loaded here, before the record, so a load failure is
707                // recorded the way the leaf path records it.
708                #[cfg(all(unix, feature = "image-metadata"))]
709                let raw = if metadata_leg {
710                    match bytes.load(&meta.source_hash) {
711                        Ok(raw) => Some(raw),
712                        Err(reason) => return self.fail(&meta, reason, started),
713                    }
714                } else {
715                    None
716                };
717                let mut meta = meta;
718                let collides = metadata_leg && self.metadata_collides(&meta.source_path);
719                if collides {
720                    meta.detect_warnings.push(
721                        convert::image_metadata::IMAGE_METADATA_NAMESPACE_OCCUPIED.to_string(),
722                    );
723                }
724                let mut record = self.base_record(&meta, Status::Dedup);
725                for warning in &canonical.warnings {
726                    if !record.warnings.contains(warning) {
727                        record.warnings.push(warning.clone());
728                    }
729                }
730                match mirror::write_atomic(&text_absolute, &text)
731                    .and_then(|()| mirror::write_atomic(&segments_absolute, &segment_lines))
732                {
733                    Ok(()) => {
734                        record.text_path = Some(text_relative);
735                        record.text_hash = Some(hash::hash_bytes(text.as_bytes()));
736                        record.artifact_kind = canonical.artifact_kind;
737                        record.converter_id = Some(canonical.converter_id.clone());
738                        record.converter_version = Some(canonical.converter_version.clone());
739                        record.dedup_of = Some(canonical.source_path.clone());
740                    }
741                    Err(e) => {
742                        record.status = Status::Failed;
743                        record.error = Some(format!("mirror_write_error: {e}"));
744                        remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
745                    }
746                }
747                record.duration_ms = Some(elapsed_ms(started));
748                self.emit(record)?;
749                #[cfg(all(unix, feature = "image-metadata"))]
750                if let Some(raw) = raw
751                    && !collides
752                {
753                    let format = meta.detection.detected.clone();
754                    let declared = meta.detection.declared.clone();
755                    return self.run_metadata_leg(
756                        &meta.source_path,
757                        &format,
758                        declared,
759                        &meta.source_hash,
760                        meta.source_size,
761                        &raw,
762                        started,
763                    );
764                }
765                return Ok(());
766            }
767        }
768
769        let Some(kind) = kind else {
770            return self.convert_leaf_with_metadata(meta, bytes, started);
771        };
772        // A top-level container owns the claim state for its whole
773        // subtree. Nested containers share the owner's state.
774        let owns_claim = self.claim.is_none();
775        if owns_claim {
776            self.claim = Some(ClaimState {
777                prefix: format!("{}.d/", meta.source_path),
778                claimed: std::collections::HashSet::new(),
779                emitted: std::collections::HashSet::new(),
780                collisions: 0,
781            });
782        }
783        let root = meta.source_path.clone();
784        let result = match kind {
785            ContainerKind::Zip => self.expand_zip(meta, bytes, depth, ancestors, started, expanded),
786            ContainerKind::Eml => self.expand_eml(meta, bytes, depth, ancestors, started, expanded),
787        };
788        if owns_claim {
789            // Reconcile even on a failure path, so a container that
790            // failed whole still retires the descendants it no longer
791            // holds and never leaves a partial expansion live.
792            if let Some(claim) = self.claim.take() {
793                self.reconcile(&root, &claim, started)?;
794            }
795        }
796        result
797    }
798
799    /// True when every text-bearing descendant of a container still
800    /// hashes to its recorded value on disk.
801    fn descendants_intact(&self, root: &str) -> bool {
802        let prefix = format!("{root}.d/");
803        for record in self.terminal.values() {
804            if !record.source_path.starts_with(&prefix) {
805                continue;
806            }
807            if let (Some(text_path), Some(text_hash)) = (&record.text_path, &record.text_hash) {
808                let segments_absolute = segments::segments_path(
809                    self.mirror_root,
810                    self.division,
811                    Path::new(&record.source_path),
812                );
813                let ok = mirror::resolve_recorded_path(self.mirror_root, text_path).is_ok_and(
814                    |artifact| envelope_intact(&artifact, text_hash, &segments_absolute),
815                );
816                if !ok {
817                    return false;
818                }
819            }
820        }
821        true
822    }
823
824    /// Retires every prior descendant a re-expansion no longer holds
825    /// with a terminal non-text record, and removes its artifact, so
826    /// no removed member survives as a live converted record.
827    fn reconcile(&mut self, root: &str, claim: &ClaimState, started: Instant) -> Result<()> {
828        let stale: Vec<Record> = self
829            .terminal
830            .values()
831            .filter(|record| {
832                record.source_path.starts_with(&claim.prefix)
833                    && !claim.emitted.contains(&record.source_path)
834                    // An already-retired record stays retired, so the
835                    // reconciliation reaches a fixpoint and does not
836                    // re-emit a removed member every run.
837                    && !(record.status == Status::Failed
838                        && record.error.as_deref() == Some(REMOVED_MEMBER_REASON))
839            })
840            .cloned()
841            .collect();
842        let _ = root;
843        for record in stale {
844            let (text_absolute, segments_absolute, _) = self.artifact_paths(&record.source_path);
845            let detection = Detection {
846                declared: record.declared_format.clone(),
847                detected: UNKNOWN_FORMAT.to_string(),
848                mismatch: false,
849            };
850            let mut retire = new_record(
851                &record.source_path,
852                "",
853                0,
854                &detection,
855                self.rules.version(),
856                Status::Failed,
857            );
858            retire.parent_source.clone_from(&record.parent_source);
859            retire.error = Some(REMOVED_MEMBER_REASON.to_string());
860            retire.duration_ms = Some(elapsed_ms(started));
861            remove_stale_artifact(&text_absolute, &segments_absolute, &mut retire);
862            self.emit(retire)?;
863        }
864        Ok(())
865    }
866
867    /// The non-container conversion path: unsupported when no converter
868    /// claims the format, else convert under the ceilings.
869    fn convert_leaf(&mut self, meta: Meta, bytes: UnitBytes, started: Instant) -> Result<()> {
870        let (text_absolute, segments_absolute, text_relative) =
871            self.artifact_paths(&meta.source_path);
872        let Some(converter) = self.rules.registry.converter_for(&meta.detection.detected) else {
873            let mut record = self.base_record(&meta, Status::Unsupported);
874            record.error = self
875                .rules
876                .registry
877                .unsupported_reason(&meta.detection.detected)
878                .map(str::to_string);
879            record.duration_ms = Some(elapsed_ms(started));
880            remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
881            return self.emit(record);
882        };
883        let converter_id = converter.id().to_string();
884        let converter_version = converter.version().to_string();
885
886        let mut record = self.base_record(&meta, Status::Converted);
887        let outcome = if meta.source_size > convert::MAX_SOURCE_BYTES {
888            Err(format!(
889                "resource_limit: source is {} bytes over the {} byte ceiling",
890                meta.source_size,
891                convert::MAX_SOURCE_BYTES
892            ))
893        } else {
894            bytes
895                .load(&meta.source_hash)
896                .and_then(|bytes| run_converter(converter, &bytes, &meta.detection.detected))
897        };
898        match outcome {
899            Ok(outcome) => match segments::to_jsonl(&outcome.segments)
900                .map_err(|e| e.to_string())
901                .and_then(|lines| {
902                    mirror::write_atomic(&text_absolute, &outcome.text)
903                        .and_then(|()| mirror::write_atomic(&segments_absolute, &lines))
904                        .map_err(|e| e.to_string())
905                }) {
906                Ok(()) => {
907                    let text_hash = hash::hash_bytes(outcome.text.as_bytes());
908                    record.text_path = Some(text_relative.clone());
909                    record.text_hash = Some(text_hash.clone());
910                    record.artifact_kind = Some(outcome.artifact_kind);
911                    record.converter_id = Some(outcome.converter_id.clone());
912                    record.converter_version = Some(outcome.converter_version.clone());
913                    record.warnings.extend(outcome.warnings.iter().cloned());
914                    self.dedup.replace(
915                        &meta.source_hash,
916                        CanonicalArtifact {
917                            source_path: meta.source_path.clone(),
918                            text_path: text_relative,
919                            text_hash,
920                            converter_id: outcome.converter_id,
921                            converter_version: outcome.converter_version,
922                            artifact_kind: record.artifact_kind,
923                            warnings: outcome.warnings,
924                        },
925                    );
926                }
927                Err(detail) => {
928                    record.status = Status::Failed;
929                    record.converter_id = Some(converter_id);
930                    record.converter_version = Some(converter_version);
931                    record.error = Some(format!("mirror_write_error: {detail}"));
932                    remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
933                }
934            },
935            Err(reason) => {
936                record.status = Status::Failed;
937                record.converter_id = Some(converter_id);
938                record.converter_version = Some(converter_version);
939                record.error = Some(reason);
940                remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
941            }
942        }
943        record.duration_ms = Some(elapsed_ms(started));
944        self.emit(record)
945    }
946
947    /// The leaf path plus, for an image carrier, the auxiliary
948    /// metadata derived-child leg.
949    ///
950    /// The primary conversion is the unchanged leaf path. When the format
951    /// is an image carrier and the metadata converter is built, one
952    /// image source additionally yields a hidden metadata child at
953    /// `<source>.d/#image-metadata`, structurally parallel to a container
954    /// member. The two legs share nothing: a failure of the metadata
955    /// child never touches the primary image record.
956    fn convert_leaf_with_metadata(
957        &mut self,
958        mut meta: Meta,
959        bytes: UnitBytes,
960        started: Instant,
961    ) -> Result<()> {
962        #[cfg(all(unix, feature = "image-metadata"))]
963        if self.metadata_leg_applies(&meta.detection.detected) {
964            return self.convert_image_with_metadata(meta, bytes, started);
965        }
966        // An image carrier in a build without the metadata converter
967        // records that its leg never ran, so a later build that carries
968        // the converter does not skip this record as unchanged.
969        if convert::image_metadata::image_metadata_applies(&meta.detection.detected) {
970            meta.detect_warnings
971                .push(convert::image_metadata::IMAGE_METADATA_NOT_BUILT.to_string());
972        }
973        self.convert_leaf(meta, bytes, started)
974    }
975
976    /// Whether the metadata derived-child leg runs for a detected
977    /// format: the format is an image carrier and the build carries the
978    /// converter.
979    fn metadata_leg_applies(&self, format: &str) -> bool {
980        #[cfg(all(unix, feature = "image-metadata"))]
981        {
982            convert::image_metadata::image_metadata_applies(format)
983                && self.rules.registry.image_metadata_converter().is_some()
984        }
985        #[cfg(not(all(unix, feature = "image-metadata")))]
986        {
987            let _ = format;
988            false
989        }
990    }
991
992    /// Whether the metadata child of an image at `source_path` would
993    /// collide with a real source, claiming the child path inside a
994    /// container expansion as it checks.
995    ///
996    /// A real walked source in the image `.d/` namespace occupies it. So
997    /// does a container member already claimed at the child path: the
998    /// literal member came first, so the child yields to it. When the
999    /// image comes first, the claim made here makes the later literal
1000    /// member the collision, so archive order never silently overwrites
1001    /// either artifact.
1002    fn metadata_collides(&mut self, source_path: &str) -> bool {
1003        let child_path = metadata_child_path(source_path);
1004        self.expansion_collides(source_path)
1005            || (self.claim.is_some()
1006                && !self
1007                    .claim
1008                    .as_mut()
1009                    .expect("checked above")
1010                    .claimed
1011                    .insert(collision_key(&child_path)))
1012    }
1013
1014    /// Runs the primary OCR leg, then the metadata derived-child leg,
1015    /// for one image source, loading the bytes once for both.
1016    #[cfg(all(unix, feature = "image-metadata"))]
1017    fn convert_image_with_metadata(
1018        &mut self,
1019        mut meta: Meta,
1020        bytes: UnitBytes,
1021        started: Instant,
1022    ) -> Result<()> {
1023        // The source ceiling is checked before any load, exactly as the
1024        // leaf path checks it, so an over-ceiling source records the same
1025        // resource-limit failure there and no child is attempted.
1026        if meta.source_size > convert::MAX_SOURCE_BYTES {
1027            return self.convert_leaf(meta, bytes, started);
1028        }
1029        // Load once. A load error means the primary leg records it and no
1030        // metadata child is attempted, so route it through the leaf path.
1031        let raw = match bytes.load(&meta.source_hash) {
1032            Ok(raw) => raw,
1033            Err(reason) => return self.fail(&meta, reason, started),
1034        };
1035        // Collision guard on the image `.d/` namespace, before the primary
1036        // leg records, so the warning rides the parent image record. A
1037        // real walked source in the namespace means the metadata leg is
1038        // skipped rather than overwriting that source's artifact.
1039        let collides = self.metadata_collides(&meta.source_path);
1040        if collides {
1041            meta.detect_warnings
1042                .push(convert::image_metadata::IMAGE_METADATA_NAMESPACE_OCCUPIED.to_string());
1043        }
1044        let source_path = meta.source_path.clone();
1045        let format = meta.detection.detected.clone();
1046        let declared = meta.detection.declared.clone();
1047        let source_hash = meta.source_hash.clone();
1048        let source_size = meta.source_size;
1049        // The primary OCR leg is the unchanged leaf path. It emits the
1050        // parent image record, carrying the collision warning when set.
1051        self.convert_leaf(meta, UnitBytes::Mem(raw.clone()), started)?;
1052        if collides {
1053            return Ok(());
1054        }
1055        self.run_metadata_leg(
1056            &source_path,
1057            &format,
1058            declared,
1059            &source_hash,
1060            source_size,
1061            &raw,
1062            started,
1063        )
1064    }
1065
1066    /// Runs the metadata child under the claim-and-reconcile machinery,
1067    /// so a re-run that finds no metadata retires a prior metadata child.
1068    ///
1069    /// A top-level image owns a claim over its own `.d/` namespace; an
1070    /// image that is itself a container member shares the owning
1071    /// container's claim, which already covers the child path.
1072    #[cfg(all(unix, feature = "image-metadata"))]
1073    #[allow(clippy::too_many_arguments)]
1074    fn run_metadata_leg(
1075        &mut self,
1076        source_path: &str,
1077        format: &str,
1078        declared: Option<String>,
1079        source_hash: &str,
1080        source_size: u64,
1081        raw: &[u8],
1082        started: Instant,
1083    ) -> Result<()> {
1084        let owns_claim = self.claim.is_none();
1085        if owns_claim {
1086            self.claim = Some(ClaimState {
1087                prefix: format!("{source_path}.d/"),
1088                claimed: std::collections::HashSet::new(),
1089                emitted: std::collections::HashSet::new(),
1090                collisions: 0,
1091            });
1092        }
1093        let result = self.emit_metadata_child(
1094            source_path,
1095            format,
1096            declared,
1097            source_hash,
1098            source_size,
1099            raw,
1100            started,
1101        );
1102        if owns_claim && let Some(claim) = self.claim.take() {
1103            self.reconcile(source_path, &claim, started)?;
1104        }
1105        result
1106    }
1107
1108    /// Converts the metadata child and emits its record. Zero rows is the
1109    /// not-applicable outcome: nothing is written and reconciliation
1110    /// retires any prior child. A malformation records one failed child
1111    /// with a machine-readable reason and no artifact.
1112    #[cfg(all(unix, feature = "image-metadata"))]
1113    #[allow(clippy::too_many_arguments)]
1114    fn emit_metadata_child(
1115        &mut self,
1116        source_path: &str,
1117        format: &str,
1118        declared: Option<String>,
1119        source_hash: &str,
1120        source_size: u64,
1121        raw: &[u8],
1122        started: Instant,
1123    ) -> Result<()> {
1124        let child_path = metadata_child_path(source_path);
1125        let (text_absolute, segments_absolute, text_relative) = self.artifact_paths(&child_path);
1126        let converter = self
1127            .rules
1128            .registry
1129            .image_metadata_converter()
1130            .expect("the metadata leg runs only when the converter is present");
1131        let detection = Detection {
1132            declared,
1133            detected: format.to_string(),
1134            mismatch: false,
1135        };
1136        let child_meta = Meta {
1137            source_path: child_path.clone(),
1138            parent_source: Some(source_path.to_string()),
1139            source_hash: source_hash.to_string(),
1140            source_size,
1141            detection,
1142            detect_warnings: Vec::new(),
1143        };
1144        match run_converter(converter, raw, format) {
1145            Ok(outcome) => {
1146                match segments::to_jsonl(&outcome.segments)
1147                    .map_err(|e| e.to_string())
1148                    .and_then(|lines| {
1149                        mirror::write_atomic(&text_absolute, &outcome.text)
1150                            .and_then(|()| mirror::write_atomic(&segments_absolute, &lines))
1151                            .map_err(|e| e.to_string())
1152                    }) {
1153                    Ok(()) => {
1154                        let mut record = self.base_record(&child_meta, Status::Converted);
1155                        record.text_path = Some(text_relative);
1156                        record.text_hash = Some(hash::hash_bytes(outcome.text.as_bytes()));
1157                        record.artifact_kind = Some(outcome.artifact_kind);
1158                        record.converter_id = Some(outcome.converter_id.clone());
1159                        record.converter_version = Some(outcome.converter_version.clone());
1160                        record.warnings.extend(outcome.warnings.iter().cloned());
1161                        record.duration_ms = Some(elapsed_ms(started));
1162                        self.emit(record)
1163                    }
1164                    Err(detail) => {
1165                        let mut record = self.base_record(&child_meta, Status::Failed);
1166                        record.converter_id =
1167                            Some(convert::image_metadata::IMAGE_METADATA_ID.to_string());
1168                        record.converter_version =
1169                            Some(convert::image_metadata::IMAGE_METADATA_VERSION.to_string());
1170                        record.error = Some(format!("mirror_write_error: {detail}"));
1171                        record.duration_ms = Some(elapsed_ms(started));
1172                        remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
1173                        self.emit(record)
1174                    }
1175                }
1176            }
1177            Err(reason)
1178                if reason.starts_with(convert::image_metadata::IMAGE_METADATA_NOT_APPLICABLE) =>
1179            {
1180                // No textual metadata: write nothing. A prior child, if
1181                // any, is retired by the owning reconciliation.
1182                Ok(())
1183            }
1184            Err(reason) => {
1185                let mut record = self.base_record(&child_meta, Status::Failed);
1186                record.converter_id = Some(convert::image_metadata::IMAGE_METADATA_ID.to_string());
1187                record.converter_version =
1188                    Some(convert::image_metadata::IMAGE_METADATA_VERSION.to_string());
1189                record.error = Some(reason);
1190                record.duration_ms = Some(elapsed_ms(started));
1191                remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
1192                self.emit(record)
1193            }
1194        }
1195    }
1196
1197    /// Writes a parent container artifact and records the parent as
1198    /// converted. Shared by both container families.
1199    fn write_parent(
1200        &mut self,
1201        meta: &Meta,
1202        outcome: &convert::Outcome,
1203        started: Instant,
1204    ) -> Result<bool> {
1205        let (text_absolute, segments_absolute, text_relative) =
1206            self.artifact_paths(&meta.source_path);
1207        let lines = match segments::to_jsonl(&outcome.segments) {
1208            Ok(lines) => lines,
1209            Err(e) => {
1210                self.fail(meta, format!("invalid_segments: {e}"), started)?;
1211                return Ok(false);
1212            }
1213        };
1214        match mirror::write_atomic(&text_absolute, &outcome.text)
1215            .and_then(|()| mirror::write_atomic(&segments_absolute, &lines))
1216        {
1217            Ok(()) => {
1218                let mut record = self.base_record(meta, Status::Converted);
1219                record.text_path = Some(text_relative);
1220                record.text_hash = Some(hash::hash_bytes(outcome.text.as_bytes()));
1221                // By design a container parent is a text manifest of its
1222                // members, not a recognized or transcribed artifact, so
1223                // it is always `Text` regardless of any member's kind. A
1224                // leaf converter's own kind flows through the single
1225                // source branch above instead.
1226                record.artifact_kind = Some(ArtifactKind::Text);
1227                record.converter_id = Some(outcome.converter_id.clone());
1228                record.converter_version = Some(outcome.converter_version.clone());
1229                record.warnings.extend(outcome.warnings.iter().cloned());
1230                record.duration_ms = Some(elapsed_ms(started));
1231                self.emit(record)?;
1232                Ok(true)
1233            }
1234            Err(e) => {
1235                self.fail(meta, format!("mirror_write_error: {e}"), started)?;
1236                Ok(false)
1237            }
1238        }
1239    }
1240
1241    /// Builds and processes one container member as a child unit.
1242    fn process_member(
1243        &mut self,
1244        parent: &Meta,
1245        member_path: &str,
1246        member_bytes: Vec<u8>,
1247        depth: usize,
1248        ancestors: &[String],
1249        expanded: &mut u64,
1250    ) -> Result<()> {
1251        let source_path = format!("{}.d/{}", parent.source_path, member_path);
1252        // Two members whose normalized child paths collide would alias
1253        // one artifact, leaving two records with one file and a
1254        // mismatched hash. The later one is refused.
1255        if let Some(claim) = &mut self.claim {
1256            let key = collision_key(&source_path);
1257            if !claim.claimed.insert(key) {
1258                claim.collisions += 1;
1259                let synthetic = format!("{}.d/#collision-{}", parent.source_path, claim.collisions);
1260                return self.emit_member_failure(
1261                    parent,
1262                    synthetic,
1263                    member_bytes.len() as u64,
1264                    "member-path-collision".to_string(),
1265                    Instant::now(),
1266                );
1267            }
1268        }
1269        let (detection, detect_warnings) = detect::detect_bytes_with_warnings(
1270            Path::new(&source_path),
1271            &member_bytes,
1272            &self.rules.table,
1273        );
1274        let unit = Unit {
1275            meta: Meta {
1276                source_hash: hash::hash_bytes(&member_bytes),
1277                source_size: member_bytes.len() as u64,
1278                source_path,
1279                parent_source: Some(parent.source_path.clone()),
1280                detection,
1281                detect_warnings,
1282            },
1283            bytes: UnitBytes::Mem(member_bytes),
1284            depth,
1285            ancestors: ancestors.to_vec(),
1286        };
1287        self.process(unit, Instant::now(), expanded)
1288    }
1289}
1290
1291/// The failure reason for a source over the conversion size ceiling.
1292fn over_source_ceiling(size: u64) -> String {
1293    format!(
1294        "resource_limit: source is {size} bytes over the {} byte ceiling",
1295        convert::MAX_SOURCE_BYTES
1296    )
1297}
1298
1299/// Runs a converter under the panic boundary and output validation.
1300fn run_converter(
1301    converter: &dyn convert::Converter,
1302    bytes: &[u8],
1303    detected: &str,
1304) -> std::result::Result<convert::Outcome, String> {
1305    let guarded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1306        converter.convert(bytes, detected)
1307    }));
1308    let outcome = match guarded {
1309        Ok(result) => result.map_err(|e| e.to_string())?,
1310        Err(_) => return Err("converter_panic: conversion panicked".to_string()),
1311    };
1312    validate_output(&outcome)?;
1313    Ok(outcome)
1314}
1315
1316/// The shared output gate: output-size ceiling, the NUL refusal, and
1317/// segments validation.
1318fn validate_output(outcome: &convert::Outcome) -> std::result::Result<(), String> {
1319    if outcome.text.len() > convert::MAX_OUTPUT_BYTES {
1320        return Err(format!(
1321            "resource_limit: output is {} bytes over the {} byte ceiling",
1322            outcome.text.len(),
1323            convert::MAX_OUTPUT_BYTES
1324        ));
1325    }
1326    if let Some(offset) = outcome.text.find('\0') {
1327        return Err(format!(
1328            "nul_bytes: NUL at byte {offset} of the converted text"
1329        ));
1330    }
1331    segments::validate(&outcome.segments, &outcome.text)
1332        .map_err(|detail| format!("invalid_segments: {detail}"))
1333}
1334
1335impl Expander<'_> {
1336    /// Emits one failed child record for a member refused before or
1337    /// during inflation.
1338    fn emit_member_failure(
1339        &mut self,
1340        parent: &Meta,
1341        child_source_path: String,
1342        source_size: u64,
1343        reason: String,
1344        started: Instant,
1345    ) -> Result<()> {
1346        let (text_absolute, segments_absolute, _) = self.artifact_paths(&child_source_path);
1347        let detection = Detection {
1348            declared: None,
1349            detected: UNKNOWN_FORMAT.to_string(),
1350            mismatch: false,
1351        };
1352        let mut record = new_record(
1353            &child_source_path,
1354            "",
1355            source_size,
1356            &detection,
1357            self.rules.version(),
1358            Status::Failed,
1359        );
1360        record.parent_source = Some(parent.source_path.clone());
1361        record.error = Some(reason);
1362        record.duration_ms = Some(elapsed_ms(started));
1363        remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
1364        self.emit(record)
1365    }
1366
1367    /// Expands a zip container: a member listing artifact for the
1368    /// parent, then each member routed back through dispatch.
1369    fn expand_zip(
1370        &mut self,
1371        meta: Meta,
1372        bytes: UnitBytes,
1373        depth: usize,
1374        ancestors: Vec<String>,
1375        started: Instant,
1376        expanded: &mut u64,
1377    ) -> Result<()> {
1378        if self.expansion_collides(&meta.source_path) {
1379            return self.fail(&meta, "container-mirror-collision".to_string(), started);
1380        }
1381        if meta.source_size > convert::MAX_SOURCE_BYTES {
1382            return self.fail(&meta, over_source_ceiling(meta.source_size), started);
1383        }
1384        let bytes = match bytes.load(&meta.source_hash) {
1385            Ok(bytes) => bytes,
1386            Err(reason) => return self.fail(&meta, reason, started),
1387        };
1388        // Preflight the raw entry count from the end-of-central-
1389        // directory record before the zip crate parses and allocates
1390        // the whole directory, and refuse on the raw count, which its
1391        // name-keyed map would otherwise undercount.
1392        if let Some(entries) = convert::preflight_entry_count(&bytes)
1393            && entries > self.limits.max_children as u64
1394        {
1395            return self.fail(&meta, "container-children-exceeded".to_string(), started);
1396        }
1397        let mut container = match convert::ZipContainer::open(&bytes) {
1398            Ok(container) => container,
1399            Err(e) => return self.fail(&meta, e.to_string(), started),
1400        };
1401
1402        // Stage 1: the child count and declared sizes, before any
1403        // inflation.
1404        let count = container.len();
1405        if count > self.limits.max_children {
1406            return self.fail(&meta, "container-children-exceeded".to_string(), started);
1407        }
1408        let mut declared_total: u64 = 0;
1409        for index in 0..count {
1410            match container.meta(index) {
1411                Ok(member) => declared_total = declared_total.saturating_add(member.declared_size),
1412                Err(e) => return self.fail(&meta, e.to_string(), started),
1413            }
1414        }
1415        if declared_total > self.limits.max_expanded_bytes {
1416            return self.fail(&meta, "container-expansion-cap".to_string(), started);
1417        }
1418        let listing = match container.listing() {
1419            Ok(listing) => listing,
1420            Err(e) => return self.fail(&meta, e.to_string(), started),
1421        };
1422
1423        let mut child_ancestors = ancestors;
1424        child_ancestors.push(meta.source_hash.clone());
1425
1426        // Stage 2: inflate and route each member. The cumulative
1427        // counter charges actual inflated bytes, and an overrun fails
1428        // the whole container.
1429        let mut parent_failure: Option<String> = None;
1430        for index in 0..count {
1431            let member = match container.meta(index) {
1432                Ok(member) => member,
1433                Err(e) => {
1434                    parent_failure = Some(e.to_string());
1435                    break;
1436                }
1437            };
1438            if member.is_dir {
1439                continue;
1440            }
1441            let Some(member_path) = member.safe_path.clone() else {
1442                self.emit_member_failure(
1443                    &meta,
1444                    format!("{}.d/#unsafe-member-{index}", meta.source_path),
1445                    0,
1446                    "unsafe-member-path".to_string(),
1447                    started,
1448                )?;
1449                continue;
1450            };
1451            let child_source = format!("{}.d/{}", meta.source_path, member_path);
1452            if member.is_symlink {
1453                self.emit_member_failure(
1454                    &meta,
1455                    child_source,
1456                    0,
1457                    "unsafe-member-path".to_string(),
1458                    started,
1459                )?;
1460                continue;
1461            }
1462            if member.encrypted {
1463                self.emit_member_failure(
1464                    &meta,
1465                    child_source,
1466                    member.declared_size,
1467                    "encrypted".to_string(),
1468                    started,
1469                )?;
1470                continue;
1471            }
1472            if member.declared_size > self.limits.max_member_size {
1473                self.emit_member_failure(
1474                    &meta,
1475                    child_source,
1476                    member.declared_size,
1477                    "container-member-too-large".to_string(),
1478                    started,
1479                )?;
1480                continue;
1481            }
1482            let remaining = self.limits.max_expanded_bytes.saturating_sub(*expanded);
1483            let cap = self.limits.max_member_size.min(remaining);
1484            match container.inflate(index, cap) {
1485                convert::Inflated::Ok(data) => {
1486                    *expanded = expanded.saturating_add(data.len() as u64);
1487                    self.process_member(
1488                        &meta,
1489                        &member_path,
1490                        data,
1491                        depth + 1,
1492                        &child_ancestors,
1493                        expanded,
1494                    )?;
1495                }
1496                convert::Inflated::TooLarge(inflated) => {
1497                    *expanded = expanded.saturating_add(inflated);
1498                    if inflated > self.limits.max_member_size {
1499                        self.emit_member_failure(
1500                            &meta,
1501                            child_source,
1502                            inflated,
1503                            "container-member-too-large".to_string(),
1504                            started,
1505                        )?;
1506                    }
1507                    if *expanded > self.limits.max_expanded_bytes {
1508                        parent_failure = Some("container-expansion-cap".to_string());
1509                        break;
1510                    }
1511                }
1512                convert::Inflated::Error(e) => {
1513                    self.emit_member_failure(
1514                        &meta,
1515                        child_source,
1516                        0,
1517                        format!("container-member-unreadable: {e}"),
1518                        started,
1519                    )?;
1520                }
1521            }
1522        }
1523
1524        match parent_failure {
1525            Some(reason) => self.fail(&meta, reason, started),
1526            None => {
1527                let segments = vec![segments::Segment::span(0, listing.len(), "document")];
1528                let outcome = convert::Outcome {
1529                    converter_id: convert::CONTAINER_ZIP_ID.to_string(),
1530                    converter_version: convert::CONTAINER_ZIP_VERSION.to_string(),
1531                    detected_format: meta.detection.detected.clone(),
1532                    text: listing,
1533                    warnings: Vec::new(),
1534                    segments,
1535                    artifact_kind: manifest::ArtifactKind::Text,
1536                };
1537                if let Err(reason) = validate_output(&outcome) {
1538                    return self.fail(&meta, reason, started);
1539                }
1540                self.write_parent(&meta, &outcome, started).map(|_| ())
1541            }
1542        }
1543    }
1544
1545    /// Expands an eml container: the message renders to the parent
1546    /// artifact, and each attachment routes back through dispatch as a
1547    /// member.
1548    fn expand_eml(
1549        &mut self,
1550        meta: Meta,
1551        bytes: UnitBytes,
1552        depth: usize,
1553        ancestors: Vec<String>,
1554        started: Instant,
1555        expanded: &mut u64,
1556    ) -> Result<()> {
1557        if self.expansion_collides(&meta.source_path) {
1558            return self.fail(&meta, "container-mirror-collision".to_string(), started);
1559        }
1560        let bytes = match bytes.load(&meta.source_hash) {
1561            Ok(bytes) => bytes,
1562            Err(reason) => return self.fail(&meta, reason, started),
1563        };
1564        if meta.source_size > convert::MAX_SOURCE_BYTES {
1565            return self.fail(&meta, over_source_ceiling(meta.source_size), started);
1566        }
1567        let guarded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1568            convert::expand_eml(&bytes, &meta.detection.detected)
1569        }));
1570        let expansion = match guarded {
1571            Ok(Ok(expansion)) => expansion,
1572            Ok(Err(e)) => return self.fail(&meta, e.to_string(), started),
1573            Err(_) => {
1574                return self.fail(
1575                    &meta,
1576                    "converter_panic: conversion panicked".to_string(),
1577                    started,
1578                );
1579            }
1580        };
1581        if let Err(reason) = validate_output(&expansion.outcome) {
1582            return self.fail(&meta, reason, started);
1583        }
1584
1585        // Expand the attachments first, so the parent record is
1586        // written last and an intact eml parent proves the whole
1587        // expansion finished, matching the zip ordering.
1588        let mut child_ancestors = ancestors;
1589        child_ancestors.push(meta.source_hash.clone());
1590        let mut parent_failure: Option<String> = None;
1591        for member in expansion.members {
1592            *expanded = expanded.saturating_add(member.bytes.len() as u64);
1593            if *expanded > self.limits.max_expanded_bytes {
1594                parent_failure = Some("container-expansion-cap".to_string());
1595                break;
1596            }
1597            self.process_member(
1598                &meta,
1599                &member.name,
1600                member.bytes,
1601                depth + 1,
1602                &child_ancestors,
1603                expanded,
1604            )?;
1605        }
1606
1607        match parent_failure {
1608            Some(reason) => self.fail(&meta, reason, started),
1609            None => self
1610                .write_parent(&meta, &expansion.outcome, started)
1611                .map(|_| ()),
1612        }
1613    }
1614}
1615
1616/// Converts a division root into the mirror tree.
1617///
1618/// Walks the root in sorted order, detects each file, and appends one
1619/// record per source to the division shard. Text-native formats pass
1620/// through the registered converter. A format no converter claims
1621/// becomes `unsupported`. A converter error becomes `failed` with a
1622/// reason and no text artifact. Safe to kill and re-run.
1623///
1624/// Dedup applies when a source's hash matches an artifact already
1625/// converted by the same converter id and version that would handle
1626/// this source. The duplicate gets a real text file at its parallel
1627/// path, hashed over the bytes actually written, with `dedup_of`
1628/// naming the canonical source. When the canonical artifact cannot be
1629/// read, the duplicate converts for itself.
1630pub fn run(rules: &Rules, options: &RunOptions) -> Result<RunReport> {
1631    validate_division(options.division)?;
1632    check_layout(options.root, options.mirror_root, options.manifest_dir)?;
1633    let entries = walk::walk_division(options.root, &options.walk)?;
1634    let shard_path = options
1635        .manifest_dir
1636        .join(format!("{}.jsonl", options.division));
1637    let (prior, mut warnings) = if shard_path.exists() {
1638        let shard = manifest::read_shard(&shard_path)?;
1639        (shard.records, shard.warnings)
1640    } else {
1641        (Vec::new(), Vec::new())
1642    };
1643    let mut terminal = terminal_by_path(prior);
1644    let mut dedup = DedupIndex::default();
1645    seed_dedup(&terminal, rules, &mut dedup);
1646
1647    // The mirror paths a container's `.d/` expansion would claim must
1648    // not collide with a real walked source, so the walked set is
1649    // checked before any container expands.
1650    let walked: std::collections::HashSet<String> = entries
1651        .iter()
1652        .filter_map(|entry| entry.path.to_str().map(str::to_string))
1653        .collect();
1654    let limits = rules.registry.container_limits().clone();
1655
1656    let mut writer = ManifestWriter::open(&shard_path)?;
1657    let mut counts = StatusCounts::default();
1658    let mut special_entries = 0u64;
1659
1660    for entry in &entries {
1661        let started = Instant::now();
1662        let relative = &entry.path;
1663        let absolute = options.root.join(relative);
1664        let text_absolute = mirror::mirror_path(options.mirror_root, options.division, relative);
1665        let segments_absolute =
1666            segments::segments_path(options.mirror_root, options.division, relative);
1667
1668        if entry.kind == EntryKind::Other {
1669            special_entries += 1;
1670            continue;
1671        }
1672
1673        // A lossy path must never name a record key or an artifact, so
1674        // a non-UTF-8 source name, symlink or file, is a recorded
1675        // failure with no conversion.
1676        let Some(source_path) = relative.to_str().map(str::to_string) else {
1677            let detection = Detection {
1678                declared: detect::declared_format(relative, &rules.table),
1679                detected: UNKNOWN_FORMAT.to_string(),
1680                mismatch: false,
1681            };
1682            let mut record = new_record(
1683                &relative.to_string_lossy(),
1684                "",
1685                0,
1686                &detection,
1687                rules.version(),
1688                Status::Failed,
1689            );
1690            record.error = Some("non_utf8_path: source path is not valid UTF-8".to_string());
1691            record.duration_ms = Some(elapsed_ms(started));
1692            remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
1693            record_outcome(&mut writer, &mut terminal, &mut counts, record)?;
1694            continue;
1695        };
1696
1697        if entry.kind == EntryKind::Symlink {
1698            let detection = Detection {
1699                declared: None,
1700                detected: SYMLINK_FORMAT.to_string(),
1701                mismatch: false,
1702            };
1703            let mut record = match fs::read_link(&absolute) {
1704                Ok(target) => {
1705                    let target_bytes = target.as_os_str().as_encoded_bytes();
1706                    let mut record = new_record(
1707                        &source_path,
1708                        &hash::hash_bytes(target_bytes),
1709                        target_bytes.len() as u64,
1710                        &detection,
1711                        rules.version(),
1712                        Status::Unsupported,
1713                    );
1714                    // Symlinks are unsupported by pipeline design,
1715                    // whatever the registry claims, so the reason is
1716                    // set directly instead of looked up.
1717                    record.error = Some(convert::NO_CONVERTER_REASON.to_string());
1718                    record
1719                }
1720                Err(e) => {
1721                    let mut record = new_record(
1722                        &source_path,
1723                        "",
1724                        0,
1725                        &detection,
1726                        rules.version(),
1727                        Status::Failed,
1728                    );
1729                    record.error = Some(format!("source_io_error: readlink: {e}"));
1730                    record
1731                }
1732            };
1733            record.duration_ms = Some(elapsed_ms(started));
1734            remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
1735            record_outcome(&mut writer, &mut terminal, &mut counts, record)?;
1736            continue;
1737        }
1738
1739        // Gate the source size before detection. Format detection
1740        // reads from the file to sniff its type, and for a zip it can
1741        // allocate from a forged member size, so an oversized source
1742        // must fail before that read, containers included.
1743        let stat = match absolute.metadata() {
1744            Ok(metadata) => metadata,
1745            Err(e) => {
1746                let detection = Detection {
1747                    declared: detect::declared_format(relative, &rules.table),
1748                    detected: UNKNOWN_FORMAT.to_string(),
1749                    mismatch: false,
1750                };
1751                let mut record = new_record(
1752                    &source_path,
1753                    "",
1754                    0,
1755                    &detection,
1756                    rules.version(),
1757                    Status::Failed,
1758                );
1759                record.error = Some(format!("source_io_error: stat: {e}"));
1760                record.duration_ms = Some(elapsed_ms(started));
1761                remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
1762                record_outcome(&mut writer, &mut terminal, &mut counts, record)?;
1763                continue;
1764            }
1765        };
1766        if stat.len() > convert::MAX_SOURCE_BYTES {
1767            let detection = Detection {
1768                declared: detect::declared_format(relative, &rules.table),
1769                detected: UNKNOWN_FORMAT.to_string(),
1770                mismatch: false,
1771            };
1772            let mut record = new_record(
1773                &source_path,
1774                "",
1775                stat.len(),
1776                &detection,
1777                rules.version(),
1778                Status::Failed,
1779            );
1780            record.error = Some(over_source_ceiling(stat.len()));
1781            record.duration_ms = Some(elapsed_ms(started));
1782            remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
1783            record_outcome(&mut writer, &mut terminal, &mut counts, record)?;
1784            continue;
1785        }
1786
1787        let front = match source_front(&absolute, &rules.table) {
1788            Ok(front) => front,
1789            Err(e) => {
1790                let detection = Detection {
1791                    declared: detect::declared_format(relative, &rules.table),
1792                    detected: UNKNOWN_FORMAT.to_string(),
1793                    mismatch: false,
1794                };
1795                let mut record = new_record(
1796                    &source_path,
1797                    "",
1798                    0,
1799                    &detection,
1800                    rules.version(),
1801                    Status::Failed,
1802                );
1803                record.error = Some(format!("source_io_error: {e}"));
1804                record.duration_ms = Some(elapsed_ms(started));
1805                remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
1806                record_outcome(&mut writer, &mut terminal, &mut counts, record)?;
1807                continue;
1808            }
1809        };
1810        let (source_hash, source_size, detection, detect_warnings) = front;
1811        let mut expanded = 0u64;
1812        let unit = Unit {
1813            meta: Meta {
1814                source_path,
1815                parent_source: None,
1816                source_hash,
1817                source_size,
1818                detection,
1819                detect_warnings,
1820            },
1821            bytes: UnitBytes::Disk(absolute.clone()),
1822            depth: 0,
1823            ancestors: Vec::new(),
1824        };
1825        let mut expander = Expander {
1826            rules,
1827            mirror_root: options.mirror_root,
1828            division: options.division,
1829            walked: &walked,
1830            limits: &limits,
1831            writer: &mut writer,
1832            terminal: &mut terminal,
1833            counts: &mut counts,
1834            dedup: &mut dedup,
1835            claim: None,
1836        };
1837        expander.process(unit, started, &mut expanded)?;
1838    }
1839
1840    warnings.sort();
1841    Ok(RunReport {
1842        division: options.division.to_string(),
1843        root: options.root.display().to_string(),
1844        sources: entries.len() as u64 - special_entries,
1845        special_entries,
1846        counts,
1847        warnings,
1848    })
1849}
1850
1851fn shard_paths(manifest_dir: &Path) -> Result<Vec<PathBuf>> {
1852    let entries = fs::read_dir(manifest_dir).map_err(|e| Error::io("read_dir", manifest_dir, e))?;
1853    let mut shards = Vec::new();
1854    for entry in entries {
1855        let entry = entry.map_err(|e| Error::io("read_dir", manifest_dir, e))?;
1856        let path = entry.path();
1857        if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
1858            shards.push(path);
1859        }
1860    }
1861    shards.sort();
1862    Ok(shards)
1863}
1864
1865fn division_name(shard: &Path) -> String {
1866    shard
1867        .file_stem()
1868        .map(|stem| stem.to_string_lossy().into_owned())
1869        .unwrap_or_default()
1870}
1871
1872/// Computes coverage per division over terminal records.
1873pub fn status(manifest_dir: &Path) -> Result<StatusReport> {
1874    let mut divisions = Vec::new();
1875    for shard_path in shard_paths(manifest_dir)? {
1876        let shard = manifest::read_shard(&shard_path)?;
1877        let terminal = terminal_by_path(shard.records);
1878        let mut counts = StatusCounts::default();
1879        let mut with_text = 0u64;
1880        for record in terminal.values() {
1881            match record.status {
1882                Status::Converted => counts.converted += 1,
1883                Status::Failed => counts.failed += 1,
1884                Status::Unsupported => counts.unsupported += 1,
1885                Status::SkippedUnchanged => counts.skipped_unchanged += 1,
1886                Status::Dedup => counts.dedup += 1,
1887            }
1888            if record.text_path.is_some() {
1889                with_text += 1;
1890            }
1891        }
1892        let sources = terminal.len() as u64;
1893        let coverage = if sources == 0 {
1894            0.0
1895        } else {
1896            with_text as f64 / sources as f64
1897        };
1898        divisions.push(DivisionStatus {
1899            division: division_name(&shard_path),
1900            sources,
1901            with_text,
1902            coverage,
1903            counts,
1904            warnings: shard.warnings,
1905        });
1906    }
1907    Ok(StatusReport { divisions })
1908}
1909
1910/// Collects every record for one source path across all shards.
1911pub fn explain(manifest_dir: &Path, source_path: &str) -> Result<Vec<ExplainEntry>> {
1912    let mut entries = Vec::new();
1913    for shard_path in shard_paths(manifest_dir)? {
1914        let division = division_name(&shard_path);
1915        for record in manifest::read_shard(&shard_path)?.records {
1916            if record.source_path == source_path {
1917                entries.push(ExplainEntry {
1918                    division: division.clone(),
1919                    record,
1920                });
1921            }
1922        }
1923    }
1924    Ok(entries)
1925}
1926
1927#[cfg(test)]
1928mod tests {
1929    use super::*;
1930
1931    #[test]
1932    fn builtin_rules_agree_on_a_version() {
1933        let rules = Rules::builtin().unwrap();
1934        assert_eq!(rules.version(), "9");
1935    }
1936
1937    /// Drives `Expander::process` directly with an over-ceiling image
1938    /// carrier whose hash has a current-rules canonical in the dedup
1939    /// index, past the run-level walker gate that would otherwise refuse
1940    /// the file before a unit exists. The ceiling precedes the dedup
1941    /// borrow, so the source falls through to the leaf path: one failed
1942    /// record with the resource-limit reason, no dedup record, no
1943    /// metadata child, and the body never loaded for the borrow.
1944    #[cfg(all(unix, feature = "image-metadata"))]
1945    #[test]
1946    fn an_over_ceiling_image_with_a_dedup_seed_is_refused_before_the_borrow() {
1947        use std::collections::HashSet;
1948        use std::io::Write as _;
1949
1950        let dir = tempfile::tempdir().unwrap();
1951        let root = dir.path().join("source");
1952        fs::create_dir_all(&root).unwrap();
1953        let mirror_root = dir.path().join("mirror");
1954        let manifest_dir = dir.path().join("manifest");
1955        let division = "unit";
1956        let rules = Rules::builtin().unwrap();
1957        assert!(
1958            rules.registry.image_metadata_converter().is_some(),
1959            "the metadata leg must be present for this path"
1960        );
1961
1962        // A sparse over-ceiling png-magic source.
1963        let huge = root.join("huge.png");
1964        let mut file = fs::File::create(&huge).unwrap();
1965        file.write_all(b"\x89PNG\r\n\x1a\n").unwrap();
1966        file.set_len(convert::MAX_SOURCE_BYTES + 1).unwrap();
1967        drop(file);
1968        let source_size = fs::metadata(&huge).unwrap().len();
1969        let source_hash = hash::hash_bytes(&fs::read(&huge).unwrap());
1970
1971        // An intact canonical artifact for that hash, under the current
1972        // primary converter, so the dedup borrow would otherwise fire.
1973        let converter = rules.registry.converter_for("png").unwrap();
1974        let canon_text = "recognized line\n";
1975        let canon_text_path = mirror::mirror_path(&mirror_root, division, Path::new("canon.png"));
1976        let canon_segments =
1977            segments::segments_path(&mirror_root, division, Path::new("canon.png"));
1978        let lines = segments::to_jsonl(&[Segment::span(0, canon_text.len(), "ocr")]).unwrap();
1979        mirror::write_atomic(&canon_text_path, canon_text).unwrap();
1980        mirror::write_atomic(&canon_segments, &lines).unwrap();
1981        let mut dedup = DedupIndex::default();
1982        dedup.insert(
1983            &source_hash,
1984            CanonicalArtifact {
1985                source_path: "canon.png".to_string(),
1986                text_path: mirror::recorded_text_path(division, "canon.png"),
1987                text_hash: hash::hash_bytes(canon_text.as_bytes()),
1988                converter_id: converter.id().to_string(),
1989                converter_version: converter.version().to_string(),
1990                artifact_kind: Some(ArtifactKind::Ocr),
1991                warnings: Vec::new(),
1992            },
1993        );
1994
1995        let shard = manifest_dir.join("unit.jsonl");
1996        let mut writer = ManifestWriter::open(&shard).unwrap();
1997        let mut terminal = HashMap::new();
1998        let mut counts = StatusCounts::default();
1999        let walked = HashSet::new();
2000        let unit = Unit {
2001            meta: Meta {
2002                source_path: "huge.png".to_string(),
2003                parent_source: None,
2004                source_hash,
2005                source_size,
2006                detection: Detection {
2007                    declared: Some("png".to_string()),
2008                    detected: "png".to_string(),
2009                    mismatch: false,
2010                },
2011                detect_warnings: Vec::new(),
2012            },
2013            bytes: UnitBytes::Disk(huge),
2014            depth: 0,
2015            ancestors: Vec::new(),
2016        };
2017        let mut expander = Expander {
2018            rules: &rules,
2019            mirror_root: &mirror_root,
2020            division,
2021            walked: &walked,
2022            limits: rules.registry.container_limits(),
2023            writer: &mut writer,
2024            terminal: &mut terminal,
2025            counts: &mut counts,
2026            dedup: &mut dedup,
2027            claim: None,
2028        };
2029        let mut expanded = 0u64;
2030        expander
2031            .process(unit, Instant::now(), &mut expanded)
2032            .unwrap();
2033        drop(writer);
2034
2035        let records = manifest::read_shard(&shard).unwrap().records;
2036        assert_eq!(records.len(), 1, "{records:?}");
2037        let record = &records[0];
2038        assert_eq!(record.source_path, "huge.png");
2039        assert_eq!(record.status, Status::Failed);
2040        assert!(
2041            record
2042                .error
2043                .as_deref()
2044                .is_some_and(|e| e.starts_with("resource_limit")),
2045            "{:?}",
2046            record.error
2047        );
2048        assert!(record.dedup_of.is_none());
2049        assert_eq!(counts.dedup, 0);
2050        assert_eq!(counts.failed, 1);
2051        assert!(!terminal.contains_key("huge.png.d/#image-metadata"));
2052        assert!(
2053            !mirror::mirror_path(
2054                &mirror_root,
2055                division,
2056                Path::new("huge.png.d/#image-metadata")
2057            )
2058            .exists()
2059        );
2060    }
2061
2062    #[test]
2063    fn the_quine_guard_matches_an_ancestor_hash() {
2064        let chain = vec!["aa".to_string(), "bb".to_string()];
2065        assert!(hash_on_chain("bb", &chain));
2066        assert!(hash_on_chain("aa", &chain));
2067        assert!(!hash_on_chain("cc", &chain));
2068        assert!(!hash_on_chain("aa", &[]));
2069    }
2070
2071    use crate::convert::{ConvertError, Converter, Outcome, PlainTextPassthrough};
2072    use crate::segments::Segment;
2073
2074    /// A converter that panics, for the boundary test. No shipped
2075    /// converter panics on purpose, so the test registry injects one.
2076    struct PanicConverter;
2077
2078    impl Converter for PanicConverter {
2079        fn id(&self) -> &'static str {
2080            "panic-test"
2081        }
2082        fn version(&self) -> &'static str {
2083            "1.0.0"
2084        }
2085        fn convert(
2086            &self,
2087            _source: &[u8],
2088            _detected_format: &str,
2089        ) -> std::result::Result<Outcome, ConvertError> {
2090            panic!("deliberate test panic");
2091        }
2092    }
2093
2094    /// A converter whose segments do not fit its text.
2095    struct BadSegmentsConverter;
2096
2097    impl Converter for BadSegmentsConverter {
2098        fn id(&self) -> &'static str {
2099            "bad-segments-test"
2100        }
2101        fn version(&self) -> &'static str {
2102            "1.0.0"
2103        }
2104        fn convert(
2105            &self,
2106            _source: &[u8],
2107            detected_format: &str,
2108        ) -> std::result::Result<Outcome, ConvertError> {
2109            Ok(Outcome {
2110                converter_id: self.id().to_string(),
2111                converter_version: self.version().to_string(),
2112                detected_format: detected_format.to_string(),
2113                text: "ok".to_string(),
2114                warnings: Vec::new(),
2115                segments: vec![Segment::span(0, 999, "document")],
2116                artifact_kind: manifest::ArtifactKind::Text,
2117            })
2118        }
2119    }
2120
2121    /// A converter that returns a valid outcome stamped
2122    /// [`manifest::ArtifactKind::Ocr`], so the pipeline's kind
2123    /// propagation through the single-source, dedup, and checkpoint paths
2124    /// can be exercised without a jailed engine.
2125    struct OcrKindConverter;
2126
2127    impl Converter for OcrKindConverter {
2128        fn id(&self) -> &'static str {
2129            "ocr-kind-test"
2130        }
2131        fn version(&self) -> &'static str {
2132            "1.0.0"
2133        }
2134        fn convert(
2135            &self,
2136            _source: &[u8],
2137            detected_format: &str,
2138        ) -> std::result::Result<Outcome, ConvertError> {
2139            let text = "recognized text\n".to_string();
2140            Ok(Outcome {
2141                converter_id: self.id().to_string(),
2142                converter_version: self.version().to_string(),
2143                detected_format: detected_format.to_string(),
2144                segments: vec![Segment::span(0, text.len(), "document")],
2145                text,
2146                warnings: Vec::new(),
2147                artifact_kind: manifest::ArtifactKind::Ocr,
2148            })
2149        }
2150    }
2151
2152    fn injected_rules() -> Rules {
2153        let table = FormatTable::parse(
2154            r#"
2155version = "9"
2156
2157[[formats]]
2158id = "text"
2159name = "Plain text"
2160extensions = ["txt"]
2161
2162[[formats]]
2163id = "panicfmt"
2164name = "Panic trigger"
2165extensions = ["panicfmt"]
2166
2167[[formats]]
2168id = "badseg"
2169name = "Bad segments trigger"
2170extensions = ["badseg"]
2171
2172[[formats]]
2173id = "ocrfmt"
2174name = "OCR kind trigger"
2175extensions = ["ocrfmt"]
2176"#,
2177            "formats.toml",
2178        )
2179        .unwrap();
2180        let registry = Registry::for_tests(
2181            "9",
2182            vec![
2183                (Box::new(PlainTextPassthrough), vec!["text"]),
2184                (Box::new(PanicConverter), vec!["panicfmt"]),
2185                (Box::new(BadSegmentsConverter), vec!["badseg"]),
2186                (Box::new(OcrKindConverter), vec!["ocrfmt"]),
2187            ],
2188        );
2189        Rules { table, registry }
2190    }
2191
2192    #[test]
2193    fn a_panicking_converter_fails_one_source_and_the_division_survives() {
2194        let dir = tempfile::tempdir().unwrap();
2195        let root = dir.path().join("src");
2196        fs::create_dir_all(&root).unwrap();
2197        fs::write(root.join("boom.panicfmt"), "trigger\n").unwrap();
2198        fs::write(root.join("fine.txt"), "safe content\n").unwrap();
2199
2200        let rules = injected_rules();
2201        let report = run(
2202            &rules,
2203            &RunOptions {
2204                root: &root,
2205                mirror_root: &dir.path().join("mirror"),
2206                manifest_dir: &dir.path().join("manifest"),
2207                division: "unit",
2208                walk: WalkOptions::default(),
2209            },
2210        )
2211        .unwrap();
2212
2213        assert_eq!(report.counts.failed, 1);
2214        assert_eq!(report.counts.converted, 1);
2215        let records = manifest::read_shard(&dir.path().join("manifest/unit.jsonl"))
2216            .unwrap()
2217            .records;
2218        let failed = records.iter().find(|r| r.status == Status::Failed).unwrap();
2219        assert_eq!(failed.source_path, "boom.panicfmt");
2220        assert!(
2221            failed
2222                .error
2223                .as_deref()
2224                .unwrap()
2225                .starts_with("converter_panic")
2226        );
2227        assert!(!dir.path().join("mirror/unit/boom.panicfmt.txt").exists());
2228    }
2229
2230    #[test]
2231    fn invalid_segments_from_a_converter_fail_before_any_write() {
2232        let dir = tempfile::tempdir().unwrap();
2233        let root = dir.path().join("src");
2234        fs::create_dir_all(&root).unwrap();
2235        fs::write(root.join("bad.badseg"), "trigger\n").unwrap();
2236
2237        let rules = injected_rules();
2238        let report = run(
2239            &rules,
2240            &RunOptions {
2241                root: &root,
2242                mirror_root: &dir.path().join("mirror"),
2243                manifest_dir: &dir.path().join("manifest"),
2244                division: "unit",
2245                walk: WalkOptions::default(),
2246            },
2247        )
2248        .unwrap();
2249
2250        assert_eq!(report.counts.failed, 1);
2251        let records = manifest::read_shard(&dir.path().join("manifest/unit.jsonl"))
2252            .unwrap()
2253            .records;
2254        assert!(
2255            records[0]
2256                .error
2257                .as_deref()
2258                .unwrap()
2259                .starts_with("invalid_segments")
2260        );
2261        assert!(!dir.path().join("mirror/unit/bad.badseg.txt").exists());
2262        assert!(
2263            !dir.path()
2264                .join("mirror/unit/bad.badseg.segments.jsonl")
2265                .exists()
2266        );
2267    }
2268
2269    #[test]
2270    fn the_ocr_artifact_kind_propagates_through_dedup_and_checkpoint() {
2271        let dir = tempfile::tempdir().unwrap();
2272        let root = dir.path().join("src");
2273        let mirror = dir.path().join("mirror");
2274        let manifest_dir = dir.path().join("manifest");
2275        fs::create_dir_all(&root).unwrap();
2276        // Two identical-content OCR sources: the first converts, the
2277        // second dedups off it within the same run.
2278        fs::write(root.join("a.ocrfmt"), "same pixels\n").unwrap();
2279        fs::write(root.join("b.ocrfmt"), "same pixels\n").unwrap();
2280
2281        let rules = injected_rules();
2282        let options = RunOptions {
2283            root: &root,
2284            mirror_root: &mirror,
2285            manifest_dir: &manifest_dir,
2286            division: "unit",
2287            walk: WalkOptions::default(),
2288        };
2289        run(&rules, &options).unwrap();
2290
2291        let shard = manifest_dir.join("unit.jsonl");
2292        let latest = |path: &str| {
2293            manifest::read_shard(&shard)
2294                .unwrap()
2295                .records
2296                .into_iter()
2297                .rev()
2298                .find(|r| r.source_path == path)
2299                .unwrap()
2300        };
2301
2302        // Same-run: one Converted, one Dedup, both carry the OCR kind.
2303        let a = latest("a.ocrfmt");
2304        let b = latest("b.ocrfmt");
2305        let (converted, deduped) = if a.status == Status::Converted {
2306            (a, b)
2307        } else {
2308            (b, a)
2309        };
2310        assert_eq!(converted.status, Status::Converted);
2311        assert_eq!(deduped.status, Status::Dedup);
2312        assert_eq!(converted.artifact_kind, Some(ArtifactKind::Ocr));
2313        assert_eq!(deduped.artifact_kind, Some(ArtifactKind::Ocr));
2314
2315        // Second run with the prior manifest present. The unchanged
2316        // sources skip and keep their OCR kind (checkpoint-skip clones
2317        // the prior record); a new identical source dedups off the
2318        // seeded prior canonical and is also OCR (seeded dedup).
2319        fs::write(root.join("c.ocrfmt"), "same pixels\n").unwrap();
2320        run(&rules, &options).unwrap();
2321
2322        let a2 = latest("a.ocrfmt");
2323        let b2 = latest("b.ocrfmt");
2324        let c = latest("c.ocrfmt");
2325        assert_eq!(a2.status, Status::SkippedUnchanged);
2326        assert_eq!(b2.status, Status::SkippedUnchanged);
2327        assert_eq!(a2.artifact_kind, Some(ArtifactKind::Ocr));
2328        assert_eq!(b2.artifact_kind, Some(ArtifactKind::Ocr));
2329        assert_eq!(c.status, Status::Dedup);
2330        assert_eq!(c.artifact_kind, Some(ArtifactKind::Ocr));
2331    }
2332
2333    #[test]
2334    fn division_names_are_validated() {
2335        assert!(validate_division("finance-eu_1").is_ok());
2336        assert!(validate_division("").is_err());
2337        assert!(validate_division("a/b").is_err());
2338        assert!(validate_division("dot.dot").is_err());
2339    }
2340
2341    #[test]
2342    fn layout_rejects_outputs_inside_the_root() {
2343        let dir = tempfile::tempdir().unwrap();
2344        let root = dir.path().join("src");
2345        fs::create_dir_all(&root).unwrap();
2346        let outside = dir.path().join("out");
2347
2348        let err = check_layout(&root, &root.join("mirror"), &outside).unwrap_err();
2349        assert!(err.to_string().contains("mirror root"));
2350
2351        let err = check_layout(&root, &outside.join("mirror"), &root.join("manifest")).unwrap_err();
2352        assert!(err.to_string().contains("manifest directory"));
2353
2354        check_layout(&root, &outside.join("mirror"), &outside.join("manifest")).unwrap();
2355    }
2356
2357    #[cfg(unix)]
2358    #[test]
2359    fn non_utf8_paths_are_rejected_without_an_artifact() {
2360        use std::os::unix::ffi::OsStrExt;
2361        let raw = std::ffi::OsStr::from_bytes(b"bad-\xff-name.txt");
2362        assert!(Path::new(raw).to_str().is_none());
2363    }
2364
2365    #[test]
2366    fn identical_sources_dedup_to_one_conversion() {
2367        let dir = tempfile::tempdir().unwrap();
2368        let root = dir.path().join("src");
2369        fs::create_dir_all(root.join("sub")).unwrap();
2370        fs::write(root.join("first.txt"), "same bytes\n").unwrap();
2371        fs::write(root.join("sub/second.txt"), "same bytes\n").unwrap();
2372
2373        let rules = Rules::builtin().unwrap();
2374        let report = run(
2375            &rules,
2376            &RunOptions {
2377                root: &root,
2378                mirror_root: &dir.path().join("mirror"),
2379                manifest_dir: &dir.path().join("manifest"),
2380                division: "unit",
2381                walk: WalkOptions::default(),
2382            },
2383        )
2384        .unwrap();
2385
2386        assert_eq!(report.counts.converted, 1);
2387        assert_eq!(report.counts.dedup, 1);
2388
2389        let records = manifest::read_shard(&dir.path().join("manifest/unit.jsonl"))
2390            .unwrap()
2391            .records;
2392        let canonical = records
2393            .iter()
2394            .find(|r| r.source_path == "first.txt")
2395            .unwrap();
2396        let duplicate = records
2397            .iter()
2398            .find(|r| r.source_path == "sub/second.txt")
2399            .unwrap();
2400        assert_eq!(canonical.status, Status::Converted);
2401        assert_eq!(duplicate.status, Status::Dedup);
2402        assert_eq!(duplicate.dedup_of.as_deref(), Some("first.txt"));
2403        assert_eq!(duplicate.text_hash, canonical.text_hash);
2404
2405        let mirrored =
2406            fs::read_to_string(dir.path().join("mirror/unit/sub/second.txt.txt")).unwrap();
2407        assert_eq!(mirrored, "same bytes\n");
2408    }
2409}