1use std::path::Path;
14
15#[derive(Debug, Clone)]
18pub struct CrateEntry {
19 pub name: String,
20 pub version: String,
21 pub cksum: String,
23 pub bytes: Vec<u8>,
24}
25
26pub fn index_path(name: &str) -> String {
29 let n = name.to_lowercase();
30 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
44pub 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
67pub 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
88fn validate_entries(crates: &[CrateEntry]) -> Result<(), CrateExportError> {
91 for e in crates {
92 validate_crate_name(&e.name)?;
93 validate_crate_version(&e.version)?;
94 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#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
112pub struct IndexDep {
113 pub name: String,
116 pub req: String,
118 pub features: Vec<String>,
119 pub optional: bool,
120 pub default_features: bool,
121 pub target: Option<String>,
123 pub kind: String,
125 pub registry: Option<String>,
127 pub package: Option<String>,
129}
130
131#[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#[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
168const DEP_SECTIONS: [(&str, &str); 3] = [
171 ("dependencies", "normal"),
172 ("dev-dependencies", "dev"),
173 ("build-dependencies", "build"),
174];
175
176const 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
191pub 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
208fn 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
251fn 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 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
328fn 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 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 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
456pub 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
466pub 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: (!meta.features2.is_empty()).then_some(2),
484 features2: meta.features2.clone(),
485 rust_version: meta.rust_version.clone(),
486 };
487 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
495pub const REGISTRY_SUBDIR: &str = "registry";
499
500pub const VENDOR_SUBDIR: &str = "vendor";
502
503pub 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
582pub fn cargo_checksum_json(cksum: &str) -> String {
588 format!(r#"{{"files":{{}},"package":"{cksum}"}}"#)
589}
590
591pub 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
611pub fn export_vendor_dir(
617 crates: &[CrateEntry],
618 vendor_dir: &Path,
619) -> Result<usize, CrateExportError> {
620 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 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
640pub fn export_local_registry(
643 crates: &[CrateEntry],
644 registry_dir: &Path,
645) -> Result<usize, CrateExportError> {
646 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 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 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 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
684pub fn export_distdir(crates: &[CrateEntry], distdir: &Path) -> Result<usize, CrateExportError> {
695 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 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 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 fn plain_manifest(name: &str, version: &str) -> String {
746 format!("[package]\nname = \"{name}\"\nversion = \"{version}\"\nedition = \"2021\"\n")
747 }
748
749 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 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 #[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"); }
775
776 #[test]
778 fn a_non_ascii_crate_name_is_an_error_not_a_panic() {
779 for bad in ["日本語", "ααα", "café-utils"] {
782 assert!(
783 validate_crate_name(bad).is_err(),
784 "{bad} must be refused, not sliced"
785 );
786 let _ = index_path(bad);
788 }
789 }
790
791 #[test]
793 fn a_name_or_version_that_would_corrupt_the_index_json_is_refused() {
794 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 #[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 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 #[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 #[test]
835 fn an_index_entry_carries_the_crates_real_deps_and_features() {
836 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 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 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 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 assert_eq!(line["features"]["default"], serde_json::json!(["std"]));
902 assert_eq!(line["features"]["std"], serde_json::json!(["serde/std"]));
903 assert_eq!(line["links"], "demolib");
905 assert_eq!(line["rust_version"], "1.70");
906 }
907
908 #[test]
910 fn namespaced_and_weak_features_go_to_features2_behind_v2() {
911 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 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 #[test]
955 fn a_dependency_the_index_cannot_express_is_an_error_naming_the_crate() {
956 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 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 #[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 #[test]
1059 fn a_crate_tarball_without_a_cargo_toml_is_refused_not_stubbed() {
1060 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 #[test]
1093 fn vendoring_never_writes_outside_the_vendor_directory() {
1094 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 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 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 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 #[test]
1148 fn vendoring_unpacks_the_crate_and_preserves_the_upstream_hash() {
1149 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 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 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 #[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 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 assert_eq!(std::fs::read(&file).unwrap(), bytes);
1211 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 #[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 #[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 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 #[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), ®).unwrap(),
1268 1
1269 );
1270 assert_eq!(std::fs::read(reg.join("demo-0.1.0.crate")).unwrap(), bytes);
1272 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 export_local_registry(std::slice::from_ref(&e), ®).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 #[test]
1284 fn a_registry_exported_from_a_layer_offers_every_version_it_pins() {
1285 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, ®).unwrap();
1304
1305 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 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 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}