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 effective_version: String,
55 provider_not_built: bool,
59}
60
61impl Rules {
62 pub fn builtin() -> Result<Self> {
64 Self::from_registry(Registry::builtin()?, None, false)
65 }
66
67 pub fn builtin_with_inventory(inventory: &convert::RuntimeInventory) -> Result<Self> {
72 Self::from_registry(Registry::builtin_with_inventory(inventory)?, None, false)
73 }
74
75 pub fn builtin_with_runtime(
85 inventory: &convert::RuntimeInventory,
86 provider: Option<&convert::provider::ProviderConfig>,
87 ) -> Result<Self> {
88 let registry = Registry::builtin_with_runtime(inventory, provider)?;
89 let wired = registry.svg_ocr_converter().is_some();
90 let configured = provider
91 .and_then(convert::provider::ProviderConfig::svg)
92 .is_some();
93 Self::from_registry(
94 registry,
95 if wired { provider } else { None },
96 configured && !wired,
97 )
98 }
99
100 pub fn from_parts(table: FormatTable, registry: Registry) -> Result<Self> {
105 Self::assemble(table, registry, None, false, None)
106 }
107
108 #[cfg(feature = "test-adapters")]
116 pub fn from_parts_provider_not_built(table: FormatTable, registry: Registry) -> Result<Self> {
117 Self::assemble(table, registry, None, true, None)
118 }
119
120 #[cfg(feature = "test-adapters")]
129 pub fn from_parts_with_runtime_under_template(
130 table: FormatTable,
131 registry: Registry,
132 provider: Option<&convert::provider::ProviderConfig>,
133 template_blake3: &str,
134 ) -> Result<Self> {
135 let wired = registry.svg_ocr_converter().is_some();
136 let configured = provider
137 .and_then(convert::provider::ProviderConfig::svg)
138 .is_some();
139 Self::assemble(
140 table,
141 registry,
142 if wired { provider } else { None },
143 configured && !wired,
144 Some(template_blake3),
145 )
146 }
147
148 pub fn from_parts_with_runtime(
153 table: FormatTable,
154 registry: Registry,
155 provider: Option<&convert::provider::ProviderConfig>,
156 ) -> Result<Self> {
157 let wired = registry.svg_ocr_converter().is_some();
158 let configured = provider
159 .and_then(convert::provider::ProviderConfig::svg)
160 .is_some();
161 Self::assemble(
162 table,
163 registry,
164 if wired { provider } else { None },
165 configured && !wired,
166 None,
167 )
168 }
169
170 fn from_registry(
171 registry: Registry,
172 provider: Option<&convert::provider::ProviderConfig>,
173 provider_not_built: bool,
174 ) -> Result<Self> {
175 Self::assemble(
176 FormatTable::builtin()?,
177 registry,
178 provider,
179 provider_not_built,
180 None,
181 )
182 }
183
184 fn assemble(
185 table: FormatTable,
186 registry: Registry,
187 provider: Option<&convert::provider::ProviderConfig>,
188 provider_not_built: bool,
189 template_blake3: Option<&str>,
190 ) -> Result<Self> {
191 if table.version() != registry.version() {
192 return Err(Error::Rules {
193 name: "rules".to_string(),
194 message: format!(
195 "formats.toml version {:?} does not match converters.toml version {:?}",
196 table.version(),
197 registry.version()
198 ),
199 });
200 }
201 let suffix = provider.and_then(|config| {
209 config.svg()?;
210 let mut identity = config.jail_identity();
211 if let Some(template) = template_blake3 {
212 identity.template_blake3 = Some(template.to_string());
213 }
214 convert::provider::ProviderConfig::version_suffix(
215 ®istry.image_ocr_limits().svg_provider,
216 &identity,
217 )
218 });
219 let effective_version = match suffix {
220 Some(suffix) => format!("{}+{suffix}", table.version()),
221 None => table.version().to_string(),
222 };
223 Ok(Rules {
224 table,
225 registry,
226 effective_version,
227 provider_not_built,
228 })
229 }
230
231 pub fn version(&self) -> &str {
233 &self.effective_version
234 }
235
236 pub fn provider_not_built(&self) -> bool {
239 self.provider_not_built
240 }
241}
242
243pub struct RunOptions<'a> {
245 pub root: &'a Path,
247 pub mirror_root: &'a Path,
249 pub manifest_dir: &'a Path,
251 pub division: &'a str,
253 pub walk: WalkOptions,
255}
256
257pub fn scan(root: &Path, rules: &Rules, options: &WalkOptions) -> Result<ScanReport> {
263 let entries = walk::walk_division(root, options)?;
264 let mut inventory: Vec<(String, u64, String)> = Vec::new();
265 for entry in &entries {
266 let absolute = root.join(&entry.path);
267 let (size, format) = match entry.kind {
268 EntryKind::File => {
269 let size = absolute
270 .metadata()
271 .map_err(|e| Error::io("stat", &absolute, e))?
272 .len();
273 let detection = detect::detect_file(&absolute, &rules.table)?;
274 (size, detection.detected)
275 }
276 EntryKind::Symlink => {
277 let size = fs::symlink_metadata(&absolute)
278 .map_err(|e| Error::io("stat", &absolute, e))?
279 .len();
280 (size, SYMLINK_FORMAT.to_string())
281 }
282 EntryKind::Other => (0, SPECIAL_FORMAT.to_string()),
283 };
284 inventory.push((entry.path.to_string_lossy().into_owned(), size, format));
285 }
286
287 let mut by_format: BTreeMap<String, (u64, u64)> = BTreeMap::new();
288 let mut total_bytes = 0u64;
289 for (_, size, format) in &inventory {
290 let entry = by_format.entry(format.clone()).or_insert((0, 0));
291 entry.0 += 1;
292 entry.1 += size;
293 total_bytes += size;
294 }
295
296 let mean = if inventory.is_empty() {
297 0.0
298 } else {
299 total_bytes as f64 / inventory.len() as f64
300 };
301 let mut outliers: Vec<SizeOutlier> = inventory
302 .iter()
303 .filter(|(_, size, _)| *size >= 1_048_576 && *size as f64 >= 10.0 * mean)
304 .map(|(path, size, format)| SizeOutlier {
305 path: path.clone(),
306 bytes: *size,
307 format: format.clone(),
308 })
309 .collect();
310 outliers.sort_by(|a, b| b.bytes.cmp(&a.bytes).then(a.path.cmp(&b.path)));
311
312 Ok(ScanReport {
313 root: root.display().to_string(),
314 rules_version: rules.version().to_string(),
315 files: inventory.len() as u64,
316 bytes: total_bytes,
317 formats: by_format
318 .into_iter()
319 .map(|(format, (files, bytes))| FormatCount {
320 format,
321 files,
322 bytes,
323 })
324 .collect(),
325 outliers,
326 })
327}
328
329pub(crate) fn validate_division(name: &str) -> Result<()> {
330 let usable = !name.is_empty()
331 && name
332 .chars()
333 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
334 if usable {
335 Ok(())
336 } else {
337 Err(Error::Division {
338 name: name.to_string(),
339 })
340 }
341}
342
343fn resolve_for_check(path: &Path) -> Result<PathBuf> {
350 use std::path::Component;
351 let absolute = std::path::absolute(path).map_err(|e| Error::io("absolutize", path, e))?;
352 let mut resolved = PathBuf::new();
353 for component in absolute.components() {
354 match component {
355 Component::Prefix(_) | Component::RootDir => resolved.push(component),
356 Component::CurDir => {}
357 Component::ParentDir => {
358 resolved.pop();
359 }
360 Component::Normal(name) => {
361 resolved.push(name);
362 if let Ok(canonical) = resolved.canonicalize() {
363 resolved = canonical;
364 }
365 }
366 }
367 }
368 Ok(resolved)
369}
370
371fn check_layout(root: &Path, mirror_root: &Path, manifest_dir: &Path) -> Result<()> {
379 let root_resolved = resolve_for_check(root)?;
380 for (name, dir) in [
381 ("mirror root", mirror_root),
382 ("manifest directory", manifest_dir),
383 ] {
384 let dir_resolved = resolve_for_check(dir)?;
385 if dir_resolved.starts_with(&root_resolved) {
386 return Err(Error::Layout {
387 message: format!(
388 "{name} {} lies inside the division root {}",
389 dir.display(),
390 root.display()
391 ),
392 });
393 }
394 if root_resolved.starts_with(&dir_resolved) {
395 return Err(Error::Layout {
396 message: format!(
397 "division root {} lies inside the {name} {}",
398 root.display(),
399 dir.display()
400 ),
401 });
402 }
403 }
404 fs::create_dir_all(mirror_root).map_err(|e| Error::io("create_dir", mirror_root, e))?;
405 fs::create_dir_all(manifest_dir).map_err(|e| Error::io("create_dir", manifest_dir, e))?;
406 Ok(())
407}
408
409fn new_record(
410 source_path: &str,
411 source_hash: &str,
412 source_size: u64,
413 detection: &Detection,
414 rules_version: &str,
415 status: Status,
416) -> Record {
417 Record {
418 schema: ManifestSchema,
419 source_path: source_path.to_string(),
420 source_hash: source_hash.to_string(),
421 source_size,
422 declared_format: detection.declared.clone(),
423 detected_format: detection.detected.clone(),
424 format_mismatch: detection.mismatch,
425 status,
426 text_path: None,
427 text_hash: None,
428 artifact_kind: None,
429 converter_id: None,
430 converter_version: None,
431 tool_version: None,
432 rules_version: rules_version.to_string(),
433 media: None,
434 parent_source: None,
435 dedup_of: None,
436 warnings: Vec::new(),
437 error: None,
438 duration_ms: None,
439 }
440}
441
442fn elapsed_ms(started: Instant) -> u64 {
443 u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
444}
445
446fn terminal_by_path(records: Vec<Record>) -> HashMap<String, Record> {
447 let mut terminal = HashMap::new();
448 for record in records {
449 terminal.insert(record.source_path.clone(), record);
450 }
451 terminal
452}
453
454fn seed_dedup(terminal: &HashMap<String, Record>, rules: &Rules, dedup: &mut DedupIndex) {
462 let mut candidates: Vec<&Record> = terminal.values().collect();
463 candidates.sort_by(|a, b| a.source_path.cmp(&b.source_path));
464 for record in candidates {
465 let canonical_status = record.status == Status::Converted
466 || (record.status == Status::SkippedUnchanged && record.dedup_of.is_none());
467 if !canonical_status || record.rules_version != rules.version() {
468 continue;
469 }
470 let (Some(text_path), Some(text_hash), Some(converter_id), Some(converter_version)) = (
471 &record.text_path,
472 &record.text_hash,
473 &record.converter_id,
474 &record.converter_version,
475 ) else {
476 continue;
477 };
478 let current = rules.registry.converter_for(&record.detected_format);
479 let versions_match =
480 current.is_some_and(|c| c.id() == converter_id && c.version() == converter_version);
481 if !versions_match {
482 continue;
483 }
484 dedup.insert(
485 &record.source_hash,
486 CanonicalArtifact {
487 source_path: record.source_path.clone(),
488 text_path: text_path.clone(),
489 text_hash: text_hash.clone(),
490 converter_id: converter_id.clone(),
491 converter_version: converter_version.clone(),
492 artifact_kind: record.artifact_kind,
493 media: record.media.clone(),
494 warnings: record.warnings.clone(),
495 },
496 );
497 }
498}
499
500fn record_outcome(
501 writer: &mut ManifestWriter,
502 terminal: &mut HashMap<String, Record>,
503 counts: &mut StatusCounts,
504 record: Record,
505) -> Result<()> {
506 writer.append(&record)?;
507 match record.status {
508 Status::Converted => counts.converted += 1,
509 Status::Failed => counts.failed += 1,
510 Status::Unsupported => counts.unsupported += 1,
511 Status::SkippedUnchanged => counts.skipped_unchanged += 1,
512 Status::Dedup => counts.dedup += 1,
513 }
514 terminal.insert(record.source_path.clone(), record);
515 Ok(())
516}
517
518fn remove_stale_artifact(text_absolute: &Path, segments_absolute: &Path, record: &mut Record) {
522 for stale in [text_absolute, segments_absolute] {
523 match fs::remove_file(stale) {
524 Ok(()) => {}
525 Err(e)
526 if e.kind() == std::io::ErrorKind::NotFound
527 || e.kind() == std::io::ErrorKind::NotADirectory => {}
528 Err(e) => record
529 .warnings
530 .push(format!("stale_artifact_not_removed: {e}")),
531 }
532 }
533}
534
535fn envelope_intact(artifact: &Path, text_hash: &str, segments_path: &Path) -> bool {
539 let Ok(text) = fs::read_to_string(artifact) else {
540 return false;
541 };
542 if hash::hash_bytes(text.as_bytes()) != text_hash {
543 return false;
544 }
545 let Ok(sidecar) = fs::read_to_string(segments_path) else {
546 return false;
547 };
548 segments::parse_jsonl(&sidecar).is_ok_and(|segs| segments::validate(&segs, &text).is_ok())
549}
550
551fn source_front(
552 absolute: &Path,
553 table: &FormatTable,
554) -> Result<(String, u64, Detection, Vec<String>)> {
555 let source_hash = hash::hash_file(absolute)?;
556 let source_size = absolute
557 .metadata()
558 .map_err(|e| Error::io("stat", absolute, e))?
559 .len();
560 let (detection, warnings) = detect::detect_file_with_warnings(absolute, table)?;
561 Ok((source_hash, source_size, detection, warnings))
562}
563
564struct Meta {
566 source_path: String,
567 parent_source: Option<String>,
568 source_hash: String,
569 source_size: u64,
570 detection: Detection,
571 detect_warnings: Vec<String>,
572}
573
574enum UnitBytes {
578 Disk(PathBuf),
579 Mem(Vec<u8>),
580}
581
582impl UnitBytes {
583 fn load(self, expected_hash: &str) -> std::result::Result<Vec<u8>, String> {
584 match self {
585 UnitBytes::Mem(bytes) => Ok(bytes),
586 UnitBytes::Disk(path) => {
587 let bytes = fs::read(&path).map_err(|e| format!("source_io_error: read: {e}"))?;
588 if hash::hash_bytes(&bytes) != expected_hash {
589 return Err(
590 "source_changed_during_run: bytes changed between hash and conversion"
591 .to_string(),
592 );
593 }
594 Ok(bytes)
595 }
596 }
597 }
598}
599
600struct Unit {
603 meta: Meta,
604 bytes: UnitBytes,
605 depth: usize,
606 ancestors: Vec<String>,
608}
609
610enum ContainerKind {
612 Zip,
613 Eml,
614}
615
616fn hash_on_chain(hash: &str, ancestors: &[String]) -> bool {
620 ancestors.iter().any(|ancestor| ancestor == hash)
621}
622
623fn collision_key(source_path: &str) -> String {
626 use unicode_normalization::UnicodeNormalization;
627 source_path.nfc().collect::<String>().to_lowercase()
628}
629
630fn metadata_child_path(source_path: &str) -> String {
632 format!("{source_path}.d/#image-metadata")
633}
634
635fn svg_ocr_child_path(source_path: &str) -> String {
640 format!("{source_path}.d/#image-ocr")
641}
642
643#[derive(Debug, Clone, Copy, PartialEq, Eq)]
653enum ChildLeg {
654 Metadata,
656 SvgOcr,
658}
659
660impl ChildLeg {
661 fn child_path(self, source_path: &str) -> String {
663 match self {
664 ChildLeg::Metadata => metadata_child_path(source_path),
665 ChildLeg::SvgOcr => svg_ocr_child_path(source_path),
666 }
667 }
668
669 fn is_nothing_to_write(self, reason: &str) -> bool {
680 match self {
681 ChildLeg::Metadata => {
682 reason.starts_with(convert::image_metadata::IMAGE_METADATA_NOT_APPLICABLE)
683 }
684 ChildLeg::SvgOcr => reason.starts_with("empty_output"),
685 }
686 }
687
688 fn converter_identity(self) -> (&'static str, &'static str) {
691 match self {
692 ChildLeg::Metadata => (
693 convert::image_metadata::IMAGE_METADATA_ID,
694 convert::image_metadata::IMAGE_METADATA_VERSION,
695 ),
696 ChildLeg::SvgOcr => (
697 convert::subprocess::IMAGE_PIXEL_OCR_ID,
698 convert::subprocess::IMAGE_PIXEL_OCR_VERSION,
699 ),
700 }
701 }
702
703 fn namespace_occupied(self) -> &'static str {
706 match self {
707 ChildLeg::Metadata => convert::image_metadata::IMAGE_METADATA_NAMESPACE_OCCUPIED,
708 ChildLeg::SvgOcr => "svg-ocr-namespace-occupied",
709 }
710 }
711}
712
713struct ClaimState {
718 prefix: String,
721 claimed: std::collections::HashSet<String>,
723 emitted: std::collections::HashSet<String>,
725 collisions: usize,
727}
728
729const REMOVED_MEMBER_REASON: &str = "container-member-removed";
732
733fn container_kind(detected: &str) -> Option<ContainerKind> {
734 match detected {
735 "zip" => Some(ContainerKind::Zip),
736 "eml" => Some(ContainerKind::Eml),
737 _ => None,
738 }
739}
740
741struct Expander<'a> {
743 rules: &'a Rules,
744 mirror_root: &'a Path,
745 division: &'a str,
746 walked: &'a std::collections::HashSet<String>,
747 limits: &'a convert::ContainerLimits,
748 writer: &'a mut ManifestWriter,
749 terminal: &'a mut HashMap<String, Record>,
750 counts: &'a mut StatusCounts,
751 dedup: &'a mut DedupIndex,
752 claim: Option<ClaimState>,
755}
756
757impl Expander<'_> {
758 fn artifact_paths(&self, source_path: &str) -> (PathBuf, PathBuf, String) {
759 let relative = Path::new(source_path);
760 (
761 mirror::mirror_path(self.mirror_root, self.division, relative),
762 segments::segments_path(self.mirror_root, self.division, relative),
763 mirror::recorded_text_path(self.division, source_path),
764 )
765 }
766
767 fn emit(&mut self, record: Record) -> Result<()> {
768 if let Some(claim) = &mut self.claim
769 && record.source_path.starts_with(&claim.prefix)
770 {
771 claim.emitted.insert(record.source_path.clone());
772 }
773 record_outcome(self.writer, self.terminal, self.counts, record)
774 }
775
776 fn base_record(&self, meta: &Meta, status: Status) -> Record {
777 let mut record = new_record(
778 &meta.source_path,
779 &meta.source_hash,
780 meta.source_size,
781 &meta.detection,
782 self.rules.version(),
783 status,
784 );
785 record.parent_source.clone_from(&meta.parent_source);
786 record.warnings.clone_from(&meta.detect_warnings);
787 record
788 }
789
790 fn fail(&mut self, meta: &Meta, reason: String, started: Instant) -> Result<()> {
793 let (text_absolute, segments_absolute, _) = self.artifact_paths(&meta.source_path);
794 let mut record = self.base_record(meta, Status::Failed);
795 record.error = Some(reason);
796 record.duration_ms = Some(elapsed_ms(started));
797 remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
798 self.emit(record)
799 }
800
801 fn expansion_collides(&self, source_path: &str) -> bool {
805 let prefix = format!("{source_path}.d/");
806 self.walked.iter().any(|walked| walked.starts_with(&prefix))
807 }
808
809 fn process(&mut self, unit: Unit, started: Instant, expanded: &mut u64) -> Result<()> {
812 let Unit {
813 meta,
814 bytes,
815 depth,
816 ancestors,
817 } = unit;
818 let (text_absolute, segments_absolute, text_relative) =
819 self.artifact_paths(&meta.source_path);
820 let kind = container_kind(&meta.detection.detected);
821 let converter = self.rules.registry.converter_for(&meta.detection.detected);
822 let current_version = match kind {
823 Some(ContainerKind::Zip) => Some(convert::CONTAINER_ZIP_VERSION),
824 _ => converter.map(|c| c.version()),
825 };
826
827 let leg = self.child_leg(&meta.detection.detected);
831 let has_children = kind.is_some() || leg.is_some();
832
833 if let Some(previous) = self.terminal.get(&meta.source_path) {
837 let key_matches = previous.source_hash == meta.source_hash
838 && previous.rules_version == self.rules.version()
839 && previous.converter_version.as_deref() == current_version;
840 let has_text = matches!(
841 previous.status,
842 Status::Converted | Status::Dedup | Status::SkippedUnchanged
843 );
844 let leg_never_ran = leg == Some(ChildLeg::Metadata)
849 && previous
850 .warnings
851 .iter()
852 .any(|w| w == convert::image_metadata::IMAGE_METADATA_NOT_BUILT);
853 let previous_not_built = previous
861 .warnings
862 .iter()
863 .any(|w| w == convert::SVG_PROVIDER_NOT_BUILT);
864 let current_not_built =
865 meta.detection.detected == "svg" && self.rules.provider_not_built();
866 let not_built_moved = previous_not_built != current_not_built;
867 let failed_provider_child = leg == Some(ChildLeg::SvgOcr)
874 && self
875 .terminal
876 .get(&svg_ocr_child_path(&meta.source_path))
877 .is_some_and(|child| child.status == Status::Failed);
878 if key_matches
879 && has_text
880 && !leg_never_ran
881 && !not_built_moved
882 && !failed_provider_child
883 {
884 let intact = match (&previous.text_path, &previous.text_hash) {
885 (Some(text_path), Some(text_hash)) => {
886 mirror::resolve_recorded_path(self.mirror_root, text_path).is_ok_and(
887 |artifact| envelope_intact(&artifact, text_hash, &segments_absolute),
888 )
889 }
890 _ => false,
891 };
892 let descendants_ok = !has_children || self.descendants_intact(&meta.source_path);
898 if intact && descendants_ok {
899 if has_children && self.claim.is_some() {
907 let prefix = format!("{}.d/", meta.source_path);
908 let kept: Vec<String> = self
909 .terminal
910 .keys()
911 .filter(|path| path.starts_with(&prefix))
912 .cloned()
913 .collect();
914 if let Some(claim) = &mut self.claim {
915 if leg.is_some() {
916 for path in &kept {
917 claim.claimed.insert(collision_key(path));
918 }
919 }
920 claim.emitted.extend(kept);
921 }
922 }
923 let mut record = previous.clone();
924 record.status = Status::SkippedUnchanged;
925 record.error = None;
926 record.duration_ms = Some(elapsed_ms(started));
927 return self.emit(record);
928 }
929 }
930 }
931
932 if kind.is_some() {
936 if depth > self.limits.max_depth {
937 return self.fail(&meta, "container-depth-exceeded".to_string(), started);
938 }
939 if hash_on_chain(&meta.source_hash, &ancestors) {
940 return self.fail(&meta, "container-recursive".to_string(), started);
941 }
942 }
943
944 let over_ceiling = meta.source_size > convert::MAX_SOURCE_BYTES;
950
951 if kind.is_none()
955 && !(leg.is_some() && over_ceiling)
956 && let Some(canonical) = self.dedup.get(&meta.source_hash)
957 && canonical.source_path != meta.source_path
958 && converter.is_some_and(|c| {
959 c.id() == canonical.converter_id && c.version() == canonical.converter_version
960 })
961 {
962 let canonical = canonical.clone();
963 let canonical_segments = segments::segments_path(
964 self.mirror_root,
965 self.division,
966 Path::new(&canonical.source_path),
967 );
968 if let Ok(canonical_text) =
969 mirror::resolve_recorded_path(self.mirror_root, &canonical.text_path)
970 && let (Ok(text), Ok(segment_lines)) = (
971 fs::read_to_string(&canonical_text),
972 fs::read_to_string(&canonical_segments),
973 )
974 && hash::hash_bytes(text.as_bytes()) == canonical.text_hash
975 && segments::parse_jsonl(&segment_lines)
976 .is_ok_and(|segs| segments::validate(&segs, &text).is_ok())
977 {
978 let raw = match leg {
984 Some(_) => match bytes.load(&meta.source_hash) {
985 Ok(raw) => Some(raw),
986 Err(reason) => return self.fail(&meta, reason, started),
987 },
988 None => None,
989 };
990 let mut meta = meta;
991 let collides = leg.is_some_and(|leg| self.leg_collides(leg, &meta.source_path));
992 if let Some(leg) = leg.filter(|_| collides) {
993 meta.detect_warnings
994 .push(leg.namespace_occupied().to_string());
995 }
996 let mut record = self.base_record(&meta, Status::Dedup);
997 for warning in &canonical.warnings {
998 if !record.warnings.contains(warning) {
999 record.warnings.push(warning.clone());
1000 }
1001 }
1002 match mirror::write_atomic(&text_absolute, &text)
1003 .and_then(|()| mirror::write_atomic(&segments_absolute, &segment_lines))
1004 {
1005 Ok(()) => {
1006 record.text_path = Some(text_relative);
1007 record.text_hash = Some(hash::hash_bytes(text.as_bytes()));
1008 record.artifact_kind = canonical.artifact_kind;
1009 record.media = canonical.media.clone();
1010 record.converter_id = Some(canonical.converter_id.clone());
1011 record.converter_version = Some(canonical.converter_version.clone());
1012 record.dedup_of = Some(canonical.source_path.clone());
1013 }
1014 Err(e) => {
1015 record.status = Status::Failed;
1016 record.error = Some(format!("mirror_write_error: {e}"));
1017 remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
1018 }
1019 }
1020 record.duration_ms = Some(elapsed_ms(started));
1021 self.emit(record)?;
1022 if let (Some(leg), Some(raw)) = (leg, raw)
1026 && !collides
1027 {
1028 let format = meta.detection.detected.clone();
1029 let declared = meta.detection.declared.clone();
1030 return self.run_child_leg(
1031 leg,
1032 &meta.source_path,
1033 &format,
1034 declared,
1035 &meta.source_hash,
1036 meta.source_size,
1037 &raw,
1038 started,
1039 );
1040 }
1041 return Ok(());
1042 }
1043 }
1044
1045 let Some(kind) = kind else {
1046 return self.convert_leaf_with_children(meta, bytes, started);
1047 };
1048 let owns_claim = self.claim.is_none();
1051 if owns_claim {
1052 self.claim = Some(ClaimState {
1053 prefix: format!("{}.d/", meta.source_path),
1054 claimed: std::collections::HashSet::new(),
1055 emitted: std::collections::HashSet::new(),
1056 collisions: 0,
1057 });
1058 }
1059 let root = meta.source_path.clone();
1060 let result = match kind {
1061 ContainerKind::Zip => self.expand_zip(meta, bytes, depth, ancestors, started, expanded),
1062 ContainerKind::Eml => self.expand_eml(meta, bytes, depth, ancestors, started, expanded),
1063 };
1064 if owns_claim {
1065 if let Some(claim) = self.claim.take() {
1069 self.reconcile(&root, &claim, started)?;
1070 }
1071 }
1072 result
1073 }
1074
1075 fn descendants_intact(&self, root: &str) -> bool {
1078 let prefix = format!("{root}.d/");
1079 for record in self.terminal.values() {
1080 if !record.source_path.starts_with(&prefix) {
1081 continue;
1082 }
1083 if let (Some(text_path), Some(text_hash)) = (&record.text_path, &record.text_hash) {
1084 let segments_absolute = segments::segments_path(
1085 self.mirror_root,
1086 self.division,
1087 Path::new(&record.source_path),
1088 );
1089 let ok = mirror::resolve_recorded_path(self.mirror_root, text_path).is_ok_and(
1090 |artifact| envelope_intact(&artifact, text_hash, &segments_absolute),
1091 );
1092 if !ok {
1093 return false;
1094 }
1095 }
1096 }
1097 true
1098 }
1099
1100 fn reconcile(&mut self, root: &str, claim: &ClaimState, started: Instant) -> Result<()> {
1104 let stale: Vec<Record> = self
1105 .terminal
1106 .values()
1107 .filter(|record| {
1108 record.source_path.starts_with(&claim.prefix)
1109 && !claim.emitted.contains(&record.source_path)
1110 && !(record.status == Status::Failed
1114 && record.error.as_deref() == Some(REMOVED_MEMBER_REASON))
1115 })
1116 .cloned()
1117 .collect();
1118 let _ = root;
1119 for record in stale {
1120 let (text_absolute, segments_absolute, _) = self.artifact_paths(&record.source_path);
1121 let detection = Detection {
1122 declared: record.declared_format.clone(),
1123 detected: UNKNOWN_FORMAT.to_string(),
1124 mismatch: false,
1125 };
1126 let mut retire = new_record(
1127 &record.source_path,
1128 "",
1129 0,
1130 &detection,
1131 self.rules.version(),
1132 Status::Failed,
1133 );
1134 retire.parent_source.clone_from(&record.parent_source);
1135 retire.error = Some(REMOVED_MEMBER_REASON.to_string());
1136 retire.duration_ms = Some(elapsed_ms(started));
1137 remove_stale_artifact(&text_absolute, &segments_absolute, &mut retire);
1138 self.emit(retire)?;
1139 }
1140 Ok(())
1141 }
1142
1143 fn convert_leaf(&mut self, meta: Meta, bytes: UnitBytes, started: Instant) -> Result<()> {
1146 let (text_absolute, segments_absolute, text_relative) =
1147 self.artifact_paths(&meta.source_path);
1148 let Some(converter) = self.rules.registry.converter_for(&meta.detection.detected) else {
1149 let mut record = self.base_record(&meta, Status::Unsupported);
1150 record.error = self
1151 .rules
1152 .registry
1153 .unsupported_reason(&meta.detection.detected)
1154 .map(str::to_string);
1155 record.duration_ms = Some(elapsed_ms(started));
1156 remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
1157 return self.emit(record);
1158 };
1159 let converter_id = converter.id().to_string();
1160 let converter_version = converter.version().to_string();
1161
1162 let mut record = self.base_record(&meta, Status::Converted);
1163 let outcome = if meta.source_size > convert::MAX_SOURCE_BYTES {
1164 Err(format!(
1165 "resource_limit: source is {} bytes over the {} byte ceiling",
1166 meta.source_size,
1167 convert::MAX_SOURCE_BYTES
1168 ))
1169 } else {
1170 bytes
1171 .load(&meta.source_hash)
1172 .and_then(|bytes| run_converter(converter, &bytes, &meta.detection.detected))
1173 };
1174 match outcome {
1175 Ok(outcome) => match segments::to_jsonl(&outcome.segments)
1176 .map_err(|e| e.to_string())
1177 .and_then(|lines| {
1178 mirror::write_atomic(&text_absolute, &outcome.text)
1179 .and_then(|()| mirror::write_atomic(&segments_absolute, &lines))
1180 .map_err(|e| e.to_string())
1181 }) {
1182 Ok(()) => {
1183 let text_hash = hash::hash_bytes(outcome.text.as_bytes());
1184 record.text_path = Some(text_relative.clone());
1185 record.text_hash = Some(text_hash.clone());
1186 record.artifact_kind = Some(outcome.artifact_kind);
1187 record.media.clone_from(&outcome.media);
1188 record.converter_id = Some(outcome.converter_id.clone());
1189 record.converter_version = Some(outcome.converter_version.clone());
1190 record.warnings.extend(outcome.warnings.iter().cloned());
1191 self.dedup.replace(
1192 &meta.source_hash,
1193 CanonicalArtifact {
1194 source_path: meta.source_path.clone(),
1195 text_path: text_relative,
1196 text_hash,
1197 converter_id: outcome.converter_id,
1198 converter_version: outcome.converter_version,
1199 artifact_kind: record.artifact_kind,
1200 media: outcome.media,
1201 warnings: outcome.warnings,
1202 },
1203 );
1204 }
1205 Err(detail) => {
1206 record.status = Status::Failed;
1207 record.converter_id = Some(converter_id);
1208 record.converter_version = Some(converter_version);
1209 record.error = Some(format!("mirror_write_error: {detail}"));
1210 remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
1211 }
1212 },
1213 Err(reason) => {
1214 record.status = Status::Failed;
1215 record.converter_id = Some(converter_id);
1216 record.converter_version = Some(converter_version);
1217 record.error = Some(reason);
1218 remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
1219 }
1220 }
1221 record.duration_ms = Some(elapsed_ms(started));
1222 self.emit(record)
1223 }
1224
1225 fn convert_leaf_with_children(
1236 &mut self,
1237 mut meta: Meta,
1238 bytes: UnitBytes,
1239 started: Instant,
1240 ) -> Result<()> {
1241 if let Some(leg) = self.child_leg(&meta.detection.detected) {
1242 return self.convert_leaf_and_child(leg, meta, bytes, started);
1243 }
1244 if convert::image_metadata::image_metadata_applies(&meta.detection.detected) {
1248 meta.detect_warnings
1249 .push(convert::image_metadata::IMAGE_METADATA_NOT_BUILT.to_string());
1250 }
1251 if meta.detection.detected == "svg" && self.rules.provider_not_built() {
1255 meta.detect_warnings
1256 .push(convert::SVG_PROVIDER_NOT_BUILT.to_string());
1257 }
1258 let prefix = format!("{}.d/", meta.source_path);
1265 let stale =
1266 self.claim.is_none() && self.terminal.keys().any(|path| path.starts_with(&prefix));
1267 if !stale {
1268 return self.convert_leaf(meta, bytes, started);
1269 }
1270 let source_path = meta.source_path.clone();
1271 self.claim = Some(ClaimState {
1272 prefix,
1273 claimed: std::collections::HashSet::new(),
1274 emitted: std::collections::HashSet::new(),
1275 collisions: 0,
1276 });
1277 let result = self.convert_leaf(meta, bytes, started);
1278 if let Some(claim) = self.claim.take() {
1279 self.reconcile(&source_path, &claim, started)?;
1280 }
1281 result
1282 }
1283
1284 fn child_leg(&self, format: &str) -> Option<ChildLeg> {
1292 if self.metadata_leg_applies(format) {
1293 return Some(ChildLeg::Metadata);
1294 }
1295 if format == "svg" && self.rules.registry.svg_ocr_converter().is_some() {
1296 return Some(ChildLeg::SvgOcr);
1297 }
1298 None
1299 }
1300
1301 fn leg_converter(&self, leg: ChildLeg) -> Option<&dyn convert::Converter> {
1304 match leg {
1305 ChildLeg::Metadata => self.rules.registry.image_metadata_converter(),
1306 ChildLeg::SvgOcr => self.rules.registry.svg_ocr_converter(),
1307 }
1308 }
1309
1310 fn metadata_leg_applies(&self, format: &str) -> bool {
1314 #[cfg(all(unix, feature = "image-metadata"))]
1315 {
1316 convert::image_metadata::image_metadata_applies(format)
1317 && self.rules.registry.image_metadata_converter().is_some()
1318 }
1319 #[cfg(not(all(unix, feature = "image-metadata")))]
1320 {
1321 let _ = format;
1322 false
1323 }
1324 }
1325
1326 fn leg_collides(&mut self, leg: ChildLeg, source_path: &str) -> bool {
1337 let child_path = leg.child_path(source_path);
1338 self.expansion_collides(source_path)
1339 || (self.claim.is_some()
1340 && !self
1341 .claim
1342 .as_mut()
1343 .expect("checked above")
1344 .claimed
1345 .insert(collision_key(&child_path)))
1346 }
1347
1348 fn convert_leaf_and_child(
1351 &mut self,
1352 leg: ChildLeg,
1353 mut meta: Meta,
1354 bytes: UnitBytes,
1355 started: Instant,
1356 ) -> Result<()> {
1357 if meta.source_size > convert::MAX_SOURCE_BYTES {
1361 return self.convert_leaf(meta, bytes, started);
1362 }
1363 let raw = match bytes.load(&meta.source_hash) {
1366 Ok(raw) => raw,
1367 Err(reason) => return self.fail(&meta, reason, started),
1368 };
1369 let collides = self.leg_collides(leg, &meta.source_path);
1374 if collides {
1375 meta.detect_warnings
1376 .push(leg.namespace_occupied().to_string());
1377 }
1378 let source_path = meta.source_path.clone();
1379 let format = meta.detection.detected.clone();
1380 let declared = meta.detection.declared.clone();
1381 let source_hash = meta.source_hash.clone();
1382 let source_size = meta.source_size;
1383 self.convert_leaf(meta, UnitBytes::Mem(raw.clone()), started)?;
1386 if collides {
1387 return Ok(());
1388 }
1389 self.run_child_leg(
1390 leg,
1391 &source_path,
1392 &format,
1393 declared,
1394 &source_hash,
1395 source_size,
1396 &raw,
1397 started,
1398 )
1399 }
1400
1401 #[allow(clippy::too_many_arguments)]
1408 fn run_child_leg(
1409 &mut self,
1410 leg: ChildLeg,
1411 source_path: &str,
1412 format: &str,
1413 declared: Option<String>,
1414 source_hash: &str,
1415 source_size: u64,
1416 raw: &[u8],
1417 started: Instant,
1418 ) -> Result<()> {
1419 let owns_claim = self.claim.is_none();
1420 if owns_claim {
1421 self.claim = Some(ClaimState {
1422 prefix: format!("{source_path}.d/"),
1423 claimed: std::collections::HashSet::new(),
1424 emitted: std::collections::HashSet::new(),
1425 collisions: 0,
1426 });
1427 }
1428 let result = self.emit_child(
1429 leg,
1430 source_path,
1431 format,
1432 declared,
1433 source_hash,
1434 source_size,
1435 raw,
1436 started,
1437 );
1438 if owns_claim && let Some(claim) = self.claim.take() {
1439 self.reconcile(source_path, &claim, started)?;
1440 }
1441 result
1442 }
1443
1444 #[allow(clippy::too_many_arguments)]
1451 fn emit_child(
1452 &mut self,
1453 leg: ChildLeg,
1454 source_path: &str,
1455 format: &str,
1456 declared: Option<String>,
1457 source_hash: &str,
1458 source_size: u64,
1459 raw: &[u8],
1460 started: Instant,
1461 ) -> Result<()> {
1462 let child_path = leg.child_path(source_path);
1463 let (text_absolute, segments_absolute, text_relative) = self.artifact_paths(&child_path);
1464 let converter = self
1465 .leg_converter(leg)
1466 .expect("a leg runs only when its converter is present");
1467 let detection = Detection {
1468 declared,
1469 detected: format.to_string(),
1470 mismatch: false,
1471 };
1472 let child_meta = Meta {
1473 source_path: child_path.clone(),
1474 parent_source: Some(source_path.to_string()),
1475 source_hash: source_hash.to_string(),
1476 source_size,
1477 detection,
1478 detect_warnings: Vec::new(),
1479 };
1480 match run_converter(converter, raw, format) {
1481 Ok(outcome) => {
1482 match segments::to_jsonl(&outcome.segments)
1483 .map_err(|e| e.to_string())
1484 .and_then(|lines| {
1485 mirror::write_atomic(&text_absolute, &outcome.text)
1486 .and_then(|()| mirror::write_atomic(&segments_absolute, &lines))
1487 .map_err(|e| e.to_string())
1488 }) {
1489 Ok(()) => {
1490 let mut record = self.base_record(&child_meta, Status::Converted);
1491 record.text_path = Some(text_relative);
1492 record.text_hash = Some(hash::hash_bytes(outcome.text.as_bytes()));
1493 record.artifact_kind = Some(outcome.artifact_kind);
1494 record.converter_id = Some(outcome.converter_id.clone());
1495 record.converter_version = Some(outcome.converter_version.clone());
1496 record.warnings.extend(outcome.warnings.iter().cloned());
1497 record.duration_ms = Some(elapsed_ms(started));
1498 self.emit(record)
1499 }
1500 Err(detail) => {
1501 let (id, version) = leg.converter_identity();
1502 let mut record = self.base_record(&child_meta, Status::Failed);
1503 record.converter_id = Some(id.to_string());
1504 record.converter_version = Some(version.to_string());
1505 record.error = Some(format!("mirror_write_error: {detail}"));
1506 record.duration_ms = Some(elapsed_ms(started));
1507 remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
1508 self.emit(record)
1509 }
1510 }
1511 }
1512 Err(reason) if leg.is_nothing_to_write(&reason) => {
1513 Ok(())
1517 }
1518 Err(reason) => {
1519 let (id, version) = leg.converter_identity();
1520 let mut record = self.base_record(&child_meta, Status::Failed);
1521 record.converter_id = Some(id.to_string());
1522 record.converter_version = Some(version.to_string());
1523 record.error = Some(reason);
1524 record.duration_ms = Some(elapsed_ms(started));
1525 remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
1526 self.emit(record)
1527 }
1528 }
1529 }
1530
1531 fn write_parent(
1534 &mut self,
1535 meta: &Meta,
1536 outcome: &convert::Outcome,
1537 started: Instant,
1538 ) -> Result<bool> {
1539 let (text_absolute, segments_absolute, text_relative) =
1540 self.artifact_paths(&meta.source_path);
1541 let lines = match segments::to_jsonl(&outcome.segments) {
1542 Ok(lines) => lines,
1543 Err(e) => {
1544 self.fail(meta, format!("invalid_segments: {e}"), started)?;
1545 return Ok(false);
1546 }
1547 };
1548 match mirror::write_atomic(&text_absolute, &outcome.text)
1549 .and_then(|()| mirror::write_atomic(&segments_absolute, &lines))
1550 {
1551 Ok(()) => {
1552 let mut record = self.base_record(meta, Status::Converted);
1553 record.text_path = Some(text_relative);
1554 record.text_hash = Some(hash::hash_bytes(outcome.text.as_bytes()));
1555 record.artifact_kind = Some(ArtifactKind::Text);
1561 record.converter_id = Some(outcome.converter_id.clone());
1562 record.converter_version = Some(outcome.converter_version.clone());
1563 record.warnings.extend(outcome.warnings.iter().cloned());
1564 record.duration_ms = Some(elapsed_ms(started));
1565 self.emit(record)?;
1566 Ok(true)
1567 }
1568 Err(e) => {
1569 self.fail(meta, format!("mirror_write_error: {e}"), started)?;
1570 Ok(false)
1571 }
1572 }
1573 }
1574
1575 fn process_member(
1577 &mut self,
1578 parent: &Meta,
1579 member_path: &str,
1580 member_bytes: Vec<u8>,
1581 depth: usize,
1582 ancestors: &[String],
1583 expanded: &mut u64,
1584 ) -> Result<()> {
1585 let source_path = format!("{}.d/{}", parent.source_path, member_path);
1586 if let Some(claim) = &mut self.claim {
1590 let key = collision_key(&source_path);
1591 if !claim.claimed.insert(key) {
1592 claim.collisions += 1;
1593 let synthetic = format!("{}.d/#collision-{}", parent.source_path, claim.collisions);
1594 return self.emit_member_failure(
1595 parent,
1596 synthetic,
1597 member_bytes.len() as u64,
1598 "member-path-collision".to_string(),
1599 Instant::now(),
1600 );
1601 }
1602 }
1603 let (detection, detect_warnings) = detect::detect_bytes_with_warnings(
1604 Path::new(&source_path),
1605 &member_bytes,
1606 &self.rules.table,
1607 );
1608 let unit = Unit {
1609 meta: Meta {
1610 source_hash: hash::hash_bytes(&member_bytes),
1611 source_size: member_bytes.len() as u64,
1612 source_path,
1613 parent_source: Some(parent.source_path.clone()),
1614 detection,
1615 detect_warnings,
1616 },
1617 bytes: UnitBytes::Mem(member_bytes),
1618 depth,
1619 ancestors: ancestors.to_vec(),
1620 };
1621 self.process(unit, Instant::now(), expanded)
1622 }
1623}
1624
1625fn over_source_ceiling(size: u64) -> String {
1627 format!(
1628 "resource_limit: source is {size} bytes over the {} byte ceiling",
1629 convert::MAX_SOURCE_BYTES
1630 )
1631}
1632
1633fn run_converter(
1635 converter: &dyn convert::Converter,
1636 bytes: &[u8],
1637 detected: &str,
1638) -> std::result::Result<convert::Outcome, String> {
1639 let guarded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1640 converter.convert(bytes, detected)
1641 }));
1642 let outcome = match guarded {
1643 Ok(result) => result.map_err(|e| e.to_string())?,
1644 Err(_) => return Err("converter_panic: conversion panicked".to_string()),
1645 };
1646 validate_output(&outcome)?;
1647 Ok(outcome)
1648}
1649
1650fn validate_output(outcome: &convert::Outcome) -> std::result::Result<(), String> {
1653 if outcome.text.len() > convert::MAX_OUTPUT_BYTES {
1654 return Err(format!(
1655 "resource_limit: output is {} bytes over the {} byte ceiling",
1656 outcome.text.len(),
1657 convert::MAX_OUTPUT_BYTES
1658 ));
1659 }
1660 if let Some(offset) = outcome.text.find('\0') {
1661 return Err(format!(
1662 "nul_bytes: NUL at byte {offset} of the converted text"
1663 ));
1664 }
1665 segments::validate(&outcome.segments, &outcome.text)
1666 .map_err(|detail| format!("invalid_segments: {detail}"))
1667}
1668
1669impl Expander<'_> {
1670 fn emit_member_failure(
1673 &mut self,
1674 parent: &Meta,
1675 child_source_path: String,
1676 source_size: u64,
1677 reason: String,
1678 started: Instant,
1679 ) -> Result<()> {
1680 let (text_absolute, segments_absolute, _) = self.artifact_paths(&child_source_path);
1681 let detection = Detection {
1682 declared: None,
1683 detected: UNKNOWN_FORMAT.to_string(),
1684 mismatch: false,
1685 };
1686 let mut record = new_record(
1687 &child_source_path,
1688 "",
1689 source_size,
1690 &detection,
1691 self.rules.version(),
1692 Status::Failed,
1693 );
1694 record.parent_source = Some(parent.source_path.clone());
1695 record.error = Some(reason);
1696 record.duration_ms = Some(elapsed_ms(started));
1697 remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
1698 self.emit(record)
1699 }
1700
1701 fn expand_zip(
1704 &mut self,
1705 meta: Meta,
1706 bytes: UnitBytes,
1707 depth: usize,
1708 ancestors: Vec<String>,
1709 started: Instant,
1710 expanded: &mut u64,
1711 ) -> Result<()> {
1712 if self.expansion_collides(&meta.source_path) {
1713 return self.fail(&meta, "container-mirror-collision".to_string(), started);
1714 }
1715 if meta.source_size > convert::MAX_SOURCE_BYTES {
1716 return self.fail(&meta, over_source_ceiling(meta.source_size), started);
1717 }
1718 let bytes = match bytes.load(&meta.source_hash) {
1719 Ok(bytes) => bytes,
1720 Err(reason) => return self.fail(&meta, reason, started),
1721 };
1722 if let Some(entries) = convert::preflight_entry_count(&bytes)
1727 && entries > self.limits.max_children as u64
1728 {
1729 return self.fail(&meta, "container-children-exceeded".to_string(), started);
1730 }
1731 let mut container = match convert::ZipContainer::open(&bytes) {
1732 Ok(container) => container,
1733 Err(e) => return self.fail(&meta, e.to_string(), started),
1734 };
1735
1736 let count = container.len();
1739 if count > self.limits.max_children {
1740 return self.fail(&meta, "container-children-exceeded".to_string(), started);
1741 }
1742 let mut declared_total: u64 = 0;
1743 for index in 0..count {
1744 match container.meta(index) {
1745 Ok(member) => declared_total = declared_total.saturating_add(member.declared_size),
1746 Err(e) => return self.fail(&meta, e.to_string(), started),
1747 }
1748 }
1749 if declared_total > self.limits.max_expanded_bytes {
1750 return self.fail(&meta, "container-expansion-cap".to_string(), started);
1751 }
1752 let listing = match container.listing() {
1753 Ok(listing) => listing,
1754 Err(e) => return self.fail(&meta, e.to_string(), started),
1755 };
1756
1757 let mut child_ancestors = ancestors;
1758 child_ancestors.push(meta.source_hash.clone());
1759
1760 let mut parent_failure: Option<String> = None;
1764 for index in 0..count {
1765 let member = match container.meta(index) {
1766 Ok(member) => member,
1767 Err(e) => {
1768 parent_failure = Some(e.to_string());
1769 break;
1770 }
1771 };
1772 if member.is_dir {
1773 continue;
1774 }
1775 let Some(member_path) = member.safe_path.clone() else {
1776 self.emit_member_failure(
1777 &meta,
1778 format!("{}.d/#unsafe-member-{index}", meta.source_path),
1779 0,
1780 "unsafe-member-path".to_string(),
1781 started,
1782 )?;
1783 continue;
1784 };
1785 let child_source = format!("{}.d/{}", meta.source_path, member_path);
1786 if member.is_symlink {
1787 self.emit_member_failure(
1788 &meta,
1789 child_source,
1790 0,
1791 "unsafe-member-path".to_string(),
1792 started,
1793 )?;
1794 continue;
1795 }
1796 if member.encrypted {
1797 self.emit_member_failure(
1798 &meta,
1799 child_source,
1800 member.declared_size,
1801 "encrypted".to_string(),
1802 started,
1803 )?;
1804 continue;
1805 }
1806 if member.declared_size > self.limits.max_member_size {
1807 self.emit_member_failure(
1808 &meta,
1809 child_source,
1810 member.declared_size,
1811 "container-member-too-large".to_string(),
1812 started,
1813 )?;
1814 continue;
1815 }
1816 let remaining = self.limits.max_expanded_bytes.saturating_sub(*expanded);
1817 let cap = self.limits.max_member_size.min(remaining);
1818 match container.inflate(index, cap) {
1819 convert::Inflated::Ok(data) => {
1820 *expanded = expanded.saturating_add(data.len() as u64);
1821 self.process_member(
1822 &meta,
1823 &member_path,
1824 data,
1825 depth + 1,
1826 &child_ancestors,
1827 expanded,
1828 )?;
1829 }
1830 convert::Inflated::TooLarge(inflated) => {
1831 *expanded = expanded.saturating_add(inflated);
1832 if inflated > self.limits.max_member_size {
1833 self.emit_member_failure(
1834 &meta,
1835 child_source,
1836 inflated,
1837 "container-member-too-large".to_string(),
1838 started,
1839 )?;
1840 }
1841 if *expanded > self.limits.max_expanded_bytes {
1842 parent_failure = Some("container-expansion-cap".to_string());
1843 break;
1844 }
1845 }
1846 convert::Inflated::Error(e) => {
1847 self.emit_member_failure(
1848 &meta,
1849 child_source,
1850 0,
1851 format!("container-member-unreadable: {e}"),
1852 started,
1853 )?;
1854 }
1855 }
1856 }
1857
1858 match parent_failure {
1859 Some(reason) => self.fail(&meta, reason, started),
1860 None => {
1861 let segments = vec![segments::Segment::span(0, listing.len(), "document")];
1862 let outcome = convert::Outcome {
1863 converter_id: convert::CONTAINER_ZIP_ID.to_string(),
1864 converter_version: convert::CONTAINER_ZIP_VERSION.to_string(),
1865 detected_format: meta.detection.detected.clone(),
1866 text: listing,
1867 warnings: Vec::new(),
1868 segments,
1869 artifact_kind: manifest::ArtifactKind::Text,
1870 media: None,
1871 };
1872 if let Err(reason) = validate_output(&outcome) {
1873 return self.fail(&meta, reason, started);
1874 }
1875 self.write_parent(&meta, &outcome, started).map(|_| ())
1876 }
1877 }
1878 }
1879
1880 fn expand_eml(
1884 &mut self,
1885 meta: Meta,
1886 bytes: UnitBytes,
1887 depth: usize,
1888 ancestors: Vec<String>,
1889 started: Instant,
1890 expanded: &mut u64,
1891 ) -> Result<()> {
1892 if self.expansion_collides(&meta.source_path) {
1893 return self.fail(&meta, "container-mirror-collision".to_string(), started);
1894 }
1895 let bytes = match bytes.load(&meta.source_hash) {
1896 Ok(bytes) => bytes,
1897 Err(reason) => return self.fail(&meta, reason, started),
1898 };
1899 if meta.source_size > convert::MAX_SOURCE_BYTES {
1900 return self.fail(&meta, over_source_ceiling(meta.source_size), started);
1901 }
1902 let guarded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1903 convert::expand_eml(&bytes, &meta.detection.detected)
1904 }));
1905 let expansion = match guarded {
1906 Ok(Ok(expansion)) => expansion,
1907 Ok(Err(e)) => return self.fail(&meta, e.to_string(), started),
1908 Err(_) => {
1909 return self.fail(
1910 &meta,
1911 "converter_panic: conversion panicked".to_string(),
1912 started,
1913 );
1914 }
1915 };
1916 if let Err(reason) = validate_output(&expansion.outcome) {
1917 return self.fail(&meta, reason, started);
1918 }
1919
1920 let mut child_ancestors = ancestors;
1924 child_ancestors.push(meta.source_hash.clone());
1925 let mut parent_failure: Option<String> = None;
1926 for member in expansion.members {
1927 *expanded = expanded.saturating_add(member.bytes.len() as u64);
1928 if *expanded > self.limits.max_expanded_bytes {
1929 parent_failure = Some("container-expansion-cap".to_string());
1930 break;
1931 }
1932 self.process_member(
1933 &meta,
1934 &member.name,
1935 member.bytes,
1936 depth + 1,
1937 &child_ancestors,
1938 expanded,
1939 )?;
1940 }
1941
1942 match parent_failure {
1943 Some(reason) => self.fail(&meta, reason, started),
1944 None => self
1945 .write_parent(&meta, &expansion.outcome, started)
1946 .map(|_| ()),
1947 }
1948 }
1949}
1950
1951pub fn run(rules: &Rules, options: &RunOptions) -> Result<RunReport> {
1966 validate_division(options.division)?;
1967 check_layout(options.root, options.mirror_root, options.manifest_dir)?;
1968 let entries = walk::walk_division(options.root, &options.walk)?;
1969 let shard_path = options
1970 .manifest_dir
1971 .join(format!("{}.jsonl", options.division));
1972 let (prior, mut warnings) = if shard_path.exists() {
1973 let shard = manifest::read_shard(&shard_path)?;
1974 (shard.records, shard.warnings)
1975 } else {
1976 (Vec::new(), Vec::new())
1977 };
1978 let mut terminal = terminal_by_path(prior);
1979 let mut dedup = DedupIndex::default();
1980 seed_dedup(&terminal, rules, &mut dedup);
1981
1982 let walked: std::collections::HashSet<String> = entries
1986 .iter()
1987 .filter_map(|entry| entry.path.to_str().map(str::to_string))
1988 .collect();
1989 let limits = rules.registry.container_limits().clone();
1990
1991 let mut writer = ManifestWriter::open(&shard_path)?;
1992 let mut counts = StatusCounts::default();
1993 let mut special_entries = 0u64;
1994
1995 for entry in &entries {
1996 let started = Instant::now();
1997 let relative = &entry.path;
1998 let absolute = options.root.join(relative);
1999 let text_absolute = mirror::mirror_path(options.mirror_root, options.division, relative);
2000 let segments_absolute =
2001 segments::segments_path(options.mirror_root, options.division, relative);
2002
2003 if entry.kind == EntryKind::Other {
2004 special_entries += 1;
2005 continue;
2006 }
2007
2008 let Some(source_path) = relative.to_str().map(str::to_string) else {
2012 let detection = Detection {
2013 declared: detect::declared_format(relative, &rules.table),
2014 detected: UNKNOWN_FORMAT.to_string(),
2015 mismatch: false,
2016 };
2017 let mut record = new_record(
2018 &relative.to_string_lossy(),
2019 "",
2020 0,
2021 &detection,
2022 rules.version(),
2023 Status::Failed,
2024 );
2025 record.error = Some("non_utf8_path: source path is not valid UTF-8".to_string());
2026 record.duration_ms = Some(elapsed_ms(started));
2027 remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
2028 record_outcome(&mut writer, &mut terminal, &mut counts, record)?;
2029 continue;
2030 };
2031
2032 if entry.kind == EntryKind::Symlink {
2033 let detection = Detection {
2034 declared: None,
2035 detected: SYMLINK_FORMAT.to_string(),
2036 mismatch: false,
2037 };
2038 let mut record = match fs::read_link(&absolute) {
2039 Ok(target) => {
2040 let target_bytes = target.as_os_str().as_encoded_bytes();
2041 let mut record = new_record(
2042 &source_path,
2043 &hash::hash_bytes(target_bytes),
2044 target_bytes.len() as u64,
2045 &detection,
2046 rules.version(),
2047 Status::Unsupported,
2048 );
2049 record.error = Some(convert::NO_CONVERTER_REASON.to_string());
2053 record
2054 }
2055 Err(e) => {
2056 let mut record = new_record(
2057 &source_path,
2058 "",
2059 0,
2060 &detection,
2061 rules.version(),
2062 Status::Failed,
2063 );
2064 record.error = Some(format!("source_io_error: readlink: {e}"));
2065 record
2066 }
2067 };
2068 record.duration_ms = Some(elapsed_ms(started));
2069 remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
2070 record_outcome(&mut writer, &mut terminal, &mut counts, record)?;
2071 continue;
2072 }
2073
2074 let stat = match absolute.metadata() {
2079 Ok(metadata) => metadata,
2080 Err(e) => {
2081 let detection = Detection {
2082 declared: detect::declared_format(relative, &rules.table),
2083 detected: UNKNOWN_FORMAT.to_string(),
2084 mismatch: false,
2085 };
2086 let mut record = new_record(
2087 &source_path,
2088 "",
2089 0,
2090 &detection,
2091 rules.version(),
2092 Status::Failed,
2093 );
2094 record.error = Some(format!("source_io_error: stat: {e}"));
2095 record.duration_ms = Some(elapsed_ms(started));
2096 remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
2097 record_outcome(&mut writer, &mut terminal, &mut counts, record)?;
2098 continue;
2099 }
2100 };
2101 if stat.len() > convert::MAX_SOURCE_BYTES {
2102 let detection = Detection {
2103 declared: detect::declared_format(relative, &rules.table),
2104 detected: UNKNOWN_FORMAT.to_string(),
2105 mismatch: false,
2106 };
2107 let mut record = new_record(
2108 &source_path,
2109 "",
2110 stat.len(),
2111 &detection,
2112 rules.version(),
2113 Status::Failed,
2114 );
2115 record.error = Some(over_source_ceiling(stat.len()));
2116 record.duration_ms = Some(elapsed_ms(started));
2117 remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
2118 record_outcome(&mut writer, &mut terminal, &mut counts, record)?;
2119 continue;
2120 }
2121
2122 let front = match source_front(&absolute, &rules.table) {
2123 Ok(front) => front,
2124 Err(e) => {
2125 let detection = Detection {
2126 declared: detect::declared_format(relative, &rules.table),
2127 detected: UNKNOWN_FORMAT.to_string(),
2128 mismatch: false,
2129 };
2130 let mut record = new_record(
2131 &source_path,
2132 "",
2133 0,
2134 &detection,
2135 rules.version(),
2136 Status::Failed,
2137 );
2138 record.error = Some(format!("source_io_error: {e}"));
2139 record.duration_ms = Some(elapsed_ms(started));
2140 remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
2141 record_outcome(&mut writer, &mut terminal, &mut counts, record)?;
2142 continue;
2143 }
2144 };
2145 let (source_hash, source_size, detection, detect_warnings) = front;
2146 let mut expanded = 0u64;
2147 let unit = Unit {
2148 meta: Meta {
2149 source_path,
2150 parent_source: None,
2151 source_hash,
2152 source_size,
2153 detection,
2154 detect_warnings,
2155 },
2156 bytes: UnitBytes::Disk(absolute.clone()),
2157 depth: 0,
2158 ancestors: Vec::new(),
2159 };
2160 let mut expander = Expander {
2161 rules,
2162 mirror_root: options.mirror_root,
2163 division: options.division,
2164 walked: &walked,
2165 limits: &limits,
2166 writer: &mut writer,
2167 terminal: &mut terminal,
2168 counts: &mut counts,
2169 dedup: &mut dedup,
2170 claim: None,
2171 };
2172 expander.process(unit, started, &mut expanded)?;
2173 }
2174
2175 warnings.sort();
2176 Ok(RunReport {
2177 division: options.division.to_string(),
2178 root: options.root.display().to_string(),
2179 sources: entries.len() as u64 - special_entries,
2180 special_entries,
2181 counts,
2182 warnings,
2183 })
2184}
2185
2186fn shard_paths(manifest_dir: &Path) -> Result<Vec<PathBuf>> {
2187 let entries = fs::read_dir(manifest_dir).map_err(|e| Error::io("read_dir", manifest_dir, e))?;
2188 let mut shards = Vec::new();
2189 for entry in entries {
2190 let entry = entry.map_err(|e| Error::io("read_dir", manifest_dir, e))?;
2191 let path = entry.path();
2192 if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
2193 shards.push(path);
2194 }
2195 }
2196 shards.sort();
2197 Ok(shards)
2198}
2199
2200fn division_name(shard: &Path) -> String {
2201 shard
2202 .file_stem()
2203 .map(|stem| stem.to_string_lossy().into_owned())
2204 .unwrap_or_default()
2205}
2206
2207pub fn status(manifest_dir: &Path) -> Result<StatusReport> {
2209 let mut divisions = Vec::new();
2210 for shard_path in shard_paths(manifest_dir)? {
2211 let shard = manifest::read_shard(&shard_path)?;
2212 let terminal = terminal_by_path(shard.records);
2213 let mut counts = StatusCounts::default();
2214 let mut with_text = 0u64;
2215 for record in terminal.values() {
2216 match record.status {
2217 Status::Converted => counts.converted += 1,
2218 Status::Failed => counts.failed += 1,
2219 Status::Unsupported => counts.unsupported += 1,
2220 Status::SkippedUnchanged => counts.skipped_unchanged += 1,
2221 Status::Dedup => counts.dedup += 1,
2222 }
2223 if record.text_path.is_some() {
2224 with_text += 1;
2225 }
2226 }
2227 let sources = terminal.len() as u64;
2228 let coverage = if sources == 0 {
2229 0.0
2230 } else {
2231 with_text as f64 / sources as f64
2232 };
2233 divisions.push(DivisionStatus {
2234 division: division_name(&shard_path),
2235 sources,
2236 with_text,
2237 coverage,
2238 counts,
2239 warnings: shard.warnings,
2240 });
2241 }
2242 Ok(StatusReport { divisions })
2243}
2244
2245pub fn explain(manifest_dir: &Path, source_path: &str) -> Result<Vec<ExplainEntry>> {
2247 let mut entries = Vec::new();
2248 for shard_path in shard_paths(manifest_dir)? {
2249 let division = division_name(&shard_path);
2250 for record in manifest::read_shard(&shard_path)?.records {
2251 if record.source_path == source_path {
2252 entries.push(ExplainEntry {
2253 division: division.clone(),
2254 record,
2255 });
2256 }
2257 }
2258 }
2259 Ok(entries)
2260}
2261
2262#[cfg(test)]
2263mod tests {
2264 use super::*;
2265
2266 #[test]
2267 fn builtin_rules_agree_on_a_version() {
2268 let rules = Rules::builtin().unwrap();
2269 assert_eq!(rules.version(), "10");
2270 }
2271
2272 #[cfg(all(unix, feature = "image-metadata"))]
2280 #[test]
2281 fn an_over_ceiling_image_with_a_dedup_seed_is_refused_before_the_borrow() {
2282 use std::collections::HashSet;
2283 use std::io::Write as _;
2284
2285 let dir = tempfile::tempdir().unwrap();
2286 let root = dir.path().join("source");
2287 fs::create_dir_all(&root).unwrap();
2288 let mirror_root = dir.path().join("mirror");
2289 let manifest_dir = dir.path().join("manifest");
2290 let division = "unit";
2291 let rules = Rules::builtin().unwrap();
2292 assert!(
2293 rules.registry.image_metadata_converter().is_some(),
2294 "the metadata leg must be present for this path"
2295 );
2296
2297 let huge = root.join("huge.png");
2299 let mut file = fs::File::create(&huge).unwrap();
2300 file.write_all(b"\x89PNG\r\n\x1a\n").unwrap();
2301 file.set_len(convert::MAX_SOURCE_BYTES + 1).unwrap();
2302 drop(file);
2303 let source_size = fs::metadata(&huge).unwrap().len();
2304 let source_hash = hash::hash_bytes(&fs::read(&huge).unwrap());
2305
2306 let converter = rules.registry.converter_for("png").unwrap();
2309 let canon_text = "recognized line\n";
2310 let canon_text_path = mirror::mirror_path(&mirror_root, division, Path::new("canon.png"));
2311 let canon_segments =
2312 segments::segments_path(&mirror_root, division, Path::new("canon.png"));
2313 let lines = segments::to_jsonl(&[Segment::span(0, canon_text.len(), "ocr")]).unwrap();
2314 mirror::write_atomic(&canon_text_path, canon_text).unwrap();
2315 mirror::write_atomic(&canon_segments, &lines).unwrap();
2316 let mut dedup = DedupIndex::default();
2317 dedup.insert(
2318 &source_hash,
2319 CanonicalArtifact {
2320 source_path: "canon.png".to_string(),
2321 text_path: mirror::recorded_text_path(division, "canon.png"),
2322 text_hash: hash::hash_bytes(canon_text.as_bytes()),
2323 converter_id: converter.id().to_string(),
2324 converter_version: converter.version().to_string(),
2325 artifact_kind: Some(ArtifactKind::Ocr),
2326 media: None,
2327 warnings: Vec::new(),
2328 },
2329 );
2330
2331 let shard = manifest_dir.join("unit.jsonl");
2332 let mut writer = ManifestWriter::open(&shard).unwrap();
2333 let mut terminal = HashMap::new();
2334 let mut counts = StatusCounts::default();
2335 let walked = HashSet::new();
2336 let unit = Unit {
2337 meta: Meta {
2338 source_path: "huge.png".to_string(),
2339 parent_source: None,
2340 source_hash,
2341 source_size,
2342 detection: Detection {
2343 declared: Some("png".to_string()),
2344 detected: "png".to_string(),
2345 mismatch: false,
2346 },
2347 detect_warnings: Vec::new(),
2348 },
2349 bytes: UnitBytes::Disk(huge),
2350 depth: 0,
2351 ancestors: Vec::new(),
2352 };
2353 let mut expander = Expander {
2354 rules: &rules,
2355 mirror_root: &mirror_root,
2356 division,
2357 walked: &walked,
2358 limits: rules.registry.container_limits(),
2359 writer: &mut writer,
2360 terminal: &mut terminal,
2361 counts: &mut counts,
2362 dedup: &mut dedup,
2363 claim: None,
2364 };
2365 let mut expanded = 0u64;
2366 expander
2367 .process(unit, Instant::now(), &mut expanded)
2368 .unwrap();
2369 drop(writer);
2370
2371 let records = manifest::read_shard(&shard).unwrap().records;
2372 assert_eq!(records.len(), 1, "{records:?}");
2373 let record = &records[0];
2374 assert_eq!(record.source_path, "huge.png");
2375 assert_eq!(record.status, Status::Failed);
2376 assert!(
2377 record
2378 .error
2379 .as_deref()
2380 .is_some_and(|e| e.starts_with("resource_limit")),
2381 "{:?}",
2382 record.error
2383 );
2384 assert!(record.dedup_of.is_none());
2385 assert_eq!(counts.dedup, 0);
2386 assert_eq!(counts.failed, 1);
2387 assert!(!terminal.contains_key("huge.png.d/#image-metadata"));
2388 assert!(
2389 !mirror::mirror_path(
2390 &mirror_root,
2391 division,
2392 Path::new("huge.png.d/#image-metadata")
2393 )
2394 .exists()
2395 );
2396 }
2397
2398 #[test]
2399 fn the_quine_guard_matches_an_ancestor_hash() {
2400 let chain = vec!["aa".to_string(), "bb".to_string()];
2401 assert!(hash_on_chain("bb", &chain));
2402 assert!(hash_on_chain("aa", &chain));
2403 assert!(!hash_on_chain("cc", &chain));
2404 assert!(!hash_on_chain("aa", &[]));
2405 }
2406
2407 use crate::convert::{ConvertError, Converter, Outcome, PlainTextPassthrough};
2408 use crate::segments::Segment;
2409
2410 struct PanicConverter;
2413
2414 impl Converter for PanicConverter {
2415 fn id(&self) -> &'static str {
2416 "panic-test"
2417 }
2418 fn version(&self) -> &'static str {
2419 "1.0.0"
2420 }
2421 fn convert(
2422 &self,
2423 _source: &[u8],
2424 _detected_format: &str,
2425 ) -> std::result::Result<Outcome, ConvertError> {
2426 panic!("deliberate test panic");
2427 }
2428 }
2429
2430 struct BadSegmentsConverter;
2432
2433 impl Converter for BadSegmentsConverter {
2434 fn id(&self) -> &'static str {
2435 "bad-segments-test"
2436 }
2437 fn version(&self) -> &'static str {
2438 "1.0.0"
2439 }
2440 fn convert(
2441 &self,
2442 _source: &[u8],
2443 detected_format: &str,
2444 ) -> std::result::Result<Outcome, ConvertError> {
2445 Ok(Outcome {
2446 converter_id: self.id().to_string(),
2447 converter_version: self.version().to_string(),
2448 detected_format: detected_format.to_string(),
2449 text: "ok".to_string(),
2450 warnings: Vec::new(),
2451 segments: vec![Segment::span(0, 999, "document")],
2452 artifact_kind: manifest::ArtifactKind::Text,
2453 media: None,
2454 })
2455 }
2456 }
2457
2458 struct OcrKindConverter;
2463
2464 impl Converter for OcrKindConverter {
2465 fn id(&self) -> &'static str {
2466 "ocr-kind-test"
2467 }
2468 fn version(&self) -> &'static str {
2469 "1.0.0"
2470 }
2471 fn convert(
2472 &self,
2473 _source: &[u8],
2474 detected_format: &str,
2475 ) -> std::result::Result<Outcome, ConvertError> {
2476 let text = "recognized text\n".to_string();
2477 Ok(Outcome {
2478 converter_id: self.id().to_string(),
2479 converter_version: self.version().to_string(),
2480 detected_format: detected_format.to_string(),
2481 segments: vec![Segment::span(0, text.len(), "document")],
2482 text,
2483 warnings: Vec::new(),
2484 artifact_kind: manifest::ArtifactKind::Ocr,
2485 media: None,
2486 })
2487 }
2488 }
2489
2490 fn injected_rules() -> Rules {
2491 let table = FormatTable::parse(
2492 r#"
2493version = "9"
2494
2495[[formats]]
2496id = "text"
2497name = "Plain text"
2498extensions = ["txt"]
2499
2500[[formats]]
2501id = "panicfmt"
2502name = "Panic trigger"
2503extensions = ["panicfmt"]
2504
2505[[formats]]
2506id = "badseg"
2507name = "Bad segments trigger"
2508extensions = ["badseg"]
2509
2510[[formats]]
2511id = "ocrfmt"
2512name = "OCR kind trigger"
2513extensions = ["ocrfmt"]
2514"#,
2515 "formats.toml",
2516 )
2517 .unwrap();
2518 let registry = Registry::for_tests(
2519 "9",
2520 vec![
2521 (Box::new(PlainTextPassthrough), vec!["text"]),
2522 (Box::new(PanicConverter), vec!["panicfmt"]),
2523 (Box::new(BadSegmentsConverter), vec!["badseg"]),
2524 (Box::new(OcrKindConverter), vec!["ocrfmt"]),
2525 ],
2526 );
2527 Rules::from_parts(table, registry).unwrap()
2528 }
2529
2530 #[test]
2531 fn a_panicking_converter_fails_one_source_and_the_division_survives() {
2532 let dir = tempfile::tempdir().unwrap();
2533 let root = dir.path().join("src");
2534 fs::create_dir_all(&root).unwrap();
2535 fs::write(root.join("boom.panicfmt"), "trigger\n").unwrap();
2536 fs::write(root.join("fine.txt"), "safe content\n").unwrap();
2537
2538 let rules = injected_rules();
2539 let report = run(
2540 &rules,
2541 &RunOptions {
2542 root: &root,
2543 mirror_root: &dir.path().join("mirror"),
2544 manifest_dir: &dir.path().join("manifest"),
2545 division: "unit",
2546 walk: WalkOptions::default(),
2547 },
2548 )
2549 .unwrap();
2550
2551 assert_eq!(report.counts.failed, 1);
2552 assert_eq!(report.counts.converted, 1);
2553 let records = manifest::read_shard(&dir.path().join("manifest/unit.jsonl"))
2554 .unwrap()
2555 .records;
2556 let failed = records.iter().find(|r| r.status == Status::Failed).unwrap();
2557 assert_eq!(failed.source_path, "boom.panicfmt");
2558 assert!(
2559 failed
2560 .error
2561 .as_deref()
2562 .unwrap()
2563 .starts_with("converter_panic")
2564 );
2565 assert!(!dir.path().join("mirror/unit/boom.panicfmt.txt").exists());
2566 }
2567
2568 #[test]
2569 fn invalid_segments_from_a_converter_fail_before_any_write() {
2570 let dir = tempfile::tempdir().unwrap();
2571 let root = dir.path().join("src");
2572 fs::create_dir_all(&root).unwrap();
2573 fs::write(root.join("bad.badseg"), "trigger\n").unwrap();
2574
2575 let rules = injected_rules();
2576 let report = run(
2577 &rules,
2578 &RunOptions {
2579 root: &root,
2580 mirror_root: &dir.path().join("mirror"),
2581 manifest_dir: &dir.path().join("manifest"),
2582 division: "unit",
2583 walk: WalkOptions::default(),
2584 },
2585 )
2586 .unwrap();
2587
2588 assert_eq!(report.counts.failed, 1);
2589 let records = manifest::read_shard(&dir.path().join("manifest/unit.jsonl"))
2590 .unwrap()
2591 .records;
2592 assert!(
2593 records[0]
2594 .error
2595 .as_deref()
2596 .unwrap()
2597 .starts_with("invalid_segments")
2598 );
2599 assert!(!dir.path().join("mirror/unit/bad.badseg.txt").exists());
2600 assert!(
2601 !dir.path()
2602 .join("mirror/unit/bad.badseg.segments.jsonl")
2603 .exists()
2604 );
2605 }
2606
2607 #[test]
2608 fn the_ocr_artifact_kind_propagates_through_dedup_and_checkpoint() {
2609 let dir = tempfile::tempdir().unwrap();
2610 let root = dir.path().join("src");
2611 let mirror = dir.path().join("mirror");
2612 let manifest_dir = dir.path().join("manifest");
2613 fs::create_dir_all(&root).unwrap();
2614 fs::write(root.join("a.ocrfmt"), "same pixels\n").unwrap();
2617 fs::write(root.join("b.ocrfmt"), "same pixels\n").unwrap();
2618
2619 let rules = injected_rules();
2620 let options = RunOptions {
2621 root: &root,
2622 mirror_root: &mirror,
2623 manifest_dir: &manifest_dir,
2624 division: "unit",
2625 walk: WalkOptions::default(),
2626 };
2627 run(&rules, &options).unwrap();
2628
2629 let shard = manifest_dir.join("unit.jsonl");
2630 let latest = |path: &str| {
2631 manifest::read_shard(&shard)
2632 .unwrap()
2633 .records
2634 .into_iter()
2635 .rev()
2636 .find(|r| r.source_path == path)
2637 .unwrap()
2638 };
2639
2640 let a = latest("a.ocrfmt");
2642 let b = latest("b.ocrfmt");
2643 let (converted, deduped) = if a.status == Status::Converted {
2644 (a, b)
2645 } else {
2646 (b, a)
2647 };
2648 assert_eq!(converted.status, Status::Converted);
2649 assert_eq!(deduped.status, Status::Dedup);
2650 assert_eq!(converted.artifact_kind, Some(ArtifactKind::Ocr));
2651 assert_eq!(deduped.artifact_kind, Some(ArtifactKind::Ocr));
2652
2653 fs::write(root.join("c.ocrfmt"), "same pixels\n").unwrap();
2658 run(&rules, &options).unwrap();
2659
2660 let a2 = latest("a.ocrfmt");
2661 let b2 = latest("b.ocrfmt");
2662 let c = latest("c.ocrfmt");
2663 assert_eq!(a2.status, Status::SkippedUnchanged);
2664 assert_eq!(b2.status, Status::SkippedUnchanged);
2665 assert_eq!(a2.artifact_kind, Some(ArtifactKind::Ocr));
2666 assert_eq!(b2.artifact_kind, Some(ArtifactKind::Ocr));
2667 assert_eq!(c.status, Status::Dedup);
2668 assert_eq!(c.artifact_kind, Some(ArtifactKind::Ocr));
2669 }
2670
2671 #[test]
2672 fn division_names_are_validated() {
2673 assert!(validate_division("finance-eu_1").is_ok());
2674 assert!(validate_division("").is_err());
2675 assert!(validate_division("a/b").is_err());
2676 assert!(validate_division("dot.dot").is_err());
2677 }
2678
2679 #[test]
2680 fn layout_rejects_outputs_inside_the_root() {
2681 let dir = tempfile::tempdir().unwrap();
2682 let root = dir.path().join("src");
2683 fs::create_dir_all(&root).unwrap();
2684 let outside = dir.path().join("out");
2685
2686 let err = check_layout(&root, &root.join("mirror"), &outside).unwrap_err();
2687 assert!(err.to_string().contains("mirror root"));
2688
2689 let err = check_layout(&root, &outside.join("mirror"), &root.join("manifest")).unwrap_err();
2690 assert!(err.to_string().contains("manifest directory"));
2691
2692 check_layout(&root, &outside.join("mirror"), &outside.join("manifest")).unwrap();
2693 }
2694
2695 #[cfg(unix)]
2696 #[test]
2697 fn non_utf8_paths_are_rejected_without_an_artifact() {
2698 use std::os::unix::ffi::OsStrExt;
2699 let raw = std::ffi::OsStr::from_bytes(b"bad-\xff-name.txt");
2700 assert!(Path::new(raw).to_str().is_none());
2701 }
2702
2703 #[test]
2704 fn identical_sources_dedup_to_one_conversion() {
2705 let dir = tempfile::tempdir().unwrap();
2706 let root = dir.path().join("src");
2707 fs::create_dir_all(root.join("sub")).unwrap();
2708 fs::write(root.join("first.txt"), "same bytes\n").unwrap();
2709 fs::write(root.join("sub/second.txt"), "same bytes\n").unwrap();
2710
2711 let rules = Rules::builtin().unwrap();
2712 let report = run(
2713 &rules,
2714 &RunOptions {
2715 root: &root,
2716 mirror_root: &dir.path().join("mirror"),
2717 manifest_dir: &dir.path().join("manifest"),
2718 division: "unit",
2719 walk: WalkOptions::default(),
2720 },
2721 )
2722 .unwrap();
2723
2724 assert_eq!(report.counts.converted, 1);
2725 assert_eq!(report.counts.dedup, 1);
2726
2727 let records = manifest::read_shard(&dir.path().join("manifest/unit.jsonl"))
2728 .unwrap()
2729 .records;
2730 let canonical = records
2731 .iter()
2732 .find(|r| r.source_path == "first.txt")
2733 .unwrap();
2734 let duplicate = records
2735 .iter()
2736 .find(|r| r.source_path == "sub/second.txt")
2737 .unwrap();
2738 assert_eq!(canonical.status, Status::Converted);
2739 assert_eq!(duplicate.status, Status::Dedup);
2740 assert_eq!(duplicate.dedup_of.as_deref(), Some("first.txt"));
2741 assert_eq!(duplicate.text_hash, canonical.text_hash);
2742
2743 let mirrored =
2744 fs::read_to_string(dir.path().join("mirror/unit/sub/second.txt.txt")).unwrap();
2745 assert_eq!(mirrored, "same bytes\n");
2746 }
2747}