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 dependency exactly as a Cargo registry index expresses it
109/// (REQ-CRATEIDX-001 clause 1). Field names and order mirror crates.io's index
110/// so a line varve writes and a line crates.io writes are the same shape.
111#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
112pub struct IndexDep {
113    /// The name the dependant refers to it by — the RENAMED name if renamed;
114    /// `package` then carries the real crate name.
115    pub name: String,
116    /// The SemVer requirement.
117    pub req: String,
118    pub features: Vec<String>,
119    pub optional: bool,
120    pub default_features: bool,
121    /// `None` for an unconditional dependency, else the `cfg(...)`/triple key.
122    pub target: Option<String>,
123    /// `normal`, `dev` or `build`.
124    pub kind: String,
125    /// Index URL of another registry, or `None` for this one.
126    pub registry: Option<String>,
127    /// The real crate name when `name` is a rename, else `None`.
128    pub package: Option<String>,
129}
130
131/// Everything an index line needs beyond identity and cksum, read from the
132/// `Cargo.toml` inside the signed `.crate` (REQ-CRATEIDX-001 clause 1).
133///
134/// `features2` exists because Cargo splits namespaced (`dep:foo`) and weak
135/// (`foo?/bar`) feature values into a second map guarded by `"v":2`, exactly as
136/// crates.io does; a modern Cargo merges the two on load.
137#[derive(Debug, Clone, Default, PartialEq, Eq)]
138pub struct CrateMeta {
139    pub deps: Vec<IndexDep>,
140    pub features: std::collections::BTreeMap<String, Vec<String>>,
141    pub features2: std::collections::BTreeMap<String, Vec<String>>,
142    pub links: Option<String>,
143    pub rust_version: Option<String>,
144}
145
146/// The serialised shape of one index line. A derived `Serialize` fixes the key
147/// order at the declaration order regardless of the map backing serde_json
148/// happens to be compiled with — so the bytes are a function of the crate, not
149/// of feature unification (REQ-REPRO-001 clause 1).
150#[derive(serde::Serialize)]
151struct IndexEntry {
152    name: String,
153    vers: String,
154    deps: Vec<IndexDep>,
155    cksum: String,
156    features: std::collections::BTreeMap<String, Vec<String>>,
157    yanked: bool,
158    #[serde(skip_serializing_if = "Option::is_none")]
159    links: Option<String>,
160    #[serde(skip_serializing_if = "Option::is_none")]
161    v: Option<u32>,
162    #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
163    features2: std::collections::BTreeMap<String, Vec<String>>,
164    #[serde(skip_serializing_if = "Option::is_none")]
165    rust_version: Option<String>,
166}
167
168/// The dependency sections of a Cargo manifest, and the index `kind` each maps
169/// to. One list, so the plain and `[target.…]` walks cannot drift apart.
170const DEP_SECTIONS: [(&str, &str); 3] = [
171    ("dependencies", "normal"),
172    ("dev-dependencies", "dev"),
173    ("build-dependencies", "build"),
174];
175
176/// Dependency keys varve knows how to transcribe. Anything else is refused by
177/// name rather than ignored — an ignored key is a changed meaning, and clause 2
178/// exists because a silently dropped entry is the failure mode that exits 0.
179const KNOWN_DEP_KEYS: [&str; 9] = [
180    "version",
181    "features",
182    "optional",
183    "default-features",
184    "default_features",
185    "package",
186    "registry-index",
187    "path",
188    "public",
189];
190
191/// Read the `deps` and `features` of a crate from the `Cargo.toml` inside its
192/// signed `.crate` tarball (REQ-CRATEIDX-001 clause 1).
193///
194/// The transcription is faithful or it refuses (clause 2): a dependency or a
195/// feature this cannot express is an error naming the crate, never an omission.
196/// Cargo resolves the graph FROM the index, so an index that under-reports is
197/// not a smaller truth — it is a build that can compile a crate with no
198/// features at all and exit 0.
199pub fn read_crate_meta(
200    name: &str,
201    version: &str,
202    tarball: &[u8],
203) -> Result<CrateMeta, CrateExportError> {
204    let text = manifest_text(name, version, tarball)?;
205    parse_crate_meta(name, version, &text)
206}
207
208/// Pull `<name>-<version>/Cargo.toml` out of a `.crate` gzip tar. The exact
209/// path is preferred; any single-directory `Cargo.toml` is accepted as a
210/// fallback, because the tarball's top directory is the producer's to name.
211fn manifest_text(name: &str, version: &str, tarball: &[u8]) -> Result<String, CrateExportError> {
212    use std::io::Read;
213    let unreadable = |why: String| CrateExportError::UnreadableManifest {
214        name: name.to_string(),
215        version: version.to_string(),
216        why,
217    };
218    let wanted = format!("{name}-{version}/Cargo.toml");
219    let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(tarball));
220    let entries = archive
221        .entries()
222        .map_err(|e| unreadable(format!("the .crate tarball could not be opened: {e}")))?;
223    let mut fallback: Option<String> = None;
224    for entry in entries {
225        let mut entry =
226            entry.map_err(|e| unreadable(format!("the .crate tarball is truncated: {e}")))?;
227        let path = entry
228            .path()
229            .map_err(|e| unreadable(format!("a tarball entry has an unusable path: {e}")))?
230            .to_string_lossy()
231            .into_owned();
232        let components: Vec<&str> = path.split('/').collect();
233        if components.len() != 2 || components[1] != "Cargo.toml" {
234            continue;
235        }
236        let mut text = String::new();
237        entry
238            .read_to_string(&mut text)
239            .map_err(|e| unreadable(format!("{path} could not be read: {e}")))?;
240        if path == wanted {
241            return Ok(text);
242        }
243        fallback.get_or_insert(text);
244    }
245    fallback.ok_or(CrateExportError::MissingManifest {
246        name: name.to_string(),
247        version: version.to_string(),
248    })
249}
250
251/// Transcribe a `Cargo.toml` into index metadata, refusing anything the index
252/// cannot express.
253fn parse_crate_meta(
254    name: &str,
255    version: &str,
256    manifest: &str,
257) -> Result<CrateMeta, CrateExportError> {
258    let doc: toml::Value =
259        toml::from_str(manifest).map_err(|e| CrateExportError::UnreadableManifest {
260            name: name.to_string(),
261            version: version.to_string(),
262            why: format!("its Cargo.toml is not valid TOML: {e}"),
263        })?;
264    let mut meta = CrateMeta::default();
265    if let Some(pkg) = doc.get("package").and_then(toml::Value::as_table) {
266        meta.links = pkg
267            .get("links")
268            .and_then(toml::Value::as_str)
269            .map(str::to_string);
270        meta.rust_version = pkg
271            .get("rust-version")
272            .and_then(toml::Value::as_str)
273            .map(str::to_string);
274    }
275    for (section, kind) in DEP_SECTIONS {
276        if let Some(table) = doc.get(section).and_then(toml::Value::as_table) {
277            collect_deps(name, version, table, kind, None, &mut meta.deps)?;
278        }
279    }
280    if let Some(targets) = doc.get("target").and_then(toml::Value::as_table) {
281        for (cfg, per_target) in targets {
282            let Some(per_target) = per_target.as_table() else {
283                return Err(CrateExportError::UnreadableManifest {
284                    name: name.to_string(),
285                    version: version.to_string(),
286                    why: format!("[target.{cfg}] is not a table"),
287                });
288            };
289            for (section, kind) in DEP_SECTIONS {
290                if let Some(table) = per_target.get(section).and_then(toml::Value::as_table) {
291                    collect_deps(name, version, table, kind, Some(cfg), &mut meta.deps)?;
292                }
293            }
294        }
295    }
296    if let Some(features) = doc.get("features").and_then(toml::Value::as_table) {
297        for (feature, values) in features {
298            let bad = |why: &str| CrateExportError::UnrepresentableFeature {
299                name: name.to_string(),
300                version: version.to_string(),
301                feature: feature.clone(),
302                why: why.to_string(),
303            };
304            let values = values.as_array().ok_or_else(|| bad("is not an array"))?;
305            let mut list = Vec::with_capacity(values.len());
306            for v in values {
307                list.push(
308                    v.as_str()
309                        .ok_or_else(|| bad("holds a value that is not a string"))?
310                        .to_string(),
311                );
312            }
313            // Namespaced (`dep:`) and weak (`?/`) values live in `features2`
314            // behind `"v":2`, exactly as crates.io splits them.
315            if list
316                .iter()
317                .any(|s| s.starts_with("dep:") || s.contains("?/"))
318            {
319                meta.features2.insert(feature.clone(), list);
320            } else {
321                meta.features.insert(feature.clone(), list);
322            }
323        }
324    }
325    Ok(meta)
326}
327
328/// Transcribe one dependency table (`[dependencies]`, `[dev-dependencies]`,
329/// `[build-dependencies]`, plain or under `[target.<cfg>]`) into index deps.
330fn collect_deps(
331    crate_name: &str,
332    crate_version: &str,
333    table: &toml::Table,
334    kind: &str,
335    target: Option<&str>,
336    out: &mut Vec<IndexDep>,
337) -> Result<(), CrateExportError> {
338    for (key, value) in table {
339        let refuse = |why: String| CrateExportError::UnrepresentableDep {
340            name: crate_name.to_string(),
341            version: crate_version.to_string(),
342            dep: key.clone(),
343            kind: kind.to_string(),
344            why,
345        };
346        let mut dep = IndexDep {
347            name: key.clone(),
348            req: String::new(),
349            features: Vec::new(),
350            optional: false,
351            default_features: true,
352            target: target.map(str::to_string),
353            kind: kind.to_string(),
354            registry: None,
355            package: None,
356        };
357        match value {
358            toml::Value::String(req) => dep.req = req.clone(),
359            toml::Value::Table(spec) => {
360                // Refuse what the index has no field for, BEFORE transcribing
361                // the rest — a half-transcribed dependency is the silent one.
362                if spec.get("workspace").and_then(toml::Value::as_bool) == Some(true) {
363                    return Err(refuse(
364                        "`workspace = true` is unresolved workspace inheritance; a packaged \
365                         .crate should carry the resolved requirement"
366                            .into(),
367                    ));
368                }
369                if spec.contains_key("git") {
370                    return Err(refuse(
371                        "a git dependency has no representation in a Cargo registry index, and \
372                         no local registry can satisfy it"
373                            .into(),
374                    ));
375                }
376                if let Some(alias) = spec.get("registry").and_then(toml::Value::as_str) {
377                    return Err(refuse(format!(
378                        "`registry = {alias:?}` is a local registry ALIAS; the index field is a \
379                         URL, and the alias means nothing to a consumer of this export"
380                    )));
381                }
382                if let Some(unknown) = spec.keys().find(|k| !KNOWN_DEP_KEYS.contains(&k.as_str())) {
383                    return Err(refuse(format!(
384                        "key `{unknown}` is one varve does not know how to transcribe into a \
385                         registry index entry; refusing rather than dropping it"
386                    )));
387                }
388                match spec.get("version") {
389                    Some(toml::Value::String(req)) => dep.req = req.clone(),
390                    Some(other) => {
391                        return Err(refuse(format!("`version` is {other}, not a string")));
392                    }
393                    None if spec.contains_key("path") => {
394                        return Err(refuse(
395                            "a path dependency with no `version` cannot be resolved from a \
396                             registry"
397                                .into(),
398                        ));
399                    }
400                    // No requirement at all means "any version", which the
401                    // index spells `*` — Cargo's own normalisation.
402                    None => dep.req = "*".into(),
403                }
404                if let Some(v) = spec.get("optional") {
405                    dep.optional = v
406                        .as_bool()
407                        .ok_or_else(|| refuse(format!("`optional` is {v}, not a boolean")))?;
408                }
409                for key in ["default-features", "default_features"] {
410                    if let Some(v) = spec.get(key) {
411                        dep.default_features = v
412                            .as_bool()
413                            .ok_or_else(|| refuse(format!("`{key}` is {v}, not a boolean")))?;
414                    }
415                }
416                if let Some(v) = spec.get("features") {
417                    let list = v
418                        .as_array()
419                        .ok_or_else(|| refuse(format!("`features` is {v}, not an array")))?;
420                    for f in list {
421                        dep.features.push(
422                            f.as_str()
423                                .ok_or_else(|| refuse(format!("feature {f} is not a string")))?
424                                .to_string(),
425                        );
426                    }
427                }
428                if let Some(v) = spec.get("package") {
429                    dep.package = Some(
430                        v.as_str()
431                            .ok_or_else(|| refuse(format!("`package` is {v}, not a string")))?
432                            .to_string(),
433                    );
434                }
435                if let Some(v) = spec.get("registry-index") {
436                    dep.registry = Some(
437                        v.as_str()
438                            .ok_or_else(|| {
439                                refuse(format!("`registry-index` is {v}, not a string"))
440                            })?
441                            .to_string(),
442                    );
443                }
444            }
445            other => {
446                return Err(refuse(format!(
447                    "is {other}, neither a version string nor a table"
448                )));
449            }
450        }
451        out.push(dep);
452    }
453    Ok(())
454}
455
456/// One index line for a crate version, carrying the crate's REAL `deps` and
457/// `features` read from the `Cargo.toml` inside its signed `.crate`
458/// (REQ-CRATEIDX-001). Cargo resolves the dependency graph from this line, so
459/// an empty `deps`/`features` is not a conservative default — it is a lie that
460/// resolves, builds, and exits 0 with the crate compiled featureless.
461pub fn index_line(entry: &CrateEntry) -> Result<String, CrateExportError> {
462    let meta = read_crate_meta(&entry.name, &entry.version, &entry.bytes)?;
463    index_line_from_meta(entry, &meta)
464}
465
466/// The line for an entry whose metadata is already in hand. Split out so the
467/// transcription can be unit-tested without a tarball, and so both halves are
468/// exercised by the same serialisation.
469pub fn index_line_from_meta(
470    entry: &CrateEntry,
471    meta: &CrateMeta,
472) -> Result<String, CrateExportError> {
473    let line = IndexEntry {
474        name: entry.name.clone(),
475        vers: entry.version.clone(),
476        deps: meta.deps.clone(),
477        cksum: entry.cksum.clone(),
478        features: meta.features.clone(),
479        yanked: false,
480        links: meta.links.clone(),
481        // `"v":2` is what tells Cargo this entry may use `features2`; without
482        // it a namespaced feature would be read as a literal feature name.
483        v: (!meta.features2.is_empty()).then_some(2),
484        features2: meta.features2.clone(),
485        rust_version: meta.rust_version.clone(),
486    };
487    // serde_json escapes; nothing here is interpolated into JSON by hand.
488    serde_json::to_string(&line).map_err(|e| CrateExportError::UnreadableManifest {
489        name: entry.name.clone(),
490        version: entry.version.clone(),
491        why: format!("its index entry could not be serialised: {e}"),
492    })
493}
494
495/// The subdirectory `export-cargo` puts the local registry in, relative to the
496/// export root. A constant, not a caller's path, so the generated config is a
497/// function of the layer alone (REQ-REPRO-001 clause 1).
498pub const REGISTRY_SUBDIR: &str = "registry";
499
500/// The subdirectory `export-crates-vendor` puts the vendored trees in.
501pub const VENDOR_SUBDIR: &str = "vendor";
502
503/// The `.cargo/config.toml` that redirects crates.io to the local registry,
504/// so an unmodified `Cargo.toml` resolves against varve's verified bytes.
505///
506/// `registry_subdir` is RELATIVE to the directory that holds `.cargo/`, which
507/// is where Cargo resolves such a path from — settled empirically against a
508/// real Cargo, not assumed: with the config at `<root>/.cargo/config.toml` and
509/// the registry at `<root>/registry`, a build run from `<root>/sub` resolves it
510/// and a registry placed at either `<root>/sub/registry` or
511/// `<root>/.cargo/registry` does not (REQ-REPRO-001 clause 1, and the
512/// `cargo_offline` oracle pins it).
513///
514/// This is the correction to REQ-PRODUCE-002's absolute path. The bug that fix
515/// reacted to was a relative `--out` embedded VERBATIM, so the string depended
516/// on the invoking cwd; a subdirectory constant depends on nothing, keeps the
517/// export copyable, and makes two exports of one layer byte-identical.
518pub fn cargo_config_toml(registry_subdir: &str) -> String {
519    format!(
520        "# Generated by `varve export-cargo` (REQ-CRATE-001).\n\
521         # Redirects crates.io to a varve-verified local registry; build --offline.\n\
522         # The path is relative to the directory holding this `.cargo/` — keep the\n\
523         # two together and the export can be copied, committed and relocated.\n\
524         [source.crates-io]\n\
525         replace-with = \"varve\"\n\n\
526         [source.varve]\n\
527         local-registry = \"{registry_subdir}\"\n",
528    )
529}
530
531#[derive(Debug, thiserror::Error)]
532pub enum CrateExportError {
533    #[error("io error at {path}")]
534    Io {
535        path: String,
536        #[source]
537        source: std::io::Error,
538    },
539    #[error("crate name {name:?} cannot be exported: {why}")]
540    UnrepresentableName { name: String, why: String },
541    #[error("crate version {version:?} cannot be exported: {why}")]
542    UnrepresentableVersion { version: String, why: String },
543    #[error("crate {name:?} has a cksum that is not a bare sha256 hex digest: {cksum:?}")]
544    UnrepresentableCksum { name: String, cksum: String },
545    #[error(
546        "crate {name:?} version {version:?}: no Cargo.toml inside the signed .crate tarball — \
547         a registry index entry cannot be written without it, and an entry with empty deps \
548         would resolve and then build the crate wrong"
549    )]
550    MissingManifest { name: String, version: String },
551    #[error("crate {name:?} version {version:?}: {why}")]
552    UnreadableManifest {
553        name: String,
554        version: String,
555        why: String,
556    },
557    #[error(
558        "crate {name:?} version {version:?}: its {kind} dependency {dep:?} cannot be expressed \
559         in a Cargo registry index — {why}. Refusing to write an index entry that omits it \
560         (REQ-CRATEIDX-001 clause 2): a dropped dependency is the failure that exits 0."
561    )]
562    UnrepresentableDep {
563        name: String,
564        version: String,
565        dep: String,
566        kind: String,
567        why: String,
568    },
569    #[error(
570        "crate {name:?} version {version:?}: its feature {feature:?} cannot be expressed in a \
571         Cargo registry index — it {why}. Refusing to write an index entry that omits it \
572         (REQ-CRATEIDX-001 clause 2)."
573    )]
574    UnrepresentableFeature {
575        name: String,
576        version: String,
577        feature: String,
578        why: String,
579    },
580}
581
582/// The `.cargo-checksum.json` for a vendored crate. `package` is the sha256 of
583/// the `.crate` tarball — the SAME digest varve signs, so the upstream
584/// integrity anchor is preserved into the vendored tree, not discarded
585/// (REQ-BRIDGE-001). `files` empty is valid for a registry-sourced crate
586/// (matches real `cargo vendor` output for registry crates).
587pub fn cargo_checksum_json(cksum: &str) -> String {
588    format!(r#"{{"files":{{}},"package":"{cksum}"}}"#)
589}
590
591/// The consumer config pairing with a vendored directory: redirects crates.io
592/// to the on-disk unpacked trees. Consumed natively by bare Cargo and by
593/// Corrosion (both proven offline). rules_rust needs generated BUILD files on
594/// top of this tree — its `crate.from_cargo` splice wants a registry index and
595/// rejects a bare directory source (v0.16.0 spike); see REQ-VENDOR-002.
596///
597/// `vendor_subdir` is relative to the directory holding `.cargo/`, for the same
598/// empirically-settled reason as `cargo_config_toml` (REQ-REPRO-001 clause 1).
599pub fn vendored_config_toml(vendor_subdir: &str) -> String {
600    format!(
601        "# Generated by `varve export-crates-vendor` (REQ-VENDOR-001).\n\
602         # The path is relative to the directory holding this `.cargo/` — keep the\n\
603         # two together and the export can be copied, committed and relocated.\n\
604         [source.crates-io]\n\
605         replace-with = \"vendored-sources\"\n\n\
606         [source.vendored-sources]\n\
607         directory = \"{vendor_subdir}\"\n",
608    )
609}
610
611/// Materialise a `cargo vendor`-shaped directory from verified crate entries:
612/// each `.crate` UNPACKED into `<vendor>/<name>-<version>/` with its
613/// `.cargo-checksum.json`. Proven offline-consumable by bare Cargo and
614/// Corrosion. (rules_rust needs BUILD files over this tree — REQ-VENDOR-002.)
615/// Returns the crate count.
616pub fn export_vendor_dir(
617    crates: &[CrateEntry],
618    vendor_dir: &Path,
619) -> Result<usize, CrateExportError> {
620    // Fail closed before writing anything: an unrepresentable name would
621    // panic the index layout or corrupt the index JSON (REQ-CRATENAME-001).
622    validate_entries(crates)?;
623    let io = |path: &Path, source: std::io::Error| CrateExportError::Io {
624        path: path.display().to_string(),
625        source,
626    };
627    std::fs::create_dir_all(vendor_dir).map_err(|e| io(vendor_dir, e))?;
628    for entry in crates {
629        // A `.crate` is a gzip tar whose top-level dir is `<name>-<version>/`.
630        let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(entry.bytes.as_slice()));
631        archive.unpack(vendor_dir).map_err(|e| io(vendor_dir, e))?;
632        let crate_dir = vendor_dir.join(format!("{}-{}", entry.name, entry.version));
633        let checksum = crate_dir.join(".cargo-checksum.json");
634        std::fs::write(&checksum, cargo_checksum_json(&entry.cksum))
635            .map_err(|e| io(&checksum, e))?;
636    }
637    Ok(crates.len())
638}
639
640/// Materialise a Cargo local registry at `registry_dir`: write each `.crate`
641/// file and its index entry. Returns the number of crates written.
642pub fn export_local_registry(
643    crates: &[CrateEntry],
644    registry_dir: &Path,
645) -> Result<usize, CrateExportError> {
646    // Fail closed before writing anything: an unrepresentable name would
647    // panic the index layout or corrupt the index JSON (REQ-CRATENAME-001),
648    // and an unrepresentable dependency must not leave a half-written registry
649    // whose index under-reports the graph (REQ-CRATEIDX-001 clause 2).
650    validate_entries(crates)?;
651    let mut lines: Vec<String> = Vec::with_capacity(crates.len());
652    for entry in crates {
653        lines.push(index_line(entry)?);
654    }
655    let io = |path: &Path, source: std::io::Error| CrateExportError::Io {
656        path: path.display().to_string(),
657        source,
658    };
659    std::fs::create_dir_all(registry_dir).map_err(|e| io(registry_dir, e))?;
660    for (entry, line) in crates.iter().zip(&lines) {
661        // The .crate tarball at <registry>/<name>-<version>.crate.
662        let crate_file = registry_dir.join(format!("{}-{}.crate", entry.name, entry.version));
663        std::fs::write(&crate_file, &entry.bytes).map_err(|e| io(&crate_file, e))?;
664
665        // The index entry, appended (multiple versions share one file).
666        let idx = registry_dir.join("index").join(index_path(&entry.name));
667        if let Some(parent) = idx.parent() {
668            std::fs::create_dir_all(parent).map_err(|e| io(parent, e))?;
669        }
670        // De-dup by (name, vers): rewrite the file with this version present.
671        let existing = std::fs::read_to_string(&idx).unwrap_or_default();
672        let prefix = format!(r#"{{"name":"{}","vers":"{}""#, entry.name, entry.version);
673        let mut kept: Vec<String> = existing
674            .lines()
675            .filter(|l| !l.starts_with(&prefix))
676            .map(str::to_string)
677            .collect();
678        kept.push(line.clone());
679        std::fs::write(&idx, kept.join("\n") + "\n").map_err(|e| io(&idx, e))?;
680    }
681    Ok(crates.len())
682}
683
684/// Materialise a Bazel **distdir** of the verified `.crate` tarballs
685/// (REQ-VENDOR-002, air-gap rules_rust). Bazel's `--distdir` resolves an
686/// `http_archive` from a local file whose sha256 matches — with NO network and
687/// NO URL rewrite. Because varve's signed `.crate` digest IS the `crate_universe`
688/// pin (== the crates.io checksum), a consumer that pre-generates its
689/// `crate_universe` output once and then builds with
690/// `bazel build --distdir=<this dir>` (network off) resolves every crate from
691/// varve's verified bytes. varve emits the bytes; the consumer commits the
692/// generated `crate_universe` output (so no repin/index lookup at build time).
693/// Returns the number of tarballs written.
694pub fn export_distdir(crates: &[CrateEntry], distdir: &Path) -> Result<usize, CrateExportError> {
695    // Fail closed before writing anything: an unrepresentable name would
696    // panic the index layout or corrupt the index JSON (REQ-CRATENAME-001).
697    validate_entries(crates)?;
698    let io = |path: &Path, source: std::io::Error| CrateExportError::Io {
699        path: path.display().to_string(),
700        source,
701    };
702    std::fs::create_dir_all(distdir).map_err(|e| io(distdir, e))?;
703    for entry in crates {
704        // Bazel matches distdir files by content sha256; the name is for
705        // humans. `<name>-<version>.crate` mirrors the crates.io asset name.
706        let file = distdir.join(format!("{}-{}.crate", entry.name, entry.version));
707        std::fs::write(&file, &entry.bytes).map_err(|e| io(&file, e))?;
708    }
709    Ok(crates.len())
710}
711
712#[cfg(test)]
713mod tests {
714    use super::*;
715
716    /// A `.crate`-shaped gzip tar: `<name>-<version>/Cargo.toml` plus a source
717    /// file. Every registry test now uses one, because the index entry is READ
718    /// from the tarball — a fixture of opaque bytes could not tell the truth
719    /// about deps and features and so could not have caught varve#73.
720    fn crate_tarball(name: &str, version: &str, cargo_toml: &str) -> Vec<u8> {
721        let mut b = tar::Builder::new(flate2::write::GzEncoder::new(
722            Vec::new(),
723            flate2::Compression::default(),
724        ));
725        for (path, body) in [
726            (
727                format!("{name}-{version}/Cargo.toml"),
728                cargo_toml.to_string(),
729            ),
730            (
731                format!("{name}-{version}/src/lib.rs"),
732                "pub fn f() {}\n".to_string(),
733            ),
734        ] {
735            let mut h = tar::Header::new_gnu();
736            h.set_size(body.len() as u64);
737            h.set_mode(0o644);
738            h.set_cksum();
739            b.append_data(&mut h, &path, body.as_bytes()).unwrap();
740        }
741        b.into_inner().unwrap().finish().unwrap()
742    }
743
744    /// The manifest of a dependency-free, feature-free crate.
745    fn plain_manifest(name: &str, version: &str) -> String {
746        format!("[package]\nname = \"{name}\"\nversion = \"{version}\"\nedition = \"2021\"\n")
747    }
748
749    /// A `CrateEntry` whose bytes are a real `.crate` carrying `cargo_toml`.
750    fn entry_with(name: &str, version: &str, cargo_toml: &str) -> CrateEntry {
751        use sha2::{Digest, Sha256};
752        let bytes = crate_tarball(name, version, cargo_toml);
753        CrateEntry {
754            name: name.into(),
755            version: version.into(),
756            cksum: hex::encode(Sha256::digest(&bytes)),
757            bytes,
758        }
759    }
760
761    /// The parsed index line for a manifest — the thing Cargo actually reads.
762    fn line_json(entry: &CrateEntry) -> serde_json::Value {
763        serde_json::from_str(&index_line(entry).unwrap()).expect("Cargo parses one JSON per line")
764    }
765
766    // rivet: verifies REQ-CRATE-001
767    #[test]
768    fn index_paths_follow_cargos_layout() {
769        assert_eq!(index_path("a"), "1/a");
770        assert_eq!(index_path("ab"), "2/ab");
771        assert_eq!(index_path("abc"), "3/a/abc");
772        assert_eq!(index_path("serde"), "se/rd/serde");
773        assert_eq!(index_path("Varve-SDK"), "va/rv/varve-sdk"); // lowercased
774    }
775
776    // rivet: verifies REQ-CRATENAME-001
777    #[test]
778    fn a_non_ascii_crate_name_is_an_error_not_a_panic() {
779        // `chars().count()` + BYTE slicing used to panic mid-codepoint here.
780        // A name Cargo's index layout cannot express must fail closed.
781        for bad in ["日本語", "ααα", "café-utils"] {
782            assert!(
783                validate_crate_name(bad).is_err(),
784                "{bad} must be refused, not sliced"
785            );
786            // And the layout function itself must never panic, whatever it gets.
787            let _ = index_path(bad);
788        }
789    }
790
791    // rivet: verifies REQ-CRATENAME-001
792    #[test]
793    fn a_name_or_version_that_would_corrupt_the_index_json_is_refused() {
794        // index_line interpolates into JSON; a quote/backslash would break the
795        // line Cargo parses. Refuse at the gate rather than emit corrupt JSON.
796        assert!(validate_crate_name("evil\"name").is_err());
797        assert!(validate_crate_name("back\\slash").is_err());
798        assert!(validate_crate_name("").is_err());
799        assert!(validate_crate_version("1.0.0\"").is_err());
800        assert!(validate_crate_name("serde_json").is_ok());
801        assert!(validate_crate_name("varve-core").is_ok());
802        assert!(validate_crate_version("0.1.0-alpha.1+build.2").is_ok());
803    }
804
805    // rivet: verifies REQ-CRATENAME-001
806    #[test]
807    fn export_refuses_an_unrepresentable_crate_name() {
808        let dir = tempfile::tempdir().unwrap();
809        let bad = [CrateEntry {
810            name: "café-utils".into(),
811            version: "0.1.0".into(),
812            cksum: "a".repeat(64),
813            bytes: vec![],
814        }];
815        // Every export adapter fails closed on the same input.
816        assert!(export_local_registry(&bad, dir.path()).is_err());
817        assert!(export_vendor_dir(&bad, dir.path()).is_err());
818        assert!(export_distdir(&bad, dir.path()).is_err());
819    }
820
821    // rivet: verifies REQ-CRATE-001
822    #[test]
823    fn an_index_line_carries_the_cksum_cargo_will_verify() {
824        let mut e = entry_with("demo", "0.1.0", &plain_manifest("demo", "0.1.0"));
825        e.cksum = "b".repeat(64);
826        let line = index_line(&e).unwrap();
827        assert!(line.contains(r#""name":"demo""#));
828        assert!(line.contains(r#""vers":"0.1.0""#));
829        assert!(line.contains(&format!(r#""cksum":"{}""#, "b".repeat(64))));
830        assert!(line.contains(r#""yanked":false"#));
831    }
832
833    // rivet: verifies REQ-CRATEIDX-001
834    #[test]
835    fn an_index_entry_carries_the_crates_real_deps_and_features() {
836        // varve#73: `deps:[]` and `features:{}` for EVERY crate, while Cargo
837        // resolves the graph FROM the index. Measured on varve's own lockfile,
838        // 228 of 250 crates declare a dependency or a feature — so the stub was
839        // wrong for 91% of a real layer, and its worst outcome was not a
840        // failure but a build that exits 0 with a crate compiled featureless.
841        let e = entry_with(
842            "demo",
843            "0.1.0",
844            r#"
845[package]
846name = "demo"
847version = "0.1.0"
848links = "demolib"
849rust-version = "1.70"
850
851[dependencies]
852serde = { version = "1.0", features = ["derive"], default-features = false }
853cfg-if = "1"
854rand = { version = "0.8", optional = true }
855renamed = { version = "2", package = "real-crate" }
856
857[dev-dependencies]
858tempfile = "3"
859
860[build-dependencies]
861cc = "1"
862
863[target."cfg(unix)".dependencies]
864libc = "0.2"
865
866[features]
867default = ["std"]
868std = ["serde/std"]
869"#,
870        );
871        let line = line_json(&e);
872        let deps = line["deps"].as_array().unwrap();
873        let find = |n: &str| {
874            deps.iter()
875                .find(|d| d["name"] == n)
876                .unwrap_or_else(|| panic!("dependency {n} missing from the index entry"))
877        };
878
879        // The plain requirement, and the defaults Cargo assumes.
880        assert_eq!(find("cfg-if")["req"], "1");
881        assert_eq!(find("cfg-if")["kind"], "normal");
882        assert_eq!(find("cfg-if")["optional"], false);
883        assert_eq!(find("cfg-if")["default_features"], true);
884        assert_eq!(find("cfg-if")["target"], serde_json::Value::Null);
885
886        // Features requested OF a dependency, and default-features turned off:
887        // the pair whose loss compiles a crate with the wrong code in it.
888        assert_eq!(find("serde")["features"], serde_json::json!(["derive"]));
889        assert_eq!(find("serde")["default_features"], false);
890
891        assert_eq!(find("rand")["optional"], true);
892        // A rename: the index `name` is the RENAME, `package` the real crate.
893        assert_eq!(find("renamed")["package"], "real-crate");
894        assert_eq!(find("tempfile")["kind"], "dev");
895        assert_eq!(find("cc")["kind"], "build");
896        assert_eq!(find("libc")["target"], "cfg(unix)");
897        assert_eq!(find("libc")["kind"], "normal");
898
899        // The crate's OWN features — `default` included, the one whose absence
900        // exits 0 with nothing enabled (aho-corasick, in the measured run).
901        assert_eq!(line["features"]["default"], serde_json::json!(["std"]));
902        assert_eq!(line["features"]["std"], serde_json::json!(["serde/std"]));
903        // …and the fields Cargo resolves with beyond the graph.
904        assert_eq!(line["links"], "demolib");
905        assert_eq!(line["rust_version"], "1.70");
906    }
907
908    // rivet: verifies REQ-CRATEIDX-001
909    #[test]
910    fn namespaced_and_weak_features_go_to_features2_behind_v2() {
911        // Cargo reads `dep:`/`?/` feature values only from `features2`, and
912        // only when `"v":2` says the entry may carry them. Putting them in
913        // plain `features` would have Cargo read `dep:serde` as the literal
914        // name of a feature that does not exist.
915        let e = entry_with(
916            "demo",
917            "0.1.0",
918            r#"
919[package]
920name = "demo"
921version = "0.1.0"
922
923[dependencies]
924serde = { version = "1", optional = true }
925rayon = { version = "1", optional = true }
926
927[features]
928plain = []
929ns = ["dep:serde"]
930weak = ["rayon?/std"]
931"#,
932        );
933        let line = line_json(&e);
934        assert_eq!(line["v"], 2, "the entry must declare index version 2");
935        assert_eq!(line["features"]["plain"], serde_json::json!([]));
936        assert!(
937            line["features"].get("ns").is_none(),
938            "a namespaced feature must not sit in plain `features`"
939        );
940        assert_eq!(line["features2"]["ns"], serde_json::json!(["dep:serde"]));
941        assert_eq!(line["features2"]["weak"], serde_json::json!(["rayon?/std"]));
942
943        // …and a crate with no such feature carries neither field, exactly as
944        // an old-style crates.io entry does.
945        let plain = entry_with("demo", "0.1.0", &plain_manifest("demo", "0.1.0"));
946        let plain = line_json(&plain);
947        assert!(plain.get("v").is_none());
948        assert!(plain.get("features2").is_none());
949        assert!(plain.get("links").is_none());
950        assert!(plain.get("rust_version").is_none());
951    }
952
953    // rivet: verifies REQ-CRATEIDX-001
954    #[test]
955    fn a_dependency_the_index_cannot_express_is_an_error_naming_the_crate() {
956        // Clause 2. The alternative — dropping it — is precisely the failure
957        // mode that exits 0, so each of these must be loud and must name the
958        // crate AND the dependency, or the reader cannot act on it.
959        // Each case names the CONSTRUCT in its message, not merely "something
960        // is wrong": three of these would also be caught by the unknown-key
961        // check, so without pinning the wording those branches could be
962        // deleted and the suite would stay green while the reader lost the
963        // one sentence that says what to do.
964        let cases = [
965            (
966                "git",
967                r#"gitdep = { git = "https://example.invalid/x" }"#,
968                "git dependency",
969            ),
970            (
971                "workspace",
972                r#"wsdep = { workspace = true }"#,
973                "workspace inheritance",
974            ),
975            (
976                "registry alias",
977                r#"aliased = { version = "1", registry = "internal" }"#,
978                "ALIAS",
979            ),
980            (
981                "bare path",
982                r#"local = { path = "../local" }"#,
983                "path dependency",
984            ),
985            (
986                "unknown key",
987                r#"weird = { version = "1", artifact = "bin" }"#,
988                "artifact",
989            ),
990            (
991                "non-string version",
992                r#"odd = { version = 1 }"#,
993                "not a string",
994            ),
995            (
996                "non-boolean optional",
997                r#"odd = { version = "1", optional = "yes" }"#,
998                "not a boolean",
999            ),
1000            (
1001                "array-valued dep",
1002                r#"odd = ["1.0"]"#,
1003                "neither a version string nor a table",
1004            ),
1005        ];
1006        for (what, dep, says) in cases {
1007            let e = entry_with(
1008                "demo",
1009                "0.1.0",
1010                &format!(
1011                    "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n\n[dependencies]\n{dep}\n"
1012                ),
1013            );
1014            let err = index_line(&e).expect_err("{what} must be refused");
1015            let msg = err.to_string();
1016            assert!(
1017                msg.contains("demo") && msg.contains("0.1.0"),
1018                "{what}: the error must name the crate: {msg}"
1019            );
1020            assert!(
1021                msg.contains(says),
1022                "{what}: the error must say what it could not express ({says:?}): {msg}"
1023            );
1024            // …and the whole export refuses, rather than writing a registry
1025            // whose index quietly under-reports the graph.
1026            let dir = tempfile::tempdir().unwrap();
1027            assert!(
1028                export_local_registry(std::slice::from_ref(&e), dir.path()).is_err(),
1029                "{what}: the export must fail closed"
1030            );
1031            assert!(
1032                !dir.path().join("demo-0.1.0.crate").exists(),
1033                "{what}: nothing may be written before the refusal"
1034            );
1035        }
1036    }
1037
1038    // rivet: verifies REQ-CRATEIDX-001
1039    #[test]
1040    fn a_feature_the_index_cannot_express_is_an_error_naming_the_crate() {
1041        for feature in [r#"bad = "notanarray""#, r#"bad = [1, 2]"#] {
1042            let e = entry_with(
1043                "demo",
1044                "0.1.0",
1045                &format!(
1046                    "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n\n[features]\n{feature}\n"
1047                ),
1048            );
1049            let err = index_line(&e).expect_err("an unrepresentable feature must be refused");
1050            assert!(
1051                err.to_string().contains("bad") && err.to_string().contains("demo"),
1052                "{err}"
1053            );
1054        }
1055    }
1056
1057    // rivet: verifies REQ-CRATEIDX-001
1058    #[test]
1059    fn a_crate_tarball_without_a_cargo_toml_is_refused_not_stubbed() {
1060        // The stub's home: bytes we cannot read used to become `deps:[]`. Now
1061        // an unreadable crate is an error, because "no dependencies" and "we
1062        // did not look" must not produce the same index line.
1063        let opaque = CrateEntry {
1064            name: "demo".into(),
1065            version: "0.1.0".into(),
1066            cksum: "a".repeat(64),
1067            bytes: b"not a gzip tarball at all".to_vec(),
1068        };
1069        assert!(index_line(&opaque).is_err());
1070
1071        let mut empty_tar = CrateEntry {
1072            name: "demo".into(),
1073            version: "0.1.0".into(),
1074            cksum: "a".repeat(64),
1075            bytes: Vec::new(),
1076        };
1077        empty_tar.bytes = {
1078            let b = tar::Builder::new(flate2::write::GzEncoder::new(
1079                Vec::new(),
1080                flate2::Compression::default(),
1081            ));
1082            b.into_inner().unwrap().finish().unwrap()
1083        };
1084        let err = index_line(&empty_tar).unwrap_err();
1085        assert!(
1086            matches!(err, CrateExportError::MissingManifest { .. }),
1087            "{err}"
1088        );
1089    }
1090
1091    // rivet: verifies REQ-CRATENAME-001
1092    #[test]
1093    fn vendoring_never_writes_outside_the_vendor_directory() {
1094        // `export_vendor_dir` untars depositor-supplied bytes. The tar crate
1095        // refuses both escapes today; this pins that guarantee so a dependency
1096        // bump or a switch to manual entry handling cannot silently lose it
1097        // (the Cargo CVE-2026-5223 bug class: symlink out, then write through).
1098        use std::io::Write;
1099        let dir = tempfile::tempdir().unwrap();
1100        let outside = dir.path().join("OUTSIDE");
1101        std::fs::create_dir_all(&outside).unwrap();
1102        let vendor = dir.path().join("vendor");
1103
1104        let mut tar_bytes = Vec::new();
1105        {
1106            let mut b = tar::Builder::new(&mut tar_bytes);
1107            // 1. a symlink escaping the destination …
1108            let mut link = tar::Header::new_gnu();
1109            link.set_entry_type(tar::EntryType::Symlink);
1110            link.set_size(0);
1111            link.set_mode(0o777);
1112            b.append_link(&mut link, "escape-0.1.0/link", &outside)
1113                .unwrap();
1114            // 2. … then a write THROUGH it.
1115            let payload = b"PWNED";
1116            let mut f = tar::Header::new_gnu();
1117            f.set_size(payload.len() as u64);
1118            f.set_mode(0o644);
1119            b.append_data(&mut f, "escape-0.1.0/link/pwned.txt", &payload[..])
1120                .unwrap();
1121            b.finish().unwrap();
1122        }
1123        let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
1124        gz.write_all(&tar_bytes).unwrap();
1125        let evil = gz.finish().unwrap();
1126
1127        let entries = [CrateEntry {
1128            name: "escape".into(),
1129            version: "0.1.0".into(),
1130            cksum: "c".repeat(64),
1131            bytes: evil,
1132        }];
1133        // Whether it errors or skips the entry, the invariant is the same:
1134        // nothing may be written outside the vendor directory.
1135        let _ = export_vendor_dir(&entries, &vendor);
1136        assert!(
1137            !outside.join("pwned.txt").exists(),
1138            "a crate tarball escaped the vendor directory"
1139        );
1140        assert!(
1141            std::fs::read_dir(&outside).unwrap().next().is_none(),
1142            "nothing may be written outside the vendor directory"
1143        );
1144    }
1145
1146    // rivet: verifies REQ-VENDOR-001, REQ-BRIDGE-001
1147    #[test]
1148    fn vendoring_unpacks_the_crate_and_preserves_the_upstream_hash() {
1149        // A `.crate`-shaped gzip tar with the top-level <name>-<version>/ dir.
1150        let mut builder = tar::Builder::new(flate2::write::GzEncoder::new(
1151            Vec::new(),
1152            flate2::Compression::default(),
1153        ));
1154        for (name, body) in [
1155            ("demo-0.1.0/Cargo.toml", "[package]\nname=\"demo\"\n"),
1156            ("demo-0.1.0/src/lib.rs", "pub fn f() {}\n"),
1157        ] {
1158            let mut h = tar::Header::new_gnu();
1159            h.set_size(body.len() as u64);
1160            h.set_mode(0o644);
1161            h.set_cksum();
1162            builder.append_data(&mut h, name, body.as_bytes()).unwrap();
1163        }
1164        let targz = builder.into_inner().unwrap().finish().unwrap();
1165
1166        let tmp = tempfile::tempdir().unwrap();
1167        let vendor = tmp.path().join("vendor");
1168        let e = CrateEntry {
1169            name: "demo".into(),
1170            version: "0.1.0".into(),
1171            cksum: "d".repeat(64),
1172            bytes: targz,
1173        };
1174        assert_eq!(
1175            export_vendor_dir(std::slice::from_ref(&e), &vendor).unwrap(),
1176            1
1177        );
1178        // The crate is UNPACKED (a directory source, not a tarball).
1179        assert!(vendor.join("demo-0.1.0/Cargo.toml").is_file());
1180        assert!(vendor.join("demo-0.1.0/src/lib.rs").is_file());
1181        // The upstream integrity anchor (the .crate sha256) is preserved.
1182        let checksum =
1183            std::fs::read_to_string(vendor.join("demo-0.1.0/.cargo-checksum.json")).unwrap();
1184        assert_eq!(
1185            checksum,
1186            format!(r#"{{"files":{{}},"package":"{}"}}"#, "d".repeat(64))
1187        );
1188    }
1189
1190    // rivet: verifies REQ-VENDOR-002
1191    #[test]
1192    fn a_distdir_holds_the_verified_crate_bytes_keyed_for_bazel() {
1193        let tmp = tempfile::tempdir().unwrap();
1194        let dd = tmp.path().join("distdir");
1195        let bytes = b"the-verified-crate-tarball-bytes".to_vec();
1196        // varve's cksum IS the sha256 of these bytes == the crate_universe pin.
1197        let cksum = {
1198            use sha2::{Digest, Sha256};
1199            hex::encode(Sha256::digest(&bytes))
1200        };
1201        let e = CrateEntry {
1202            name: "cfg-if".into(),
1203            version: "1.0.0".into(),
1204            cksum: cksum.clone(),
1205            bytes: bytes.clone(),
1206        };
1207        assert_eq!(export_distdir(std::slice::from_ref(&e), &dd).unwrap(), 1);
1208        let file = dd.join("cfg-if-1.0.0.crate");
1209        // The exact verified bytes are present...
1210        assert_eq!(std::fs::read(&file).unwrap(), bytes);
1211        // ...and their sha256 is the join key Bazel's --distdir matches on.
1212        let on_disk = {
1213            use sha2::{Digest, Sha256};
1214            hex::encode(Sha256::digest(std::fs::read(&file).unwrap()))
1215        };
1216        assert_eq!(
1217            on_disk, cksum,
1218            "distdir file sha256 must equal the crate_universe pin"
1219        );
1220    }
1221
1222    // rivet: verifies REQ-VENDOR-001
1223    #[test]
1224    fn the_vendored_config_replaces_with_a_directory_source() {
1225        let cfg = vendored_config_toml(VENDOR_SUBDIR);
1226        assert!(cfg.contains(r#"replace-with = "vendored-sources""#));
1227        assert!(cfg.contains(r#"directory = "vendor""#));
1228    }
1229
1230    // rivet: verifies REQ-CRATE-001, REQ-REPRO-001
1231    #[test]
1232    fn config_redirects_crates_io_to_the_local_registry() {
1233        let cfg = cargo_config_toml(REGISTRY_SUBDIR);
1234        assert!(cfg.contains(r#"replace-with = "varve""#));
1235        assert!(cfg.contains(r#"local-registry = "registry""#));
1236        // REQ-REPRO-001 clause 1: nothing machine-specific may reach the file.
1237        // Cargo resolves this against the directory holding `.cargo/` (proven
1238        // in the `cargo_offline` oracle), so a relative subdirectory is both
1239        // correct and identical between two exports of the same layer.
1240        for cfg in [
1241            cargo_config_toml(REGISTRY_SUBDIR),
1242            vendored_config_toml(VENDOR_SUBDIR),
1243        ] {
1244            for line in cfg
1245                .lines()
1246                .filter(|l| l.starts_with("local-registry") || l.starts_with("directory"))
1247            {
1248                let path = line.split('"').nth(1).expect("a quoted path");
1249                assert!(
1250                    !std::path::Path::new(path).is_absolute() && !path.contains('/'),
1251                    "a generated config must carry a bare relative subdirectory, \
1252                     not a machine-specific path: {line}"
1253                );
1254            }
1255        }
1256    }
1257
1258    // rivet: verifies REQ-CRATE-001
1259    #[test]
1260    fn materialising_writes_the_crate_and_a_matching_index_entry() {
1261        let tmp = tempfile::tempdir().unwrap();
1262        let reg = tmp.path().join("registry");
1263        let mut e = entry_with("demo", "0.1.0", &plain_manifest("demo", "0.1.0"));
1264        e.cksum = "e".repeat(64);
1265        let bytes = e.bytes.clone();
1266        assert_eq!(
1267            export_local_registry(std::slice::from_ref(&e), &reg).unwrap(),
1268            1
1269        );
1270        // The .crate file is the verified bytes.
1271        assert_eq!(std::fs::read(reg.join("demo-0.1.0.crate")).unwrap(), bytes);
1272        // The index entry is at Cargo's path and carries the cksum.
1273        let idx = std::fs::read_to_string(reg.join("index/de/mo/demo")).unwrap();
1274        assert!(idx.contains(&format!(r#""cksum":"{}""#, "e".repeat(64))));
1275
1276        // Re-exporting the same version does not duplicate the index line.
1277        export_local_registry(std::slice::from_ref(&e), &reg).unwrap();
1278        let idx2 = std::fs::read_to_string(reg.join("index/de/mo/demo")).unwrap();
1279        assert_eq!(idx2.lines().count(), 1, "one line per (name, version)");
1280    }
1281
1282    // rivet: verifies REQ-STORE-002
1283    #[test]
1284    fn a_registry_exported_from_a_layer_offers_every_version_it_pins() {
1285        // Clause 5. A lockfile that names two majors of one crate needs BOTH
1286        // present to build offline — varve's own lockfile has 14 such names —
1287        // so the exported registry must OFFER both, with an index entry per
1288        // version carrying that version's own cksum. Checked by reading the
1289        // index Cargo reads, not by counting what we handed in.
1290        use sha2::{Digest, Sha256};
1291        let tmp = tempfile::tempdir().unwrap();
1292        let reg = tmp.path().join("registry");
1293        let entry = |v: &str| entry_with("serde", v, &plain_manifest("serde", v));
1294        let crates = [entry("1.0.200"), entry("1.0.210")];
1295        let bytes = |v: &str| {
1296            crates
1297                .iter()
1298                .find(|c| c.version == v)
1299                .unwrap()
1300                .bytes
1301                .clone()
1302        };
1303        export_local_registry(&crates, &reg).unwrap();
1304
1305        // Both tarballs are on disk, each holding its OWN bytes.
1306        for v in ["1.0.200", "1.0.210"] {
1307            assert_eq!(
1308                std::fs::read(reg.join(format!("serde-{v}.crate"))).unwrap(),
1309                bytes(v),
1310                "version {v} must export its own bytes"
1311            );
1312        }
1313
1314        // And the index — one JSON line per version, at Cargo's path, each
1315        // with the cksum Cargo will check the corresponding tarball against.
1316        let idx = std::fs::read_to_string(reg.join("index/se/rd/serde")).unwrap();
1317        let lines: Vec<serde_json::Value> = idx
1318            .lines()
1319            .filter(|l| !l.trim().is_empty())
1320            .map(|l| serde_json::from_str(l).expect("each index line is JSON Cargo can parse"))
1321            .collect();
1322        let mut offered: Vec<(String, String)> = lines
1323            .iter()
1324            .map(|l| {
1325                (
1326                    l["vers"].as_str().unwrap().to_string(),
1327                    l["cksum"].as_str().unwrap().to_string(),
1328                )
1329            })
1330            .collect();
1331        offered.sort();
1332        assert_eq!(
1333            offered,
1334            vec![
1335                (
1336                    "1.0.200".to_string(),
1337                    hex::encode(Sha256::digest(bytes("1.0.200")))
1338                ),
1339                (
1340                    "1.0.210".to_string(),
1341                    hex::encode(Sha256::digest(bytes("1.0.210")))
1342                ),
1343            ],
1344            "the index must offer BOTH versions, each bound to its own bytes"
1345        );
1346        assert!(lines.iter().all(|l| l["name"] == "serde"));
1347
1348        // A distdir and a vendor tree hold both versions too — the same layer
1349        // must be exportable to every adapter without one version vanishing.
1350        let dd = tmp.path().join("distdir");
1351        export_distdir(&crates, &dd).unwrap();
1352        assert_eq!(
1353            std::fs::read(dd.join("serde-1.0.200.crate")).unwrap(),
1354            bytes("1.0.200")
1355        );
1356        assert_eq!(
1357            std::fs::read(dd.join("serde-1.0.210.crate")).unwrap(),
1358            bytes("1.0.210")
1359        );
1360    }
1361}