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
463struct ClaimState {
468 prefix: String,
471 claimed: std::collections::HashSet<String>,
473 emitted: std::collections::HashSet<String>,
475 collisions: usize,
477}
478
479const REMOVED_MEMBER_REASON: &str = "container-member-removed";
482
483fn container_kind(detected: &str) -> Option<ContainerKind> {
484 match detected {
485 "zip" => Some(ContainerKind::Zip),
486 "eml" => Some(ContainerKind::Eml),
487 _ => None,
488 }
489}
490
491struct Expander<'a> {
493 rules: &'a Rules,
494 mirror_root: &'a Path,
495 division: &'a str,
496 walked: &'a std::collections::HashSet<String>,
497 limits: &'a convert::ContainerLimits,
498 writer: &'a mut ManifestWriter,
499 terminal: &'a mut HashMap<String, Record>,
500 counts: &'a mut StatusCounts,
501 dedup: &'a mut DedupIndex,
502 claim: Option<ClaimState>,
505}
506
507impl Expander<'_> {
508 fn artifact_paths(&self, source_path: &str) -> (PathBuf, PathBuf, String) {
509 let relative = Path::new(source_path);
510 (
511 mirror::mirror_path(self.mirror_root, self.division, relative),
512 segments::segments_path(self.mirror_root, self.division, relative),
513 mirror::recorded_text_path(self.division, source_path),
514 )
515 }
516
517 fn emit(&mut self, record: Record) -> Result<()> {
518 if let Some(claim) = &mut self.claim
519 && record.source_path.starts_with(&claim.prefix)
520 {
521 claim.emitted.insert(record.source_path.clone());
522 }
523 record_outcome(self.writer, self.terminal, self.counts, record)
524 }
525
526 fn base_record(&self, meta: &Meta, status: Status) -> Record {
527 let mut record = new_record(
528 &meta.source_path,
529 &meta.source_hash,
530 meta.source_size,
531 &meta.detection,
532 self.rules.version(),
533 status,
534 );
535 record.parent_source.clone_from(&meta.parent_source);
536 record.warnings.clone_from(&meta.detect_warnings);
537 record
538 }
539
540 fn fail(&mut self, meta: &Meta, reason: String, started: Instant) -> Result<()> {
543 let (text_absolute, segments_absolute, _) = self.artifact_paths(&meta.source_path);
544 let mut record = self.base_record(meta, Status::Failed);
545 record.error = Some(reason);
546 record.duration_ms = Some(elapsed_ms(started));
547 remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
548 self.emit(record)
549 }
550
551 fn expansion_collides(&self, source_path: &str) -> bool {
555 let prefix = format!("{source_path}.d/");
556 self.walked.iter().any(|walked| walked.starts_with(&prefix))
557 }
558
559 fn process(&mut self, unit: Unit, started: Instant, expanded: &mut u64) -> Result<()> {
562 let Unit {
563 meta,
564 bytes,
565 depth,
566 ancestors,
567 } = unit;
568 let (text_absolute, segments_absolute, text_relative) =
569 self.artifact_paths(&meta.source_path);
570 let kind = container_kind(&meta.detection.detected);
571 let converter = self.rules.registry.converter_for(&meta.detection.detected);
572 let current_version = match kind {
573 Some(ContainerKind::Zip) => Some(convert::CONTAINER_ZIP_VERSION),
574 _ => converter.map(|c| c.version()),
575 };
576
577 if let Some(previous) = self.terminal.get(&meta.source_path) {
581 let key_matches = previous.source_hash == meta.source_hash
582 && previous.rules_version == self.rules.version()
583 && previous.converter_version.as_deref() == current_version;
584 let has_text = matches!(
585 previous.status,
586 Status::Converted | Status::Dedup | Status::SkippedUnchanged
587 );
588 if key_matches && has_text {
589 let intact = match (&previous.text_path, &previous.text_hash) {
590 (Some(text_path), Some(text_hash)) => {
591 mirror::resolve_recorded_path(self.mirror_root, text_path).is_ok_and(
592 |artifact| envelope_intact(&artifact, text_hash, &segments_absolute),
593 )
594 }
595 _ => false,
596 };
597 let descendants_ok = kind.is_none() || self.descendants_intact(&meta.source_path);
602 if intact && descendants_ok {
603 if kind.is_some() && self.claim.is_some() {
609 let prefix = format!("{}.d/", meta.source_path);
610 let kept: Vec<String> = self
611 .terminal
612 .keys()
613 .filter(|path| path.starts_with(&prefix))
614 .cloned()
615 .collect();
616 if let Some(claim) = &mut self.claim {
617 claim.emitted.extend(kept);
618 }
619 }
620 let mut record = previous.clone();
621 record.status = Status::SkippedUnchanged;
622 record.error = None;
623 record.duration_ms = Some(elapsed_ms(started));
624 return self.emit(record);
625 }
626 }
627 }
628
629 if kind.is_some() {
633 if depth > self.limits.max_depth {
634 return self.fail(&meta, "container-depth-exceeded".to_string(), started);
635 }
636 if hash_on_chain(&meta.source_hash, &ancestors) {
637 return self.fail(&meta, "container-recursive".to_string(), started);
638 }
639 }
640
641 if kind.is_none()
645 && let Some(canonical) = self.dedup.get(&meta.source_hash)
646 && canonical.source_path != meta.source_path
647 && converter.is_some_and(|c| {
648 c.id() == canonical.converter_id && c.version() == canonical.converter_version
649 })
650 {
651 let canonical = canonical.clone();
652 let canonical_segments = segments::segments_path(
653 self.mirror_root,
654 self.division,
655 Path::new(&canonical.source_path),
656 );
657 if let Ok(canonical_text) =
658 mirror::resolve_recorded_path(self.mirror_root, &canonical.text_path)
659 && let (Ok(text), Ok(segment_lines)) = (
660 fs::read_to_string(&canonical_text),
661 fs::read_to_string(&canonical_segments),
662 )
663 && hash::hash_bytes(text.as_bytes()) == canonical.text_hash
664 && segments::parse_jsonl(&segment_lines)
665 .is_ok_and(|segs| segments::validate(&segs, &text).is_ok())
666 {
667 let mut record = self.base_record(&meta, Status::Dedup);
668 for warning in &canonical.warnings {
669 if !record.warnings.contains(warning) {
670 record.warnings.push(warning.clone());
671 }
672 }
673 match mirror::write_atomic(&text_absolute, &text)
674 .and_then(|()| mirror::write_atomic(&segments_absolute, &segment_lines))
675 {
676 Ok(()) => {
677 record.text_path = Some(text_relative);
678 record.text_hash = Some(hash::hash_bytes(text.as_bytes()));
679 record.artifact_kind = canonical.artifact_kind;
680 record.converter_id = Some(canonical.converter_id.clone());
681 record.converter_version = Some(canonical.converter_version.clone());
682 record.dedup_of = Some(canonical.source_path.clone());
683 }
684 Err(e) => {
685 record.status = Status::Failed;
686 record.error = Some(format!("mirror_write_error: {e}"));
687 remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
688 }
689 }
690 record.duration_ms = Some(elapsed_ms(started));
691 return self.emit(record);
692 }
693 }
694
695 let Some(kind) = kind else {
696 return self.convert_leaf(meta, bytes, started);
697 };
698 let owns_claim = self.claim.is_none();
701 if owns_claim {
702 self.claim = Some(ClaimState {
703 prefix: format!("{}.d/", meta.source_path),
704 claimed: std::collections::HashSet::new(),
705 emitted: std::collections::HashSet::new(),
706 collisions: 0,
707 });
708 }
709 let root = meta.source_path.clone();
710 let result = match kind {
711 ContainerKind::Zip => self.expand_zip(meta, bytes, depth, ancestors, started, expanded),
712 ContainerKind::Eml => self.expand_eml(meta, bytes, depth, ancestors, started, expanded),
713 };
714 if owns_claim {
715 if let Some(claim) = self.claim.take() {
719 self.reconcile(&root, &claim, started)?;
720 }
721 }
722 result
723 }
724
725 fn descendants_intact(&self, root: &str) -> bool {
728 let prefix = format!("{root}.d/");
729 for record in self.terminal.values() {
730 if !record.source_path.starts_with(&prefix) {
731 continue;
732 }
733 if let (Some(text_path), Some(text_hash)) = (&record.text_path, &record.text_hash) {
734 let segments_absolute = segments::segments_path(
735 self.mirror_root,
736 self.division,
737 Path::new(&record.source_path),
738 );
739 let ok = mirror::resolve_recorded_path(self.mirror_root, text_path).is_ok_and(
740 |artifact| envelope_intact(&artifact, text_hash, &segments_absolute),
741 );
742 if !ok {
743 return false;
744 }
745 }
746 }
747 true
748 }
749
750 fn reconcile(&mut self, root: &str, claim: &ClaimState, started: Instant) -> Result<()> {
754 let stale: Vec<Record> = self
755 .terminal
756 .values()
757 .filter(|record| {
758 record.source_path.starts_with(&claim.prefix)
759 && !claim.emitted.contains(&record.source_path)
760 && !(record.status == Status::Failed
764 && record.error.as_deref() == Some(REMOVED_MEMBER_REASON))
765 })
766 .cloned()
767 .collect();
768 let _ = root;
769 for record in stale {
770 let (text_absolute, segments_absolute, _) = self.artifact_paths(&record.source_path);
771 let detection = Detection {
772 declared: record.declared_format.clone(),
773 detected: UNKNOWN_FORMAT.to_string(),
774 mismatch: false,
775 };
776 let mut retire = new_record(
777 &record.source_path,
778 "",
779 0,
780 &detection,
781 self.rules.version(),
782 Status::Failed,
783 );
784 retire.parent_source.clone_from(&record.parent_source);
785 retire.error = Some(REMOVED_MEMBER_REASON.to_string());
786 retire.duration_ms = Some(elapsed_ms(started));
787 remove_stale_artifact(&text_absolute, &segments_absolute, &mut retire);
788 self.emit(retire)?;
789 }
790 Ok(())
791 }
792
793 fn convert_leaf(&mut self, meta: Meta, bytes: UnitBytes, started: Instant) -> Result<()> {
796 let (text_absolute, segments_absolute, text_relative) =
797 self.artifact_paths(&meta.source_path);
798 let Some(converter) = self.rules.registry.converter_for(&meta.detection.detected) else {
799 let mut record = self.base_record(&meta, Status::Unsupported);
800 record.error = self
801 .rules
802 .registry
803 .unsupported_reason(&meta.detection.detected)
804 .map(str::to_string);
805 record.duration_ms = Some(elapsed_ms(started));
806 remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
807 return self.emit(record);
808 };
809 let converter_id = converter.id().to_string();
810 let converter_version = converter.version().to_string();
811
812 let mut record = self.base_record(&meta, Status::Converted);
813 let outcome = if meta.source_size > convert::MAX_SOURCE_BYTES {
814 Err(format!(
815 "resource_limit: source is {} bytes over the {} byte ceiling",
816 meta.source_size,
817 convert::MAX_SOURCE_BYTES
818 ))
819 } else {
820 bytes
821 .load(&meta.source_hash)
822 .and_then(|bytes| run_converter(converter, &bytes, &meta.detection.detected))
823 };
824 match outcome {
825 Ok(outcome) => match segments::to_jsonl(&outcome.segments)
826 .map_err(|e| e.to_string())
827 .and_then(|lines| {
828 mirror::write_atomic(&text_absolute, &outcome.text)
829 .and_then(|()| mirror::write_atomic(&segments_absolute, &lines))
830 .map_err(|e| e.to_string())
831 }) {
832 Ok(()) => {
833 let text_hash = hash::hash_bytes(outcome.text.as_bytes());
834 record.text_path = Some(text_relative.clone());
835 record.text_hash = Some(text_hash.clone());
836 record.artifact_kind = Some(ArtifactKind::Text);
837 record.converter_id = Some(outcome.converter_id.clone());
838 record.converter_version = Some(outcome.converter_version.clone());
839 record.warnings.extend(outcome.warnings.iter().cloned());
840 self.dedup.replace(
841 &meta.source_hash,
842 CanonicalArtifact {
843 source_path: meta.source_path.clone(),
844 text_path: text_relative,
845 text_hash,
846 converter_id: outcome.converter_id,
847 converter_version: outcome.converter_version,
848 artifact_kind: record.artifact_kind,
849 warnings: outcome.warnings,
850 },
851 );
852 }
853 Err(detail) => {
854 record.status = Status::Failed;
855 record.converter_id = Some(converter_id);
856 record.converter_version = Some(converter_version);
857 record.error = Some(format!("mirror_write_error: {detail}"));
858 remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
859 }
860 },
861 Err(reason) => {
862 record.status = Status::Failed;
863 record.converter_id = Some(converter_id);
864 record.converter_version = Some(converter_version);
865 record.error = Some(reason);
866 remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
867 }
868 }
869 record.duration_ms = Some(elapsed_ms(started));
870 self.emit(record)
871 }
872
873 fn write_parent(
876 &mut self,
877 meta: &Meta,
878 outcome: &convert::Outcome,
879 started: Instant,
880 ) -> Result<bool> {
881 let (text_absolute, segments_absolute, text_relative) =
882 self.artifact_paths(&meta.source_path);
883 let lines = match segments::to_jsonl(&outcome.segments) {
884 Ok(lines) => lines,
885 Err(e) => {
886 self.fail(meta, format!("invalid_segments: {e}"), started)?;
887 return Ok(false);
888 }
889 };
890 match mirror::write_atomic(&text_absolute, &outcome.text)
891 .and_then(|()| mirror::write_atomic(&segments_absolute, &lines))
892 {
893 Ok(()) => {
894 let mut record = self.base_record(meta, Status::Converted);
895 record.text_path = Some(text_relative);
896 record.text_hash = Some(hash::hash_bytes(outcome.text.as_bytes()));
897 record.artifact_kind = Some(ArtifactKind::Text);
898 record.converter_id = Some(outcome.converter_id.clone());
899 record.converter_version = Some(outcome.converter_version.clone());
900 record.warnings.extend(outcome.warnings.iter().cloned());
901 record.duration_ms = Some(elapsed_ms(started));
902 self.emit(record)?;
903 Ok(true)
904 }
905 Err(e) => {
906 self.fail(meta, format!("mirror_write_error: {e}"), started)?;
907 Ok(false)
908 }
909 }
910 }
911
912 fn process_member(
914 &mut self,
915 parent: &Meta,
916 member_path: &str,
917 member_bytes: Vec<u8>,
918 depth: usize,
919 ancestors: &[String],
920 expanded: &mut u64,
921 ) -> Result<()> {
922 let source_path = format!("{}.d/{}", parent.source_path, member_path);
923 if let Some(claim) = &mut self.claim {
927 let key = collision_key(&source_path);
928 if !claim.claimed.insert(key) {
929 claim.collisions += 1;
930 let synthetic = format!("{}.d/#collision-{}", parent.source_path, claim.collisions);
931 return self.emit_member_failure(
932 parent,
933 synthetic,
934 member_bytes.len() as u64,
935 "member-path-collision".to_string(),
936 Instant::now(),
937 );
938 }
939 }
940 let (detection, detect_warnings) = detect::detect_bytes_with_warnings(
941 Path::new(&source_path),
942 &member_bytes,
943 &self.rules.table,
944 );
945 let unit = Unit {
946 meta: Meta {
947 source_hash: hash::hash_bytes(&member_bytes),
948 source_size: member_bytes.len() as u64,
949 source_path,
950 parent_source: Some(parent.source_path.clone()),
951 detection,
952 detect_warnings,
953 },
954 bytes: UnitBytes::Mem(member_bytes),
955 depth,
956 ancestors: ancestors.to_vec(),
957 };
958 self.process(unit, Instant::now(), expanded)
959 }
960}
961
962fn over_source_ceiling(size: u64) -> String {
964 format!(
965 "resource_limit: source is {size} bytes over the {} byte ceiling",
966 convert::MAX_SOURCE_BYTES
967 )
968}
969
970fn run_converter(
972 converter: &dyn convert::Converter,
973 bytes: &[u8],
974 detected: &str,
975) -> std::result::Result<convert::Outcome, String> {
976 let guarded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
977 converter.convert(bytes, detected)
978 }));
979 let outcome = match guarded {
980 Ok(result) => result.map_err(|e| e.to_string())?,
981 Err(_) => return Err("converter_panic: conversion panicked".to_string()),
982 };
983 validate_output(&outcome)?;
984 Ok(outcome)
985}
986
987fn validate_output(outcome: &convert::Outcome) -> std::result::Result<(), String> {
990 if outcome.text.len() > convert::MAX_OUTPUT_BYTES {
991 return Err(format!(
992 "resource_limit: output is {} bytes over the {} byte ceiling",
993 outcome.text.len(),
994 convert::MAX_OUTPUT_BYTES
995 ));
996 }
997 if let Some(offset) = outcome.text.find('\0') {
998 return Err(format!(
999 "nul_bytes: NUL at byte {offset} of the converted text"
1000 ));
1001 }
1002 segments::validate(&outcome.segments, &outcome.text)
1003 .map_err(|detail| format!("invalid_segments: {detail}"))
1004}
1005
1006impl Expander<'_> {
1007 fn emit_member_failure(
1010 &mut self,
1011 parent: &Meta,
1012 child_source_path: String,
1013 source_size: u64,
1014 reason: String,
1015 started: Instant,
1016 ) -> Result<()> {
1017 let (text_absolute, segments_absolute, _) = self.artifact_paths(&child_source_path);
1018 let detection = Detection {
1019 declared: None,
1020 detected: UNKNOWN_FORMAT.to_string(),
1021 mismatch: false,
1022 };
1023 let mut record = new_record(
1024 &child_source_path,
1025 "",
1026 source_size,
1027 &detection,
1028 self.rules.version(),
1029 Status::Failed,
1030 );
1031 record.parent_source = Some(parent.source_path.clone());
1032 record.error = Some(reason);
1033 record.duration_ms = Some(elapsed_ms(started));
1034 remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
1035 self.emit(record)
1036 }
1037
1038 fn expand_zip(
1041 &mut self,
1042 meta: Meta,
1043 bytes: UnitBytes,
1044 depth: usize,
1045 ancestors: Vec<String>,
1046 started: Instant,
1047 expanded: &mut u64,
1048 ) -> Result<()> {
1049 if self.expansion_collides(&meta.source_path) {
1050 return self.fail(&meta, "container-mirror-collision".to_string(), started);
1051 }
1052 if meta.source_size > convert::MAX_SOURCE_BYTES {
1053 return self.fail(&meta, over_source_ceiling(meta.source_size), started);
1054 }
1055 let bytes = match bytes.load(&meta.source_hash) {
1056 Ok(bytes) => bytes,
1057 Err(reason) => return self.fail(&meta, reason, started),
1058 };
1059 if let Some(entries) = convert::preflight_entry_count(&bytes)
1064 && entries > self.limits.max_children as u64
1065 {
1066 return self.fail(&meta, "container-children-exceeded".to_string(), started);
1067 }
1068 let mut container = match convert::ZipContainer::open(&bytes) {
1069 Ok(container) => container,
1070 Err(e) => return self.fail(&meta, e.to_string(), started),
1071 };
1072
1073 let count = container.len();
1076 if count > self.limits.max_children {
1077 return self.fail(&meta, "container-children-exceeded".to_string(), started);
1078 }
1079 let mut declared_total: u64 = 0;
1080 for index in 0..count {
1081 match container.meta(index) {
1082 Ok(member) => declared_total = declared_total.saturating_add(member.declared_size),
1083 Err(e) => return self.fail(&meta, e.to_string(), started),
1084 }
1085 }
1086 if declared_total > self.limits.max_expanded_bytes {
1087 return self.fail(&meta, "container-expansion-cap".to_string(), started);
1088 }
1089 let listing = match container.listing() {
1090 Ok(listing) => listing,
1091 Err(e) => return self.fail(&meta, e.to_string(), started),
1092 };
1093
1094 let mut child_ancestors = ancestors;
1095 child_ancestors.push(meta.source_hash.clone());
1096
1097 let mut parent_failure: Option<String> = None;
1101 for index in 0..count {
1102 let member = match container.meta(index) {
1103 Ok(member) => member,
1104 Err(e) => {
1105 parent_failure = Some(e.to_string());
1106 break;
1107 }
1108 };
1109 if member.is_dir {
1110 continue;
1111 }
1112 let Some(member_path) = member.safe_path.clone() else {
1113 self.emit_member_failure(
1114 &meta,
1115 format!("{}.d/#unsafe-member-{index}", meta.source_path),
1116 0,
1117 "unsafe-member-path".to_string(),
1118 started,
1119 )?;
1120 continue;
1121 };
1122 let child_source = format!("{}.d/{}", meta.source_path, member_path);
1123 if member.is_symlink {
1124 self.emit_member_failure(
1125 &meta,
1126 child_source,
1127 0,
1128 "unsafe-member-path".to_string(),
1129 started,
1130 )?;
1131 continue;
1132 }
1133 if member.encrypted {
1134 self.emit_member_failure(
1135 &meta,
1136 child_source,
1137 member.declared_size,
1138 "encrypted".to_string(),
1139 started,
1140 )?;
1141 continue;
1142 }
1143 if member.declared_size > self.limits.max_member_size {
1144 self.emit_member_failure(
1145 &meta,
1146 child_source,
1147 member.declared_size,
1148 "container-member-too-large".to_string(),
1149 started,
1150 )?;
1151 continue;
1152 }
1153 let remaining = self.limits.max_expanded_bytes.saturating_sub(*expanded);
1154 let cap = self.limits.max_member_size.min(remaining);
1155 match container.inflate(index, cap) {
1156 convert::Inflated::Ok(data) => {
1157 *expanded = expanded.saturating_add(data.len() as u64);
1158 self.process_member(
1159 &meta,
1160 &member_path,
1161 data,
1162 depth + 1,
1163 &child_ancestors,
1164 expanded,
1165 )?;
1166 }
1167 convert::Inflated::TooLarge(inflated) => {
1168 *expanded = expanded.saturating_add(inflated);
1169 if inflated > self.limits.max_member_size {
1170 self.emit_member_failure(
1171 &meta,
1172 child_source,
1173 inflated,
1174 "container-member-too-large".to_string(),
1175 started,
1176 )?;
1177 }
1178 if *expanded > self.limits.max_expanded_bytes {
1179 parent_failure = Some("container-expansion-cap".to_string());
1180 break;
1181 }
1182 }
1183 convert::Inflated::Error(e) => {
1184 self.emit_member_failure(
1185 &meta,
1186 child_source,
1187 0,
1188 format!("container-member-unreadable: {e}"),
1189 started,
1190 )?;
1191 }
1192 }
1193 }
1194
1195 match parent_failure {
1196 Some(reason) => self.fail(&meta, reason, started),
1197 None => {
1198 let segments = vec![segments::Segment::span(0, listing.len(), "document")];
1199 let outcome = convert::Outcome {
1200 converter_id: convert::CONTAINER_ZIP_ID.to_string(),
1201 converter_version: convert::CONTAINER_ZIP_VERSION.to_string(),
1202 detected_format: meta.detection.detected.clone(),
1203 text: listing,
1204 warnings: Vec::new(),
1205 segments,
1206 };
1207 if let Err(reason) = validate_output(&outcome) {
1208 return self.fail(&meta, reason, started);
1209 }
1210 self.write_parent(&meta, &outcome, started).map(|_| ())
1211 }
1212 }
1213 }
1214
1215 fn expand_eml(
1219 &mut self,
1220 meta: Meta,
1221 bytes: UnitBytes,
1222 depth: usize,
1223 ancestors: Vec<String>,
1224 started: Instant,
1225 expanded: &mut u64,
1226 ) -> Result<()> {
1227 if self.expansion_collides(&meta.source_path) {
1228 return self.fail(&meta, "container-mirror-collision".to_string(), started);
1229 }
1230 let bytes = match bytes.load(&meta.source_hash) {
1231 Ok(bytes) => bytes,
1232 Err(reason) => return self.fail(&meta, reason, started),
1233 };
1234 if meta.source_size > convert::MAX_SOURCE_BYTES {
1235 return self.fail(&meta, over_source_ceiling(meta.source_size), started);
1236 }
1237 let guarded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1238 convert::expand_eml(&bytes, &meta.detection.detected)
1239 }));
1240 let expansion = match guarded {
1241 Ok(Ok(expansion)) => expansion,
1242 Ok(Err(e)) => return self.fail(&meta, e.to_string(), started),
1243 Err(_) => {
1244 return self.fail(
1245 &meta,
1246 "converter_panic: conversion panicked".to_string(),
1247 started,
1248 );
1249 }
1250 };
1251 if let Err(reason) = validate_output(&expansion.outcome) {
1252 return self.fail(&meta, reason, started);
1253 }
1254
1255 let mut child_ancestors = ancestors;
1259 child_ancestors.push(meta.source_hash.clone());
1260 let mut parent_failure: Option<String> = None;
1261 for member in expansion.members {
1262 *expanded = expanded.saturating_add(member.bytes.len() as u64);
1263 if *expanded > self.limits.max_expanded_bytes {
1264 parent_failure = Some("container-expansion-cap".to_string());
1265 break;
1266 }
1267 self.process_member(
1268 &meta,
1269 &member.name,
1270 member.bytes,
1271 depth + 1,
1272 &child_ancestors,
1273 expanded,
1274 )?;
1275 }
1276
1277 match parent_failure {
1278 Some(reason) => self.fail(&meta, reason, started),
1279 None => self
1280 .write_parent(&meta, &expansion.outcome, started)
1281 .map(|_| ()),
1282 }
1283 }
1284}
1285
1286pub fn run(rules: &Rules, options: &RunOptions) -> Result<RunReport> {
1301 validate_division(options.division)?;
1302 check_layout(options.root, options.mirror_root, options.manifest_dir)?;
1303 let entries = walk::walk_division(options.root, &options.walk)?;
1304 let shard_path = options
1305 .manifest_dir
1306 .join(format!("{}.jsonl", options.division));
1307 let (prior, mut warnings) = if shard_path.exists() {
1308 let shard = manifest::read_shard(&shard_path)?;
1309 (shard.records, shard.warnings)
1310 } else {
1311 (Vec::new(), Vec::new())
1312 };
1313 let mut terminal = terminal_by_path(prior);
1314 let mut dedup = DedupIndex::default();
1315 seed_dedup(&terminal, rules, &mut dedup);
1316
1317 let walked: std::collections::HashSet<String> = entries
1321 .iter()
1322 .filter_map(|entry| entry.path.to_str().map(str::to_string))
1323 .collect();
1324 let limits = rules.registry.container_limits().clone();
1325
1326 let mut writer = ManifestWriter::open(&shard_path)?;
1327 let mut counts = StatusCounts::default();
1328 let mut special_entries = 0u64;
1329
1330 for entry in &entries {
1331 let started = Instant::now();
1332 let relative = &entry.path;
1333 let absolute = options.root.join(relative);
1334 let text_absolute = mirror::mirror_path(options.mirror_root, options.division, relative);
1335 let segments_absolute =
1336 segments::segments_path(options.mirror_root, options.division, relative);
1337
1338 if entry.kind == EntryKind::Other {
1339 special_entries += 1;
1340 continue;
1341 }
1342
1343 let Some(source_path) = relative.to_str().map(str::to_string) else {
1347 let detection = Detection {
1348 declared: detect::declared_format(relative, &rules.table),
1349 detected: UNKNOWN_FORMAT.to_string(),
1350 mismatch: false,
1351 };
1352 let mut record = new_record(
1353 &relative.to_string_lossy(),
1354 "",
1355 0,
1356 &detection,
1357 rules.version(),
1358 Status::Failed,
1359 );
1360 record.error = Some("non_utf8_path: source path is not valid UTF-8".to_string());
1361 record.duration_ms = Some(elapsed_ms(started));
1362 remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
1363 record_outcome(&mut writer, &mut terminal, &mut counts, record)?;
1364 continue;
1365 };
1366
1367 if entry.kind == EntryKind::Symlink {
1368 let detection = Detection {
1369 declared: None,
1370 detected: SYMLINK_FORMAT.to_string(),
1371 mismatch: false,
1372 };
1373 let mut record = match fs::read_link(&absolute) {
1374 Ok(target) => {
1375 let target_bytes = target.as_os_str().as_encoded_bytes();
1376 let mut record = new_record(
1377 &source_path,
1378 &hash::hash_bytes(target_bytes),
1379 target_bytes.len() as u64,
1380 &detection,
1381 rules.version(),
1382 Status::Unsupported,
1383 );
1384 record.error = Some(convert::NO_CONVERTER_REASON.to_string());
1388 record
1389 }
1390 Err(e) => {
1391 let mut record = new_record(
1392 &source_path,
1393 "",
1394 0,
1395 &detection,
1396 rules.version(),
1397 Status::Failed,
1398 );
1399 record.error = Some(format!("source_io_error: readlink: {e}"));
1400 record
1401 }
1402 };
1403 record.duration_ms = Some(elapsed_ms(started));
1404 remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
1405 record_outcome(&mut writer, &mut terminal, &mut counts, record)?;
1406 continue;
1407 }
1408
1409 let stat = match absolute.metadata() {
1414 Ok(metadata) => metadata,
1415 Err(e) => {
1416 let detection = Detection {
1417 declared: detect::declared_format(relative, &rules.table),
1418 detected: UNKNOWN_FORMAT.to_string(),
1419 mismatch: false,
1420 };
1421 let mut record = new_record(
1422 &source_path,
1423 "",
1424 0,
1425 &detection,
1426 rules.version(),
1427 Status::Failed,
1428 );
1429 record.error = Some(format!("source_io_error: stat: {e}"));
1430 record.duration_ms = Some(elapsed_ms(started));
1431 remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
1432 record_outcome(&mut writer, &mut terminal, &mut counts, record)?;
1433 continue;
1434 }
1435 };
1436 if stat.len() > convert::MAX_SOURCE_BYTES {
1437 let detection = Detection {
1438 declared: detect::declared_format(relative, &rules.table),
1439 detected: UNKNOWN_FORMAT.to_string(),
1440 mismatch: false,
1441 };
1442 let mut record = new_record(
1443 &source_path,
1444 "",
1445 stat.len(),
1446 &detection,
1447 rules.version(),
1448 Status::Failed,
1449 );
1450 record.error = Some(over_source_ceiling(stat.len()));
1451 record.duration_ms = Some(elapsed_ms(started));
1452 remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
1453 record_outcome(&mut writer, &mut terminal, &mut counts, record)?;
1454 continue;
1455 }
1456
1457 let front = match source_front(&absolute, &rules.table) {
1458 Ok(front) => front,
1459 Err(e) => {
1460 let detection = Detection {
1461 declared: detect::declared_format(relative, &rules.table),
1462 detected: UNKNOWN_FORMAT.to_string(),
1463 mismatch: false,
1464 };
1465 let mut record = new_record(
1466 &source_path,
1467 "",
1468 0,
1469 &detection,
1470 rules.version(),
1471 Status::Failed,
1472 );
1473 record.error = Some(format!("source_io_error: {e}"));
1474 record.duration_ms = Some(elapsed_ms(started));
1475 remove_stale_artifact(&text_absolute, &segments_absolute, &mut record);
1476 record_outcome(&mut writer, &mut terminal, &mut counts, record)?;
1477 continue;
1478 }
1479 };
1480 let (source_hash, source_size, detection, detect_warnings) = front;
1481 let mut expanded = 0u64;
1482 let unit = Unit {
1483 meta: Meta {
1484 source_path,
1485 parent_source: None,
1486 source_hash,
1487 source_size,
1488 detection,
1489 detect_warnings,
1490 },
1491 bytes: UnitBytes::Disk(absolute.clone()),
1492 depth: 0,
1493 ancestors: Vec::new(),
1494 };
1495 let mut expander = Expander {
1496 rules,
1497 mirror_root: options.mirror_root,
1498 division: options.division,
1499 walked: &walked,
1500 limits: &limits,
1501 writer: &mut writer,
1502 terminal: &mut terminal,
1503 counts: &mut counts,
1504 dedup: &mut dedup,
1505 claim: None,
1506 };
1507 expander.process(unit, started, &mut expanded)?;
1508 }
1509
1510 warnings.sort();
1511 Ok(RunReport {
1512 division: options.division.to_string(),
1513 root: options.root.display().to_string(),
1514 sources: entries.len() as u64 - special_entries,
1515 special_entries,
1516 counts,
1517 warnings,
1518 })
1519}
1520
1521fn shard_paths(manifest_dir: &Path) -> Result<Vec<PathBuf>> {
1522 let entries = fs::read_dir(manifest_dir).map_err(|e| Error::io("read_dir", manifest_dir, e))?;
1523 let mut shards = Vec::new();
1524 for entry in entries {
1525 let entry = entry.map_err(|e| Error::io("read_dir", manifest_dir, e))?;
1526 let path = entry.path();
1527 if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
1528 shards.push(path);
1529 }
1530 }
1531 shards.sort();
1532 Ok(shards)
1533}
1534
1535fn division_name(shard: &Path) -> String {
1536 shard
1537 .file_stem()
1538 .map(|stem| stem.to_string_lossy().into_owned())
1539 .unwrap_or_default()
1540}
1541
1542pub fn status(manifest_dir: &Path) -> Result<StatusReport> {
1544 let mut divisions = Vec::new();
1545 for shard_path in shard_paths(manifest_dir)? {
1546 let shard = manifest::read_shard(&shard_path)?;
1547 let terminal = terminal_by_path(shard.records);
1548 let mut counts = StatusCounts::default();
1549 let mut with_text = 0u64;
1550 for record in terminal.values() {
1551 match record.status {
1552 Status::Converted => counts.converted += 1,
1553 Status::Failed => counts.failed += 1,
1554 Status::Unsupported => counts.unsupported += 1,
1555 Status::SkippedUnchanged => counts.skipped_unchanged += 1,
1556 Status::Dedup => counts.dedup += 1,
1557 }
1558 if record.text_path.is_some() {
1559 with_text += 1;
1560 }
1561 }
1562 let sources = terminal.len() as u64;
1563 let coverage = if sources == 0 {
1564 0.0
1565 } else {
1566 with_text as f64 / sources as f64
1567 };
1568 divisions.push(DivisionStatus {
1569 division: division_name(&shard_path),
1570 sources,
1571 with_text,
1572 coverage,
1573 counts,
1574 warnings: shard.warnings,
1575 });
1576 }
1577 Ok(StatusReport { divisions })
1578}
1579
1580pub fn explain(manifest_dir: &Path, source_path: &str) -> Result<Vec<ExplainEntry>> {
1582 let mut entries = Vec::new();
1583 for shard_path in shard_paths(manifest_dir)? {
1584 let division = division_name(&shard_path);
1585 for record in manifest::read_shard(&shard_path)?.records {
1586 if record.source_path == source_path {
1587 entries.push(ExplainEntry {
1588 division: division.clone(),
1589 record,
1590 });
1591 }
1592 }
1593 }
1594 Ok(entries)
1595}
1596
1597#[cfg(test)]
1598mod tests {
1599 use super::*;
1600
1601 #[test]
1602 fn builtin_rules_agree_on_a_version() {
1603 let rules = Rules::builtin().unwrap();
1604 assert_eq!(rules.version(), "7");
1605 }
1606
1607 #[test]
1608 fn the_quine_guard_matches_an_ancestor_hash() {
1609 let chain = vec!["aa".to_string(), "bb".to_string()];
1610 assert!(hash_on_chain("bb", &chain));
1611 assert!(hash_on_chain("aa", &chain));
1612 assert!(!hash_on_chain("cc", &chain));
1613 assert!(!hash_on_chain("aa", &[]));
1614 }
1615
1616 use crate::convert::{ConvertError, Converter, Outcome, PlainTextPassthrough};
1617 use crate::segments::Segment;
1618
1619 struct PanicConverter;
1622
1623 impl Converter for PanicConverter {
1624 fn id(&self) -> &'static str {
1625 "panic-test"
1626 }
1627 fn version(&self) -> &'static str {
1628 "1.0.0"
1629 }
1630 fn convert(
1631 &self,
1632 _source: &[u8],
1633 _detected_format: &str,
1634 ) -> std::result::Result<Outcome, ConvertError> {
1635 panic!("deliberate test panic");
1636 }
1637 }
1638
1639 struct BadSegmentsConverter;
1641
1642 impl Converter for BadSegmentsConverter {
1643 fn id(&self) -> &'static str {
1644 "bad-segments-test"
1645 }
1646 fn version(&self) -> &'static str {
1647 "1.0.0"
1648 }
1649 fn convert(
1650 &self,
1651 _source: &[u8],
1652 detected_format: &str,
1653 ) -> std::result::Result<Outcome, ConvertError> {
1654 Ok(Outcome {
1655 converter_id: self.id().to_string(),
1656 converter_version: self.version().to_string(),
1657 detected_format: detected_format.to_string(),
1658 text: "ok".to_string(),
1659 warnings: Vec::new(),
1660 segments: vec![Segment::span(0, 999, "document")],
1661 })
1662 }
1663 }
1664
1665 fn injected_rules() -> Rules {
1666 let table = FormatTable::parse(
1667 r#"
1668version = "9"
1669
1670[[formats]]
1671id = "text"
1672name = "Plain text"
1673extensions = ["txt"]
1674
1675[[formats]]
1676id = "panicfmt"
1677name = "Panic trigger"
1678extensions = ["panicfmt"]
1679
1680[[formats]]
1681id = "badseg"
1682name = "Bad segments trigger"
1683extensions = ["badseg"]
1684"#,
1685 "formats.toml",
1686 )
1687 .unwrap();
1688 let registry = Registry::for_tests(
1689 "9",
1690 vec![
1691 (Box::new(PlainTextPassthrough), vec!["text"]),
1692 (Box::new(PanicConverter), vec!["panicfmt"]),
1693 (Box::new(BadSegmentsConverter), vec!["badseg"]),
1694 ],
1695 );
1696 Rules { table, registry }
1697 }
1698
1699 #[test]
1700 fn a_panicking_converter_fails_one_source_and_the_division_survives() {
1701 let dir = tempfile::tempdir().unwrap();
1702 let root = dir.path().join("src");
1703 fs::create_dir_all(&root).unwrap();
1704 fs::write(root.join("boom.panicfmt"), "trigger\n").unwrap();
1705 fs::write(root.join("fine.txt"), "safe content\n").unwrap();
1706
1707 let rules = injected_rules();
1708 let report = run(
1709 &rules,
1710 &RunOptions {
1711 root: &root,
1712 mirror_root: &dir.path().join("mirror"),
1713 manifest_dir: &dir.path().join("manifest"),
1714 division: "unit",
1715 walk: WalkOptions::default(),
1716 },
1717 )
1718 .unwrap();
1719
1720 assert_eq!(report.counts.failed, 1);
1721 assert_eq!(report.counts.converted, 1);
1722 let records = manifest::read_shard(&dir.path().join("manifest/unit.jsonl"))
1723 .unwrap()
1724 .records;
1725 let failed = records.iter().find(|r| r.status == Status::Failed).unwrap();
1726 assert_eq!(failed.source_path, "boom.panicfmt");
1727 assert!(
1728 failed
1729 .error
1730 .as_deref()
1731 .unwrap()
1732 .starts_with("converter_panic")
1733 );
1734 assert!(!dir.path().join("mirror/unit/boom.panicfmt.txt").exists());
1735 }
1736
1737 #[test]
1738 fn invalid_segments_from_a_converter_fail_before_any_write() {
1739 let dir = tempfile::tempdir().unwrap();
1740 let root = dir.path().join("src");
1741 fs::create_dir_all(&root).unwrap();
1742 fs::write(root.join("bad.badseg"), "trigger\n").unwrap();
1743
1744 let rules = injected_rules();
1745 let report = run(
1746 &rules,
1747 &RunOptions {
1748 root: &root,
1749 mirror_root: &dir.path().join("mirror"),
1750 manifest_dir: &dir.path().join("manifest"),
1751 division: "unit",
1752 walk: WalkOptions::default(),
1753 },
1754 )
1755 .unwrap();
1756
1757 assert_eq!(report.counts.failed, 1);
1758 let records = manifest::read_shard(&dir.path().join("manifest/unit.jsonl"))
1759 .unwrap()
1760 .records;
1761 assert!(
1762 records[0]
1763 .error
1764 .as_deref()
1765 .unwrap()
1766 .starts_with("invalid_segments")
1767 );
1768 assert!(!dir.path().join("mirror/unit/bad.badseg.txt").exists());
1769 assert!(
1770 !dir.path()
1771 .join("mirror/unit/bad.badseg.segments.jsonl")
1772 .exists()
1773 );
1774 }
1775
1776 #[test]
1777 fn division_names_are_validated() {
1778 assert!(validate_division("finance-eu_1").is_ok());
1779 assert!(validate_division("").is_err());
1780 assert!(validate_division("a/b").is_err());
1781 assert!(validate_division("dot.dot").is_err());
1782 }
1783
1784 #[test]
1785 fn layout_rejects_outputs_inside_the_root() {
1786 let dir = tempfile::tempdir().unwrap();
1787 let root = dir.path().join("src");
1788 fs::create_dir_all(&root).unwrap();
1789 let outside = dir.path().join("out");
1790
1791 let err = check_layout(&root, &root.join("mirror"), &outside).unwrap_err();
1792 assert!(err.to_string().contains("mirror root"));
1793
1794 let err = check_layout(&root, &outside.join("mirror"), &root.join("manifest")).unwrap_err();
1795 assert!(err.to_string().contains("manifest directory"));
1796
1797 check_layout(&root, &outside.join("mirror"), &outside.join("manifest")).unwrap();
1798 }
1799
1800 #[cfg(unix)]
1801 #[test]
1802 fn non_utf8_paths_are_rejected_without_an_artifact() {
1803 use std::os::unix::ffi::OsStrExt;
1804 let raw = std::ffi::OsStr::from_bytes(b"bad-\xff-name.txt");
1805 assert!(Path::new(raw).to_str().is_none());
1806 }
1807
1808 #[test]
1809 fn identical_sources_dedup_to_one_conversion() {
1810 let dir = tempfile::tempdir().unwrap();
1811 let root = dir.path().join("src");
1812 fs::create_dir_all(root.join("sub")).unwrap();
1813 fs::write(root.join("first.txt"), "same bytes\n").unwrap();
1814 fs::write(root.join("sub/second.txt"), "same bytes\n").unwrap();
1815
1816 let rules = Rules::builtin().unwrap();
1817 let report = run(
1818 &rules,
1819 &RunOptions {
1820 root: &root,
1821 mirror_root: &dir.path().join("mirror"),
1822 manifest_dir: &dir.path().join("manifest"),
1823 division: "unit",
1824 walk: WalkOptions::default(),
1825 },
1826 )
1827 .unwrap();
1828
1829 assert_eq!(report.counts.converted, 1);
1830 assert_eq!(report.counts.dedup, 1);
1831
1832 let records = manifest::read_shard(&dir.path().join("manifest/unit.jsonl"))
1833 .unwrap()
1834 .records;
1835 let canonical = records
1836 .iter()
1837 .find(|r| r.source_path == "first.txt")
1838 .unwrap();
1839 let duplicate = records
1840 .iter()
1841 .find(|r| r.source_path == "sub/second.txt")
1842 .unwrap();
1843 assert_eq!(canonical.status, Status::Converted);
1844 assert_eq!(duplicate.status, Status::Dedup);
1845 assert_eq!(duplicate.dedup_of.as_deref(), Some("first.txt"));
1846 assert_eq!(duplicate.text_hash, canonical.text_hash);
1847
1848 let mirrored =
1849 fs::read_to_string(dir.path().join("mirror/unit/sub/second.txt.txt")).unwrap();
1850 assert_eq!(mirrored, "same bytes\n");
1851 }
1852}