Skip to main content

text_mirror/
pipeline.rs

1//! The end-to-end pipeline behind the CLI verbs.
2//!
3//! `run` is incremental and idempotent. The manifest is the
4//! checkpoint. A prior terminal record whose key of source path,
5//! source hash, converter version, and rules version matches the
6//! current pass, and whose artifact still hashes to the recorded
7//! text hash, becomes a `skipped_unchanged` record with no work done.
8//! Failed and unsupported sources are re-evaluated every run, so their
9//! terminal records keep the current reason and the enumerated
10//! remainder stays visible.
11//!
12//! Per-source problems, meaning IO errors, converter errors, and
13//! mirror write failures, become `failed` records and the run
14//! continues. Only run-level problems abort: a walk failure, a rules
15//! failure, an invalid layout, or a manifest open or append failure.
16
17use std::collections::{BTreeMap, HashMap};
18use std::fs;
19use std::path::{Path, PathBuf};
20use std::time::Instant;
21
22use crate::convert::{self, Registry};
23use crate::detect::{self, Detection, FormatTable, UNKNOWN_FORMAT};
24use crate::hash::{self, CanonicalArtifact, DedupIndex};
25use crate::manifest::{self, ArtifactKind, ManifestSchema, ManifestWriter, Record, Status};
26use crate::mirror;
27use crate::report::{
28    DivisionStatus, ExplainEntry, FormatCount, RunReport, ScanReport, SizeOutlier, StatusCounts,
29    StatusReport,
30};
31use crate::segments;
32use crate::walk::{self, EntryKind, WalkOptions};
33use crate::{Error, Result};
34
35/// The detected format recorded for a symlink entry.
36pub const SYMLINK_FORMAT: &str = "symlink";
37
38/// The detected format recorded in scan output for FIFOs and sockets.
39pub const SPECIAL_FORMAT: &str = "special";
40
41/// The rules files loaded for one run.
42///
43/// The format table and the converter registry must declare the same
44/// version. That shared version is recorded in every record as
45/// `rules_version`.
46pub struct Rules {
47    /// The format table from `rules/formats.toml`.
48    pub table: FormatTable,
49    /// The converter registry from `rules/converters.toml`.
50    pub registry: Registry,
51}
52
53impl Rules {
54    /// The rules compiled into the binary.
55    pub fn builtin() -> Result<Self> {
56        let table = FormatTable::builtin()?;
57        let registry = Registry::builtin()?;
58        if table.version() != registry.version() {
59            return Err(Error::Rules {
60                name: "rules".to_string(),
61                message: format!(
62                    "formats.toml version {:?} does not match converters.toml version {:?}",
63                    table.version(),
64                    registry.version()
65                ),
66            });
67        }
68        Ok(Rules { table, registry })
69    }
70
71    /// The shared rules version.
72    pub fn version(&self) -> &str {
73        self.table.version()
74    }
75}
76
77/// Options for one conversion run.
78pub struct RunOptions<'a> {
79    /// The division root to walk.
80    pub root: &'a Path,
81    /// The mirror tree root.
82    pub mirror_root: &'a Path,
83    /// The directory holding manifest shards.
84    pub manifest_dir: &'a Path,
85    /// The division name, also the shard file stem.
86    pub division: &'a str,
87    /// Traversal options.
88    pub walk: WalkOptions,
89}
90
91/// Inventories a division root without converting anything.
92///
93/// A file is a size outlier when it is at least 1 MiB and at least ten
94/// times the mean file size of the tree. Symlinks appear under the
95/// `symlink` format and FIFOs and sockets under `special`.
96pub fn scan(root: &Path, rules: &Rules, options: &WalkOptions) -> Result<ScanReport> {
97    let entries = walk::walk_division(root, options)?;
98    let mut inventory: Vec<(String, u64, String)> = Vec::new();
99    for entry in &entries {
100        let absolute = root.join(&entry.path);
101        let (size, format) = match entry.kind {
102            EntryKind::File => {
103                let size = absolute
104                    .metadata()
105                    .map_err(|e| Error::io("stat", &absolute, e))?
106                    .len();
107                let detection = detect::detect_file(&absolute, &rules.table)?;
108                (size, detection.detected)
109            }
110            EntryKind::Symlink => {
111                let size = fs::symlink_metadata(&absolute)
112                    .map_err(|e| Error::io("stat", &absolute, e))?
113                    .len();
114                (size, SYMLINK_FORMAT.to_string())
115            }
116            EntryKind::Other => (0, SPECIAL_FORMAT.to_string()),
117        };
118        inventory.push((entry.path.to_string_lossy().into_owned(), size, format));
119    }
120
121    let mut by_format: BTreeMap<String, (u64, u64)> = BTreeMap::new();
122    let mut total_bytes = 0u64;
123    for (_, size, format) in &inventory {
124        let entry = by_format.entry(format.clone()).or_insert((0, 0));
125        entry.0 += 1;
126        entry.1 += size;
127        total_bytes += size;
128    }
129
130    let mean = if inventory.is_empty() {
131        0.0
132    } else {
133        total_bytes as f64 / inventory.len() as f64
134    };
135    let mut outliers: Vec<SizeOutlier> = inventory
136        .iter()
137        .filter(|(_, size, _)| *size >= 1_048_576 && *size as f64 >= 10.0 * mean)
138        .map(|(path, size, format)| SizeOutlier {
139            path: path.clone(),
140            bytes: *size,
141            format: format.clone(),
142        })
143        .collect();
144    outliers.sort_by(|a, b| b.bytes.cmp(&a.bytes).then(a.path.cmp(&b.path)));
145
146    Ok(ScanReport {
147        root: root.display().to_string(),
148        rules_version: rules.version().to_string(),
149        files: inventory.len() as u64,
150        bytes: total_bytes,
151        formats: by_format
152            .into_iter()
153            .map(|(format, (files, bytes))| FormatCount {
154                format,
155                files,
156                bytes,
157            })
158            .collect(),
159        outliers,
160    })
161}
162
163pub(crate) fn validate_division(name: &str) -> Result<()> {
164    let usable = !name.is_empty()
165        && name
166            .chars()
167            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
168    if usable {
169        Ok(())
170    } else {
171        Err(Error::Division {
172            name: name.to_string(),
173        })
174    }
175}
176
177/// Resolves a path for containment checks without creating anything.
178///
179/// Existing prefixes canonicalize, so symlinks resolve. Components
180/// past the first missing one cannot be symlinks, so they resolve
181/// lexically, and a parent component pops a base that is already
182/// symlink-free.
183fn resolve_for_check(path: &Path) -> Result<PathBuf> {
184    use std::path::Component;
185    let absolute = std::path::absolute(path).map_err(|e| Error::io("absolutize", path, e))?;
186    let mut resolved = PathBuf::new();
187    for component in absolute.components() {
188        match component {
189            Component::Prefix(_) | Component::RootDir => resolved.push(component),
190            Component::CurDir => {}
191            Component::ParentDir => {
192                resolved.pop();
193            }
194            Component::Normal(name) => {
195                resolved.push(name);
196                if let Ok(canonical) = resolved.canonicalize() {
197                    resolved = canonical;
198                }
199            }
200        }
201    }
202    Ok(resolved)
203}
204
205/// Refuses a layout that nests the division root and an output root
206/// either way, so outputs can never become next-run sources and
207/// artifacts can never land inside the source tree.
208///
209/// Every path resolves without touching the filesystem state, and the
210/// output directories are created only after the layout is approved,
211/// so a refusal leaves the division root untouched.
212fn check_layout(root: &Path, mirror_root: &Path, manifest_dir: &Path) -> Result<()> {
213    let root_resolved = resolve_for_check(root)?;
214    for (name, dir) in [
215        ("mirror root", mirror_root),
216        ("manifest directory", manifest_dir),
217    ] {
218        let dir_resolved = resolve_for_check(dir)?;
219        if dir_resolved.starts_with(&root_resolved) {
220            return Err(Error::Layout {
221                message: format!(
222                    "{name} {} lies inside the division root {}",
223                    dir.display(),
224                    root.display()
225                ),
226            });
227        }
228        if root_resolved.starts_with(&dir_resolved) {
229            return Err(Error::Layout {
230                message: format!(
231                    "division root {} lies inside the {name} {}",
232                    root.display(),
233                    dir.display()
234                ),
235            });
236        }
237    }
238    fs::create_dir_all(mirror_root).map_err(|e| Error::io("create_dir", mirror_root, e))?;
239    fs::create_dir_all(manifest_dir).map_err(|e| Error::io("create_dir", manifest_dir, e))?;
240    Ok(())
241}
242
243fn new_record(
244    source_path: &str,
245    source_hash: &str,
246    source_size: u64,
247    detection: &Detection,
248    rules_version: &str,
249    status: Status,
250) -> Record {
251    Record {
252        schema: ManifestSchema,
253        source_path: source_path.to_string(),
254        source_hash: source_hash.to_string(),
255        source_size,
256        declared_format: detection.declared.clone(),
257        detected_format: detection.detected.clone(),
258        format_mismatch: detection.mismatch,
259        status,
260        text_path: None,
261        text_hash: None,
262        artifact_kind: None,
263        converter_id: None,
264        converter_version: None,
265        tool_version: None,
266        rules_version: rules_version.to_string(),
267        media: None,
268        parent_source: None,
269        dedup_of: None,
270        warnings: Vec::new(),
271        error: None,
272        duration_ms: None,
273    }
274}
275
276fn elapsed_ms(started: Instant) -> u64 {
277    u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
278}
279
280fn terminal_by_path(records: Vec<Record>) -> HashMap<String, Record> {
281    let mut terminal = HashMap::new();
282    for record in records {
283        terminal.insert(record.source_path.clone(), record);
284    }
285    terminal
286}
287
288/// Seeds the dedup index from prior terminal records.
289///
290/// Only records whose converter version and rules version match the
291/// current run are eligible, so a rules bump never propagates stale
292/// artifacts. Candidates are visited sorted by source path, and the
293/// first insert per hash wins, so the canonical choice is
294/// deterministic.
295fn seed_dedup(terminal: &HashMap<String, Record>, rules: &Rules, dedup: &mut DedupIndex) {
296    let mut candidates: Vec<&Record> = terminal.values().collect();
297    candidates.sort_by(|a, b| a.source_path.cmp(&b.source_path));
298    for record in candidates {
299        let canonical_status = record.status == Status::Converted
300            || (record.status == Status::SkippedUnchanged && record.dedup_of.is_none());
301        if !canonical_status || record.rules_version != rules.version() {
302            continue;
303        }
304        let (Some(text_path), Some(text_hash), Some(converter_id), Some(converter_version)) = (
305            &record.text_path,
306            &record.text_hash,
307            &record.converter_id,
308            &record.converter_version,
309        ) else {
310            continue;
311        };
312        let current = rules.registry.converter_for(&record.detected_format);
313        let versions_match =
314            current.is_some_and(|c| c.id() == converter_id && c.version() == converter_version);
315        if !versions_match {
316            continue;
317        }
318        dedup.insert(
319            &record.source_hash,
320            CanonicalArtifact {
321                source_path: record.source_path.clone(),
322                text_path: text_path.clone(),
323                text_hash: text_hash.clone(),
324                converter_id: converter_id.clone(),
325                converter_version: converter_version.clone(),
326                artifact_kind: record.artifact_kind,
327                warnings: record.warnings.clone(),
328            },
329        );
330    }
331}
332
333fn record_outcome(
334    writer: &mut ManifestWriter,
335    terminal: &mut HashMap<String, Record>,
336    counts: &mut StatusCounts,
337    record: Record,
338) -> Result<()> {
339    writer.append(&record)?;
340    match record.status {
341        Status::Converted => counts.converted += 1,
342        Status::Failed => counts.failed += 1,
343        Status::Unsupported => counts.unsupported += 1,
344        Status::SkippedUnchanged => counts.skipped_unchanged += 1,
345        Status::Dedup => counts.dedup += 1,
346    }
347    terminal.insert(record.source_path.clone(), record);
348    Ok(())
349}
350
351/// Removes any artifact and segments file left behind for a source
352/// whose terminal outcome carries no text, so the mirror never
353/// contradicts the manifest.
354fn remove_stale_artifact(text_absolute: &Path, segments_absolute: &Path, record: &mut Record) {
355    for stale in [text_absolute, segments_absolute] {
356        match fs::remove_file(stale) {
357            Ok(()) => {}
358            Err(e)
359                if e.kind() == std::io::ErrorKind::NotFound
360                    || e.kind() == std::io::ErrorKind::NotADirectory => {}
361            Err(e) => record
362                .warnings
363                .push(format!("stale_artifact_not_removed: {e}")),
364        }
365    }
366}
367
368/// True when an artifact and its sidecar still match the recorded
369/// hash: the text bytes hash to `text_hash` and the sidecar parses as
370/// segments@1 and validates against the text.
371fn envelope_intact(artifact: &Path, text_hash: &str, segments_path: &Path) -> bool {
372    let Ok(text) = fs::read_to_string(artifact) else {
373        return false;
374    };
375    if hash::hash_bytes(text.as_bytes()) != text_hash {
376        return false;
377    }
378    let Ok(sidecar) = fs::read_to_string(segments_path) else {
379        return false;
380    };
381    segments::parse_jsonl(&sidecar).is_ok_and(|segs| segments::validate(&segs, &text).is_ok())
382}
383
384fn source_front(
385    absolute: &Path,
386    table: &FormatTable,
387) -> Result<(String, u64, Detection, Vec<String>)> {
388    let source_hash = hash::hash_file(absolute)?;
389    let source_size = absolute
390        .metadata()
391        .map_err(|e| Error::io("stat", absolute, e))?
392        .len();
393    let (detection, warnings) = detect::detect_file_with_warnings(absolute, table)?;
394    Ok((source_hash, source_size, detection, warnings))
395}
396
397/// Identity of one source, container child or top-level file alike.
398struct Meta {
399    source_path: String,
400    parent_source: Option<String>,
401    source_hash: String,
402    source_size: u64,
403    detection: Detection,
404    detect_warnings: Vec<String>,
405}
406
407/// Where a unit's bytes come from. A top-level file reads from disk
408/// after the skip and dedup decisions, so an unchanged file never
409/// pays a second read. A container member already holds its bytes.
410enum UnitBytes {
411    Disk(PathBuf),
412    Mem(Vec<u8>),
413}
414
415impl UnitBytes {
416    fn load(self, expected_hash: &str) -> std::result::Result<Vec<u8>, String> {
417        match self {
418            UnitBytes::Mem(bytes) => Ok(bytes),
419            UnitBytes::Disk(path) => {
420                let bytes = fs::read(&path).map_err(|e| format!("source_io_error: read: {e}"))?;
421                if hash::hash_bytes(&bytes) != expected_hash {
422                    return Err(
423                        "source_changed_during_run: bytes changed between hash and conversion"
424                            .to_string(),
425                    );
426                }
427                Ok(bytes)
428            }
429        }
430    }
431}
432
433/// One unit of conversion work: a top-level source or a container
434/// member.
435struct Unit {
436    meta: Meta,
437    bytes: UnitBytes,
438    depth: usize,
439    /// Container hashes on this unit's chain, for the quine guard.
440    ancestors: Vec<String>,
441}
442
443/// Which container family a detected format belongs to, if any.
444enum ContainerKind {
445    Zip,
446    Eml,
447}
448
449/// True when a container member's hash repeats a container already on
450/// its chain, which is the zip-quine signature. Kills a quine before
451/// it spends the depth budget.
452fn hash_on_chain(hash: &str, ancestors: &[String]) -> bool {
453    ancestors.iter().any(|ancestor| ancestor == hash)
454}
455
456/// Case-folded, NFC-normalized key for a child source path, so two
457/// members whose paths differ only by case or Unicode form collide.
458fn collision_key(source_path: &str) -> String {
459    use unicode_normalization::UnicodeNormalization;
460    source_path.nfc().collect::<String>().to_lowercase()
461}
462
463/// The claimed-path state for one top-level container's whole
464/// expansion, so members cannot alias each other's paths and a
465/// re-expansion can retire the descendants a shrunken member set
466/// dropped.
467struct ClaimState {
468    /// `<container>.d/`, the namespace every descendant path begins
469    /// with.
470    prefix: String,
471    /// Collision keys already claimed this expansion.
472    claimed: std::collections::HashSet<String>,
473    /// Actual descendant source paths recorded this expansion.
474    emitted: std::collections::HashSet<String>,
475    /// Counter for synthetic collision-child paths.
476    collisions: usize,
477}
478
479/// Reason recorded when a re-expanded container no longer holds a
480/// member its prior manifest still named.
481const REMOVED_MEMBER_REASON: &str = "container-member-removed";
482
483fn container_kind(detected: &str) -> Option<ContainerKind> {
484    match detected {
485        "zip" => Some(ContainerKind::Zip),
486        "eml" => Some(ContainerKind::Eml),
487        _ => None,
488    }
489}
490
491/// Shared run state for the recursive conversion of one division.
492struct Expander<'a> {
493    rules: &'a Rules,
494    mirror_root: &'a Path,
495    division: &'a str,
496    walked: &'a std::collections::HashSet<String>,
497    limits: &'a convert::ContainerLimits,
498    writer: &'a mut ManifestWriter,
499    terminal: &'a mut HashMap<String, Record>,
500    counts: &'a mut StatusCounts,
501    dedup: &'a mut DedupIndex,
502    /// Set while a top-level container expands, tracking claimed and
503    /// emitted descendant paths for aliasing and reconciliation.
504    claim: Option<ClaimState>,
505}
506
507impl Expander<'_> {
508    fn artifact_paths(&self, source_path: &str) -> (PathBuf, PathBuf, String) {
509        let relative = Path::new(source_path);
510        (
511            mirror::mirror_path(self.mirror_root, self.division, relative),
512            segments::segments_path(self.mirror_root, self.division, relative),
513            mirror::recorded_text_path(self.division, source_path),
514        )
515    }
516
517    fn emit(&mut self, record: Record) -> Result<()> {
518        if let Some(claim) = &mut self.claim
519            && record.source_path.starts_with(&claim.prefix)
520        {
521            claim.emitted.insert(record.source_path.clone());
522        }
523        record_outcome(self.writer, self.terminal, self.counts, record)
524    }
525
526    fn base_record(&self, meta: &Meta, status: Status) -> Record {
527        let mut record = new_record(
528            &meta.source_path,
529            &meta.source_hash,
530            meta.source_size,
531            &meta.detection,
532            self.rules.version(),
533            status,
534        );
535        record.parent_source.clone_from(&meta.parent_source);
536        record.warnings.clone_from(&meta.detect_warnings);
537        record
538    }
539
540    /// Emits a failed record for a whole unit and clears any stale
541    /// artifact.
542    fn fail(&mut self, meta: &Meta, reason: String, started: Instant) -> Result<()> {
543        let (text_absolute, segments_absolute, _) = self.artifact_paths(&meta.source_path);
544        let mut record = self.base_record(meta, Status::Failed);
545        record.error = Some(reason);
546        record.duration_ms = Some(elapsed_ms(started));
547        remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
548        self.emit(record)
549    }
550
551    /// True when a real walked source already occupies this
552    /// container's expansion namespace, which would collide two
553    /// artifacts on one mirror path.
554    fn expansion_collides(&self, source_path: &str) -> bool {
555        let prefix = format!("{source_path}.d/");
556        self.walked.iter().any(|walked| walked.starts_with(&prefix))
557    }
558
559    /// Processes one unit: checkpoint, dedup, then conversion or
560    /// container expansion, appending one record per source.
561    fn process(&mut self, unit: Unit, started: Instant, expanded: &mut u64) -> Result<()> {
562        let Unit {
563            meta,
564            bytes,
565            depth,
566            ancestors,
567        } = unit;
568        let (text_absolute, segments_absolute, text_relative) =
569            self.artifact_paths(&meta.source_path);
570        let kind = container_kind(&meta.detection.detected);
571        let converter = self.rules.registry.converter_for(&meta.detection.detected);
572        let current_version = match kind {
573            Some(ContainerKind::Zip) => Some(convert::CONTAINER_ZIP_VERSION),
574            _ => converter.map(|c| c.version()),
575        };
576
577        // Checkpoint: an unchanged source with an intact artifact
578        // skips, containers included. A skipped container leaves its
579        // children untouched, and their prior records stay terminal.
580        if let Some(previous) = self.terminal.get(&meta.source_path) {
581            let key_matches = previous.source_hash == meta.source_hash
582                && previous.rules_version == self.rules.version()
583                && previous.converter_version.as_deref() == current_version;
584            let has_text = matches!(
585                previous.status,
586                Status::Converted | Status::Dedup | Status::SkippedUnchanged
587            );
588            if key_matches && has_text {
589                let intact = match (&previous.text_path, &previous.text_hash) {
590                    (Some(text_path), Some(text_hash)) => {
591                        mirror::resolve_recorded_path(self.mirror_root, text_path).is_ok_and(
592                            |artifact| envelope_intact(&artifact, text_hash, &segments_absolute),
593                        )
594                    }
595                    _ => false,
596                };
597                // A container is reachable only through its parent,
598                // so an intact parent envelope is not enough: every
599                // text-bearing descendant must still hash to its
600                // record, or the container re-expands.
601                let descendants_ok = kind.is_none() || self.descendants_intact(&meta.source_path);
602                if intact && descendants_ok {
603                    // A nested container that skips inside a
604                    // re-expanding parent just validated its whole
605                    // subtree intact, so every prior descendant under
606                    // it is kept: the owner's reconciliation must not
607                    // retire them as removed.
608                    if kind.is_some() && self.claim.is_some() {
609                        let prefix = format!("{}.d/", meta.source_path);
610                        let kept: Vec<String> = self
611                            .terminal
612                            .keys()
613                            .filter(|path| path.starts_with(&prefix))
614                            .cloned()
615                            .collect();
616                        if let Some(claim) = &mut self.claim {
617                            claim.emitted.extend(kept);
618                        }
619                    }
620                    let mut record = previous.clone();
621                    record.status = Status::SkippedUnchanged;
622                    record.error = None;
623                    record.duration_ms = Some(elapsed_ms(started));
624                    return self.emit(record);
625                }
626            }
627        }
628
629        // Container guards, before any expansion. A container past the
630        // depth cap or one whose bytes repeat an ancestor container is
631        // a failed child, unexpanded.
632        if kind.is_some() {
633            if depth > self.limits.max_depth {
634                return self.fail(&meta, "container-depth-exceeded".to_string(), started);
635            }
636            if hash_on_chain(&meta.source_hash, &ancestors) {
637                return self.fail(&meta, "container-recursive".to_string(), started);
638            }
639        }
640
641        // Dedup borrow, non-containers only. A container never dedups,
642        // because borrowing its artifact would skip re-expanding its
643        // members under their own paths.
644        if kind.is_none()
645            && let Some(canonical) = self.dedup.get(&meta.source_hash)
646            && canonical.source_path != meta.source_path
647            && converter.is_some_and(|c| {
648                c.id() == canonical.converter_id && c.version() == canonical.converter_version
649            })
650        {
651            let canonical = canonical.clone();
652            let canonical_segments = segments::segments_path(
653                self.mirror_root,
654                self.division,
655                Path::new(&canonical.source_path),
656            );
657            if let Ok(canonical_text) =
658                mirror::resolve_recorded_path(self.mirror_root, &canonical.text_path)
659                && let (Ok(text), Ok(segment_lines)) = (
660                    fs::read_to_string(&canonical_text),
661                    fs::read_to_string(&canonical_segments),
662                )
663                && hash::hash_bytes(text.as_bytes()) == canonical.text_hash
664                && segments::parse_jsonl(&segment_lines)
665                    .is_ok_and(|segs| segments::validate(&segs, &text).is_ok())
666            {
667                let mut record = self.base_record(&meta, Status::Dedup);
668                for warning in &canonical.warnings {
669                    if !record.warnings.contains(warning) {
670                        record.warnings.push(warning.clone());
671                    }
672                }
673                match mirror::write_atomic(&text_absolute, &text)
674                    .and_then(|()| mirror::write_atomic(&segments_absolute, &segment_lines))
675                {
676                    Ok(()) => {
677                        record.text_path = Some(text_relative);
678                        record.text_hash = Some(hash::hash_bytes(text.as_bytes()));
679                        record.artifact_kind = canonical.artifact_kind;
680                        record.converter_id = Some(canonical.converter_id.clone());
681                        record.converter_version = Some(canonical.converter_version.clone());
682                        record.dedup_of = Some(canonical.source_path.clone());
683                    }
684                    Err(e) => {
685                        record.status = Status::Failed;
686                        record.error = Some(format!("mirror_write_error: {e}"));
687                        remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
688                    }
689                }
690                record.duration_ms = Some(elapsed_ms(started));
691                return self.emit(record);
692            }
693        }
694
695        let Some(kind) = kind else {
696            return self.convert_leaf(meta, bytes, started);
697        };
698        // A top-level container owns the claim state for its whole
699        // subtree. Nested containers share the owner's state.
700        let owns_claim = self.claim.is_none();
701        if owns_claim {
702            self.claim = Some(ClaimState {
703                prefix: format!("{}.d/", meta.source_path),
704                claimed: std::collections::HashSet::new(),
705                emitted: std::collections::HashSet::new(),
706                collisions: 0,
707            });
708        }
709        let root = meta.source_path.clone();
710        let result = match kind {
711            ContainerKind::Zip => self.expand_zip(meta, bytes, depth, ancestors, started, expanded),
712            ContainerKind::Eml => self.expand_eml(meta, bytes, depth, ancestors, started, expanded),
713        };
714        if owns_claim {
715            // Reconcile even on a failure path, so a container that
716            // failed whole still retires the descendants it no longer
717            // holds and never leaves a partial expansion live.
718            if let Some(claim) = self.claim.take() {
719                self.reconcile(&root, &claim, started)?;
720            }
721        }
722        result
723    }
724
725    /// True when every text-bearing descendant of a container still
726    /// hashes to its recorded value on disk.
727    fn descendants_intact(&self, root: &str) -> bool {
728        let prefix = format!("{root}.d/");
729        for record in self.terminal.values() {
730            if !record.source_path.starts_with(&prefix) {
731                continue;
732            }
733            if let (Some(text_path), Some(text_hash)) = (&record.text_path, &record.text_hash) {
734                let segments_absolute = segments::segments_path(
735                    self.mirror_root,
736                    self.division,
737                    Path::new(&record.source_path),
738                );
739                let ok = mirror::resolve_recorded_path(self.mirror_root, text_path).is_ok_and(
740                    |artifact| envelope_intact(&artifact, text_hash, &segments_absolute),
741                );
742                if !ok {
743                    return false;
744                }
745            }
746        }
747        true
748    }
749
750    /// Retires every prior descendant a re-expansion no longer holds
751    /// with a terminal non-text record, and removes its artifact, so
752    /// no removed member survives as a live converted record.
753    fn reconcile(&mut self, root: &str, claim: &ClaimState, started: Instant) -> Result<()> {
754        let stale: Vec<Record> = self
755            .terminal
756            .values()
757            .filter(|record| {
758                record.source_path.starts_with(&claim.prefix)
759                    && !claim.emitted.contains(&record.source_path)
760                    // An already-retired record stays retired, so the
761                    // reconciliation reaches a fixpoint and does not
762                    // re-emit a removed member every run.
763                    && !(record.status == Status::Failed
764                        && record.error.as_deref() == Some(REMOVED_MEMBER_REASON))
765            })
766            .cloned()
767            .collect();
768        let _ = root;
769        for record in stale {
770            let (text_absolute, segments_absolute, _) = self.artifact_paths(&record.source_path);
771            let detection = Detection {
772                declared: record.declared_format.clone(),
773                detected: UNKNOWN_FORMAT.to_string(),
774                mismatch: false,
775            };
776            let mut retire = new_record(
777                &record.source_path,
778                "",
779                0,
780                &detection,
781                self.rules.version(),
782                Status::Failed,
783            );
784            retire.parent_source.clone_from(&record.parent_source);
785            retire.error = Some(REMOVED_MEMBER_REASON.to_string());
786            retire.duration_ms = Some(elapsed_ms(started));
787            remove_stale_artifact(&text_absolute, &segments_absolute, &mut retire);
788            self.emit(retire)?;
789        }
790        Ok(())
791    }
792
793    /// The non-container conversion path: unsupported when no converter
794    /// claims the format, else convert under the ceilings.
795    fn convert_leaf(&mut self, meta: Meta, bytes: UnitBytes, started: Instant) -> Result<()> {
796        let (text_absolute, segments_absolute, text_relative) =
797            self.artifact_paths(&meta.source_path);
798        let Some(converter) = self.rules.registry.converter_for(&meta.detection.detected) else {
799            let mut record = self.base_record(&meta, Status::Unsupported);
800            record.error = self
801                .rules
802                .registry
803                .unsupported_reason(&meta.detection.detected)
804                .map(str::to_string);
805            record.duration_ms = Some(elapsed_ms(started));
806            remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
807            return self.emit(record);
808        };
809        let converter_id = converter.id().to_string();
810        let converter_version = converter.version().to_string();
811
812        let mut record = self.base_record(&meta, Status::Converted);
813        let outcome = if meta.source_size > convert::MAX_SOURCE_BYTES {
814            Err(format!(
815                "resource_limit: source is {} bytes over the {} byte ceiling",
816                meta.source_size,
817                convert::MAX_SOURCE_BYTES
818            ))
819        } else {
820            bytes
821                .load(&meta.source_hash)
822                .and_then(|bytes| run_converter(converter, &bytes, &meta.detection.detected))
823        };
824        match outcome {
825            Ok(outcome) => match segments::to_jsonl(&outcome.segments)
826                .map_err(|e| e.to_string())
827                .and_then(|lines| {
828                    mirror::write_atomic(&text_absolute, &outcome.text)
829                        .and_then(|()| mirror::write_atomic(&segments_absolute, &lines))
830                        .map_err(|e| e.to_string())
831                }) {
832                Ok(()) => {
833                    let text_hash = hash::hash_bytes(outcome.text.as_bytes());
834                    record.text_path = Some(text_relative.clone());
835                    record.text_hash = Some(text_hash.clone());
836                    record.artifact_kind = Some(ArtifactKind::Text);
837                    record.converter_id = Some(outcome.converter_id.clone());
838                    record.converter_version = Some(outcome.converter_version.clone());
839                    record.warnings.extend(outcome.warnings.iter().cloned());
840                    self.dedup.replace(
841                        &meta.source_hash,
842                        CanonicalArtifact {
843                            source_path: meta.source_path.clone(),
844                            text_path: text_relative,
845                            text_hash,
846                            converter_id: outcome.converter_id,
847                            converter_version: outcome.converter_version,
848                            artifact_kind: record.artifact_kind,
849                            warnings: outcome.warnings,
850                        },
851                    );
852                }
853                Err(detail) => {
854                    record.status = Status::Failed;
855                    record.converter_id = Some(converter_id);
856                    record.converter_version = Some(converter_version);
857                    record.error = Some(format!("mirror_write_error: {detail}"));
858                    remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
859                }
860            },
861            Err(reason) => {
862                record.status = Status::Failed;
863                record.converter_id = Some(converter_id);
864                record.converter_version = Some(converter_version);
865                record.error = Some(reason);
866                remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
867            }
868        }
869        record.duration_ms = Some(elapsed_ms(started));
870        self.emit(record)
871    }
872
873    /// Writes a parent container artifact and records the parent as
874    /// converted. Shared by both container families.
875    fn write_parent(
876        &mut self,
877        meta: &Meta,
878        outcome: &convert::Outcome,
879        started: Instant,
880    ) -> Result<bool> {
881        let (text_absolute, segments_absolute, text_relative) =
882            self.artifact_paths(&meta.source_path);
883        let lines = match segments::to_jsonl(&outcome.segments) {
884            Ok(lines) => lines,
885            Err(e) => {
886                self.fail(meta, format!("invalid_segments: {e}"), started)?;
887                return Ok(false);
888            }
889        };
890        match mirror::write_atomic(&text_absolute, &outcome.text)
891            .and_then(|()| mirror::write_atomic(&segments_absolute, &lines))
892        {
893            Ok(()) => {
894                let mut record = self.base_record(meta, Status::Converted);
895                record.text_path = Some(text_relative);
896                record.text_hash = Some(hash::hash_bytes(outcome.text.as_bytes()));
897                record.artifact_kind = Some(ArtifactKind::Text);
898                record.converter_id = Some(outcome.converter_id.clone());
899                record.converter_version = Some(outcome.converter_version.clone());
900                record.warnings.extend(outcome.warnings.iter().cloned());
901                record.duration_ms = Some(elapsed_ms(started));
902                self.emit(record)?;
903                Ok(true)
904            }
905            Err(e) => {
906                self.fail(meta, format!("mirror_write_error: {e}"), started)?;
907                Ok(false)
908            }
909        }
910    }
911
912    /// Builds and processes one container member as a child unit.
913    fn process_member(
914        &mut self,
915        parent: &Meta,
916        member_path: &str,
917        member_bytes: Vec<u8>,
918        depth: usize,
919        ancestors: &[String],
920        expanded: &mut u64,
921    ) -> Result<()> {
922        let source_path = format!("{}.d/{}", parent.source_path, member_path);
923        // Two members whose normalized child paths collide would alias
924        // one artifact, leaving two records with one file and a
925        // mismatched hash. The later one is refused.
926        if let Some(claim) = &mut self.claim {
927            let key = collision_key(&source_path);
928            if !claim.claimed.insert(key) {
929                claim.collisions += 1;
930                let synthetic = format!("{}.d/#collision-{}", parent.source_path, claim.collisions);
931                return self.emit_member_failure(
932                    parent,
933                    synthetic,
934                    member_bytes.len() as u64,
935                    "member-path-collision".to_string(),
936                    Instant::now(),
937                );
938            }
939        }
940        let (detection, detect_warnings) = detect::detect_bytes_with_warnings(
941            Path::new(&source_path),
942            &member_bytes,
943            &self.rules.table,
944        );
945        let unit = Unit {
946            meta: Meta {
947                source_hash: hash::hash_bytes(&member_bytes),
948                source_size: member_bytes.len() as u64,
949                source_path,
950                parent_source: Some(parent.source_path.clone()),
951                detection,
952                detect_warnings,
953            },
954            bytes: UnitBytes::Mem(member_bytes),
955            depth,
956            ancestors: ancestors.to_vec(),
957        };
958        self.process(unit, Instant::now(), expanded)
959    }
960}
961
962/// The failure reason for a source over the conversion size ceiling.
963fn over_source_ceiling(size: u64) -> String {
964    format!(
965        "resource_limit: source is {size} bytes over the {} byte ceiling",
966        convert::MAX_SOURCE_BYTES
967    )
968}
969
970/// Runs a converter under the panic boundary and output validation.
971fn run_converter(
972    converter: &dyn convert::Converter,
973    bytes: &[u8],
974    detected: &str,
975) -> std::result::Result<convert::Outcome, String> {
976    let guarded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
977        converter.convert(bytes, detected)
978    }));
979    let outcome = match guarded {
980        Ok(result) => result.map_err(|e| e.to_string())?,
981        Err(_) => return Err("converter_panic: conversion panicked".to_string()),
982    };
983    validate_output(&outcome)?;
984    Ok(outcome)
985}
986
987/// The shared output gate: output-size ceiling, the NUL refusal, and
988/// segments validation.
989fn validate_output(outcome: &convert::Outcome) -> std::result::Result<(), String> {
990    if outcome.text.len() > convert::MAX_OUTPUT_BYTES {
991        return Err(format!(
992            "resource_limit: output is {} bytes over the {} byte ceiling",
993            outcome.text.len(),
994            convert::MAX_OUTPUT_BYTES
995        ));
996    }
997    if let Some(offset) = outcome.text.find('\0') {
998        return Err(format!(
999            "nul_bytes: NUL at byte {offset} of the converted text"
1000        ));
1001    }
1002    segments::validate(&outcome.segments, &outcome.text)
1003        .map_err(|detail| format!("invalid_segments: {detail}"))
1004}
1005
1006impl Expander<'_> {
1007    /// Emits one failed child record for a member refused before or
1008    /// during inflation.
1009    fn emit_member_failure(
1010        &mut self,
1011        parent: &Meta,
1012        child_source_path: String,
1013        source_size: u64,
1014        reason: String,
1015        started: Instant,
1016    ) -> Result<()> {
1017        let (text_absolute, segments_absolute, _) = self.artifact_paths(&child_source_path);
1018        let detection = Detection {
1019            declared: None,
1020            detected: UNKNOWN_FORMAT.to_string(),
1021            mismatch: false,
1022        };
1023        let mut record = new_record(
1024            &child_source_path,
1025            "",
1026            source_size,
1027            &detection,
1028            self.rules.version(),
1029            Status::Failed,
1030        );
1031        record.parent_source = Some(parent.source_path.clone());
1032        record.error = Some(reason);
1033        record.duration_ms = Some(elapsed_ms(started));
1034        remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
1035        self.emit(record)
1036    }
1037
1038    /// Expands a zip container: a member listing artifact for the
1039    /// parent, then each member routed back through dispatch.
1040    fn expand_zip(
1041        &mut self,
1042        meta: Meta,
1043        bytes: UnitBytes,
1044        depth: usize,
1045        ancestors: Vec<String>,
1046        started: Instant,
1047        expanded: &mut u64,
1048    ) -> Result<()> {
1049        if self.expansion_collides(&meta.source_path) {
1050            return self.fail(&meta, "container-mirror-collision".to_string(), started);
1051        }
1052        if meta.source_size > convert::MAX_SOURCE_BYTES {
1053            return self.fail(&meta, over_source_ceiling(meta.source_size), started);
1054        }
1055        let bytes = match bytes.load(&meta.source_hash) {
1056            Ok(bytes) => bytes,
1057            Err(reason) => return self.fail(&meta, reason, started),
1058        };
1059        // Preflight the raw entry count from the end-of-central-
1060        // directory record before the zip crate parses and allocates
1061        // the whole directory, and refuse on the raw count, which its
1062        // name-keyed map would otherwise undercount.
1063        if let Some(entries) = convert::preflight_entry_count(&bytes)
1064            && entries > self.limits.max_children as u64
1065        {
1066            return self.fail(&meta, "container-children-exceeded".to_string(), started);
1067        }
1068        let mut container = match convert::ZipContainer::open(&bytes) {
1069            Ok(container) => container,
1070            Err(e) => return self.fail(&meta, e.to_string(), started),
1071        };
1072
1073        // Stage 1: the child count and declared sizes, before any
1074        // inflation.
1075        let count = container.len();
1076        if count > self.limits.max_children {
1077            return self.fail(&meta, "container-children-exceeded".to_string(), started);
1078        }
1079        let mut declared_total: u64 = 0;
1080        for index in 0..count {
1081            match container.meta(index) {
1082                Ok(member) => declared_total = declared_total.saturating_add(member.declared_size),
1083                Err(e) => return self.fail(&meta, e.to_string(), started),
1084            }
1085        }
1086        if declared_total > self.limits.max_expanded_bytes {
1087            return self.fail(&meta, "container-expansion-cap".to_string(), started);
1088        }
1089        let listing = match container.listing() {
1090            Ok(listing) => listing,
1091            Err(e) => return self.fail(&meta, e.to_string(), started),
1092        };
1093
1094        let mut child_ancestors = ancestors;
1095        child_ancestors.push(meta.source_hash.clone());
1096
1097        // Stage 2: inflate and route each member. The cumulative
1098        // counter charges actual inflated bytes, and an overrun fails
1099        // the whole container.
1100        let mut parent_failure: Option<String> = None;
1101        for index in 0..count {
1102            let member = match container.meta(index) {
1103                Ok(member) => member,
1104                Err(e) => {
1105                    parent_failure = Some(e.to_string());
1106                    break;
1107                }
1108            };
1109            if member.is_dir {
1110                continue;
1111            }
1112            let Some(member_path) = member.safe_path.clone() else {
1113                self.emit_member_failure(
1114                    &meta,
1115                    format!("{}.d/#unsafe-member-{index}", meta.source_path),
1116                    0,
1117                    "unsafe-member-path".to_string(),
1118                    started,
1119                )?;
1120                continue;
1121            };
1122            let child_source = format!("{}.d/{}", meta.source_path, member_path);
1123            if member.is_symlink {
1124                self.emit_member_failure(
1125                    &meta,
1126                    child_source,
1127                    0,
1128                    "unsafe-member-path".to_string(),
1129                    started,
1130                )?;
1131                continue;
1132            }
1133            if member.encrypted {
1134                self.emit_member_failure(
1135                    &meta,
1136                    child_source,
1137                    member.declared_size,
1138                    "encrypted".to_string(),
1139                    started,
1140                )?;
1141                continue;
1142            }
1143            if member.declared_size > self.limits.max_member_size {
1144                self.emit_member_failure(
1145                    &meta,
1146                    child_source,
1147                    member.declared_size,
1148                    "container-member-too-large".to_string(),
1149                    started,
1150                )?;
1151                continue;
1152            }
1153            let remaining = self.limits.max_expanded_bytes.saturating_sub(*expanded);
1154            let cap = self.limits.max_member_size.min(remaining);
1155            match container.inflate(index, cap) {
1156                convert::Inflated::Ok(data) => {
1157                    *expanded = expanded.saturating_add(data.len() as u64);
1158                    self.process_member(
1159                        &meta,
1160                        &member_path,
1161                        data,
1162                        depth + 1,
1163                        &child_ancestors,
1164                        expanded,
1165                    )?;
1166                }
1167                convert::Inflated::TooLarge(inflated) => {
1168                    *expanded = expanded.saturating_add(inflated);
1169                    if inflated > self.limits.max_member_size {
1170                        self.emit_member_failure(
1171                            &meta,
1172                            child_source,
1173                            inflated,
1174                            "container-member-too-large".to_string(),
1175                            started,
1176                        )?;
1177                    }
1178                    if *expanded > self.limits.max_expanded_bytes {
1179                        parent_failure = Some("container-expansion-cap".to_string());
1180                        break;
1181                    }
1182                }
1183                convert::Inflated::Error(e) => {
1184                    self.emit_member_failure(
1185                        &meta,
1186                        child_source,
1187                        0,
1188                        format!("container-member-unreadable: {e}"),
1189                        started,
1190                    )?;
1191                }
1192            }
1193        }
1194
1195        match parent_failure {
1196            Some(reason) => self.fail(&meta, reason, started),
1197            None => {
1198                let segments = vec![segments::Segment::span(0, listing.len(), "document")];
1199                let outcome = convert::Outcome {
1200                    converter_id: convert::CONTAINER_ZIP_ID.to_string(),
1201                    converter_version: convert::CONTAINER_ZIP_VERSION.to_string(),
1202                    detected_format: meta.detection.detected.clone(),
1203                    text: listing,
1204                    warnings: Vec::new(),
1205                    segments,
1206                };
1207                if let Err(reason) = validate_output(&outcome) {
1208                    return self.fail(&meta, reason, started);
1209                }
1210                self.write_parent(&meta, &outcome, started).map(|_| ())
1211            }
1212        }
1213    }
1214
1215    /// Expands an eml container: the message renders to the parent
1216    /// artifact, and each attachment routes back through dispatch as a
1217    /// member.
1218    fn expand_eml(
1219        &mut self,
1220        meta: Meta,
1221        bytes: UnitBytes,
1222        depth: usize,
1223        ancestors: Vec<String>,
1224        started: Instant,
1225        expanded: &mut u64,
1226    ) -> Result<()> {
1227        if self.expansion_collides(&meta.source_path) {
1228            return self.fail(&meta, "container-mirror-collision".to_string(), started);
1229        }
1230        let bytes = match bytes.load(&meta.source_hash) {
1231            Ok(bytes) => bytes,
1232            Err(reason) => return self.fail(&meta, reason, started),
1233        };
1234        if meta.source_size > convert::MAX_SOURCE_BYTES {
1235            return self.fail(&meta, over_source_ceiling(meta.source_size), started);
1236        }
1237        let guarded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1238            convert::expand_eml(&bytes, &meta.detection.detected)
1239        }));
1240        let expansion = match guarded {
1241            Ok(Ok(expansion)) => expansion,
1242            Ok(Err(e)) => return self.fail(&meta, e.to_string(), started),
1243            Err(_) => {
1244                return self.fail(
1245                    &meta,
1246                    "converter_panic: conversion panicked".to_string(),
1247                    started,
1248                );
1249            }
1250        };
1251        if let Err(reason) = validate_output(&expansion.outcome) {
1252            return self.fail(&meta, reason, started);
1253        }
1254
1255        // Expand the attachments first, so the parent record is
1256        // written last and an intact eml parent proves the whole
1257        // expansion finished, matching the zip ordering.
1258        let mut child_ancestors = ancestors;
1259        child_ancestors.push(meta.source_hash.clone());
1260        let mut parent_failure: Option<String> = None;
1261        for member in expansion.members {
1262            *expanded = expanded.saturating_add(member.bytes.len() as u64);
1263            if *expanded > self.limits.max_expanded_bytes {
1264                parent_failure = Some("container-expansion-cap".to_string());
1265                break;
1266            }
1267            self.process_member(
1268                &meta,
1269                &member.name,
1270                member.bytes,
1271                depth + 1,
1272                &child_ancestors,
1273                expanded,
1274            )?;
1275        }
1276
1277        match parent_failure {
1278            Some(reason) => self.fail(&meta, reason, started),
1279            None => self
1280                .write_parent(&meta, &expansion.outcome, started)
1281                .map(|_| ()),
1282        }
1283    }
1284}
1285
1286/// Converts a division root into the mirror tree.
1287///
1288/// Walks the root in sorted order, detects each file, and appends one
1289/// record per source to the division shard. Text-native formats pass
1290/// through the registered converter. A format no converter claims
1291/// becomes `unsupported`. A converter error becomes `failed` with a
1292/// reason and no text artifact. Safe to kill and re-run.
1293///
1294/// Dedup applies when a source's hash matches an artifact already
1295/// converted by the same converter id and version that would handle
1296/// this source. The duplicate gets a real text file at its parallel
1297/// path, hashed over the bytes actually written, with `dedup_of`
1298/// naming the canonical source. When the canonical artifact cannot be
1299/// read, the duplicate converts for itself.
1300pub fn run(rules: &Rules, options: &RunOptions) -> Result<RunReport> {
1301    validate_division(options.division)?;
1302    check_layout(options.root, options.mirror_root, options.manifest_dir)?;
1303    let entries = walk::walk_division(options.root, &options.walk)?;
1304    let shard_path = options
1305        .manifest_dir
1306        .join(format!("{}.jsonl", options.division));
1307    let (prior, mut warnings) = if shard_path.exists() {
1308        let shard = manifest::read_shard(&shard_path)?;
1309        (shard.records, shard.warnings)
1310    } else {
1311        (Vec::new(), Vec::new())
1312    };
1313    let mut terminal = terminal_by_path(prior);
1314    let mut dedup = DedupIndex::default();
1315    seed_dedup(&terminal, rules, &mut dedup);
1316
1317    // The mirror paths a container's `.d/` expansion would claim must
1318    // not collide with a real walked source, so the walked set is
1319    // checked before any container expands.
1320    let walked: std::collections::HashSet<String> = entries
1321        .iter()
1322        .filter_map(|entry| entry.path.to_str().map(str::to_string))
1323        .collect();
1324    let limits = rules.registry.container_limits().clone();
1325
1326    let mut writer = ManifestWriter::open(&shard_path)?;
1327    let mut counts = StatusCounts::default();
1328    let mut special_entries = 0u64;
1329
1330    for entry in &entries {
1331        let started = Instant::now();
1332        let relative = &entry.path;
1333        let absolute = options.root.join(relative);
1334        let text_absolute = mirror::mirror_path(options.mirror_root, options.division, relative);
1335        let segments_absolute =
1336            segments::segments_path(options.mirror_root, options.division, relative);
1337
1338        if entry.kind == EntryKind::Other {
1339            special_entries += 1;
1340            continue;
1341        }
1342
1343        // A lossy path must never name a record key or an artifact, so
1344        // a non-UTF-8 source name, symlink or file, is a recorded
1345        // failure with no conversion.
1346        let Some(source_path) = relative.to_str().map(str::to_string) else {
1347            let detection = Detection {
1348                declared: detect::declared_format(relative, &rules.table),
1349                detected: UNKNOWN_FORMAT.to_string(),
1350                mismatch: false,
1351            };
1352            let mut record = new_record(
1353                &relative.to_string_lossy(),
1354                "",
1355                0,
1356                &detection,
1357                rules.version(),
1358                Status::Failed,
1359            );
1360            record.error = Some("non_utf8_path: source path is not valid UTF-8".to_string());
1361            record.duration_ms = Some(elapsed_ms(started));
1362            remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
1363            record_outcome(&mut writer, &mut terminal, &mut counts, record)?;
1364            continue;
1365        };
1366
1367        if entry.kind == EntryKind::Symlink {
1368            let detection = Detection {
1369                declared: None,
1370                detected: SYMLINK_FORMAT.to_string(),
1371                mismatch: false,
1372            };
1373            let mut record = match fs::read_link(&absolute) {
1374                Ok(target) => {
1375                    let target_bytes = target.as_os_str().as_encoded_bytes();
1376                    let mut record = new_record(
1377                        &source_path,
1378                        &hash::hash_bytes(target_bytes),
1379                        target_bytes.len() as u64,
1380                        &detection,
1381                        rules.version(),
1382                        Status::Unsupported,
1383                    );
1384                    // Symlinks are unsupported by pipeline design,
1385                    // whatever the registry claims, so the reason is
1386                    // set directly instead of looked up.
1387                    record.error = Some(convert::NO_CONVERTER_REASON.to_string());
1388                    record
1389                }
1390                Err(e) => {
1391                    let mut record = new_record(
1392                        &source_path,
1393                        "",
1394                        0,
1395                        &detection,
1396                        rules.version(),
1397                        Status::Failed,
1398                    );
1399                    record.error = Some(format!("source_io_error: readlink: {e}"));
1400                    record
1401                }
1402            };
1403            record.duration_ms = Some(elapsed_ms(started));
1404            remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
1405            record_outcome(&mut writer, &mut terminal, &mut counts, record)?;
1406            continue;
1407        }
1408
1409        // Gate the source size before detection. Format detection
1410        // reads from the file to sniff its type, and for a zip it can
1411        // allocate from a forged member size, so an oversized source
1412        // must fail before that read, containers included.
1413        let stat = match absolute.metadata() {
1414            Ok(metadata) => metadata,
1415            Err(e) => {
1416                let detection = Detection {
1417                    declared: detect::declared_format(relative, &rules.table),
1418                    detected: UNKNOWN_FORMAT.to_string(),
1419                    mismatch: false,
1420                };
1421                let mut record = new_record(
1422                    &source_path,
1423                    "",
1424                    0,
1425                    &detection,
1426                    rules.version(),
1427                    Status::Failed,
1428                );
1429                record.error = Some(format!("source_io_error: stat: {e}"));
1430                record.duration_ms = Some(elapsed_ms(started));
1431                remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
1432                record_outcome(&mut writer, &mut terminal, &mut counts, record)?;
1433                continue;
1434            }
1435        };
1436        if stat.len() > convert::MAX_SOURCE_BYTES {
1437            let detection = Detection {
1438                declared: detect::declared_format(relative, &rules.table),
1439                detected: UNKNOWN_FORMAT.to_string(),
1440                mismatch: false,
1441            };
1442            let mut record = new_record(
1443                &source_path,
1444                "",
1445                stat.len(),
1446                &detection,
1447                rules.version(),
1448                Status::Failed,
1449            );
1450            record.error = Some(over_source_ceiling(stat.len()));
1451            record.duration_ms = Some(elapsed_ms(started));
1452            remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
1453            record_outcome(&mut writer, &mut terminal, &mut counts, record)?;
1454            continue;
1455        }
1456
1457        let front = match source_front(&absolute, &rules.table) {
1458            Ok(front) => front,
1459            Err(e) => {
1460                let detection = Detection {
1461                    declared: detect::declared_format(relative, &rules.table),
1462                    detected: UNKNOWN_FORMAT.to_string(),
1463                    mismatch: false,
1464                };
1465                let mut record = new_record(
1466                    &source_path,
1467                    "",
1468                    0,
1469                    &detection,
1470                    rules.version(),
1471                    Status::Failed,
1472                );
1473                record.error = Some(format!("source_io_error: {e}"));
1474                record.duration_ms = Some(elapsed_ms(started));
1475                remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
1476                record_outcome(&mut writer, &mut terminal, &mut counts, record)?;
1477                continue;
1478            }
1479        };
1480        let (source_hash, source_size, detection, detect_warnings) = front;
1481        let mut expanded = 0u64;
1482        let unit = Unit {
1483            meta: Meta {
1484                source_path,
1485                parent_source: None,
1486                source_hash,
1487                source_size,
1488                detection,
1489                detect_warnings,
1490            },
1491            bytes: UnitBytes::Disk(absolute.clone()),
1492            depth: 0,
1493            ancestors: Vec::new(),
1494        };
1495        let mut expander = Expander {
1496            rules,
1497            mirror_root: options.mirror_root,
1498            division: options.division,
1499            walked: &walked,
1500            limits: &limits,
1501            writer: &mut writer,
1502            terminal: &mut terminal,
1503            counts: &mut counts,
1504            dedup: &mut dedup,
1505            claim: None,
1506        };
1507        expander.process(unit, started, &mut expanded)?;
1508    }
1509
1510    warnings.sort();
1511    Ok(RunReport {
1512        division: options.division.to_string(),
1513        root: options.root.display().to_string(),
1514        sources: entries.len() as u64 - special_entries,
1515        special_entries,
1516        counts,
1517        warnings,
1518    })
1519}
1520
1521fn shard_paths(manifest_dir: &Path) -> Result<Vec<PathBuf>> {
1522    let entries = fs::read_dir(manifest_dir).map_err(|e| Error::io("read_dir", manifest_dir, e))?;
1523    let mut shards = Vec::new();
1524    for entry in entries {
1525        let entry = entry.map_err(|e| Error::io("read_dir", manifest_dir, e))?;
1526        let path = entry.path();
1527        if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
1528            shards.push(path);
1529        }
1530    }
1531    shards.sort();
1532    Ok(shards)
1533}
1534
1535fn division_name(shard: &Path) -> String {
1536    shard
1537        .file_stem()
1538        .map(|stem| stem.to_string_lossy().into_owned())
1539        .unwrap_or_default()
1540}
1541
1542/// Computes coverage per division over terminal records.
1543pub fn status(manifest_dir: &Path) -> Result<StatusReport> {
1544    let mut divisions = Vec::new();
1545    for shard_path in shard_paths(manifest_dir)? {
1546        let shard = manifest::read_shard(&shard_path)?;
1547        let terminal = terminal_by_path(shard.records);
1548        let mut counts = StatusCounts::default();
1549        let mut with_text = 0u64;
1550        for record in terminal.values() {
1551            match record.status {
1552                Status::Converted => counts.converted += 1,
1553                Status::Failed => counts.failed += 1,
1554                Status::Unsupported => counts.unsupported += 1,
1555                Status::SkippedUnchanged => counts.skipped_unchanged += 1,
1556                Status::Dedup => counts.dedup += 1,
1557            }
1558            if record.text_path.is_some() {
1559                with_text += 1;
1560            }
1561        }
1562        let sources = terminal.len() as u64;
1563        let coverage = if sources == 0 {
1564            0.0
1565        } else {
1566            with_text as f64 / sources as f64
1567        };
1568        divisions.push(DivisionStatus {
1569            division: division_name(&shard_path),
1570            sources,
1571            with_text,
1572            coverage,
1573            counts,
1574            warnings: shard.warnings,
1575        });
1576    }
1577    Ok(StatusReport { divisions })
1578}
1579
1580/// Collects every record for one source path across all shards.
1581pub fn explain(manifest_dir: &Path, source_path: &str) -> Result<Vec<ExplainEntry>> {
1582    let mut entries = Vec::new();
1583    for shard_path in shard_paths(manifest_dir)? {
1584        let division = division_name(&shard_path);
1585        for record in manifest::read_shard(&shard_path)?.records {
1586            if record.source_path == source_path {
1587                entries.push(ExplainEntry {
1588                    division: division.clone(),
1589                    record,
1590                });
1591            }
1592        }
1593    }
1594    Ok(entries)
1595}
1596
1597#[cfg(test)]
1598mod tests {
1599    use super::*;
1600
1601    #[test]
1602    fn builtin_rules_agree_on_a_version() {
1603        let rules = Rules::builtin().unwrap();
1604        assert_eq!(rules.version(), "7");
1605    }
1606
1607    #[test]
1608    fn the_quine_guard_matches_an_ancestor_hash() {
1609        let chain = vec!["aa".to_string(), "bb".to_string()];
1610        assert!(hash_on_chain("bb", &chain));
1611        assert!(hash_on_chain("aa", &chain));
1612        assert!(!hash_on_chain("cc", &chain));
1613        assert!(!hash_on_chain("aa", &[]));
1614    }
1615
1616    use crate::convert::{ConvertError, Converter, Outcome, PlainTextPassthrough};
1617    use crate::segments::Segment;
1618
1619    /// A converter that panics, for the boundary test. No shipped
1620    /// converter panics on purpose, so the test registry injects one.
1621    struct PanicConverter;
1622
1623    impl Converter for PanicConverter {
1624        fn id(&self) -> &'static str {
1625            "panic-test"
1626        }
1627        fn version(&self) -> &'static str {
1628            "1.0.0"
1629        }
1630        fn convert(
1631            &self,
1632            _source: &[u8],
1633            _detected_format: &str,
1634        ) -> std::result::Result<Outcome, ConvertError> {
1635            panic!("deliberate test panic");
1636        }
1637    }
1638
1639    /// A converter whose segments do not fit its text.
1640    struct BadSegmentsConverter;
1641
1642    impl Converter for BadSegmentsConverter {
1643        fn id(&self) -> &'static str {
1644            "bad-segments-test"
1645        }
1646        fn version(&self) -> &'static str {
1647            "1.0.0"
1648        }
1649        fn convert(
1650            &self,
1651            _source: &[u8],
1652            detected_format: &str,
1653        ) -> std::result::Result<Outcome, ConvertError> {
1654            Ok(Outcome {
1655                converter_id: self.id().to_string(),
1656                converter_version: self.version().to_string(),
1657                detected_format: detected_format.to_string(),
1658                text: "ok".to_string(),
1659                warnings: Vec::new(),
1660                segments: vec![Segment::span(0, 999, "document")],
1661            })
1662        }
1663    }
1664
1665    fn injected_rules() -> Rules {
1666        let table = FormatTable::parse(
1667            r#"
1668version = "9"
1669
1670[[formats]]
1671id = "text"
1672name = "Plain text"
1673extensions = ["txt"]
1674
1675[[formats]]
1676id = "panicfmt"
1677name = "Panic trigger"
1678extensions = ["panicfmt"]
1679
1680[[formats]]
1681id = "badseg"
1682name = "Bad segments trigger"
1683extensions = ["badseg"]
1684"#,
1685            "formats.toml",
1686        )
1687        .unwrap();
1688        let registry = Registry::for_tests(
1689            "9",
1690            vec![
1691                (Box::new(PlainTextPassthrough), vec!["text"]),
1692                (Box::new(PanicConverter), vec!["panicfmt"]),
1693                (Box::new(BadSegmentsConverter), vec!["badseg"]),
1694            ],
1695        );
1696        Rules { table, registry }
1697    }
1698
1699    #[test]
1700    fn a_panicking_converter_fails_one_source_and_the_division_survives() {
1701        let dir = tempfile::tempdir().unwrap();
1702        let root = dir.path().join("src");
1703        fs::create_dir_all(&root).unwrap();
1704        fs::write(root.join("boom.panicfmt"), "trigger\n").unwrap();
1705        fs::write(root.join("fine.txt"), "safe content\n").unwrap();
1706
1707        let rules = injected_rules();
1708        let report = run(
1709            &rules,
1710            &RunOptions {
1711                root: &root,
1712                mirror_root: &dir.path().join("mirror"),
1713                manifest_dir: &dir.path().join("manifest"),
1714                division: "unit",
1715                walk: WalkOptions::default(),
1716            },
1717        )
1718        .unwrap();
1719
1720        assert_eq!(report.counts.failed, 1);
1721        assert_eq!(report.counts.converted, 1);
1722        let records = manifest::read_shard(&dir.path().join("manifest/unit.jsonl"))
1723            .unwrap()
1724            .records;
1725        let failed = records.iter().find(|r| r.status == Status::Failed).unwrap();
1726        assert_eq!(failed.source_path, "boom.panicfmt");
1727        assert!(
1728            failed
1729                .error
1730                .as_deref()
1731                .unwrap()
1732                .starts_with("converter_panic")
1733        );
1734        assert!(!dir.path().join("mirror/unit/boom.panicfmt.txt").exists());
1735    }
1736
1737    #[test]
1738    fn invalid_segments_from_a_converter_fail_before_any_write() {
1739        let dir = tempfile::tempdir().unwrap();
1740        let root = dir.path().join("src");
1741        fs::create_dir_all(&root).unwrap();
1742        fs::write(root.join("bad.badseg"), "trigger\n").unwrap();
1743
1744        let rules = injected_rules();
1745        let report = run(
1746            &rules,
1747            &RunOptions {
1748                root: &root,
1749                mirror_root: &dir.path().join("mirror"),
1750                manifest_dir: &dir.path().join("manifest"),
1751                division: "unit",
1752                walk: WalkOptions::default(),
1753            },
1754        )
1755        .unwrap();
1756
1757        assert_eq!(report.counts.failed, 1);
1758        let records = manifest::read_shard(&dir.path().join("manifest/unit.jsonl"))
1759            .unwrap()
1760            .records;
1761        assert!(
1762            records[0]
1763                .error
1764                .as_deref()
1765                .unwrap()
1766                .starts_with("invalid_segments")
1767        );
1768        assert!(!dir.path().join("mirror/unit/bad.badseg.txt").exists());
1769        assert!(
1770            !dir.path()
1771                .join("mirror/unit/bad.badseg.segments.jsonl")
1772                .exists()
1773        );
1774    }
1775
1776    #[test]
1777    fn division_names_are_validated() {
1778        assert!(validate_division("finance-eu_1").is_ok());
1779        assert!(validate_division("").is_err());
1780        assert!(validate_division("a/b").is_err());
1781        assert!(validate_division("dot.dot").is_err());
1782    }
1783
1784    #[test]
1785    fn layout_rejects_outputs_inside_the_root() {
1786        let dir = tempfile::tempdir().unwrap();
1787        let root = dir.path().join("src");
1788        fs::create_dir_all(&root).unwrap();
1789        let outside = dir.path().join("out");
1790
1791        let err = check_layout(&root, &root.join("mirror"), &outside).unwrap_err();
1792        assert!(err.to_string().contains("mirror root"));
1793
1794        let err = check_layout(&root, &outside.join("mirror"), &root.join("manifest")).unwrap_err();
1795        assert!(err.to_string().contains("manifest directory"));
1796
1797        check_layout(&root, &outside.join("mirror"), &outside.join("manifest")).unwrap();
1798    }
1799
1800    #[cfg(unix)]
1801    #[test]
1802    fn non_utf8_paths_are_rejected_without_an_artifact() {
1803        use std::os::unix::ffi::OsStrExt;
1804        let raw = std::ffi::OsStr::from_bytes(b"bad-\xff-name.txt");
1805        assert!(Path::new(raw).to_str().is_none());
1806    }
1807
1808    #[test]
1809    fn identical_sources_dedup_to_one_conversion() {
1810        let dir = tempfile::tempdir().unwrap();
1811        let root = dir.path().join("src");
1812        fs::create_dir_all(root.join("sub")).unwrap();
1813        fs::write(root.join("first.txt"), "same bytes\n").unwrap();
1814        fs::write(root.join("sub/second.txt"), "same bytes\n").unwrap();
1815
1816        let rules = Rules::builtin().unwrap();
1817        let report = run(
1818            &rules,
1819            &RunOptions {
1820                root: &root,
1821                mirror_root: &dir.path().join("mirror"),
1822                manifest_dir: &dir.path().join("manifest"),
1823                division: "unit",
1824                walk: WalkOptions::default(),
1825            },
1826        )
1827        .unwrap();
1828
1829        assert_eq!(report.counts.converted, 1);
1830        assert_eq!(report.counts.dedup, 1);
1831
1832        let records = manifest::read_shard(&dir.path().join("manifest/unit.jsonl"))
1833            .unwrap()
1834            .records;
1835        let canonical = records
1836            .iter()
1837            .find(|r| r.source_path == "first.txt")
1838            .unwrap();
1839        let duplicate = records
1840            .iter()
1841            .find(|r| r.source_path == "sub/second.txt")
1842            .unwrap();
1843        assert_eq!(canonical.status, Status::Converted);
1844        assert_eq!(duplicate.status, Status::Dedup);
1845        assert_eq!(duplicate.dedup_of.as_deref(), Some("first.txt"));
1846        assert_eq!(duplicate.text_hash, canonical.text_hash);
1847
1848        let mirrored =
1849            fs::read_to_string(dir.path().join("mirror/unit/sub/second.txt.txt")).unwrap();
1850        assert_eq!(mirrored, "same bytes\n");
1851    }
1852}