1use 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
35pub const SYMLINK_FORMAT: &str = "symlink";
37
38pub const SPECIAL_FORMAT: &str = "special";
40
41pub struct Rules {
47 pub table: FormatTable,
49 pub registry: Registry,
51}
52
53impl Rules {
54 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 pub fn version(&self) -> &str {
73 self.table.version()
74 }
75}
76
77pub struct RunOptions<'a> {
79 pub root: &'a Path,
81 pub mirror_root: &'a Path,
83 pub manifest_dir: &'a Path,
85 pub division: &'a str,
87 pub walk: WalkOptions,
89}
90
91pub 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
177fn 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
205fn 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
288fn 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
351fn 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
368fn 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
397struct 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
407enum 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
433struct Unit {
436 meta: Meta,
437 bytes: UnitBytes,
438 depth: usize,
439 ancestors: Vec<String>,
441}
442
443enum ContainerKind {
445 Zip,
446 Eml,
447}
448
449fn hash_on_chain(hash: &str, ancestors: &[String]) -> bool {
453 ancestors.iter().any(|ancestor| ancestor == hash)
454}
455
456fn collision_key(source_path: &str) -> String {
459 use unicode_normalization::UnicodeNormalization;
460 source_path.nfc().collect::<String>().to_lowercase()
461}
462
463fn metadata_child_path(source_path: &str) -> String {
465 format!("{source_path}.d/#image-metadata")
466}
467
468struct ClaimState {
473 prefix: String,
476 claimed: std::collections::HashSet<String>,
478 emitted: std::collections::HashSet<String>,
480 collisions: usize,
482}
483
484const REMOVED_MEMBER_REASON: &str = "container-member-removed";
487
488fn container_kind(detected: &str) -> Option<ContainerKind> {
489 match detected {
490 "zip" => Some(ContainerKind::Zip),
491 "eml" => Some(ContainerKind::Eml),
492 _ => None,
493 }
494}
495
496struct Expander<'a> {
498 rules: &'a Rules,
499 mirror_root: &'a Path,
500 division: &'a str,
501 walked: &'a std::collections::HashSet<String>,
502 limits: &'a convert::ContainerLimits,
503 writer: &'a mut ManifestWriter,
504 terminal: &'a mut HashMap<String, Record>,
505 counts: &'a mut StatusCounts,
506 dedup: &'a mut DedupIndex,
507 claim: Option<ClaimState>,
510}
511
512impl Expander<'_> {
513 fn artifact_paths(&self, source_path: &str) -> (PathBuf, PathBuf, String) {
514 let relative = Path::new(source_path);
515 (
516 mirror::mirror_path(self.mirror_root, self.division, relative),
517 segments::segments_path(self.mirror_root, self.division, relative),
518 mirror::recorded_text_path(self.division, source_path),
519 )
520 }
521
522 fn emit(&mut self, record: Record) -> Result<()> {
523 if let Some(claim) = &mut self.claim
524 && record.source_path.starts_with(&claim.prefix)
525 {
526 claim.emitted.insert(record.source_path.clone());
527 }
528 record_outcome(self.writer, self.terminal, self.counts, record)
529 }
530
531 fn base_record(&self, meta: &Meta, status: Status) -> Record {
532 let mut record = new_record(
533 &meta.source_path,
534 &meta.source_hash,
535 meta.source_size,
536 &meta.detection,
537 self.rules.version(),
538 status,
539 );
540 record.parent_source.clone_from(&meta.parent_source);
541 record.warnings.clone_from(&meta.detect_warnings);
542 record
543 }
544
545 fn fail(&mut self, meta: &Meta, reason: String, started: Instant) -> Result<()> {
548 let (text_absolute, segments_absolute, _) = self.artifact_paths(&meta.source_path);
549 let mut record = self.base_record(meta, Status::Failed);
550 record.error = Some(reason);
551 record.duration_ms = Some(elapsed_ms(started));
552 remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
553 self.emit(record)
554 }
555
556 fn expansion_collides(&self, source_path: &str) -> bool {
560 let prefix = format!("{source_path}.d/");
561 self.walked.iter().any(|walked| walked.starts_with(&prefix))
562 }
563
564 fn process(&mut self, unit: Unit, started: Instant, expanded: &mut u64) -> Result<()> {
567 let Unit {
568 meta,
569 bytes,
570 depth,
571 ancestors,
572 } = unit;
573 let (text_absolute, segments_absolute, text_relative) =
574 self.artifact_paths(&meta.source_path);
575 let kind = container_kind(&meta.detection.detected);
576 let converter = self.rules.registry.converter_for(&meta.detection.detected);
577 let current_version = match kind {
578 Some(ContainerKind::Zip) => Some(convert::CONTAINER_ZIP_VERSION),
579 _ => converter.map(|c| c.version()),
580 };
581
582 let metadata_leg = self.metadata_leg_applies(&meta.detection.detected);
586 let has_children = kind.is_some() || metadata_leg;
587
588 if let Some(previous) = self.terminal.get(&meta.source_path) {
592 let key_matches = previous.source_hash == meta.source_hash
593 && previous.rules_version == self.rules.version()
594 && previous.converter_version.as_deref() == current_version;
595 let has_text = matches!(
596 previous.status,
597 Status::Converted | Status::Dedup | Status::SkippedUnchanged
598 );
599 let leg_never_ran = metadata_leg
604 && previous
605 .warnings
606 .iter()
607 .any(|w| w == convert::image_metadata::IMAGE_METADATA_NOT_BUILT);
608 if key_matches && has_text && !leg_never_ran {
609 let intact = match (&previous.text_path, &previous.text_hash) {
610 (Some(text_path), Some(text_hash)) => {
611 mirror::resolve_recorded_path(self.mirror_root, text_path).is_ok_and(
612 |artifact| envelope_intact(&artifact, text_hash, &segments_absolute),
613 )
614 }
615 _ => false,
616 };
617 let descendants_ok = !has_children || self.descendants_intact(&meta.source_path);
623 if intact && descendants_ok {
624 if has_children && self.claim.is_some() {
632 let prefix = format!("{}.d/", meta.source_path);
633 let kept: Vec<String> = self
634 .terminal
635 .keys()
636 .filter(|path| path.starts_with(&prefix))
637 .cloned()
638 .collect();
639 if let Some(claim) = &mut self.claim {
640 if metadata_leg {
641 for path in &kept {
642 claim.claimed.insert(collision_key(path));
643 }
644 }
645 claim.emitted.extend(kept);
646 }
647 }
648 let mut record = previous.clone();
649 record.status = Status::SkippedUnchanged;
650 record.error = None;
651 record.duration_ms = Some(elapsed_ms(started));
652 return self.emit(record);
653 }
654 }
655 }
656
657 if kind.is_some() {
661 if depth > self.limits.max_depth {
662 return self.fail(&meta, "container-depth-exceeded".to_string(), started);
663 }
664 if hash_on_chain(&meta.source_hash, &ancestors) {
665 return self.fail(&meta, "container-recursive".to_string(), started);
666 }
667 }
668
669 let over_ceiling = meta.source_size > convert::MAX_SOURCE_BYTES;
675
676 if kind.is_none()
680 && !(metadata_leg && over_ceiling)
681 && let Some(canonical) = self.dedup.get(&meta.source_hash)
682 && canonical.source_path != meta.source_path
683 && converter.is_some_and(|c| {
684 c.id() == canonical.converter_id && c.version() == canonical.converter_version
685 })
686 {
687 let canonical = canonical.clone();
688 let canonical_segments = segments::segments_path(
689 self.mirror_root,
690 self.division,
691 Path::new(&canonical.source_path),
692 );
693 if let Ok(canonical_text) =
694 mirror::resolve_recorded_path(self.mirror_root, &canonical.text_path)
695 && let (Ok(text), Ok(segment_lines)) = (
696 fs::read_to_string(&canonical_text),
697 fs::read_to_string(&canonical_segments),
698 )
699 && hash::hash_bytes(text.as_bytes()) == canonical.text_hash
700 && segments::parse_jsonl(&segment_lines)
701 .is_ok_and(|segs| segments::validate(&segs, &text).is_ok())
702 {
703 #[cfg(all(unix, feature = "image-metadata"))]
709 let raw = if metadata_leg {
710 match bytes.load(&meta.source_hash) {
711 Ok(raw) => Some(raw),
712 Err(reason) => return self.fail(&meta, reason, started),
713 }
714 } else {
715 None
716 };
717 let mut meta = meta;
718 let collides = metadata_leg && self.metadata_collides(&meta.source_path);
719 if collides {
720 meta.detect_warnings.push(
721 convert::image_metadata::IMAGE_METADATA_NAMESPACE_OCCUPIED.to_string(),
722 );
723 }
724 let mut record = self.base_record(&meta, Status::Dedup);
725 for warning in &canonical.warnings {
726 if !record.warnings.contains(warning) {
727 record.warnings.push(warning.clone());
728 }
729 }
730 match mirror::write_atomic(&text_absolute, &text)
731 .and_then(|()| mirror::write_atomic(&segments_absolute, &segment_lines))
732 {
733 Ok(()) => {
734 record.text_path = Some(text_relative);
735 record.text_hash = Some(hash::hash_bytes(text.as_bytes()));
736 record.artifact_kind = canonical.artifact_kind;
737 record.converter_id = Some(canonical.converter_id.clone());
738 record.converter_version = Some(canonical.converter_version.clone());
739 record.dedup_of = Some(canonical.source_path.clone());
740 }
741 Err(e) => {
742 record.status = Status::Failed;
743 record.error = Some(format!("mirror_write_error: {e}"));
744 remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
745 }
746 }
747 record.duration_ms = Some(elapsed_ms(started));
748 self.emit(record)?;
749 #[cfg(all(unix, feature = "image-metadata"))]
750 if let Some(raw) = raw
751 && !collides
752 {
753 let format = meta.detection.detected.clone();
754 let declared = meta.detection.declared.clone();
755 return self.run_metadata_leg(
756 &meta.source_path,
757 &format,
758 declared,
759 &meta.source_hash,
760 meta.source_size,
761 &raw,
762 started,
763 );
764 }
765 return Ok(());
766 }
767 }
768
769 let Some(kind) = kind else {
770 return self.convert_leaf_with_metadata(meta, bytes, started);
771 };
772 let owns_claim = self.claim.is_none();
775 if owns_claim {
776 self.claim = Some(ClaimState {
777 prefix: format!("{}.d/", meta.source_path),
778 claimed: std::collections::HashSet::new(),
779 emitted: std::collections::HashSet::new(),
780 collisions: 0,
781 });
782 }
783 let root = meta.source_path.clone();
784 let result = match kind {
785 ContainerKind::Zip => self.expand_zip(meta, bytes, depth, ancestors, started, expanded),
786 ContainerKind::Eml => self.expand_eml(meta, bytes, depth, ancestors, started, expanded),
787 };
788 if owns_claim {
789 if let Some(claim) = self.claim.take() {
793 self.reconcile(&root, &claim, started)?;
794 }
795 }
796 result
797 }
798
799 fn descendants_intact(&self, root: &str) -> bool {
802 let prefix = format!("{root}.d/");
803 for record in self.terminal.values() {
804 if !record.source_path.starts_with(&prefix) {
805 continue;
806 }
807 if let (Some(text_path), Some(text_hash)) = (&record.text_path, &record.text_hash) {
808 let segments_absolute = segments::segments_path(
809 self.mirror_root,
810 self.division,
811 Path::new(&record.source_path),
812 );
813 let ok = mirror::resolve_recorded_path(self.mirror_root, text_path).is_ok_and(
814 |artifact| envelope_intact(&artifact, text_hash, &segments_absolute),
815 );
816 if !ok {
817 return false;
818 }
819 }
820 }
821 true
822 }
823
824 fn reconcile(&mut self, root: &str, claim: &ClaimState, started: Instant) -> Result<()> {
828 let stale: Vec<Record> = self
829 .terminal
830 .values()
831 .filter(|record| {
832 record.source_path.starts_with(&claim.prefix)
833 && !claim.emitted.contains(&record.source_path)
834 && !(record.status == Status::Failed
838 && record.error.as_deref() == Some(REMOVED_MEMBER_REASON))
839 })
840 .cloned()
841 .collect();
842 let _ = root;
843 for record in stale {
844 let (text_absolute, segments_absolute, _) = self.artifact_paths(&record.source_path);
845 let detection = Detection {
846 declared: record.declared_format.clone(),
847 detected: UNKNOWN_FORMAT.to_string(),
848 mismatch: false,
849 };
850 let mut retire = new_record(
851 &record.source_path,
852 "",
853 0,
854 &detection,
855 self.rules.version(),
856 Status::Failed,
857 );
858 retire.parent_source.clone_from(&record.parent_source);
859 retire.error = Some(REMOVED_MEMBER_REASON.to_string());
860 retire.duration_ms = Some(elapsed_ms(started));
861 remove_stale_artifact(&text_absolute, &segments_absolute, &mut retire);
862 self.emit(retire)?;
863 }
864 Ok(())
865 }
866
867 fn convert_leaf(&mut self, meta: Meta, bytes: UnitBytes, started: Instant) -> Result<()> {
870 let (text_absolute, segments_absolute, text_relative) =
871 self.artifact_paths(&meta.source_path);
872 let Some(converter) = self.rules.registry.converter_for(&meta.detection.detected) else {
873 let mut record = self.base_record(&meta, Status::Unsupported);
874 record.error = self
875 .rules
876 .registry
877 .unsupported_reason(&meta.detection.detected)
878 .map(str::to_string);
879 record.duration_ms = Some(elapsed_ms(started));
880 remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
881 return self.emit(record);
882 };
883 let converter_id = converter.id().to_string();
884 let converter_version = converter.version().to_string();
885
886 let mut record = self.base_record(&meta, Status::Converted);
887 let outcome = if meta.source_size > convert::MAX_SOURCE_BYTES {
888 Err(format!(
889 "resource_limit: source is {} bytes over the {} byte ceiling",
890 meta.source_size,
891 convert::MAX_SOURCE_BYTES
892 ))
893 } else {
894 bytes
895 .load(&meta.source_hash)
896 .and_then(|bytes| run_converter(converter, &bytes, &meta.detection.detected))
897 };
898 match outcome {
899 Ok(outcome) => match segments::to_jsonl(&outcome.segments)
900 .map_err(|e| e.to_string())
901 .and_then(|lines| {
902 mirror::write_atomic(&text_absolute, &outcome.text)
903 .and_then(|()| mirror::write_atomic(&segments_absolute, &lines))
904 .map_err(|e| e.to_string())
905 }) {
906 Ok(()) => {
907 let text_hash = hash::hash_bytes(outcome.text.as_bytes());
908 record.text_path = Some(text_relative.clone());
909 record.text_hash = Some(text_hash.clone());
910 record.artifact_kind = Some(outcome.artifact_kind);
911 record.converter_id = Some(outcome.converter_id.clone());
912 record.converter_version = Some(outcome.converter_version.clone());
913 record.warnings.extend(outcome.warnings.iter().cloned());
914 self.dedup.replace(
915 &meta.source_hash,
916 CanonicalArtifact {
917 source_path: meta.source_path.clone(),
918 text_path: text_relative,
919 text_hash,
920 converter_id: outcome.converter_id,
921 converter_version: outcome.converter_version,
922 artifact_kind: record.artifact_kind,
923 warnings: outcome.warnings,
924 },
925 );
926 }
927 Err(detail) => {
928 record.status = Status::Failed;
929 record.converter_id = Some(converter_id);
930 record.converter_version = Some(converter_version);
931 record.error = Some(format!("mirror_write_error: {detail}"));
932 remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
933 }
934 },
935 Err(reason) => {
936 record.status = Status::Failed;
937 record.converter_id = Some(converter_id);
938 record.converter_version = Some(converter_version);
939 record.error = Some(reason);
940 remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
941 }
942 }
943 record.duration_ms = Some(elapsed_ms(started));
944 self.emit(record)
945 }
946
947 fn convert_leaf_with_metadata(
957 &mut self,
958 mut meta: Meta,
959 bytes: UnitBytes,
960 started: Instant,
961 ) -> Result<()> {
962 #[cfg(all(unix, feature = "image-metadata"))]
963 if self.metadata_leg_applies(&meta.detection.detected) {
964 return self.convert_image_with_metadata(meta, bytes, started);
965 }
966 if convert::image_metadata::image_metadata_applies(&meta.detection.detected) {
970 meta.detect_warnings
971 .push(convert::image_metadata::IMAGE_METADATA_NOT_BUILT.to_string());
972 }
973 self.convert_leaf(meta, bytes, started)
974 }
975
976 fn metadata_leg_applies(&self, format: &str) -> bool {
980 #[cfg(all(unix, feature = "image-metadata"))]
981 {
982 convert::image_metadata::image_metadata_applies(format)
983 && self.rules.registry.image_metadata_converter().is_some()
984 }
985 #[cfg(not(all(unix, feature = "image-metadata")))]
986 {
987 let _ = format;
988 false
989 }
990 }
991
992 fn metadata_collides(&mut self, source_path: &str) -> bool {
1003 let child_path = metadata_child_path(source_path);
1004 self.expansion_collides(source_path)
1005 || (self.claim.is_some()
1006 && !self
1007 .claim
1008 .as_mut()
1009 .expect("checked above")
1010 .claimed
1011 .insert(collision_key(&child_path)))
1012 }
1013
1014 #[cfg(all(unix, feature = "image-metadata"))]
1017 fn convert_image_with_metadata(
1018 &mut self,
1019 mut meta: Meta,
1020 bytes: UnitBytes,
1021 started: Instant,
1022 ) -> Result<()> {
1023 if meta.source_size > convert::MAX_SOURCE_BYTES {
1027 return self.convert_leaf(meta, bytes, started);
1028 }
1029 let raw = match bytes.load(&meta.source_hash) {
1032 Ok(raw) => raw,
1033 Err(reason) => return self.fail(&meta, reason, started),
1034 };
1035 let collides = self.metadata_collides(&meta.source_path);
1040 if collides {
1041 meta.detect_warnings
1042 .push(convert::image_metadata::IMAGE_METADATA_NAMESPACE_OCCUPIED.to_string());
1043 }
1044 let source_path = meta.source_path.clone();
1045 let format = meta.detection.detected.clone();
1046 let declared = meta.detection.declared.clone();
1047 let source_hash = meta.source_hash.clone();
1048 let source_size = meta.source_size;
1049 self.convert_leaf(meta, UnitBytes::Mem(raw.clone()), started)?;
1052 if collides {
1053 return Ok(());
1054 }
1055 self.run_metadata_leg(
1056 &source_path,
1057 &format,
1058 declared,
1059 &source_hash,
1060 source_size,
1061 &raw,
1062 started,
1063 )
1064 }
1065
1066 #[cfg(all(unix, feature = "image-metadata"))]
1073 #[allow(clippy::too_many_arguments)]
1074 fn run_metadata_leg(
1075 &mut self,
1076 source_path: &str,
1077 format: &str,
1078 declared: Option<String>,
1079 source_hash: &str,
1080 source_size: u64,
1081 raw: &[u8],
1082 started: Instant,
1083 ) -> Result<()> {
1084 let owns_claim = self.claim.is_none();
1085 if owns_claim {
1086 self.claim = Some(ClaimState {
1087 prefix: format!("{source_path}.d/"),
1088 claimed: std::collections::HashSet::new(),
1089 emitted: std::collections::HashSet::new(),
1090 collisions: 0,
1091 });
1092 }
1093 let result = self.emit_metadata_child(
1094 source_path,
1095 format,
1096 declared,
1097 source_hash,
1098 source_size,
1099 raw,
1100 started,
1101 );
1102 if owns_claim && let Some(claim) = self.claim.take() {
1103 self.reconcile(source_path, &claim, started)?;
1104 }
1105 result
1106 }
1107
1108 #[cfg(all(unix, feature = "image-metadata"))]
1113 #[allow(clippy::too_many_arguments)]
1114 fn emit_metadata_child(
1115 &mut self,
1116 source_path: &str,
1117 format: &str,
1118 declared: Option<String>,
1119 source_hash: &str,
1120 source_size: u64,
1121 raw: &[u8],
1122 started: Instant,
1123 ) -> Result<()> {
1124 let child_path = metadata_child_path(source_path);
1125 let (text_absolute, segments_absolute, text_relative) = self.artifact_paths(&child_path);
1126 let converter = self
1127 .rules
1128 .registry
1129 .image_metadata_converter()
1130 .expect("the metadata leg runs only when the converter is present");
1131 let detection = Detection {
1132 declared,
1133 detected: format.to_string(),
1134 mismatch: false,
1135 };
1136 let child_meta = Meta {
1137 source_path: child_path.clone(),
1138 parent_source: Some(source_path.to_string()),
1139 source_hash: source_hash.to_string(),
1140 source_size,
1141 detection,
1142 detect_warnings: Vec::new(),
1143 };
1144 match run_converter(converter, raw, format) {
1145 Ok(outcome) => {
1146 match segments::to_jsonl(&outcome.segments)
1147 .map_err(|e| e.to_string())
1148 .and_then(|lines| {
1149 mirror::write_atomic(&text_absolute, &outcome.text)
1150 .and_then(|()| mirror::write_atomic(&segments_absolute, &lines))
1151 .map_err(|e| e.to_string())
1152 }) {
1153 Ok(()) => {
1154 let mut record = self.base_record(&child_meta, Status::Converted);
1155 record.text_path = Some(text_relative);
1156 record.text_hash = Some(hash::hash_bytes(outcome.text.as_bytes()));
1157 record.artifact_kind = Some(outcome.artifact_kind);
1158 record.converter_id = Some(outcome.converter_id.clone());
1159 record.converter_version = Some(outcome.converter_version.clone());
1160 record.warnings.extend(outcome.warnings.iter().cloned());
1161 record.duration_ms = Some(elapsed_ms(started));
1162 self.emit(record)
1163 }
1164 Err(detail) => {
1165 let mut record = self.base_record(&child_meta, Status::Failed);
1166 record.converter_id =
1167 Some(convert::image_metadata::IMAGE_METADATA_ID.to_string());
1168 record.converter_version =
1169 Some(convert::image_metadata::IMAGE_METADATA_VERSION.to_string());
1170 record.error = Some(format!("mirror_write_error: {detail}"));
1171 record.duration_ms = Some(elapsed_ms(started));
1172 remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
1173 self.emit(record)
1174 }
1175 }
1176 }
1177 Err(reason)
1178 if reason.starts_with(convert::image_metadata::IMAGE_METADATA_NOT_APPLICABLE) =>
1179 {
1180 Ok(())
1183 }
1184 Err(reason) => {
1185 let mut record = self.base_record(&child_meta, Status::Failed);
1186 record.converter_id = Some(convert::image_metadata::IMAGE_METADATA_ID.to_string());
1187 record.converter_version =
1188 Some(convert::image_metadata::IMAGE_METADATA_VERSION.to_string());
1189 record.error = Some(reason);
1190 record.duration_ms = Some(elapsed_ms(started));
1191 remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
1192 self.emit(record)
1193 }
1194 }
1195 }
1196
1197 fn write_parent(
1200 &mut self,
1201 meta: &Meta,
1202 outcome: &convert::Outcome,
1203 started: Instant,
1204 ) -> Result<bool> {
1205 let (text_absolute, segments_absolute, text_relative) =
1206 self.artifact_paths(&meta.source_path);
1207 let lines = match segments::to_jsonl(&outcome.segments) {
1208 Ok(lines) => lines,
1209 Err(e) => {
1210 self.fail(meta, format!("invalid_segments: {e}"), started)?;
1211 return Ok(false);
1212 }
1213 };
1214 match mirror::write_atomic(&text_absolute, &outcome.text)
1215 .and_then(|()| mirror::write_atomic(&segments_absolute, &lines))
1216 {
1217 Ok(()) => {
1218 let mut record = self.base_record(meta, Status::Converted);
1219 record.text_path = Some(text_relative);
1220 record.text_hash = Some(hash::hash_bytes(outcome.text.as_bytes()));
1221 record.artifact_kind = Some(ArtifactKind::Text);
1227 record.converter_id = Some(outcome.converter_id.clone());
1228 record.converter_version = Some(outcome.converter_version.clone());
1229 record.warnings.extend(outcome.warnings.iter().cloned());
1230 record.duration_ms = Some(elapsed_ms(started));
1231 self.emit(record)?;
1232 Ok(true)
1233 }
1234 Err(e) => {
1235 self.fail(meta, format!("mirror_write_error: {e}"), started)?;
1236 Ok(false)
1237 }
1238 }
1239 }
1240
1241 fn process_member(
1243 &mut self,
1244 parent: &Meta,
1245 member_path: &str,
1246 member_bytes: Vec<u8>,
1247 depth: usize,
1248 ancestors: &[String],
1249 expanded: &mut u64,
1250 ) -> Result<()> {
1251 let source_path = format!("{}.d/{}", parent.source_path, member_path);
1252 if let Some(claim) = &mut self.claim {
1256 let key = collision_key(&source_path);
1257 if !claim.claimed.insert(key) {
1258 claim.collisions += 1;
1259 let synthetic = format!("{}.d/#collision-{}", parent.source_path, claim.collisions);
1260 return self.emit_member_failure(
1261 parent,
1262 synthetic,
1263 member_bytes.len() as u64,
1264 "member-path-collision".to_string(),
1265 Instant::now(),
1266 );
1267 }
1268 }
1269 let (detection, detect_warnings) = detect::detect_bytes_with_warnings(
1270 Path::new(&source_path),
1271 &member_bytes,
1272 &self.rules.table,
1273 );
1274 let unit = Unit {
1275 meta: Meta {
1276 source_hash: hash::hash_bytes(&member_bytes),
1277 source_size: member_bytes.len() as u64,
1278 source_path,
1279 parent_source: Some(parent.source_path.clone()),
1280 detection,
1281 detect_warnings,
1282 },
1283 bytes: UnitBytes::Mem(member_bytes),
1284 depth,
1285 ancestors: ancestors.to_vec(),
1286 };
1287 self.process(unit, Instant::now(), expanded)
1288 }
1289}
1290
1291fn over_source_ceiling(size: u64) -> String {
1293 format!(
1294 "resource_limit: source is {size} bytes over the {} byte ceiling",
1295 convert::MAX_SOURCE_BYTES
1296 )
1297}
1298
1299fn run_converter(
1301 converter: &dyn convert::Converter,
1302 bytes: &[u8],
1303 detected: &str,
1304) -> std::result::Result<convert::Outcome, String> {
1305 let guarded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1306 converter.convert(bytes, detected)
1307 }));
1308 let outcome = match guarded {
1309 Ok(result) => result.map_err(|e| e.to_string())?,
1310 Err(_) => return Err("converter_panic: conversion panicked".to_string()),
1311 };
1312 validate_output(&outcome)?;
1313 Ok(outcome)
1314}
1315
1316fn validate_output(outcome: &convert::Outcome) -> std::result::Result<(), String> {
1319 if outcome.text.len() > convert::MAX_OUTPUT_BYTES {
1320 return Err(format!(
1321 "resource_limit: output is {} bytes over the {} byte ceiling",
1322 outcome.text.len(),
1323 convert::MAX_OUTPUT_BYTES
1324 ));
1325 }
1326 if let Some(offset) = outcome.text.find('\0') {
1327 return Err(format!(
1328 "nul_bytes: NUL at byte {offset} of the converted text"
1329 ));
1330 }
1331 segments::validate(&outcome.segments, &outcome.text)
1332 .map_err(|detail| format!("invalid_segments: {detail}"))
1333}
1334
1335impl Expander<'_> {
1336 fn emit_member_failure(
1339 &mut self,
1340 parent: &Meta,
1341 child_source_path: String,
1342 source_size: u64,
1343 reason: String,
1344 started: Instant,
1345 ) -> Result<()> {
1346 let (text_absolute, segments_absolute, _) = self.artifact_paths(&child_source_path);
1347 let detection = Detection {
1348 declared: None,
1349 detected: UNKNOWN_FORMAT.to_string(),
1350 mismatch: false,
1351 };
1352 let mut record = new_record(
1353 &child_source_path,
1354 "",
1355 source_size,
1356 &detection,
1357 self.rules.version(),
1358 Status::Failed,
1359 );
1360 record.parent_source = Some(parent.source_path.clone());
1361 record.error = Some(reason);
1362 record.duration_ms = Some(elapsed_ms(started));
1363 remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
1364 self.emit(record)
1365 }
1366
1367 fn expand_zip(
1370 &mut self,
1371 meta: Meta,
1372 bytes: UnitBytes,
1373 depth: usize,
1374 ancestors: Vec<String>,
1375 started: Instant,
1376 expanded: &mut u64,
1377 ) -> Result<()> {
1378 if self.expansion_collides(&meta.source_path) {
1379 return self.fail(&meta, "container-mirror-collision".to_string(), started);
1380 }
1381 if meta.source_size > convert::MAX_SOURCE_BYTES {
1382 return self.fail(&meta, over_source_ceiling(meta.source_size), started);
1383 }
1384 let bytes = match bytes.load(&meta.source_hash) {
1385 Ok(bytes) => bytes,
1386 Err(reason) => return self.fail(&meta, reason, started),
1387 };
1388 if let Some(entries) = convert::preflight_entry_count(&bytes)
1393 && entries > self.limits.max_children as u64
1394 {
1395 return self.fail(&meta, "container-children-exceeded".to_string(), started);
1396 }
1397 let mut container = match convert::ZipContainer::open(&bytes) {
1398 Ok(container) => container,
1399 Err(e) => return self.fail(&meta, e.to_string(), started),
1400 };
1401
1402 let count = container.len();
1405 if count > self.limits.max_children {
1406 return self.fail(&meta, "container-children-exceeded".to_string(), started);
1407 }
1408 let mut declared_total: u64 = 0;
1409 for index in 0..count {
1410 match container.meta(index) {
1411 Ok(member) => declared_total = declared_total.saturating_add(member.declared_size),
1412 Err(e) => return self.fail(&meta, e.to_string(), started),
1413 }
1414 }
1415 if declared_total > self.limits.max_expanded_bytes {
1416 return self.fail(&meta, "container-expansion-cap".to_string(), started);
1417 }
1418 let listing = match container.listing() {
1419 Ok(listing) => listing,
1420 Err(e) => return self.fail(&meta, e.to_string(), started),
1421 };
1422
1423 let mut child_ancestors = ancestors;
1424 child_ancestors.push(meta.source_hash.clone());
1425
1426 let mut parent_failure: Option<String> = None;
1430 for index in 0..count {
1431 let member = match container.meta(index) {
1432 Ok(member) => member,
1433 Err(e) => {
1434 parent_failure = Some(e.to_string());
1435 break;
1436 }
1437 };
1438 if member.is_dir {
1439 continue;
1440 }
1441 let Some(member_path) = member.safe_path.clone() else {
1442 self.emit_member_failure(
1443 &meta,
1444 format!("{}.d/#unsafe-member-{index}", meta.source_path),
1445 0,
1446 "unsafe-member-path".to_string(),
1447 started,
1448 )?;
1449 continue;
1450 };
1451 let child_source = format!("{}.d/{}", meta.source_path, member_path);
1452 if member.is_symlink {
1453 self.emit_member_failure(
1454 &meta,
1455 child_source,
1456 0,
1457 "unsafe-member-path".to_string(),
1458 started,
1459 )?;
1460 continue;
1461 }
1462 if member.encrypted {
1463 self.emit_member_failure(
1464 &meta,
1465 child_source,
1466 member.declared_size,
1467 "encrypted".to_string(),
1468 started,
1469 )?;
1470 continue;
1471 }
1472 if member.declared_size > self.limits.max_member_size {
1473 self.emit_member_failure(
1474 &meta,
1475 child_source,
1476 member.declared_size,
1477 "container-member-too-large".to_string(),
1478 started,
1479 )?;
1480 continue;
1481 }
1482 let remaining = self.limits.max_expanded_bytes.saturating_sub(*expanded);
1483 let cap = self.limits.max_member_size.min(remaining);
1484 match container.inflate(index, cap) {
1485 convert::Inflated::Ok(data) => {
1486 *expanded = expanded.saturating_add(data.len() as u64);
1487 self.process_member(
1488 &meta,
1489 &member_path,
1490 data,
1491 depth + 1,
1492 &child_ancestors,
1493 expanded,
1494 )?;
1495 }
1496 convert::Inflated::TooLarge(inflated) => {
1497 *expanded = expanded.saturating_add(inflated);
1498 if inflated > self.limits.max_member_size {
1499 self.emit_member_failure(
1500 &meta,
1501 child_source,
1502 inflated,
1503 "container-member-too-large".to_string(),
1504 started,
1505 )?;
1506 }
1507 if *expanded > self.limits.max_expanded_bytes {
1508 parent_failure = Some("container-expansion-cap".to_string());
1509 break;
1510 }
1511 }
1512 convert::Inflated::Error(e) => {
1513 self.emit_member_failure(
1514 &meta,
1515 child_source,
1516 0,
1517 format!("container-member-unreadable: {e}"),
1518 started,
1519 )?;
1520 }
1521 }
1522 }
1523
1524 match parent_failure {
1525 Some(reason) => self.fail(&meta, reason, started),
1526 None => {
1527 let segments = vec![segments::Segment::span(0, listing.len(), "document")];
1528 let outcome = convert::Outcome {
1529 converter_id: convert::CONTAINER_ZIP_ID.to_string(),
1530 converter_version: convert::CONTAINER_ZIP_VERSION.to_string(),
1531 detected_format: meta.detection.detected.clone(),
1532 text: listing,
1533 warnings: Vec::new(),
1534 segments,
1535 artifact_kind: manifest::ArtifactKind::Text,
1536 };
1537 if let Err(reason) = validate_output(&outcome) {
1538 return self.fail(&meta, reason, started);
1539 }
1540 self.write_parent(&meta, &outcome, started).map(|_| ())
1541 }
1542 }
1543 }
1544
1545 fn expand_eml(
1549 &mut self,
1550 meta: Meta,
1551 bytes: UnitBytes,
1552 depth: usize,
1553 ancestors: Vec<String>,
1554 started: Instant,
1555 expanded: &mut u64,
1556 ) -> Result<()> {
1557 if self.expansion_collides(&meta.source_path) {
1558 return self.fail(&meta, "container-mirror-collision".to_string(), started);
1559 }
1560 let bytes = match bytes.load(&meta.source_hash) {
1561 Ok(bytes) => bytes,
1562 Err(reason) => return self.fail(&meta, reason, started),
1563 };
1564 if meta.source_size > convert::MAX_SOURCE_BYTES {
1565 return self.fail(&meta, over_source_ceiling(meta.source_size), started);
1566 }
1567 let guarded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1568 convert::expand_eml(&bytes, &meta.detection.detected)
1569 }));
1570 let expansion = match guarded {
1571 Ok(Ok(expansion)) => expansion,
1572 Ok(Err(e)) => return self.fail(&meta, e.to_string(), started),
1573 Err(_) => {
1574 return self.fail(
1575 &meta,
1576 "converter_panic: conversion panicked".to_string(),
1577 started,
1578 );
1579 }
1580 };
1581 if let Err(reason) = validate_output(&expansion.outcome) {
1582 return self.fail(&meta, reason, started);
1583 }
1584
1585 let mut child_ancestors = ancestors;
1589 child_ancestors.push(meta.source_hash.clone());
1590 let mut parent_failure: Option<String> = None;
1591 for member in expansion.members {
1592 *expanded = expanded.saturating_add(member.bytes.len() as u64);
1593 if *expanded > self.limits.max_expanded_bytes {
1594 parent_failure = Some("container-expansion-cap".to_string());
1595 break;
1596 }
1597 self.process_member(
1598 &meta,
1599 &member.name,
1600 member.bytes,
1601 depth + 1,
1602 &child_ancestors,
1603 expanded,
1604 )?;
1605 }
1606
1607 match parent_failure {
1608 Some(reason) => self.fail(&meta, reason, started),
1609 None => self
1610 .write_parent(&meta, &expansion.outcome, started)
1611 .map(|_| ()),
1612 }
1613 }
1614}
1615
1616pub fn run(rules: &Rules, options: &RunOptions) -> Result<RunReport> {
1631 validate_division(options.division)?;
1632 check_layout(options.root, options.mirror_root, options.manifest_dir)?;
1633 let entries = walk::walk_division(options.root, &options.walk)?;
1634 let shard_path = options
1635 .manifest_dir
1636 .join(format!("{}.jsonl", options.division));
1637 let (prior, mut warnings) = if shard_path.exists() {
1638 let shard = manifest::read_shard(&shard_path)?;
1639 (shard.records, shard.warnings)
1640 } else {
1641 (Vec::new(), Vec::new())
1642 };
1643 let mut terminal = terminal_by_path(prior);
1644 let mut dedup = DedupIndex::default();
1645 seed_dedup(&terminal, rules, &mut dedup);
1646
1647 let walked: std::collections::HashSet<String> = entries
1651 .iter()
1652 .filter_map(|entry| entry.path.to_str().map(str::to_string))
1653 .collect();
1654 let limits = rules.registry.container_limits().clone();
1655
1656 let mut writer = ManifestWriter::open(&shard_path)?;
1657 let mut counts = StatusCounts::default();
1658 let mut special_entries = 0u64;
1659
1660 for entry in &entries {
1661 let started = Instant::now();
1662 let relative = &entry.path;
1663 let absolute = options.root.join(relative);
1664 let text_absolute = mirror::mirror_path(options.mirror_root, options.division, relative);
1665 let segments_absolute =
1666 segments::segments_path(options.mirror_root, options.division, relative);
1667
1668 if entry.kind == EntryKind::Other {
1669 special_entries += 1;
1670 continue;
1671 }
1672
1673 let Some(source_path) = relative.to_str().map(str::to_string) else {
1677 let detection = Detection {
1678 declared: detect::declared_format(relative, &rules.table),
1679 detected: UNKNOWN_FORMAT.to_string(),
1680 mismatch: false,
1681 };
1682 let mut record = new_record(
1683 &relative.to_string_lossy(),
1684 "",
1685 0,
1686 &detection,
1687 rules.version(),
1688 Status::Failed,
1689 );
1690 record.error = Some("non_utf8_path: source path is not valid UTF-8".to_string());
1691 record.duration_ms = Some(elapsed_ms(started));
1692 remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
1693 record_outcome(&mut writer, &mut terminal, &mut counts, record)?;
1694 continue;
1695 };
1696
1697 if entry.kind == EntryKind::Symlink {
1698 let detection = Detection {
1699 declared: None,
1700 detected: SYMLINK_FORMAT.to_string(),
1701 mismatch: false,
1702 };
1703 let mut record = match fs::read_link(&absolute) {
1704 Ok(target) => {
1705 let target_bytes = target.as_os_str().as_encoded_bytes();
1706 let mut record = new_record(
1707 &source_path,
1708 &hash::hash_bytes(target_bytes),
1709 target_bytes.len() as u64,
1710 &detection,
1711 rules.version(),
1712 Status::Unsupported,
1713 );
1714 record.error = Some(convert::NO_CONVERTER_REASON.to_string());
1718 record
1719 }
1720 Err(e) => {
1721 let mut record = new_record(
1722 &source_path,
1723 "",
1724 0,
1725 &detection,
1726 rules.version(),
1727 Status::Failed,
1728 );
1729 record.error = Some(format!("source_io_error: readlink: {e}"));
1730 record
1731 }
1732 };
1733 record.duration_ms = Some(elapsed_ms(started));
1734 remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
1735 record_outcome(&mut writer, &mut terminal, &mut counts, record)?;
1736 continue;
1737 }
1738
1739 let stat = match absolute.metadata() {
1744 Ok(metadata) => metadata,
1745 Err(e) => {
1746 let detection = Detection {
1747 declared: detect::declared_format(relative, &rules.table),
1748 detected: UNKNOWN_FORMAT.to_string(),
1749 mismatch: false,
1750 };
1751 let mut record = new_record(
1752 &source_path,
1753 "",
1754 0,
1755 &detection,
1756 rules.version(),
1757 Status::Failed,
1758 );
1759 record.error = Some(format!("source_io_error: stat: {e}"));
1760 record.duration_ms = Some(elapsed_ms(started));
1761 remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
1762 record_outcome(&mut writer, &mut terminal, &mut counts, record)?;
1763 continue;
1764 }
1765 };
1766 if stat.len() > convert::MAX_SOURCE_BYTES {
1767 let detection = Detection {
1768 declared: detect::declared_format(relative, &rules.table),
1769 detected: UNKNOWN_FORMAT.to_string(),
1770 mismatch: false,
1771 };
1772 let mut record = new_record(
1773 &source_path,
1774 "",
1775 stat.len(),
1776 &detection,
1777 rules.version(),
1778 Status::Failed,
1779 );
1780 record.error = Some(over_source_ceiling(stat.len()));
1781 record.duration_ms = Some(elapsed_ms(started));
1782 remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
1783 record_outcome(&mut writer, &mut terminal, &mut counts, record)?;
1784 continue;
1785 }
1786
1787 let front = match source_front(&absolute, &rules.table) {
1788 Ok(front) => front,
1789 Err(e) => {
1790 let detection = Detection {
1791 declared: detect::declared_format(relative, &rules.table),
1792 detected: UNKNOWN_FORMAT.to_string(),
1793 mismatch: false,
1794 };
1795 let mut record = new_record(
1796 &source_path,
1797 "",
1798 0,
1799 &detection,
1800 rules.version(),
1801 Status::Failed,
1802 );
1803 record.error = Some(format!("source_io_error: {e}"));
1804 record.duration_ms = Some(elapsed_ms(started));
1805 remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
1806 record_outcome(&mut writer, &mut terminal, &mut counts, record)?;
1807 continue;
1808 }
1809 };
1810 let (source_hash, source_size, detection, detect_warnings) = front;
1811 let mut expanded = 0u64;
1812 let unit = Unit {
1813 meta: Meta {
1814 source_path,
1815 parent_source: None,
1816 source_hash,
1817 source_size,
1818 detection,
1819 detect_warnings,
1820 },
1821 bytes: UnitBytes::Disk(absolute.clone()),
1822 depth: 0,
1823 ancestors: Vec::new(),
1824 };
1825 let mut expander = Expander {
1826 rules,
1827 mirror_root: options.mirror_root,
1828 division: options.division,
1829 walked: &walked,
1830 limits: &limits,
1831 writer: &mut writer,
1832 terminal: &mut terminal,
1833 counts: &mut counts,
1834 dedup: &mut dedup,
1835 claim: None,
1836 };
1837 expander.process(unit, started, &mut expanded)?;
1838 }
1839
1840 warnings.sort();
1841 Ok(RunReport {
1842 division: options.division.to_string(),
1843 root: options.root.display().to_string(),
1844 sources: entries.len() as u64 - special_entries,
1845 special_entries,
1846 counts,
1847 warnings,
1848 })
1849}
1850
1851fn shard_paths(manifest_dir: &Path) -> Result<Vec<PathBuf>> {
1852 let entries = fs::read_dir(manifest_dir).map_err(|e| Error::io("read_dir", manifest_dir, e))?;
1853 let mut shards = Vec::new();
1854 for entry in entries {
1855 let entry = entry.map_err(|e| Error::io("read_dir", manifest_dir, e))?;
1856 let path = entry.path();
1857 if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
1858 shards.push(path);
1859 }
1860 }
1861 shards.sort();
1862 Ok(shards)
1863}
1864
1865fn division_name(shard: &Path) -> String {
1866 shard
1867 .file_stem()
1868 .map(|stem| stem.to_string_lossy().into_owned())
1869 .unwrap_or_default()
1870}
1871
1872pub fn status(manifest_dir: &Path) -> Result<StatusReport> {
1874 let mut divisions = Vec::new();
1875 for shard_path in shard_paths(manifest_dir)? {
1876 let shard = manifest::read_shard(&shard_path)?;
1877 let terminal = terminal_by_path(shard.records);
1878 let mut counts = StatusCounts::default();
1879 let mut with_text = 0u64;
1880 for record in terminal.values() {
1881 match record.status {
1882 Status::Converted => counts.converted += 1,
1883 Status::Failed => counts.failed += 1,
1884 Status::Unsupported => counts.unsupported += 1,
1885 Status::SkippedUnchanged => counts.skipped_unchanged += 1,
1886 Status::Dedup => counts.dedup += 1,
1887 }
1888 if record.text_path.is_some() {
1889 with_text += 1;
1890 }
1891 }
1892 let sources = terminal.len() as u64;
1893 let coverage = if sources == 0 {
1894 0.0
1895 } else {
1896 with_text as f64 / sources as f64
1897 };
1898 divisions.push(DivisionStatus {
1899 division: division_name(&shard_path),
1900 sources,
1901 with_text,
1902 coverage,
1903 counts,
1904 warnings: shard.warnings,
1905 });
1906 }
1907 Ok(StatusReport { divisions })
1908}
1909
1910pub fn explain(manifest_dir: &Path, source_path: &str) -> Result<Vec<ExplainEntry>> {
1912 let mut entries = Vec::new();
1913 for shard_path in shard_paths(manifest_dir)? {
1914 let division = division_name(&shard_path);
1915 for record in manifest::read_shard(&shard_path)?.records {
1916 if record.source_path == source_path {
1917 entries.push(ExplainEntry {
1918 division: division.clone(),
1919 record,
1920 });
1921 }
1922 }
1923 }
1924 Ok(entries)
1925}
1926
1927#[cfg(test)]
1928mod tests {
1929 use super::*;
1930
1931 #[test]
1932 fn builtin_rules_agree_on_a_version() {
1933 let rules = Rules::builtin().unwrap();
1934 assert_eq!(rules.version(), "9");
1935 }
1936
1937 #[cfg(all(unix, feature = "image-metadata"))]
1945 #[test]
1946 fn an_over_ceiling_image_with_a_dedup_seed_is_refused_before_the_borrow() {
1947 use std::collections::HashSet;
1948 use std::io::Write as _;
1949
1950 let dir = tempfile::tempdir().unwrap();
1951 let root = dir.path().join("source");
1952 fs::create_dir_all(&root).unwrap();
1953 let mirror_root = dir.path().join("mirror");
1954 let manifest_dir = dir.path().join("manifest");
1955 let division = "unit";
1956 let rules = Rules::builtin().unwrap();
1957 assert!(
1958 rules.registry.image_metadata_converter().is_some(),
1959 "the metadata leg must be present for this path"
1960 );
1961
1962 let huge = root.join("huge.png");
1964 let mut file = fs::File::create(&huge).unwrap();
1965 file.write_all(b"\x89PNG\r\n\x1a\n").unwrap();
1966 file.set_len(convert::MAX_SOURCE_BYTES + 1).unwrap();
1967 drop(file);
1968 let source_size = fs::metadata(&huge).unwrap().len();
1969 let source_hash = hash::hash_bytes(&fs::read(&huge).unwrap());
1970
1971 let converter = rules.registry.converter_for("png").unwrap();
1974 let canon_text = "recognized line\n";
1975 let canon_text_path = mirror::mirror_path(&mirror_root, division, Path::new("canon.png"));
1976 let canon_segments =
1977 segments::segments_path(&mirror_root, division, Path::new("canon.png"));
1978 let lines = segments::to_jsonl(&[Segment::span(0, canon_text.len(), "ocr")]).unwrap();
1979 mirror::write_atomic(&canon_text_path, canon_text).unwrap();
1980 mirror::write_atomic(&canon_segments, &lines).unwrap();
1981 let mut dedup = DedupIndex::default();
1982 dedup.insert(
1983 &source_hash,
1984 CanonicalArtifact {
1985 source_path: "canon.png".to_string(),
1986 text_path: mirror::recorded_text_path(division, "canon.png"),
1987 text_hash: hash::hash_bytes(canon_text.as_bytes()),
1988 converter_id: converter.id().to_string(),
1989 converter_version: converter.version().to_string(),
1990 artifact_kind: Some(ArtifactKind::Ocr),
1991 warnings: Vec::new(),
1992 },
1993 );
1994
1995 let shard = manifest_dir.join("unit.jsonl");
1996 let mut writer = ManifestWriter::open(&shard).unwrap();
1997 let mut terminal = HashMap::new();
1998 let mut counts = StatusCounts::default();
1999 let walked = HashSet::new();
2000 let unit = Unit {
2001 meta: Meta {
2002 source_path: "huge.png".to_string(),
2003 parent_source: None,
2004 source_hash,
2005 source_size,
2006 detection: Detection {
2007 declared: Some("png".to_string()),
2008 detected: "png".to_string(),
2009 mismatch: false,
2010 },
2011 detect_warnings: Vec::new(),
2012 },
2013 bytes: UnitBytes::Disk(huge),
2014 depth: 0,
2015 ancestors: Vec::new(),
2016 };
2017 let mut expander = Expander {
2018 rules: &rules,
2019 mirror_root: &mirror_root,
2020 division,
2021 walked: &walked,
2022 limits: rules.registry.container_limits(),
2023 writer: &mut writer,
2024 terminal: &mut terminal,
2025 counts: &mut counts,
2026 dedup: &mut dedup,
2027 claim: None,
2028 };
2029 let mut expanded = 0u64;
2030 expander
2031 .process(unit, Instant::now(), &mut expanded)
2032 .unwrap();
2033 drop(writer);
2034
2035 let records = manifest::read_shard(&shard).unwrap().records;
2036 assert_eq!(records.len(), 1, "{records:?}");
2037 let record = &records[0];
2038 assert_eq!(record.source_path, "huge.png");
2039 assert_eq!(record.status, Status::Failed);
2040 assert!(
2041 record
2042 .error
2043 .as_deref()
2044 .is_some_and(|e| e.starts_with("resource_limit")),
2045 "{:?}",
2046 record.error
2047 );
2048 assert!(record.dedup_of.is_none());
2049 assert_eq!(counts.dedup, 0);
2050 assert_eq!(counts.failed, 1);
2051 assert!(!terminal.contains_key("huge.png.d/#image-metadata"));
2052 assert!(
2053 !mirror::mirror_path(
2054 &mirror_root,
2055 division,
2056 Path::new("huge.png.d/#image-metadata")
2057 )
2058 .exists()
2059 );
2060 }
2061
2062 #[test]
2063 fn the_quine_guard_matches_an_ancestor_hash() {
2064 let chain = vec!["aa".to_string(), "bb".to_string()];
2065 assert!(hash_on_chain("bb", &chain));
2066 assert!(hash_on_chain("aa", &chain));
2067 assert!(!hash_on_chain("cc", &chain));
2068 assert!(!hash_on_chain("aa", &[]));
2069 }
2070
2071 use crate::convert::{ConvertError, Converter, Outcome, PlainTextPassthrough};
2072 use crate::segments::Segment;
2073
2074 struct PanicConverter;
2077
2078 impl Converter for PanicConverter {
2079 fn id(&self) -> &'static str {
2080 "panic-test"
2081 }
2082 fn version(&self) -> &'static str {
2083 "1.0.0"
2084 }
2085 fn convert(
2086 &self,
2087 _source: &[u8],
2088 _detected_format: &str,
2089 ) -> std::result::Result<Outcome, ConvertError> {
2090 panic!("deliberate test panic");
2091 }
2092 }
2093
2094 struct BadSegmentsConverter;
2096
2097 impl Converter for BadSegmentsConverter {
2098 fn id(&self) -> &'static str {
2099 "bad-segments-test"
2100 }
2101 fn version(&self) -> &'static str {
2102 "1.0.0"
2103 }
2104 fn convert(
2105 &self,
2106 _source: &[u8],
2107 detected_format: &str,
2108 ) -> std::result::Result<Outcome, ConvertError> {
2109 Ok(Outcome {
2110 converter_id: self.id().to_string(),
2111 converter_version: self.version().to_string(),
2112 detected_format: detected_format.to_string(),
2113 text: "ok".to_string(),
2114 warnings: Vec::new(),
2115 segments: vec![Segment::span(0, 999, "document")],
2116 artifact_kind: manifest::ArtifactKind::Text,
2117 })
2118 }
2119 }
2120
2121 struct OcrKindConverter;
2126
2127 impl Converter for OcrKindConverter {
2128 fn id(&self) -> &'static str {
2129 "ocr-kind-test"
2130 }
2131 fn version(&self) -> &'static str {
2132 "1.0.0"
2133 }
2134 fn convert(
2135 &self,
2136 _source: &[u8],
2137 detected_format: &str,
2138 ) -> std::result::Result<Outcome, ConvertError> {
2139 let text = "recognized text\n".to_string();
2140 Ok(Outcome {
2141 converter_id: self.id().to_string(),
2142 converter_version: self.version().to_string(),
2143 detected_format: detected_format.to_string(),
2144 segments: vec![Segment::span(0, text.len(), "document")],
2145 text,
2146 warnings: Vec::new(),
2147 artifact_kind: manifest::ArtifactKind::Ocr,
2148 })
2149 }
2150 }
2151
2152 fn injected_rules() -> Rules {
2153 let table = FormatTable::parse(
2154 r#"
2155version = "9"
2156
2157[[formats]]
2158id = "text"
2159name = "Plain text"
2160extensions = ["txt"]
2161
2162[[formats]]
2163id = "panicfmt"
2164name = "Panic trigger"
2165extensions = ["panicfmt"]
2166
2167[[formats]]
2168id = "badseg"
2169name = "Bad segments trigger"
2170extensions = ["badseg"]
2171
2172[[formats]]
2173id = "ocrfmt"
2174name = "OCR kind trigger"
2175extensions = ["ocrfmt"]
2176"#,
2177 "formats.toml",
2178 )
2179 .unwrap();
2180 let registry = Registry::for_tests(
2181 "9",
2182 vec![
2183 (Box::new(PlainTextPassthrough), vec!["text"]),
2184 (Box::new(PanicConverter), vec!["panicfmt"]),
2185 (Box::new(BadSegmentsConverter), vec!["badseg"]),
2186 (Box::new(OcrKindConverter), vec!["ocrfmt"]),
2187 ],
2188 );
2189 Rules { table, registry }
2190 }
2191
2192 #[test]
2193 fn a_panicking_converter_fails_one_source_and_the_division_survives() {
2194 let dir = tempfile::tempdir().unwrap();
2195 let root = dir.path().join("src");
2196 fs::create_dir_all(&root).unwrap();
2197 fs::write(root.join("boom.panicfmt"), "trigger\n").unwrap();
2198 fs::write(root.join("fine.txt"), "safe content\n").unwrap();
2199
2200 let rules = injected_rules();
2201 let report = run(
2202 &rules,
2203 &RunOptions {
2204 root: &root,
2205 mirror_root: &dir.path().join("mirror"),
2206 manifest_dir: &dir.path().join("manifest"),
2207 division: "unit",
2208 walk: WalkOptions::default(),
2209 },
2210 )
2211 .unwrap();
2212
2213 assert_eq!(report.counts.failed, 1);
2214 assert_eq!(report.counts.converted, 1);
2215 let records = manifest::read_shard(&dir.path().join("manifest/unit.jsonl"))
2216 .unwrap()
2217 .records;
2218 let failed = records.iter().find(|r| r.status == Status::Failed).unwrap();
2219 assert_eq!(failed.source_path, "boom.panicfmt");
2220 assert!(
2221 failed
2222 .error
2223 .as_deref()
2224 .unwrap()
2225 .starts_with("converter_panic")
2226 );
2227 assert!(!dir.path().join("mirror/unit/boom.panicfmt.txt").exists());
2228 }
2229
2230 #[test]
2231 fn invalid_segments_from_a_converter_fail_before_any_write() {
2232 let dir = tempfile::tempdir().unwrap();
2233 let root = dir.path().join("src");
2234 fs::create_dir_all(&root).unwrap();
2235 fs::write(root.join("bad.badseg"), "trigger\n").unwrap();
2236
2237 let rules = injected_rules();
2238 let report = run(
2239 &rules,
2240 &RunOptions {
2241 root: &root,
2242 mirror_root: &dir.path().join("mirror"),
2243 manifest_dir: &dir.path().join("manifest"),
2244 division: "unit",
2245 walk: WalkOptions::default(),
2246 },
2247 )
2248 .unwrap();
2249
2250 assert_eq!(report.counts.failed, 1);
2251 let records = manifest::read_shard(&dir.path().join("manifest/unit.jsonl"))
2252 .unwrap()
2253 .records;
2254 assert!(
2255 records[0]
2256 .error
2257 .as_deref()
2258 .unwrap()
2259 .starts_with("invalid_segments")
2260 );
2261 assert!(!dir.path().join("mirror/unit/bad.badseg.txt").exists());
2262 assert!(
2263 !dir.path()
2264 .join("mirror/unit/bad.badseg.segments.jsonl")
2265 .exists()
2266 );
2267 }
2268
2269 #[test]
2270 fn the_ocr_artifact_kind_propagates_through_dedup_and_checkpoint() {
2271 let dir = tempfile::tempdir().unwrap();
2272 let root = dir.path().join("src");
2273 let mirror = dir.path().join("mirror");
2274 let manifest_dir = dir.path().join("manifest");
2275 fs::create_dir_all(&root).unwrap();
2276 fs::write(root.join("a.ocrfmt"), "same pixels\n").unwrap();
2279 fs::write(root.join("b.ocrfmt"), "same pixels\n").unwrap();
2280
2281 let rules = injected_rules();
2282 let options = RunOptions {
2283 root: &root,
2284 mirror_root: &mirror,
2285 manifest_dir: &manifest_dir,
2286 division: "unit",
2287 walk: WalkOptions::default(),
2288 };
2289 run(&rules, &options).unwrap();
2290
2291 let shard = manifest_dir.join("unit.jsonl");
2292 let latest = |path: &str| {
2293 manifest::read_shard(&shard)
2294 .unwrap()
2295 .records
2296 .into_iter()
2297 .rev()
2298 .find(|r| r.source_path == path)
2299 .unwrap()
2300 };
2301
2302 let a = latest("a.ocrfmt");
2304 let b = latest("b.ocrfmt");
2305 let (converted, deduped) = if a.status == Status::Converted {
2306 (a, b)
2307 } else {
2308 (b, a)
2309 };
2310 assert_eq!(converted.status, Status::Converted);
2311 assert_eq!(deduped.status, Status::Dedup);
2312 assert_eq!(converted.artifact_kind, Some(ArtifactKind::Ocr));
2313 assert_eq!(deduped.artifact_kind, Some(ArtifactKind::Ocr));
2314
2315 fs::write(root.join("c.ocrfmt"), "same pixels\n").unwrap();
2320 run(&rules, &options).unwrap();
2321
2322 let a2 = latest("a.ocrfmt");
2323 let b2 = latest("b.ocrfmt");
2324 let c = latest("c.ocrfmt");
2325 assert_eq!(a2.status, Status::SkippedUnchanged);
2326 assert_eq!(b2.status, Status::SkippedUnchanged);
2327 assert_eq!(a2.artifact_kind, Some(ArtifactKind::Ocr));
2328 assert_eq!(b2.artifact_kind, Some(ArtifactKind::Ocr));
2329 assert_eq!(c.status, Status::Dedup);
2330 assert_eq!(c.artifact_kind, Some(ArtifactKind::Ocr));
2331 }
2332
2333 #[test]
2334 fn division_names_are_validated() {
2335 assert!(validate_division("finance-eu_1").is_ok());
2336 assert!(validate_division("").is_err());
2337 assert!(validate_division("a/b").is_err());
2338 assert!(validate_division("dot.dot").is_err());
2339 }
2340
2341 #[test]
2342 fn layout_rejects_outputs_inside_the_root() {
2343 let dir = tempfile::tempdir().unwrap();
2344 let root = dir.path().join("src");
2345 fs::create_dir_all(&root).unwrap();
2346 let outside = dir.path().join("out");
2347
2348 let err = check_layout(&root, &root.join("mirror"), &outside).unwrap_err();
2349 assert!(err.to_string().contains("mirror root"));
2350
2351 let err = check_layout(&root, &outside.join("mirror"), &root.join("manifest")).unwrap_err();
2352 assert!(err.to_string().contains("manifest directory"));
2353
2354 check_layout(&root, &outside.join("mirror"), &outside.join("manifest")).unwrap();
2355 }
2356
2357 #[cfg(unix)]
2358 #[test]
2359 fn non_utf8_paths_are_rejected_without_an_artifact() {
2360 use std::os::unix::ffi::OsStrExt;
2361 let raw = std::ffi::OsStr::from_bytes(b"bad-\xff-name.txt");
2362 assert!(Path::new(raw).to_str().is_none());
2363 }
2364
2365 #[test]
2366 fn identical_sources_dedup_to_one_conversion() {
2367 let dir = tempfile::tempdir().unwrap();
2368 let root = dir.path().join("src");
2369 fs::create_dir_all(root.join("sub")).unwrap();
2370 fs::write(root.join("first.txt"), "same bytes\n").unwrap();
2371 fs::write(root.join("sub/second.txt"), "same bytes\n").unwrap();
2372
2373 let rules = Rules::builtin().unwrap();
2374 let report = run(
2375 &rules,
2376 &RunOptions {
2377 root: &root,
2378 mirror_root: &dir.path().join("mirror"),
2379 manifest_dir: &dir.path().join("manifest"),
2380 division: "unit",
2381 walk: WalkOptions::default(),
2382 },
2383 )
2384 .unwrap();
2385
2386 assert_eq!(report.counts.converted, 1);
2387 assert_eq!(report.counts.dedup, 1);
2388
2389 let records = manifest::read_shard(&dir.path().join("manifest/unit.jsonl"))
2390 .unwrap()
2391 .records;
2392 let canonical = records
2393 .iter()
2394 .find(|r| r.source_path == "first.txt")
2395 .unwrap();
2396 let duplicate = records
2397 .iter()
2398 .find(|r| r.source_path == "sub/second.txt")
2399 .unwrap();
2400 assert_eq!(canonical.status, Status::Converted);
2401 assert_eq!(duplicate.status, Status::Dedup);
2402 assert_eq!(duplicate.dedup_of.as_deref(), Some("first.txt"));
2403 assert_eq!(duplicate.text_hash, canonical.text_hash);
2404
2405 let mirrored =
2406 fs::read_to_string(dir.path().join("mirror/unit/sub/second.txt.txt")).unwrap();
2407 assert_eq!(mirrored, "same bytes\n");
2408 }
2409}