Skip to main content

text_mirror/
bundle.rs

1//! Bundle packaging, verification, and merging.
2//!
3//! A bundle is the self-contained handoff unit between the conversion
4//! machine and the consumer: the division-namespaced mirror subtree,
5//! the manifest shard, the rules snapshot that produced it,
6//! `checksums.b3` over every bundle file, and `BUNDLE.json`. Bundles
7//! are always written clean. The producer's torn-line tolerance is
8//! crash recovery for its own resume path, and packaging refuses a
9//! shard with a torn tail instead of repairing it, because a torn
10//! tail means an interrupted run and the remedy is resuming the run.
11//! Once a bundle passes verification, every line of every shard
12//! parses as a complete record.
13//!
14//! Verification refuses the whole bundle on any failure and names
15//! every offender the failing step found. Merging verifies every
16//! input, copies bytes without rewriting a record or a path, and
17//! verifies its own output before reporting success. `BUNDLE.json`
18//! carries no timestamp, so packaging and merging the same inputs
19//! again is byte-identical.
20
21use std::collections::{BTreeMap, BTreeSet, HashMap};
22use std::fs;
23use std::path::{Path, PathBuf};
24
25use serde::de::Error as _;
26use serde::{Deserialize, Deserializer, Serialize, Serializer};
27
28use crate::manifest::{self, ManifestSchema, Record, Status};
29use crate::{Error, Result, hash};
30
31/// The bundle schema identifier carried by every BUNDLE.json.
32pub const BUNDLE_SCHEMA: &str = "text-mirror/bundle@1";
33
34/// The checksum file name at the bundle root.
35pub const CHECKSUMS_NAME: &str = "checksums.b3";
36
37/// The descriptor file name at the bundle root.
38pub const DESCRIPTOR_NAME: &str = "BUNDLE.json";
39
40/// The `schema` field of a [`BundleDescriptor`]. Serializes to the
41/// literal `text-mirror/bundle@1` and rejects every other value, so a
42/// consumer fails closed on a bundle version it does not recognize.
43#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
44pub struct BundleSchema;
45
46impl Serialize for BundleSchema {
47    fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
48        serializer.serialize_str(BUNDLE_SCHEMA)
49    }
50}
51
52impl<'de> Deserialize<'de> for BundleSchema {
53    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
54        let value = String::deserialize(deserializer)?;
55        if value == BUNDLE_SCHEMA {
56            Ok(BundleSchema)
57        } else {
58            Err(D::Error::custom(format!(
59                "unrecognized schema {value:?}, this consumer accepts only {BUNDLE_SCHEMA:?}"
60            )))
61        }
62    }
63}
64
65/// Per-division record counts over every shard line, superseded
66/// outcomes included.
67#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(deny_unknown_fields)]
69pub struct DivisionCounts {
70    /// Lines with status converted.
71    pub converted: u64,
72    /// Lines with status failed.
73    pub failed: u64,
74    /// Lines with status unsupported.
75    pub unsupported: u64,
76    /// Lines with status skipped_unchanged.
77    pub skipped_unchanged: u64,
78    /// Lines with status dedup.
79    pub dedup: u64,
80    /// Every line in the shard.
81    pub total: u64,
82}
83
84/// Coverage for one division as an exact integer pair.
85#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(deny_unknown_fields)]
87pub struct DivisionCoverage {
88    /// Effective records with a text-bearing status: converted,
89    /// dedup, or skipped_unchanged. Verify separately confirms every
90    /// such artifact exists and matches its hash.
91    pub covered: u64,
92    /// All effective records.
93    pub total: u64,
94}
95
96/// The BUNDLE.json contents. The seven-field set is the frozen
97/// bundle@1 shape.
98///
99/// The descriptor carries no timestamp, so packaging or merging the
100/// same inputs again is byte-identical.
101#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
102#[serde(deny_unknown_fields)]
103pub struct BundleDescriptor {
104    /// The literal string `text-mirror/bundle@1`.
105    pub schema: BundleSchema,
106    /// The manifest schema every shard record carries.
107    pub manifest_schema: ManifestSchema,
108    /// Bytewise-sorted identifiers of the runs whose outcomes the
109    /// bundle carries, each matching the run id grammar. A run
110    /// identifier is the BLAKE3 hex of the division's shard bytes at
111    /// packaging time, so it names the exact outcome set and stays
112    /// byte-identical across re-runs.
113    pub run_ids: Vec<String>,
114    /// Bytewise-sorted division names.
115    pub divisions: Vec<String>,
116    /// Per-division counts over every shard line, superseded records
117    /// included, so a shard trimmed of history fails verification.
118    pub counts: BTreeMap<String, DivisionCounts>,
119    /// Per-division coverage over effective records only, the true
120    /// denominator.
121    pub coverage: BTreeMap<String, DivisionCoverage>,
122    /// BLAKE3 hex of the exact bytes of checksums.b3. This chain
123    /// detects corruption and partial transfer, and it does not
124    /// detect tampering, because nothing signs BUNDLE.json.
125    pub checksums_hash: String,
126}
127
128/// True when a run id fits the bundle@1 grammar: one to sixty four
129/// ASCII characters, alphanumeric first, then alphanumerics, dot,
130/// underscore, hyphen.
131pub fn valid_run_id(id: &str) -> bool {
132    let bytes = id.as_bytes();
133    if bytes.is_empty() || bytes.len() > 64 || !bytes[0].is_ascii_alphanumeric() {
134        return false;
135    }
136    bytes[1..]
137        .iter()
138        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
139}
140
141/// The portable collision key for a text_path: NFC normalization and
142/// ASCII case folding. Bytewise-distinct paths that fold together
143/// would corrupt the mirror on a case-insensitive receiving
144/// filesystem.
145fn collision_key(text_path: &str) -> String {
146    use unicode_normalization::UnicodeNormalization;
147    text_path.nfc().collect::<String>().to_ascii_lowercase()
148}
149
150fn refused(verb: &'static str, problems: Vec<String>) -> Error {
151    Error::Refused { verb, problems }
152}
153
154/// The `version` a rules file declares, read without binding the rest
155/// of the file, so a snapshot from any rules generation parses.
156fn rules_file_version(text: &str, name: &str) -> std::result::Result<String, String> {
157    #[derive(Deserialize)]
158    struct VersionOnly {
159        version: String,
160    }
161    toml::from_str::<VersionOnly>(text)
162        .map(|v| v.version)
163        .map_err(|e| format!("{name}: cannot read the declared rules version: {e}"))
164}
165
166/// Every entry under `root`: sorted slash-relative paths with a
167/// directory flag. Non-regular entries are refused, because a bundle
168/// carries files and directories and nothing else.
169fn enumerate_entries(root: &Path) -> Result<Vec<(String, bool)>> {
170    fn visit(dir: &Path, base: &Path, out: &mut Vec<(String, bool)>) -> Result<()> {
171        let entries = fs::read_dir(dir).map_err(|e| Error::io("read_dir", dir, e))?;
172        for entry in entries {
173            let entry = entry.map_err(|e| Error::io("read_dir", dir, e))?;
174            let path = entry.path();
175            let file_type = entry.file_type().map_err(|e| Error::io("stat", &path, e))?;
176            let relative = path
177                .strip_prefix(base)
178                .expect("entries live under the base")
179                .components()
180                .map(|c| c.as_os_str().to_string_lossy())
181                .collect::<Vec<_>>()
182                .join("/");
183            if file_type.is_dir() {
184                out.push((relative, true));
185                visit(&path, base, out)?;
186            } else if file_type.is_file() {
187                out.push((relative, false));
188            } else {
189                return Err(Error::io(
190                    "enumerate",
191                    &path,
192                    std::io::Error::new(
193                        std::io::ErrorKind::InvalidInput,
194                        "bundle trees hold regular files only",
195                    ),
196                ));
197            }
198        }
199        Ok(())
200    }
201    let mut entries = Vec::new();
202    visit(root, root, &mut entries)?;
203    entries.sort();
204    Ok(entries)
205}
206
207/// The last record per source path within one shard's append order.
208///
209/// The effective-record key is (division, source_path). Division is
210/// the shard identity from the shard filename, so this reduction runs
211/// per shard and records are never pooled across shards.
212fn effective_records(records: &[Record]) -> BTreeMap<String, &Record> {
213    let mut effective = BTreeMap::new();
214    for record in records {
215        effective.insert(record.source_path.clone(), record);
216    }
217    effective
218}
219
220fn covered_status(status: Status) -> bool {
221    matches!(
222        status,
223        Status::Converted | Status::Dedup | Status::SkippedUnchanged
224    )
225}
226
227/// counts is a ledger over every shard line, superseded outcomes
228/// included, and total equals the shard's line count.
229fn tally_counts(records: &[Record]) -> DivisionCounts {
230    let mut counts = DivisionCounts::default();
231    for record in records {
232        match record.status {
233            Status::Converted => counts.converted += 1,
234            Status::Failed => counts.failed += 1,
235            Status::Unsupported => counts.unsupported += 1,
236            Status::SkippedUnchanged => counts.skipped_unchanged += 1,
237            Status::Dedup => counts.dedup += 1,
238        }
239        counts.total += 1;
240    }
241    counts
242}
243
244/// coverage is over effective records only.
245fn tally_coverage(effective: &BTreeMap<String, &Record>) -> DivisionCoverage {
246    let mut coverage = DivisionCoverage::default();
247    for record in effective.values() {
248        coverage.total += 1;
249        if covered_status(record.status) {
250            coverage.covered += 1;
251        }
252    }
253    coverage
254}
255
256/// Every regular file under `root` as a sorted list of
257/// slash-separated relative paths. Non-regular entries are refused,
258/// because a bundle carries files and nothing else.
259fn enumerate_files(root: &Path) -> Result<Vec<String>> {
260    fn visit(dir: &Path, base: &Path, out: &mut Vec<String>) -> Result<()> {
261        let entries = fs::read_dir(dir).map_err(|e| Error::io("read_dir", dir, e))?;
262        for entry in entries {
263            let entry = entry.map_err(|e| Error::io("read_dir", dir, e))?;
264            let path = entry.path();
265            let file_type = entry.file_type().map_err(|e| Error::io("stat", &path, e))?;
266            if file_type.is_dir() {
267                visit(&path, base, out)?;
268            } else if file_type.is_file() {
269                let relative = path
270                    .strip_prefix(base)
271                    .expect("entries live under the base")
272                    .components()
273                    .map(|c| c.as_os_str().to_string_lossy())
274                    .collect::<Vec<_>>()
275                    .join("/");
276                out.push(relative);
277            } else {
278                return Err(Error::io(
279                    "enumerate",
280                    &path,
281                    std::io::Error::new(
282                        std::io::ErrorKind::InvalidInput,
283                        "bundle trees hold regular files only",
284                    ),
285                ));
286            }
287        }
288        Ok(())
289    }
290    let mut files = Vec::new();
291    visit(root, root, &mut files)?;
292    files.sort();
293    Ok(files)
294}
295
296/// Builds the checksums.b3 content for the listed files: one
297/// `<hash><two spaces><path>` line per file in bytewise path order,
298/// LF endings, trailing newline.
299fn checksum_lines(root: &Path, files: &[String]) -> Result<String> {
300    let mut out = String::new();
301    for relative in files {
302        let digest = hash::hash_file(&root.join(relative))?;
303        out.push_str(&digest);
304        out.push_str("  ");
305        out.push_str(relative);
306        out.push('\n');
307    }
308    Ok(out)
309}
310
311fn write_file(root: &Path, relative: &str, bytes: &[u8]) -> Result<()> {
312    let dest = root.join(relative);
313    if let Some(dir) = dest.parent() {
314        fs::create_dir_all(dir).map_err(|e| Error::io("create_dir", dir, e))?;
315    }
316    fs::write(&dest, bytes).map_err(|e| Error::io("write", &dest, e))
317}
318
319/// Copies every regular file under `from` to the same relative path
320/// under `to`, byte-identical.
321fn copy_tree(from: &Path, to: &Path) -> Result<u64> {
322    let files = enumerate_files(from)?;
323    for relative in &files {
324        let source = from.join(relative);
325        let dest = to.join(relative);
326        if let Some(dir) = dest.parent() {
327            fs::create_dir_all(dir).map_err(|e| Error::io("create_dir", dir, e))?;
328        }
329        fs::copy(&source, &dest).map_err(|e| Error::io("copy", &source, e))?;
330    }
331    Ok(files.len() as u64)
332}
333
334fn descriptor_bytes(descriptor: &BundleDescriptor) -> Result<Vec<u8>> {
335    let mut json = serde_json::to_string_pretty(descriptor).map_err(|e| Error::Encode {
336        path: PathBuf::from(DESCRIPTOR_NAME),
337        message: e.to_string(),
338    })?;
339    json.push('\n');
340    Ok(json.into_bytes())
341}
342
343fn refuse_dirty_output(output: &Path) -> Result<()> {
344    if output.exists() {
345        let mut entries = fs::read_dir(output).map_err(|e| Error::io("read_dir", output, e))?;
346        if entries.next().is_some() {
347            return Err(Error::Layout {
348                message: format!("output {} exists and is not empty", output.display()),
349            });
350        }
351    }
352    Ok(())
353}
354
355/// Options for packaging one division.
356pub struct BundleOptions<'a> {
357    /// The mirror root the run wrote into.
358    pub mirror_root: &'a Path,
359    /// The directory holding manifest shards.
360    pub manifest_dir: &'a Path,
361    /// The division to package.
362    pub division: &'a str,
363    /// The bundle output directory, created empty.
364    pub output: &'a Path,
365}
366
367/// The `bundle` verb output.
368#[derive(Debug, Clone, Serialize, Deserialize)]
369pub struct BundleReport {
370    /// The packaged division.
371    pub division: String,
372    /// The bundle root.
373    pub output: String,
374    /// Files listed in checksums.b3.
375    pub files: u64,
376    /// Counts over every shard line, superseded records included.
377    pub counts: DivisionCounts,
378    /// Coverage.
379    pub coverage: DivisionCoverage,
380    /// BLAKE3 hex of checksums.b3.
381    pub checksums_hash: String,
382}
383
384/// Packages one division into a bundle and verifies the result.
385pub fn bundle(options: &BundleOptions) -> Result<BundleReport> {
386    crate::pipeline::validate_division(options.division)?;
387    let shard_path = options
388        .manifest_dir
389        .join(format!("{}.jsonl", options.division));
390    let shard_bytes = fs::read(&shard_path).map_err(|e| Error::io("read", &shard_path, e))?;
391    // Clean-write is a bundle@1 property: every shard ends with a
392    // newline and every line parses complete. A torn or unterminated
393    // tail means an interrupted run, and the remedy is resuming the
394    // run, not repairing the shard here.
395    if !shard_bytes.ends_with(b"\n") {
396        return Err(refused(
397            "bundle",
398            vec![format!(
399                "shard {} does not end with a newline, resume the run to complete the division",
400                shard_path.display()
401            )],
402        ));
403    }
404    let shard = manifest::read_shard(&shard_path)?;
405    if !shard.warnings.is_empty() {
406        return Err(refused(
407            "bundle",
408            vec![format!(
409                "shard {} ends in a torn line, resume the run to complete the division",
410                shard_path.display()
411            )],
412        ));
413    }
414    let effective = effective_records(&shard.records);
415    let counts = tally_counts(&shard.records);
416    let coverage = tally_coverage(&effective);
417
418    // Every text-bearing effective record must have its artifact on
419    // disk before anything is packaged.
420    let mut problems = Vec::new();
421    for record in effective.values() {
422        let Some(text_path) = &record.text_path else {
423            continue;
424        };
425        let present = crate::mirror::resolve_recorded_path(options.mirror_root, text_path)
426            .is_ok_and(|artifact| artifact.is_file());
427        if !present {
428            problems.push(format!(
429                "{}: artifact {text_path} is missing",
430                record.source_path
431            ));
432        }
433    }
434    if !problems.is_empty() {
435        return Err(refused("bundle", problems));
436    }
437
438    // The snapshot this bundle writes must describe the records it
439    // carries. Effective records from another rules generation mean
440    // the division needs a re-run under the current rules before it
441    // can ship. Superseded lines are exempt, append-only history
442    // legitimately spans rules bumps.
443    let snapshot_version =
444        rules_file_version(include_str!("../rules/converters.toml"), "converters.toml").map_err(
445            |message| Error::Rules {
446                name: "converters.toml".to_string(),
447                message,
448            },
449        )?;
450    let mut problems = Vec::new();
451    for record in effective.values() {
452        if record.rules_version != snapshot_version {
453            problems.push(format!(
454                "{}: record rules_version {:?} does not match the snapshot rules version {:?}, re-run the division under the current rules",
455                record.source_path, record.rules_version, snapshot_version
456            ));
457        }
458    }
459    if !problems.is_empty() {
460        return Err(refused("bundle", problems));
461    }
462
463    refuse_dirty_output(options.output)?;
464    fs::create_dir_all(options.output).map_err(|e| Error::io("create_dir", options.output, e))?;
465
466    // The mirror subtree copies byte-identical into the same shape,
467    // so no recorded path is ever rewritten. The division's mirror
468    // directory exists in every bundle, empty when nothing converted.
469    let bundle_mirror = options.output.join("mirror").join(options.division);
470    fs::create_dir_all(&bundle_mirror).map_err(|e| Error::io("create_dir", &bundle_mirror, e))?;
471    let division_mirror = options.mirror_root.join(options.division);
472    if division_mirror.is_dir() {
473        copy_tree(&division_mirror, &bundle_mirror)?;
474    }
475    write_file(
476        options.output,
477        &format!("manifest/{}.jsonl", options.division),
478        &shard_bytes,
479    )?;
480    write_file(
481        options.output,
482        "rules/formats.toml",
483        include_str!("../rules/formats.toml").as_bytes(),
484    )?;
485    write_file(
486        options.output,
487        "rules/converters.toml",
488        include_str!("../rules/converters.toml").as_bytes(),
489    )?;
490
491    let files = enumerate_files(options.output)?;
492    let checksums = checksum_lines(options.output, &files)?;
493    let checksums_hash = hash::hash_bytes(checksums.as_bytes());
494    write_file(options.output, CHECKSUMS_NAME, checksums.as_bytes())?;
495
496    let descriptor = BundleDescriptor {
497        schema: BundleSchema,
498        manifest_schema: ManifestSchema,
499        run_ids: {
500            let run_id = hash::hash_bytes(&shard_bytes);
501            if !valid_run_id(&run_id) {
502                return Err(refused(
503                    "bundle",
504                    vec![format!("run id {run_id:?} is outside the run id grammar")],
505                ));
506            }
507            vec![run_id]
508        },
509        divisions: vec![options.division.to_string()],
510        counts: BTreeMap::from([(options.division.to_string(), counts.clone())]),
511        coverage: BTreeMap::from([(options.division.to_string(), coverage.clone())]),
512        checksums_hash: checksums_hash.clone(),
513    };
514    write_file(
515        options.output,
516        DESCRIPTOR_NAME,
517        &descriptor_bytes(&descriptor)?,
518    )?;
519
520    // A bundle that fails its own verification must not report
521    // success, same rule merge follows.
522    verify(options.output).map_err(|error| match error {
523        Error::Refused { problems, .. } => refused("bundle", problems),
524        other => other,
525    })?;
526
527    Ok(BundleReport {
528        division: options.division.to_string(),
529        output: options.output.display().to_string(),
530        files: files.len() as u64,
531        counts,
532        coverage,
533        checksums_hash,
534    })
535}
536
537/// The `verify` verb output.
538#[derive(Debug, Clone, Serialize, Deserialize)]
539pub struct VerifyReport {
540    /// The bundle root the checks ran against.
541    pub bundle: String,
542    /// Divisions the bundle carries.
543    pub divisions: Vec<String>,
544    /// Files whose checksums were recomputed and matched.
545    pub files_verified: u64,
546    /// Shard records parsed.
547    pub records: u64,
548    /// Counts per division over every shard line, recomputed.
549    pub counts: BTreeMap<String, DivisionCounts>,
550    /// Coverage per division, recomputed.
551    pub coverage: BTreeMap<String, DivisionCoverage>,
552}
553
554fn parse_checksum_lines(content: &str) -> std::result::Result<Vec<(String, String)>, Vec<String>> {
555    let mut problems = Vec::new();
556    let mut entries: Vec<(String, String)> = Vec::new();
557    let mut seen = BTreeSet::new();
558    for (index, line) in content.lines().enumerate() {
559        let number = index + 1;
560        let Some((digest, path)) = line.split_once("  ") else {
561            problems.push(format!("checksums.b3 line {number}: not a checksum line"));
562            continue;
563        };
564        let digest_ok = digest.len() == 64 && digest.chars().all(|c| c.is_ascii_hexdigit());
565        // Backslash and colon are separators on other hosts, so a
566        // path carrying them does not mean one file everywhere.
567        let path_ok = !path.is_empty()
568            && !path.contains(['\\', ':'])
569            && path
570                .split('/')
571                .all(|segment| !segment.is_empty() && segment != "." && segment != "..");
572        if !digest_ok || !path_ok {
573            problems.push(format!("checksums.b3 line {number}: malformed entry"));
574            continue;
575        }
576        if !seen.insert(path.to_string()) {
577            problems.push(format!("checksums.b3 line {number}: duplicate path {path}"));
578            continue;
579        }
580        if let Some((_, previous)) = entries.last()
581            && path <= previous.as_str()
582        {
583            problems.push(format!(
584                "checksums.b3 line {number}: paths are not in bytewise order"
585            ));
586            continue;
587        }
588        entries.push((digest.to_ascii_lowercase(), path.to_string()));
589    }
590    if problems.is_empty() {
591        Ok(entries)
592    } else {
593        Err(problems)
594    }
595}
596
597fn read_descriptor(root: &Path) -> Result<BundleDescriptor> {
598    let path = root.join(DESCRIPTOR_NAME);
599    let bytes = fs::read(&path)
600        .map_err(|e| refused("verify", vec![format!("{DESCRIPTOR_NAME} unreadable: {e}")]))?;
601    serde_json::from_slice(&bytes)
602        .map_err(|e| refused("verify", vec![format!("{DESCRIPTOR_NAME}: {e}")]))
603}
604
605/// Verifies a bundle in place.
606///
607/// Every descriptor value is untrusted until validated. The checks
608/// run in order and any failing step refuses the whole bundle,
609/// naming every offender that step found: descriptor schemas plus
610/// division and run id validation, the normative layout at every
611/// depth, checksum framing and the checksum chain with the listed
612/// set proven equal to the enumerated tree before anything is hashed
613/// by name, complete newline-terminated shards, the rules snapshot
614/// bound to the effective records, artifact identity through the
615/// pure path mapping with hashes and no unreferenced mirror file,
616/// the portable collision check, and exact named count and coverage
617/// agreement.
618pub fn verify(root: &Path) -> Result<VerifyReport> {
619    let verb = "verify";
620
621    // Step 1: the descriptor parses, its schemas are recognized, and
622    // its arrays hold: run ids inside the grammar, bytewise sorted,
623    // unique. Division names inside the producer grammar, bytewise
624    // sorted, unique, and unique under ASCII case folding, before any
625    // name is ever used as a path.
626    let descriptor = read_descriptor(root)?;
627    let mut problems = Vec::new();
628    for (index, run_id) in descriptor.run_ids.iter().enumerate() {
629        if !valid_run_id(run_id) {
630            problems.push(format!("run id {run_id:?} is outside the run id grammar"));
631        }
632        if index > 0 {
633            match run_id.cmp(&descriptor.run_ids[index - 1]) {
634                std::cmp::Ordering::Greater => {}
635                std::cmp::Ordering::Equal => {
636                    problems.push(format!("duplicate run id {run_id:?}"));
637                }
638                std::cmp::Ordering::Less => {
639                    problems.push(format!("run id {run_id:?} is not in bytewise order"));
640                }
641            }
642        }
643    }
644    let mut folded = BTreeSet::new();
645    for (index, division) in descriptor.divisions.iter().enumerate() {
646        if crate::pipeline::validate_division(division).is_err() {
647            problems.push(format!("division name {division:?} is outside the grammar"));
648            continue;
649        }
650        if index > 0 && division <= &descriptor.divisions[index - 1] {
651            problems.push(format!(
652                "division {division:?} is duplicated or not in bytewise order"
653            ));
654        }
655        if !folded.insert(division.to_ascii_lowercase()) {
656            problems.push(format!(
657                "division {division:?} collides with another under ASCII case folding"
658            ));
659        }
660    }
661    if !problems.is_empty() {
662        return Err(refused(verb, problems));
663    }
664
665    // Step 2: the normative layout at every depth. The root holds
666    // exactly the five entries, manifest/ exactly the shard files,
667    // rules/ exactly the two snapshot files, mirror/ exactly one
668    // subtree per division, and no directory below mirror/<division>
669    // exists without a file inside it.
670    let entries = enumerate_entries(root)?;
671    let mut problems = Vec::new();
672    let mut expected_shards: BTreeSet<String> = descriptor
673        .divisions
674        .iter()
675        .map(|d| format!("{d}.jsonl"))
676        .collect();
677    let division_set: BTreeSet<&str> = descriptor.divisions.iter().map(String::as_str).collect();
678    let mut dirs_with_files: BTreeSet<String> = BTreeSet::new();
679    for (path, is_dir) in &entries {
680        if !is_dir {
681            let mut ancestor = path.as_str();
682            while let Some(slash) = ancestor.rfind('/') {
683                ancestor = &ancestor[..slash];
684                dirs_with_files.insert(ancestor.to_string());
685            }
686        }
687    }
688    for (path, is_dir) in &entries {
689        let segments: Vec<&str> = path.split('/').collect();
690        let expected = match (segments.as_slice(), *is_dir) {
691            ([name], false) if *name == DESCRIPTOR_NAME || *name == CHECKSUMS_NAME => true,
692            (["manifest"] | ["mirror"] | ["rules"], true) => true,
693            (["manifest", shard], false) => expected_shards.remove(*shard),
694            (["rules", file], false) => *file == "formats.toml" || *file == "converters.toml",
695            (["mirror", division], true) => division_set.contains(division),
696            (["mirror", division, ..], is_dir) => {
697                division_set.contains(division) && (!is_dir || dirs_with_files.contains(path))
698            }
699            _ => false,
700        };
701        if !expected {
702            let what = if *is_dir && dirs_with_files.contains(path) {
703                "unexpected entry"
704            } else if *is_dir {
705                "empty directory"
706            } else {
707                "unexpected entry"
708            };
709            problems.push(format!("{what}: {path}"));
710        }
711    }
712    for missing in &expected_shards {
713        problems.push(format!("missing shard: manifest/{missing}"));
714    }
715    for required in [
716        DESCRIPTOR_NAME,
717        CHECKSUMS_NAME,
718        "manifest",
719        "mirror",
720        "rules",
721    ] {
722        if !entries.iter().any(|(path, _)| path == required) {
723            problems.push(format!("missing bundle entry: {required}"));
724        }
725    }
726    for name in ["rules/formats.toml", "rules/converters.toml"] {
727        if !entries.iter().any(|(path, is_dir)| path == name && !is_dir) {
728            problems.push(format!("missing bundle entry: {name}"));
729        }
730    }
731    for division in &descriptor.divisions {
732        let dir = format!("mirror/{division}");
733        if !entries.iter().any(|(path, is_dir)| *path == dir && *is_dir) {
734            problems.push(format!("missing mirror subtree: {dir}"));
735        }
736    }
737    if !problems.is_empty() {
738        return Err(refused(verb, problems));
739    }
740
741    // Step 3: checksums.b3 framing (UTF-8, LF only, trailing
742    // newline), the recorded hash, and the chain. The listed path set
743    // must equal the enumerated tree set before anything is hashed,
744    // so a listed path can never name a file outside the bundle on
745    // any host.
746    let checksums_path = root.join(CHECKSUMS_NAME);
747    let checksums_bytes = fs::read(&checksums_path)
748        .map_err(|e| refused(verb, vec![format!("{CHECKSUMS_NAME} unreadable: {e}")]))?;
749    if checksums_bytes.contains(&b'\r') {
750        return Err(refused(
751            verb,
752            vec![format!(
753                "{CHECKSUMS_NAME} contains a carriage return, the file is LF only"
754            )],
755        ));
756    }
757    if !checksums_bytes.ends_with(b"\n") {
758        return Err(refused(
759            verb,
760            vec![format!("{CHECKSUMS_NAME} does not end with a newline")],
761        ));
762    }
763    let actual = hash::hash_bytes(&checksums_bytes);
764    if actual != descriptor.checksums_hash {
765        return Err(refused(
766            verb,
767            vec![format!(
768                "{CHECKSUMS_NAME} hashes to {actual} but the descriptor records {}",
769                descriptor.checksums_hash
770            )],
771        ));
772    }
773    let checksums_text = String::from_utf8(checksums_bytes)
774        .map_err(|_| refused(verb, vec![format!("{CHECKSUMS_NAME} is not UTF-8")]))?;
775    let listed =
776        parse_checksum_lines(&checksums_text).map_err(|problems| refused(verb, problems))?;
777    let tree_files: BTreeSet<&str> = entries
778        .iter()
779        .filter(|(path, is_dir)| !is_dir && path != DESCRIPTOR_NAME && path != CHECKSUMS_NAME)
780        .map(|(path, _)| path.as_str())
781        .collect();
782    let listed_map: BTreeMap<&str, &str> = listed
783        .iter()
784        .map(|(digest, path)| (path.as_str(), digest.as_str()))
785        .collect();
786    let mut problems = Vec::new();
787    for path in listed_map.keys() {
788        if !tree_files.contains(path) {
789            problems.push(format!("listed file missing: {path}"));
790        }
791    }
792    for path in &tree_files {
793        if !listed_map.contains_key(path) {
794            problems.push(format!("unlisted file: {path}"));
795        }
796    }
797    if !problems.is_empty() {
798        return Err(refused(verb, problems));
799    }
800    let mut computed: HashMap<String, String> = HashMap::new();
801    for path in &tree_files {
802        let digest = hash::hash_file(&root.join(path))?;
803        if digest != listed_map[path] {
804            problems.push(format!("checksum mismatch: {path}"));
805        }
806        computed.insert((*path).to_string(), digest);
807    }
808    if !problems.is_empty() {
809        return Err(refused(verb, problems));
810    }
811
812    // Step 4: every shard ends with a newline and every line parses
813    // as a complete record. Clean-write is a bundle@1 property, and
814    // the producer's torn-line tolerance never touches a bundle.
815    let mut shards: Vec<(String, Vec<Record>)> = Vec::new();
816    let mut record_count = 0u64;
817    let mut problems = Vec::new();
818    for division in &descriptor.divisions {
819        let relative = format!("manifest/{division}.jsonl");
820        match manifest::read_shard_strict(&root.join(&relative)) {
821            Ok(records) => {
822                record_count += records.len() as u64;
823                shards.push((division.clone(), records));
824            }
825            Err(error) => problems.push(format!("{relative}: {error}")),
826        }
827    }
828    if !problems.is_empty() {
829        return Err(refused(verb, problems));
830    }
831
832    // Step 5: the rules snapshot binds to the records, and every
833    // text-bearing effective record carries exactly the pure path
834    // mapping with its artifact present and matching. text_path is
835    // mirror-root-relative, checksums paths are bundle-root-relative,
836    // and the mirror/ prefix maps between the two bases. Every mirror
837    // file must belong to some effective record's envelope.
838    let mut problems = Vec::new();
839    let formats_text = fs::read_to_string(root.join("rules/formats.toml"))
840        .map_err(|e| Error::io("read", &root.join("rules/formats.toml"), e))?;
841    let converters_text = fs::read_to_string(root.join("rules/converters.toml"))
842        .map_err(|e| Error::io("read", &root.join("rules/converters.toml"), e))?;
843    let snapshot_version = match (
844        rules_file_version(&formats_text, "rules/formats.toml"),
845        rules_file_version(&converters_text, "rules/converters.toml"),
846    ) {
847        (Ok(formats_version), Ok(converters_version)) => {
848            if formats_version != converters_version {
849                return Err(refused(
850                    verb,
851                    vec![format!(
852                        "rules snapshot files disagree: formats.toml declares {formats_version:?}, converters.toml declares {converters_version:?}"
853                    )],
854                ));
855            }
856            formats_version
857        }
858        (formats, converters) => {
859            let mut problems = Vec::new();
860            if let Err(message) = formats {
861                problems.push(message);
862            }
863            if let Err(message) = converters {
864                problems.push(message);
865            }
866            return Err(refused(verb, problems));
867        }
868    };
869    let mut referenced: BTreeSet<String> = BTreeSet::new();
870    let mut owners: BTreeMap<String, Vec<String>> = BTreeMap::new();
871    let mut counts = BTreeMap::new();
872    let mut coverage = BTreeMap::new();
873    let mut effective_by_division = Vec::new();
874    for (division, records) in &shards {
875        let effective = effective_records(records);
876        counts.insert(division.clone(), tally_counts(records));
877        coverage.insert(division.clone(), tally_coverage(&effective));
878        effective_by_division.push((division.clone(), effective));
879    }
880    for (division, effective) in &effective_by_division {
881        for record in effective.values() {
882            if record.rules_version != snapshot_version {
883                problems.push(format!(
884                    "{division}:{}: record rules_version {:?} does not match the snapshot rules version {snapshot_version:?}",
885                    record.source_path, record.rules_version
886                ));
887            }
888            let (Some(text_path), Some(text_hash)) = (&record.text_path, &record.text_hash) else {
889                continue;
890            };
891            let expected = crate::mirror::recorded_text_path(division, &record.source_path);
892            if *text_path != expected {
893                problems.push(format!(
894                    "{division}:{}: text_path {text_path} is not the pure mapping {expected}",
895                    record.source_path
896                ));
897                continue;
898            }
899            owners
900                .entry(collision_key(text_path))
901                .or_default()
902                .push(format!("{division}:{}", record.source_path));
903            let bundle_path = format!("mirror/{text_path}");
904            match computed.get(bundle_path.as_str()) {
905                Some(actual) if actual == text_hash => {}
906                Some(_) => problems.push(format!(
907                    "{division}:{}: artifact {text_path} does not hash to the recorded text_hash",
908                    record.source_path
909                )),
910                None => problems.push(format!(
911                    "{division}:{}: artifact {text_path} is missing",
912                    record.source_path
913                )),
914            }
915            referenced.insert(bundle_path.clone());
916            if let Some(base) = bundle_path.strip_suffix(".txt") {
917                referenced.insert(format!("{base}.segments.jsonl"));
918                referenced.insert(format!("{base}.review.md"));
919            }
920        }
921    }
922    for path in &tree_files {
923        if path.starts_with("mirror/") && !referenced.contains(*path) {
924            problems.push(format!("unreferenced mirror file: {path}"));
925        }
926    }
927    if !problems.is_empty() {
928        return Err(refused(verb, problems));
929    }
930
931    // Step 6: the portable collision check. The receiving fleet has
932    // case-insensitive filesystems, so paths that fold together after
933    // NFC and ASCII case folding would corrupt the mirror on
934    // extraction.
935    let mut problems = Vec::new();
936    for (key, owner) in &owners {
937        if owner.len() > 1 {
938            problems.push(format!(
939                "text_path collision on {key}: {}",
940                owner.join(", ")
941            ));
942        }
943    }
944    if !problems.is_empty() {
945        return Err(refused(verb, problems));
946    }
947
948    // Step 7: the descriptor's counts and coverage match the shards
949    // exactly, named per division and per field.
950    let mut problems = Vec::new();
951    let count_fields = |c: &DivisionCounts| {
952        [
953            ("converted", c.converted),
954            ("failed", c.failed),
955            ("unsupported", c.unsupported),
956            ("skipped_unchanged", c.skipped_unchanged),
957            ("dedup", c.dedup),
958            ("total", c.total),
959        ]
960    };
961    let divisions_union: BTreeSet<&String> =
962        descriptor.counts.keys().chain(counts.keys()).collect();
963    for division in divisions_union {
964        match (descriptor.counts.get(division), counts.get(division)) {
965            (Some(recorded), Some(recomputed)) => {
966                for ((field, recorded), (_, recomputed)) in count_fields(recorded)
967                    .into_iter()
968                    .zip(count_fields(recomputed))
969                {
970                    if recorded != recomputed {
971                        problems.push(format!(
972                            "counts.{field} for {division}: descriptor {recorded}, shards {recomputed}"
973                        ));
974                    }
975                }
976            }
977            (Some(_), None) => problems.push(format!("counts for {division}: no such shard")),
978            (None, Some(_)) => {
979                problems.push(format!("counts for {division}: absent from the descriptor"));
980            }
981            (None, None) => {}
982        }
983    }
984    let coverage_fields = |c: &DivisionCoverage| [("covered", c.covered), ("total", c.total)];
985    let divisions_union: BTreeSet<&String> =
986        descriptor.coverage.keys().chain(coverage.keys()).collect();
987    for division in divisions_union {
988        match (descriptor.coverage.get(division), coverage.get(division)) {
989            (Some(recorded), Some(recomputed)) => {
990                for ((field, recorded), (_, recomputed)) in coverage_fields(recorded)
991                    .into_iter()
992                    .zip(coverage_fields(recomputed))
993                {
994                    if recorded != recomputed {
995                        problems.push(format!(
996                            "coverage.{field} for {division}: descriptor {recorded}, shards {recomputed}"
997                        ));
998                    }
999                }
1000            }
1001            (Some(_), None) => problems.push(format!("coverage for {division}: no such shard")),
1002            (None, Some(_)) => {
1003                problems.push(format!(
1004                    "coverage for {division}: absent from the descriptor"
1005                ));
1006            }
1007            (None, None) => {}
1008        }
1009    }
1010    if !problems.is_empty() {
1011        return Err(refused(verb, problems));
1012    }
1013
1014    Ok(VerifyReport {
1015        bundle: root.display().to_string(),
1016        divisions: descriptor.divisions.clone(),
1017        files_verified: listed.len() as u64,
1018        records: record_count,
1019        counts,
1020        coverage,
1021    })
1022}
1023
1024/// The `merge` verb output.
1025#[derive(Debug, Clone, Serialize, Deserialize)]
1026pub struct MergeReport {
1027    /// The merged bundle root.
1028    pub output: String,
1029    /// How many input bundles merged.
1030    pub inputs: u64,
1031    /// Divisions the merged bundle carries.
1032    pub divisions: Vec<String>,
1033    /// BLAKE3 hex of the merged checksums.b3.
1034    pub checksums_hash: String,
1035}
1036
1037/// Merges two or more bundles into one, checking each in full first.
1038///
1039/// Every input verifies first, divisions must be disjoint
1040/// case-insensitively, rules snapshots must be byte-identical, and
1041/// every shard, artifact, and rules file copies through byte for
1042/// byte. The output verifies before the merge reports success.
1043pub fn merge(inputs: &[PathBuf], output: &Path) -> Result<MergeReport> {
1044    let verb = "merge";
1045    if inputs.len() < 2 {
1046        return Err(Error::Layout {
1047            message: "merge needs at least two input bundles".to_string(),
1048        });
1049    }
1050
1051    // Verify every input and load its descriptor. The schema tags
1052    // make every parsed descriptor carry identical schema and
1053    // manifest_schema strings, so equality holds by construction.
1054    let mut descriptors = Vec::new();
1055    for input in inputs {
1056        verify(input).map_err(|error| match error {
1057            Error::Refused { problems, .. } => refused(
1058                verb,
1059                problems
1060                    .into_iter()
1061                    .map(|p| format!("input {}: {p}", input.display()))
1062                    .collect(),
1063            ),
1064            other => other,
1065        })?;
1066        descriptors.push((input.clone(), read_descriptor(input)?));
1067    }
1068
1069    // Divisions must be disjoint, case-insensitively, because a
1070    // re-bundle supersedes rather than unions and merge never guesses
1071    // which same-named division is newer.
1072    let mut problems = Vec::new();
1073    let mut owners: BTreeMap<String, Vec<String>> = BTreeMap::new();
1074    for (input, descriptor) in &descriptors {
1075        for division in &descriptor.divisions {
1076            owners
1077                .entry(division.to_ascii_lowercase())
1078                .or_default()
1079                .push(format!("{division} in {}", input.display()));
1080        }
1081    }
1082    for owner in owners.values() {
1083        if owner.len() > 1 {
1084            problems.push(format!(
1085                "division appears in more than one input: {}",
1086                owner.join(", ")
1087            ));
1088        }
1089    }
1090
1091    // One bundle states one rules provenance.
1092    let rules_snapshot = |root: &Path| -> Result<BTreeMap<String, Vec<u8>>> {
1093        let rules_root = root.join("rules");
1094        let mut snapshot = BTreeMap::new();
1095        for relative in enumerate_files(&rules_root)? {
1096            let bytes = fs::read(rules_root.join(&relative))
1097                .map_err(|e| Error::io("read", &rules_root.join(&relative), e))?;
1098            snapshot.insert(relative, bytes);
1099        }
1100        Ok(snapshot)
1101    };
1102    let first_rules = rules_snapshot(&descriptors[0].0)?;
1103    for (input, _) in &descriptors[1..] {
1104        if rules_snapshot(input)? != first_rules {
1105            problems.push(format!(
1106                "rules snapshot in {} differs from {}",
1107                input.display(),
1108                descriptors[0].0.display()
1109            ));
1110        }
1111    }
1112    if !problems.is_empty() {
1113        return Err(refused(verb, problems));
1114    }
1115
1116    refuse_dirty_output(output)?;
1117    fs::create_dir_all(output).map_err(|e| Error::io("create_dir", output, e))?;
1118
1119    // Copy everything byte-identical: shards, mirror trees, and the
1120    // shared rules snapshot. Nothing is rewritten.
1121    let mut checksum_entries: Vec<String> = Vec::new();
1122    for (input, _descriptor) in &descriptors {
1123        for relative in enumerate_files(input)? {
1124            if relative == DESCRIPTOR_NAME || relative == CHECKSUMS_NAME {
1125                continue;
1126            }
1127            if relative.starts_with("rules/") && *input != descriptors[0].0 {
1128                continue;
1129            }
1130            let source = input.join(&relative);
1131            let dest = output.join(&relative);
1132            if let Some(dir) = dest.parent() {
1133                fs::create_dir_all(dir).map_err(|e| Error::io("create_dir", dir, e))?;
1134            }
1135            fs::copy(&source, &dest).map_err(|e| Error::io("copy", &source, e))?;
1136        }
1137        let checksums = fs::read_to_string(input.join(CHECKSUMS_NAME))
1138            .map_err(|e| Error::io("read", &input.join(CHECKSUMS_NAME), e))?;
1139        checksum_entries.extend(checksums.lines().map(str::to_string));
1140    }
1141    // Every division's mirror subtree exists in the output, empty
1142    // divisions included, so the merged layout stays normative.
1143    for (_, descriptor) in &descriptors {
1144        for division in &descriptor.divisions {
1145            let dir = output.join("mirror").join(division);
1146            fs::create_dir_all(&dir).map_err(|e| Error::io("create_dir", &dir, e))?;
1147        }
1148    }
1149    // Lines sort bytewise by path, not by the whole line, so the
1150    // merged file keeps the same canonical order bundle writes.
1151    checksum_entries.sort_by(|a, b| {
1152        let path_of = |line: &str| line.split_once("  ").map(|(_, p)| p.to_string());
1153        path_of(a).cmp(&path_of(b)).then_with(|| a.cmp(b))
1154    });
1155    checksum_entries.dedup();
1156    let mut checksums = checksum_entries.join("\n");
1157    checksums.push('\n');
1158    let checksums_hash = hash::hash_bytes(checksums.as_bytes());
1159    write_file(output, CHECKSUMS_NAME, checksums.as_bytes())?;
1160
1161    // Aggregate the descriptor: sorted unions, entries carried
1162    // through unchanged.
1163    let mut run_ids: Vec<String> = descriptors
1164        .iter()
1165        .flat_map(|(_, d)| d.run_ids.iter().cloned())
1166        .collect();
1167    run_ids.sort();
1168    run_ids.dedup();
1169    let mut divisions: Vec<String> = descriptors
1170        .iter()
1171        .flat_map(|(_, d)| d.divisions.iter().cloned())
1172        .collect();
1173    divisions.sort();
1174    let mut counts = BTreeMap::new();
1175    let mut coverage = BTreeMap::new();
1176    for (_, descriptor) in &descriptors {
1177        counts.extend(descriptor.counts.clone());
1178        coverage.extend(descriptor.coverage.clone());
1179    }
1180    let descriptor = BundleDescriptor {
1181        schema: BundleSchema,
1182        manifest_schema: ManifestSchema,
1183        run_ids,
1184        divisions: divisions.clone(),
1185        counts,
1186        coverage,
1187        checksums_hash: checksums_hash.clone(),
1188    };
1189    write_file(output, DESCRIPTOR_NAME, &descriptor_bytes(&descriptor)?)?;
1190
1191    // A merge that emits an unverifiable bundle is a failed merge.
1192    verify(output).map_err(|error| match error {
1193        Error::Refused { problems, .. } => refused(
1194            verb,
1195            problems
1196                .into_iter()
1197                .map(|p| format!("merged output: {p}"))
1198                .collect(),
1199        ),
1200        other => other,
1201    })?;
1202
1203    Ok(MergeReport {
1204        output: output.display().to_string(),
1205        inputs: inputs.len() as u64,
1206        divisions,
1207        checksums_hash,
1208    })
1209}
1210
1211#[cfg(test)]
1212mod tests {
1213    use super::*;
1214
1215    #[test]
1216    fn run_id_grammar_accepts_and_rejects() {
1217        assert!(valid_run_id("a"));
1218        assert!(valid_run_id(&"f".repeat(64)));
1219        assert!(valid_run_id("run-1.2_3"));
1220        assert!(!valid_run_id(""));
1221        assert!(!valid_run_id(&"f".repeat(65)));
1222        assert!(!valid_run_id("-leading-dash"));
1223        assert!(!valid_run_id(".leading-dot"));
1224        assert!(!valid_run_id("has space"));
1225        assert!(!valid_run_id("ctrl\u{7}char"));
1226        assert!(!valid_run_id("caf\u{e9}"));
1227    }
1228
1229    #[test]
1230    fn collision_keys_fold_ascii_case_and_nfc() {
1231        assert_eq!(
1232            collision_key("emea/A.TXT.txt"),
1233            collision_key("emea/a.txt.txt")
1234        );
1235        // Composed and decomposed forms of the same accented name.
1236        assert_eq!(
1237            collision_key("emea/r\u{e9}sum\u{e9}.txt"),
1238            collision_key("emea/re\u{301}sume\u{301}.txt")
1239        );
1240        // The fold is ASCII-only: non-ASCII case stays distinct.
1241        assert_ne!(
1242            collision_key("emea/\u{c9}.txt"),
1243            collision_key("emea/\u{e9}.txt")
1244        );
1245        assert_ne!(collision_key("emea/a.txt"), collision_key("apac/a.txt"));
1246    }
1247}