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