Skip to main content

varve_core/
crateexport.rs

1//! Cargo local-registry export (REQ-CRATE-001).
2//!
3//! A `crate`-kind entry carries a `.crate` tarball. `export-cargo` materialises
4//! a Cargo LOCAL REGISTRY from the verified store — the `.crate` files plus a
5//! registry index — and emits a `.cargo/config.toml` source-replacement, so a
6//! consumer builds fully offline against crates whose bytes varve signed.
7//!
8//! The trust chain needs nothing new: a `.crate` is signed like any blob (its
9//! digest is in the DSSE-signed layer manifest), and that digest IS the sha256
10//! Cargo records as the index `cksum`. Cargo re-checks the `.crate` against the
11//! cksum, so the consumer verifies a second time on its own terms.
12
13use std::path::Path;
14
15/// One crate to place in the registry: its identity, the Cargo cksum (bare
16/// sha256 hex of the `.crate`), and the tarball bytes.
17#[derive(Debug, Clone)]
18pub struct CrateEntry {
19    pub name: String,
20    pub version: String,
21    /// Bare sha256 hex (no `sha256:` prefix) of the `.crate` tarball.
22    pub cksum: String,
23    pub bytes: Vec<u8>,
24}
25
26/// The registry-index sub-path for a crate name, per Cargo's layout: 1/2/3
27/// char names get special prefixes, 4+ use first-two/next-two. Lowercased.
28pub fn index_path(name: &str) -> String {
29    let n = name.to_lowercase();
30    // Slice by CHARACTER, not byte: matching on `chars().count()` and then
31    // indexing bytes panicked mid-codepoint on any non-ASCII name. Export
32    // paths refuse such names outright (`validate_crate_name`); this stays
33    // total anyway so a layout helper can never crash a client.
34    let take =
35        |from: usize, to: usize| -> String { n.chars().skip(from).take(to - from).collect() };
36    match n.chars().count() {
37        1 => format!("1/{n}"),
38        2 => format!("2/{n}"),
39        3 => format!("3/{}/{n}", take(0, 1)),
40        _ => format!("{}/{}/{n}", take(0, 2), take(2, 4)),
41    }
42}
43
44/// Refuse a crate name Cargo's registry index cannot express, or that would
45/// corrupt the index line's JSON. Cargo's rule: ASCII alphanumeric, `-`, `_`,
46/// non-empty. Failing closed here keeps `index_path`/`index_line` honest — the
47/// alternative is a panic or a silently malformed registry (REQ-CRATENAME-001).
48pub fn validate_crate_name(name: &str) -> Result<(), CrateExportError> {
49    if name.is_empty() {
50        return Err(CrateExportError::UnrepresentableName {
51            name: name.to_string(),
52            why: "empty".into(),
53        });
54    }
55    if let Some(bad) = name
56        .chars()
57        .find(|c| !(c.is_ascii_alphanumeric() || *c == '-' || *c == '_'))
58    {
59        return Err(CrateExportError::UnrepresentableName {
60            name: name.to_string(),
61            why: format!("contains {bad:?}; Cargo names are ASCII alphanumeric, '-' or '_'"),
62        });
63    }
64    Ok(())
65}
66
67/// Refuse a version string that would corrupt the index line's JSON. Semver's
68/// own alphabet (alphanumerics, `.`, `-`, `+`) admits no quote or backslash.
69pub fn validate_crate_version(version: &str) -> Result<(), CrateExportError> {
70    if version.is_empty() {
71        return Err(CrateExportError::UnrepresentableVersion {
72            version: version.to_string(),
73            why: "empty".into(),
74        });
75    }
76    if let Some(bad) = version
77        .chars()
78        .find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+')))
79    {
80        return Err(CrateExportError::UnrepresentableVersion {
81            version: version.to_string(),
82            why: format!("contains {bad:?}; semver is ASCII alphanumeric, '.', '-' or '+'"),
83        });
84    }
85    Ok(())
86}
87
88/// Gate every export adapter: no entry leaves the store unless its name and
89/// version are representable. One place, so the adapters cannot drift apart.
90fn validate_entries(crates: &[CrateEntry]) -> Result<(), CrateExportError> {
91    for e in crates {
92        validate_crate_name(&e.name)?;
93        validate_crate_version(&e.version)?;
94        // The cksum is the THIRD string interpolated into the index JSON with
95        // no escaping. The CLI can only produce a real digest here (it re-hashes
96        // the bytes against the signed digest first), but this is a public
97        // library API and a caller could hand us anything.
98        if e.cksum.len() != 64 || !e.cksum.chars().all(|c| c.is_ascii_hexdigit()) {
99            return Err(CrateExportError::UnrepresentableCksum {
100                name: e.name.clone(),
101                cksum: e.cksum.clone(),
102            });
103        }
104    }
105    Ok(())
106}
107
108/// One index line for a crate version. `deps` empty is correct for a leaf
109/// crate; Cargo cross-checks the line against the `.crate`'s own Cargo.toml,
110/// so a crate with dependencies needs those listed (a follow-up parses them).
111pub fn index_line(entry: &CrateEntry) -> String {
112    // Compact JSON, one line, stable key order — Cargo parses per line.
113    format!(
114        r#"{{"name":"{}","vers":"{}","deps":[],"cksum":"{}","features":{{}},"yanked":false}}"#,
115        entry.name, entry.version, entry.cksum
116    )
117}
118
119/// The `.cargo/config.toml` that redirects crates.io to the local registry,
120/// so an unmodified `Cargo.toml` resolves against varve's verified bytes.
121pub fn cargo_config_toml(registry_dir: &Path) -> String {
122    format!(
123        "# Generated by `varve export-cargo` (REQ-CRATE-001).\n\
124         # Redirects crates.io to a varve-verified local registry; build --offline.\n\
125         [source.crates-io]\n\
126         replace-with = \"varve\"\n\n\
127         [source.varve]\n\
128         local-registry = \"{}\"\n",
129        registry_dir.display()
130    )
131}
132
133#[derive(Debug, thiserror::Error)]
134pub enum CrateExportError {
135    #[error("io error at {path}: {source}")]
136    Io {
137        path: String,
138        #[source]
139        source: std::io::Error,
140    },
141    #[error("crate name {name:?} cannot be exported: {why}")]
142    UnrepresentableName { name: String, why: String },
143    #[error("crate version {version:?} cannot be exported: {why}")]
144    UnrepresentableVersion { version: String, why: String },
145    #[error("crate {name:?} has a cksum that is not a bare sha256 hex digest: {cksum:?}")]
146    UnrepresentableCksum { name: String, cksum: String },
147}
148
149/// The `.cargo-checksum.json` for a vendored crate. `package` is the sha256 of
150/// the `.crate` tarball — the SAME digest varve signs, so the upstream
151/// integrity anchor is preserved into the vendored tree, not discarded
152/// (REQ-BRIDGE-001). `files` empty is valid for a registry-sourced crate
153/// (matches real `cargo vendor` output for registry crates).
154pub fn cargo_checksum_json(cksum: &str) -> String {
155    format!(r#"{{"files":{{}},"package":"{cksum}"}}"#)
156}
157
158/// The consumer config pairing with a vendored directory: redirects crates.io
159/// to the on-disk unpacked trees. Consumed natively by bare Cargo and by
160/// Corrosion (both proven offline). rules_rust needs generated BUILD files on
161/// top of this tree — its `crate.from_cargo` splice wants a registry index and
162/// rejects a bare directory source (v0.16.0 spike); see REQ-VENDOR-002.
163pub fn vendored_config_toml(vendor_dir: &Path) -> String {
164    format!(
165        "# Generated by `varve export-crates-vendor` (REQ-VENDOR-001).\n\
166         [source.crates-io]\n\
167         replace-with = \"vendored-sources\"\n\n\
168         [source.vendored-sources]\n\
169         directory = \"{}\"\n",
170        vendor_dir.display()
171    )
172}
173
174/// Materialise a `cargo vendor`-shaped directory from verified crate entries:
175/// each `.crate` UNPACKED into `<vendor>/<name>-<version>/` with its
176/// `.cargo-checksum.json`. Proven offline-consumable by bare Cargo and
177/// Corrosion. (rules_rust needs BUILD files over this tree — REQ-VENDOR-002.)
178/// Returns the crate count.
179pub fn export_vendor_dir(
180    crates: &[CrateEntry],
181    vendor_dir: &Path,
182) -> Result<usize, CrateExportError> {
183    // Fail closed before writing anything: an unrepresentable name would
184    // panic the index layout or corrupt the index JSON (REQ-CRATENAME-001).
185    validate_entries(crates)?;
186    let io = |path: &Path, source: std::io::Error| CrateExportError::Io {
187        path: path.display().to_string(),
188        source,
189    };
190    std::fs::create_dir_all(vendor_dir).map_err(|e| io(vendor_dir, e))?;
191    for entry in crates {
192        // A `.crate` is a gzip tar whose top-level dir is `<name>-<version>/`.
193        let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(entry.bytes.as_slice()));
194        archive.unpack(vendor_dir).map_err(|e| io(vendor_dir, e))?;
195        let crate_dir = vendor_dir.join(format!("{}-{}", entry.name, entry.version));
196        let checksum = crate_dir.join(".cargo-checksum.json");
197        std::fs::write(&checksum, cargo_checksum_json(&entry.cksum))
198            .map_err(|e| io(&checksum, e))?;
199    }
200    Ok(crates.len())
201}
202
203/// Materialise a Cargo local registry at `registry_dir`: write each `.crate`
204/// file and its index entry. Returns the number of crates written.
205pub fn export_local_registry(
206    crates: &[CrateEntry],
207    registry_dir: &Path,
208) -> Result<usize, CrateExportError> {
209    // Fail closed before writing anything: an unrepresentable name would
210    // panic the index layout or corrupt the index JSON (REQ-CRATENAME-001).
211    validate_entries(crates)?;
212    let io = |path: &Path, source: std::io::Error| CrateExportError::Io {
213        path: path.display().to_string(),
214        source,
215    };
216    std::fs::create_dir_all(registry_dir).map_err(|e| io(registry_dir, e))?;
217    for entry in crates {
218        // The .crate tarball at <registry>/<name>-<version>.crate.
219        let crate_file = registry_dir.join(format!("{}-{}.crate", entry.name, entry.version));
220        std::fs::write(&crate_file, &entry.bytes).map_err(|e| io(&crate_file, e))?;
221
222        // The index entry, appended (multiple versions share one file).
223        let idx = registry_dir.join("index").join(index_path(&entry.name));
224        if let Some(parent) = idx.parent() {
225            std::fs::create_dir_all(parent).map_err(|e| io(parent, e))?;
226        }
227        let mut line = index_line(entry);
228        line.push('\n');
229        // De-dup by (name, vers): rewrite the file with this version present.
230        let existing = std::fs::read_to_string(&idx).unwrap_or_default();
231        let prefix = format!(r#"{{"name":"{}","vers":"{}""#, entry.name, entry.version);
232        let mut kept: Vec<String> = existing
233            .lines()
234            .filter(|l| !l.starts_with(&prefix))
235            .map(str::to_string)
236            .collect();
237        kept.push(line.trim_end().to_string());
238        std::fs::write(&idx, kept.join("\n") + "\n").map_err(|e| io(&idx, e))?;
239    }
240    Ok(crates.len())
241}
242
243/// Materialise a Bazel **distdir** of the verified `.crate` tarballs
244/// (REQ-VENDOR-002, air-gap rules_rust). Bazel's `--distdir` resolves an
245/// `http_archive` from a local file whose sha256 matches — with NO network and
246/// NO URL rewrite. Because varve's signed `.crate` digest IS the `crate_universe`
247/// pin (== the crates.io checksum), a consumer that pre-generates its
248/// `crate_universe` output once and then builds with
249/// `bazel build --distdir=<this dir>` (network off) resolves every crate from
250/// varve's verified bytes. varve emits the bytes; the consumer commits the
251/// generated `crate_universe` output (so no repin/index lookup at build time).
252/// Returns the number of tarballs written.
253pub fn export_distdir(crates: &[CrateEntry], distdir: &Path) -> Result<usize, CrateExportError> {
254    // Fail closed before writing anything: an unrepresentable name would
255    // panic the index layout or corrupt the index JSON (REQ-CRATENAME-001).
256    validate_entries(crates)?;
257    let io = |path: &Path, source: std::io::Error| CrateExportError::Io {
258        path: path.display().to_string(),
259        source,
260    };
261    std::fs::create_dir_all(distdir).map_err(|e| io(distdir, e))?;
262    for entry in crates {
263        // Bazel matches distdir files by content sha256; the name is for
264        // humans. `<name>-<version>.crate` mirrors the crates.io asset name.
265        let file = distdir.join(format!("{}-{}.crate", entry.name, entry.version));
266        std::fs::write(&file, &entry.bytes).map_err(|e| io(&file, e))?;
267    }
268    Ok(crates.len())
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    // rivet: verifies REQ-CRATE-001
276    #[test]
277    fn index_paths_follow_cargos_layout() {
278        assert_eq!(index_path("a"), "1/a");
279        assert_eq!(index_path("ab"), "2/ab");
280        assert_eq!(index_path("abc"), "3/a/abc");
281        assert_eq!(index_path("serde"), "se/rd/serde");
282        assert_eq!(index_path("Varve-SDK"), "va/rv/varve-sdk"); // lowercased
283    }
284
285    // rivet: verifies REQ-CRATENAME-001
286    #[test]
287    fn a_non_ascii_crate_name_is_an_error_not_a_panic() {
288        // `chars().count()` + BYTE slicing used to panic mid-codepoint here.
289        // A name Cargo's index layout cannot express must fail closed.
290        for bad in ["日本語", "ααα", "café-utils"] {
291            assert!(
292                validate_crate_name(bad).is_err(),
293                "{bad} must be refused, not sliced"
294            );
295            // And the layout function itself must never panic, whatever it gets.
296            let _ = index_path(bad);
297        }
298    }
299
300    // rivet: verifies REQ-CRATENAME-001
301    #[test]
302    fn a_name_or_version_that_would_corrupt_the_index_json_is_refused() {
303        // index_line interpolates into JSON; a quote/backslash would break the
304        // line Cargo parses. Refuse at the gate rather than emit corrupt JSON.
305        assert!(validate_crate_name("evil\"name").is_err());
306        assert!(validate_crate_name("back\\slash").is_err());
307        assert!(validate_crate_name("").is_err());
308        assert!(validate_crate_version("1.0.0\"").is_err());
309        assert!(validate_crate_name("serde_json").is_ok());
310        assert!(validate_crate_name("varve-core").is_ok());
311        assert!(validate_crate_version("0.1.0-alpha.1+build.2").is_ok());
312    }
313
314    // rivet: verifies REQ-CRATENAME-001
315    #[test]
316    fn export_refuses_an_unrepresentable_crate_name() {
317        let dir = tempfile::tempdir().unwrap();
318        let bad = [CrateEntry {
319            name: "café-utils".into(),
320            version: "0.1.0".into(),
321            cksum: "a".repeat(64),
322            bytes: vec![],
323        }];
324        // Every export adapter fails closed on the same input.
325        assert!(export_local_registry(&bad, dir.path()).is_err());
326        assert!(export_vendor_dir(&bad, dir.path()).is_err());
327        assert!(export_distdir(&bad, dir.path()).is_err());
328    }
329
330    // rivet: verifies REQ-CRATE-001
331    #[test]
332    fn an_index_line_carries_the_cksum_cargo_will_verify() {
333        let e = CrateEntry {
334            name: "demo".into(),
335            version: "0.1.0".into(),
336            cksum: "b".repeat(64),
337            bytes: vec![],
338        };
339        let line = index_line(&e);
340        assert!(line.contains(r#""name":"demo""#));
341        assert!(line.contains(r#""vers":"0.1.0""#));
342        assert!(line.contains(&format!(r#""cksum":"{}""#, "b".repeat(64))));
343        assert!(line.contains(r#""yanked":false"#));
344    }
345
346    // rivet: verifies REQ-CRATENAME-001
347    #[test]
348    fn vendoring_never_writes_outside_the_vendor_directory() {
349        // `export_vendor_dir` untars depositor-supplied bytes. The tar crate
350        // refuses both escapes today; this pins that guarantee so a dependency
351        // bump or a switch to manual entry handling cannot silently lose it
352        // (the Cargo CVE-2026-5223 bug class: symlink out, then write through).
353        use std::io::Write;
354        let dir = tempfile::tempdir().unwrap();
355        let outside = dir.path().join("OUTSIDE");
356        std::fs::create_dir_all(&outside).unwrap();
357        let vendor = dir.path().join("vendor");
358
359        let mut tar_bytes = Vec::new();
360        {
361            let mut b = tar::Builder::new(&mut tar_bytes);
362            // 1. a symlink escaping the destination …
363            let mut link = tar::Header::new_gnu();
364            link.set_entry_type(tar::EntryType::Symlink);
365            link.set_size(0);
366            link.set_mode(0o777);
367            b.append_link(&mut link, "escape-0.1.0/link", &outside)
368                .unwrap();
369            // 2. … then a write THROUGH it.
370            let payload = b"PWNED";
371            let mut f = tar::Header::new_gnu();
372            f.set_size(payload.len() as u64);
373            f.set_mode(0o644);
374            b.append_data(&mut f, "escape-0.1.0/link/pwned.txt", &payload[..])
375                .unwrap();
376            b.finish().unwrap();
377        }
378        let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
379        gz.write_all(&tar_bytes).unwrap();
380        let evil = gz.finish().unwrap();
381
382        let entries = [CrateEntry {
383            name: "escape".into(),
384            version: "0.1.0".into(),
385            cksum: "c".repeat(64),
386            bytes: evil,
387        }];
388        // Whether it errors or skips the entry, the invariant is the same:
389        // nothing may be written outside the vendor directory.
390        let _ = export_vendor_dir(&entries, &vendor);
391        assert!(
392            !outside.join("pwned.txt").exists(),
393            "a crate tarball escaped the vendor directory"
394        );
395        assert!(
396            std::fs::read_dir(&outside).unwrap().next().is_none(),
397            "nothing may be written outside the vendor directory"
398        );
399    }
400
401    // rivet: verifies REQ-VENDOR-001, REQ-BRIDGE-001
402    #[test]
403    fn vendoring_unpacks_the_crate_and_preserves_the_upstream_hash() {
404        // A `.crate`-shaped gzip tar with the top-level <name>-<version>/ dir.
405        let mut builder = tar::Builder::new(flate2::write::GzEncoder::new(
406            Vec::new(),
407            flate2::Compression::default(),
408        ));
409        for (name, body) in [
410            ("demo-0.1.0/Cargo.toml", "[package]\nname=\"demo\"\n"),
411            ("demo-0.1.0/src/lib.rs", "pub fn f() {}\n"),
412        ] {
413            let mut h = tar::Header::new_gnu();
414            h.set_size(body.len() as u64);
415            h.set_mode(0o644);
416            h.set_cksum();
417            builder.append_data(&mut h, name, body.as_bytes()).unwrap();
418        }
419        let targz = builder.into_inner().unwrap().finish().unwrap();
420
421        let tmp = tempfile::tempdir().unwrap();
422        let vendor = tmp.path().join("vendor");
423        let e = CrateEntry {
424            name: "demo".into(),
425            version: "0.1.0".into(),
426            cksum: "d".repeat(64),
427            bytes: targz,
428        };
429        assert_eq!(
430            export_vendor_dir(std::slice::from_ref(&e), &vendor).unwrap(),
431            1
432        );
433        // The crate is UNPACKED (a directory source, not a tarball).
434        assert!(vendor.join("demo-0.1.0/Cargo.toml").is_file());
435        assert!(vendor.join("demo-0.1.0/src/lib.rs").is_file());
436        // The upstream integrity anchor (the .crate sha256) is preserved.
437        let checksum =
438            std::fs::read_to_string(vendor.join("demo-0.1.0/.cargo-checksum.json")).unwrap();
439        assert_eq!(
440            checksum,
441            format!(r#"{{"files":{{}},"package":"{}"}}"#, "d".repeat(64))
442        );
443    }
444
445    // rivet: verifies REQ-VENDOR-002
446    #[test]
447    fn a_distdir_holds_the_verified_crate_bytes_keyed_for_bazel() {
448        let tmp = tempfile::tempdir().unwrap();
449        let dd = tmp.path().join("distdir");
450        let bytes = b"the-verified-crate-tarball-bytes".to_vec();
451        // varve's cksum IS the sha256 of these bytes == the crate_universe pin.
452        let cksum = {
453            use sha2::{Digest, Sha256};
454            hex::encode(Sha256::digest(&bytes))
455        };
456        let e = CrateEntry {
457            name: "cfg-if".into(),
458            version: "1.0.0".into(),
459            cksum: cksum.clone(),
460            bytes: bytes.clone(),
461        };
462        assert_eq!(export_distdir(std::slice::from_ref(&e), &dd).unwrap(), 1);
463        let file = dd.join("cfg-if-1.0.0.crate");
464        // The exact verified bytes are present...
465        assert_eq!(std::fs::read(&file).unwrap(), bytes);
466        // ...and their sha256 is the join key Bazel's --distdir matches on.
467        let on_disk = {
468            use sha2::{Digest, Sha256};
469            hex::encode(Sha256::digest(std::fs::read(&file).unwrap()))
470        };
471        assert_eq!(
472            on_disk, cksum,
473            "distdir file sha256 must equal the crate_universe pin"
474        );
475    }
476
477    // rivet: verifies REQ-VENDOR-001
478    #[test]
479    fn the_vendored_config_replaces_with_a_directory_source() {
480        let cfg = vendored_config_toml(Path::new("/v/dir"));
481        assert!(cfg.contains(r#"replace-with = "vendored-sources""#));
482        assert!(cfg.contains(r#"directory = "/v/dir""#));
483    }
484
485    // rivet: verifies REQ-CRATE-001
486    #[test]
487    fn config_redirects_crates_io_to_the_local_registry() {
488        let cfg = cargo_config_toml(Path::new("/verified/reg"));
489        assert!(cfg.contains(r#"replace-with = "varve""#));
490        assert!(cfg.contains(r#"local-registry = "/verified/reg""#));
491    }
492
493    // rivet: verifies REQ-CRATE-001
494    #[test]
495    fn materialising_writes_the_crate_and_a_matching_index_entry() {
496        let tmp = tempfile::tempdir().unwrap();
497        let reg = tmp.path().join("registry");
498        let e = CrateEntry {
499            name: "demo".into(),
500            version: "0.1.0".into(),
501            cksum: "e".repeat(64),
502            bytes: b"crate-tarball-bytes".to_vec(),
503        };
504        assert_eq!(
505            export_local_registry(std::slice::from_ref(&e), &reg).unwrap(),
506            1
507        );
508        // The .crate file is the verified bytes.
509        assert_eq!(
510            std::fs::read(reg.join("demo-0.1.0.crate")).unwrap(),
511            b"crate-tarball-bytes"
512        );
513        // The index entry is at Cargo's path and carries the cksum.
514        let idx = std::fs::read_to_string(reg.join("index/de/mo/demo")).unwrap();
515        assert!(idx.contains(&format!(r#""cksum":"{}""#, "e".repeat(64))));
516
517        // Re-exporting the same version does not duplicate the index line.
518        export_local_registry(std::slice::from_ref(&e), &reg).unwrap();
519        let idx2 = std::fs::read_to_string(reg.join("index/de/mo/demo")).unwrap();
520        assert_eq!(idx2.lines().count(), 1, "one line per (name, version)");
521    }
522
523    // rivet: verifies REQ-STORE-002
524    #[test]
525    fn a_registry_exported_from_a_layer_offers_every_version_it_pins() {
526        // Clause 5. A lockfile that names two majors of one crate needs BOTH
527        // present to build offline — varve's own lockfile has 14 such names —
528        // so the exported registry must OFFER both, with an index entry per
529        // version carrying that version's own cksum. Checked by reading the
530        // index Cargo reads, not by counting what we handed in.
531        use sha2::{Digest, Sha256};
532        let tmp = tempfile::tempdir().unwrap();
533        let reg = tmp.path().join("registry");
534        let bytes = |v: &str| format!("serde-{v}-crate-tarball").into_bytes();
535        let entry = |v: &str| CrateEntry {
536            name: "serde".into(),
537            version: v.into(),
538            cksum: hex::encode(Sha256::digest(bytes(v))),
539            bytes: bytes(v),
540        };
541        let crates = [entry("1.0.200"), entry("1.0.210")];
542        export_local_registry(&crates, &reg).unwrap();
543
544        // Both tarballs are on disk, each holding its OWN bytes.
545        for v in ["1.0.200", "1.0.210"] {
546            assert_eq!(
547                std::fs::read(reg.join(format!("serde-{v}.crate"))).unwrap(),
548                bytes(v),
549                "version {v} must export its own bytes"
550            );
551        }
552
553        // And the index — one JSON line per version, at Cargo's path, each
554        // with the cksum Cargo will check the corresponding tarball against.
555        let idx = std::fs::read_to_string(reg.join("index/se/rd/serde")).unwrap();
556        let lines: Vec<serde_json::Value> = idx
557            .lines()
558            .filter(|l| !l.trim().is_empty())
559            .map(|l| serde_json::from_str(l).expect("each index line is JSON Cargo can parse"))
560            .collect();
561        let mut offered: Vec<(String, String)> = lines
562            .iter()
563            .map(|l| {
564                (
565                    l["vers"].as_str().unwrap().to_string(),
566                    l["cksum"].as_str().unwrap().to_string(),
567                )
568            })
569            .collect();
570        offered.sort();
571        assert_eq!(
572            offered,
573            vec![
574                (
575                    "1.0.200".to_string(),
576                    hex::encode(Sha256::digest(bytes("1.0.200")))
577                ),
578                (
579                    "1.0.210".to_string(),
580                    hex::encode(Sha256::digest(bytes("1.0.210")))
581                ),
582            ],
583            "the index must offer BOTH versions, each bound to its own bytes"
584        );
585        assert!(lines.iter().all(|l| l["name"] == "serde"));
586
587        // A distdir and a vendor tree hold both versions too — the same layer
588        // must be exportable to every adapter without one version vanishing.
589        let dd = tmp.path().join("distdir");
590        export_distdir(&crates, &dd).unwrap();
591        assert_eq!(
592            std::fs::read(dd.join("serde-1.0.200.crate")).unwrap(),
593            bytes("1.0.200")
594        );
595        assert_eq!(
596            std::fs::read(dd.join("serde-1.0.210.crate")).unwrap(),
597            bytes("1.0.210")
598        );
599    }
600}