1use std::collections::BTreeMap;
4use std::error::Error as StdError;
5use std::io::Read as _;
6use std::num::TryFromIntError;
7use std::path::{Path, PathBuf};
8use std::{fs, io};
9
10use facet::Facet;
11use flate2::read::GzDecoder;
12use semver::VersionReq;
13use toml_edit::{DocumentMut, Item, TableLike};
14
15const CRATES_IO_INDEX: &str = "https://github.com/rust-lang/crates.io-index";
16
17const SECTIONS: [(&str, &str); 3] = [
18 ("dependencies", "normal"),
19 ("dev-dependencies", "dev"),
20 ("build-dependencies", "build"),
21];
22
23#[derive(Debug)]
25pub(crate) struct CratePackage {
26 metadata: Vec<u8>,
27 bytes: Vec<u8>,
28}
29
30#[derive(Debug, thiserror::Error)]
32pub(crate) enum CrateError {
33 #[error("cannot read {}", path.display())]
35 Read {
36 path: PathBuf,
38 #[source]
40 source: io::Error,
41 },
42 #[error("cannot unpack {}", path.display())]
44 Archive {
45 path: PathBuf,
47 #[source]
49 source: io::Error,
50 },
51 #[error("{} has no {entry}", path.display())]
53 MissingEntry {
54 path: PathBuf,
56 entry: String,
58 },
59 #[error("invalid manifest in {}", path.display())]
61 Manifest {
62 path: PathBuf,
64 #[source]
66 source: Box<toml_edit::TomlError>,
67 },
68 #[error("packaged manifest has no package.{0}")]
70 MissingField(&'static str),
71 #[error("crate holds {actual}, not {expected}")]
73 WrongPackage {
74 expected: String,
76 actual: String,
78 },
79 #[error("dependency {0:?} is malformed")]
81 Dependency(String),
82 #[error("dependency {name:?} has an invalid version requirement")]
84 VersionRequirement {
85 name: String,
87 #[source]
89 source: semver::Error,
90 },
91 #[error("cannot write the crate metadata")]
93 Metadata(#[source] Box<dyn StdError + Send + Sync>),
94 #[error("crate upload part is too large")]
96 TooLarge(#[source] TryFromIntError),
97}
98
99#[derive(Facet)]
100struct Metadata {
101 name: String,
102 vers: String,
103 deps: Vec<Dependency>,
104 features: BTreeMap<String, Vec<String>>,
105 authors: Vec<String>,
106 description: Option<String>,
107 documentation: Option<String>,
108 homepage: Option<String>,
109 readme: Option<String>,
110 readme_file: Option<String>,
111 keywords: Vec<String>,
112 categories: Vec<String>,
113 license: Option<String>,
114 license_file: Option<String>,
115 repository: Option<String>,
116 badges: BTreeMap<String, String>,
117 links: Option<String>,
118 rust_version: Option<String>,
119}
120
121struct Archive<'a> {
122 path: &'a Path,
123 bytes: &'a [u8],
124}
125
126#[derive(Facet)]
127struct Dependency {
128 name: String,
129 version_req: String,
130 features: Vec<String>,
131 optional: bool,
132 default_features: bool,
133 target: Option<String>,
134 kind: String,
135 registry: Option<String>,
136 explicit_name_in_toml: Option<String>,
137}
138
139impl CratePackage {
140 pub(crate) fn read(
146 path: &Path,
147 name: &str,
148 version: &str,
149 target_index: &str,
150 ) -> Result<Self, CrateError> {
151 let bytes = fs::read(path).map_err(|source| CrateError::Read {
152 path: path.to_owned(),
153 source,
154 })?;
155 let root = format!("{name}-{version}");
156 let archive = Archive {
157 path,
158 bytes: &bytes,
159 };
160 let manifest_path = format!("{root}/Cargo.toml");
161 let manifest = archive.text(&manifest_path)?;
162 let document: DocumentMut = manifest.parse().map_err(|source| CrateError::Manifest {
163 path: path.to_owned(),
164 source: Box::new(source),
165 })?;
166 let mut metadata = Metadata::from_manifest(&document, target_index)?;
167 if (metadata.name.as_str(), metadata.vers.as_str()) != (name, version) {
168 return Err(CrateError::WrongPackage {
169 expected: root,
170 actual: format!("{}-{}", metadata.name, metadata.vers),
171 });
172 }
173 if let Some(readme) = &metadata.readme_file {
174 metadata.readme = Some(archive.text(&format!("{root}/{readme}"))?);
175 }
176 let metadata =
177 facet_json::to_vec(&metadata).map_err(|error| CrateError::Metadata(Box::new(error)))?;
178 Ok(Self { metadata, bytes })
179 }
180
181 pub(crate) fn body(&self) -> Result<Vec<u8>, CrateError> {
184 let mut body = Vec::with_capacity(
185 self.metadata
186 .len()
187 .saturating_add(self.bytes.len())
188 .saturating_add(8),
189 );
190 for part in [&self.metadata, &self.bytes] {
191 let length = u32::try_from(part.len()).map_err(CrateError::TooLarge)?;
192 body.extend_from_slice(&length.to_le_bytes());
193 body.extend_from_slice(part);
194 }
195 Ok(body)
196 }
197}
198
199impl Archive<'_> {
200 fn text(&self, entry: &str) -> Result<String, CrateError> {
201 let archive_error = |source| CrateError::Archive {
202 path: self.path.to_owned(),
203 source,
204 };
205 let mut archive = tar::Archive::new(GzDecoder::new(self.bytes));
206 for file in archive.entries().map_err(archive_error)? {
207 let mut file = file.map_err(archive_error)?;
208 if file.path().map_err(archive_error)?.as_ref() == Path::new(entry) {
209 let mut text = String::new();
210 file.read_to_string(&mut text).map_err(archive_error)?;
211 return Ok(text);
212 }
213 }
214 Err(CrateError::MissingEntry {
215 path: self.path.to_owned(),
216 entry: entry.to_owned(),
217 })
218 }
219}
220
221impl Metadata {
222 fn from_manifest(document: &DocumentMut, target_index: &str) -> Result<Self, CrateError> {
223 let package = document
224 .get("package")
225 .and_then(Item::as_table_like)
226 .ok_or(CrateError::MissingField("name"))?;
227 let text = |key: &str| package.get(key).and_then(Item::as_str).map(str::to_owned);
228 let list = |key: &str| strings(package.get(key));
229 let readme_file = text("readme");
230 Ok(Self {
231 name: text("name").ok_or(CrateError::MissingField("name"))?,
232 vers: text("version").ok_or(CrateError::MissingField("version"))?,
233 deps: dependencies(document.as_table(), target_index)?,
234 features: document
235 .get("features")
236 .and_then(Item::as_table_like)
237 .map(|features| {
238 features
239 .iter()
240 .map(|(name, values)| (name.to_owned(), strings(Some(values))))
241 .collect()
242 })
243 .unwrap_or_default(),
244 authors: list("authors"),
245 description: text("description"),
246 documentation: text("documentation"),
247 homepage: text("homepage"),
248 readme: None,
249 readme_file,
250 keywords: list("keywords"),
251 categories: list("categories"),
252 license: text("license"),
253 license_file: text("license-file"),
254 repository: text("repository"),
255 badges: BTreeMap::new(),
256 links: text("links"),
257 rust_version: text("rust-version"),
258 })
259 }
260}
261
262impl Dependency {
263 fn new(
264 key: &str,
265 spec: &Item,
266 kind: &str,
267 target: Option<&str>,
268 target_index: &str,
269 ) -> Result<Self, CrateError> {
270 let malformed = || CrateError::Dependency(key.to_owned());
271 let (requirement, table) = if let Some(requirement) = spec.as_str() {
272 (Some(requirement), None)
273 } else {
274 let table = spec.as_table_like().ok_or_else(malformed)?;
275 (table.get("version").and_then(Item::as_str), Some(table))
276 };
277 let text = |field: &str| {
278 table
279 .and_then(|table| table.get(field))
280 .and_then(Item::as_str)
281 .map(str::to_owned)
282 };
283 let flag = |field: &str, default| {
284 table
285 .and_then(|table| table.get(field))
286 .and_then(Item::as_bool)
287 .unwrap_or(default)
288 };
289 let renamed = text("package");
290 let version_req = match requirement {
291 Some(requirement) => VersionReq::parse(requirement)
292 .map_err(|source| CrateError::VersionRequirement {
293 name: key.to_owned(),
294 source,
295 })?
296 .to_string(),
297 None => VersionReq::STAR.to_string(),
298 };
299 Ok(Self {
300 name: renamed.clone().unwrap_or_else(|| key.to_owned()),
301 version_req,
302 features: strings(table.and_then(|table| table.get("features"))),
303 optional: flag("optional", false),
304 default_features: flag("default-features", true),
305 target: target.map(str::to_owned),
306 kind: kind.to_owned(),
307 registry: registry(text("registry-index").as_deref(), target_index),
308 explicit_name_in_toml: renamed.map(|_| key.to_owned()),
309 })
310 }
311}
312
313fn registry(index: Option<&str>, target_index: &str) -> Option<String> {
318 let index = index.unwrap_or(CRATES_IO_INDEX);
319 let normal = |url: &str| {
320 url.strip_prefix("sparse+")
321 .unwrap_or(url)
322 .trim_end_matches('/')
323 .to_owned()
324 };
325 let crates_io = |url: &str| {
326 let url = normal(url);
327 url == CRATES_IO_INDEX || url.ends_with("//index.crates.io") || url.ends_with("//crates.io")
328 };
329 let same =
330 normal(index) == normal(target_index) || (crates_io(index) && crates_io(target_index));
331 (!same).then(|| index.to_owned())
332}
333
334fn strings(item: Option<&Item>) -> Vec<String> {
335 item.and_then(Item::as_array)
336 .into_iter()
337 .flatten()
338 .filter_map(|value| value.as_str().map(str::to_owned))
339 .collect()
340}
341
342fn dependencies(
343 manifest: &dyn TableLike,
344 target_index: &str,
345) -> Result<Vec<Dependency>, CrateError> {
346 let mut result = Vec::new();
347 collect(manifest, None, target_index, &mut result)?;
348 let targets = manifest.get("target").and_then(Item::as_table_like);
349 for (target, table) in targets.into_iter().flat_map(TableLike::iter) {
350 if let Some(table) = table.as_table_like() {
351 collect(table, Some(target), target_index, &mut result)?;
352 }
353 }
354 Ok(result)
355}
356
357fn collect(
358 table: &dyn TableLike,
359 target: Option<&str>,
360 target_index: &str,
361 result: &mut Vec<Dependency>,
362) -> Result<(), CrateError> {
363 for (section, kind) in SECTIONS {
364 let entries = table.get(section).and_then(Item::as_table_like);
365 for (key, spec) in entries.into_iter().flat_map(TableLike::iter) {
366 result.push(Dependency::new(key, spec, kind, target, target_index)?);
367 }
368 }
369 Ok(())
370}
371
372#[cfg(test)]
373mod tests {
374 use std::fs;
375 use std::io::Write as _;
376 use std::path::{Path, PathBuf};
377
378 use flate2::Compression;
379 use flate2::write::GzEncoder;
380
381 use super::{CRATES_IO_INDEX, CrateError, CratePackage};
382
383 const CRATES_IO: &str = "https://crates.io/";
384 const FORGE: &str = "https://forge.example/api/packages/acme/cargo/";
385
386 const MANIFEST: &str = r#"
387[package]
388edition = "2024"
389name = "core"
390version = "1.2.3"
391authors = ["Ann <ann@example.com>"]
392description = "The core"
393readme = "README.md"
394keywords = ["a", "b"]
395categories = ["development-tools"]
396license = "MIT"
397repository = "https://example.com/core"
398rust-version = "1.99"
399links = "corelib"
400
401[features]
402default = ["fast"]
403fast = ["dep:rayon"]
404
405[dependencies.serde]
406version = "1.0"
407features = ["derive"]
408default-features = false
409
410[dependencies.rayon]
411version = "1"
412optional = true
413
414[dependencies.json]
415version = "0.3"
416package = "serde_json"
417
418[dev-dependencies]
419tempfile = "3"
420
421[build-dependencies.cc]
422version = "1"
423
424[target.'cfg(unix)'.dependencies]
425libc = "0.2"
426"#;
427
428 const METADATA: &str = concat!(
429 r##"{"name":"core","vers":"1.2.3","deps":["##,
430 r##"{"name":"serde","version_req":"^1.0","features":["derive"],"optional":false,"default_features":false,"target":null,"kind":"normal","registry":null,"explicit_name_in_toml":null},"##,
431 r##"{"name":"rayon","version_req":"^1","features":[],"optional":true,"default_features":true,"target":null,"kind":"normal","registry":null,"explicit_name_in_toml":null},"##,
432 r##"{"name":"serde_json","version_req":"^0.3","features":[],"optional":false,"default_features":true,"target":null,"kind":"normal","registry":null,"explicit_name_in_toml":"json"},"##,
433 r##"{"name":"tempfile","version_req":"^3","features":[],"optional":false,"default_features":true,"target":null,"kind":"dev","registry":null,"explicit_name_in_toml":null},"##,
434 r##"{"name":"cc","version_req":"^1","features":[],"optional":false,"default_features":true,"target":null,"kind":"build","registry":null,"explicit_name_in_toml":null},"##,
435 r##"{"name":"libc","version_req":"^0.2","features":[],"optional":false,"default_features":true,"target":"cfg(unix)","kind":"normal","registry":null,"explicit_name_in_toml":null}],"##,
436 r##""features":{"default":["fast"],"fast":["dep:rayon"]},"authors":["Ann <ann@example.com>"],"##,
437 r##""description":"The core","documentation":null,"homepage":null,"readme":"# Core\n","readme_file":"README.md","##,
438 r##""keywords":["a","b"],"categories":["development-tools"],"license":"MIT","license_file":null,"##,
439 r##""repository":"https://example.com/core","badges":{},"links":"corelib","rust_version":"1.99"}"##,
440 );
441
442 fn crate_file(directory: &Path, entries: &[(&str, &str)]) -> PathBuf {
443 let mut archive = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
444 for (path, contents) in entries {
445 let mut header = tar::Header::new_gnu();
446 header.set_size(contents.len() as u64);
447 header.set_mode(0o644);
448 header.set_cksum();
449 archive
450 .append_data(&mut header, path, contents.as_bytes())
451 .unwrap();
452 }
453 let bytes = archive.into_inner().unwrap().finish().unwrap();
454 let path = directory.join("core-1.2.3.crate");
455 fs::File::create(&path).unwrap().write_all(&bytes).unwrap();
456 path
457 }
458
459 fn full(directory: &Path) -> PathBuf {
460 crate_file(
461 directory,
462 &[
463 ("core-1.2.3/Cargo.toml", MANIFEST),
464 ("core-1.2.3/README.md", "# Core\n"),
465 ("core-1.2.3/src/lib.rs", ""),
466 ],
467 )
468 }
469
470 #[test]
471 fn metadata_comes_from_the_packaged_manifest() {
472 let directory = tempfile::tempdir().unwrap();
473 let package =
474 CratePackage::read(&full(directory.path()), "core", "1.2.3", CRATES_IO).unwrap();
475 assert_eq!(String::from_utf8(package.metadata).unwrap(), METADATA);
476 }
477
478 #[test]
479 fn the_body_frames_the_metadata_and_the_crate_with_little_endian_lengths() {
480 let directory = tempfile::tempdir().unwrap();
481 let path = full(directory.path());
482 let package = CratePackage::read(&path, "core", "1.2.3", CRATES_IO).unwrap();
483 let body = package.body().unwrap();
484 let crate_bytes = fs::read(&path).unwrap();
485 let mut expected = Vec::new();
486 expected.extend_from_slice(&u32::try_from(METADATA.len()).unwrap().to_le_bytes());
487 expected.extend_from_slice(METADATA.as_bytes());
488 expected.extend_from_slice(&u32::try_from(crate_bytes.len()).unwrap().to_le_bytes());
489 expected.extend_from_slice(&crate_bytes);
490 assert_eq!(body, expected);
491 }
492
493 #[test]
494 fn a_crate_of_another_package_is_rejected() {
495 let directory = tempfile::tempdir().unwrap();
496 let path = crate_file(
497 directory.path(),
498 &[(
499 "core-1.2.3/Cargo.toml",
500 "[package]\nname = \"other\"\nversion = \"1.2.3\"\n",
501 )],
502 );
503 assert!(matches!(
504 CratePackage::read(&path, "core", "1.2.3", CRATES_IO),
505 Err(CrateError::WrongPackage { expected, actual })
506 if expected == "core-1.2.3" && actual == "other-1.2.3"
507 ));
508 }
509
510 #[test]
511 fn a_crate_without_a_manifest_is_rejected() {
512 let directory = tempfile::tempdir().unwrap();
513 let path = crate_file(directory.path(), &[("core-1.2.3/src/lib.rs", "")]);
514 assert!(matches!(
515 CratePackage::read(&path, "core", "1.2.3", CRATES_IO),
516 Err(CrateError::MissingEntry { path: _, entry }) if entry == "core-1.2.3/Cargo.toml"
517 ));
518 }
519
520 #[test]
521 fn a_file_that_is_not_gzip_is_an_archive_error() {
522 let directory = tempfile::tempdir().unwrap();
523 let path = directory.path().join("core-1.2.3.crate");
524 fs::write(&path, "plain text").unwrap();
525 assert!(matches!(
526 CratePackage::read(&path, "core", "1.2.3", CRATES_IO),
527 Err(CrateError::Archive { path: _, source: _ })
528 ));
529 }
530
531 #[test]
532 fn a_missing_file_is_a_read_error() {
533 let directory = tempfile::tempdir().unwrap();
534 assert!(matches!(
535 CratePackage::read(
536 &directory.path().join("absent.crate"),
537 "core",
538 "1.2.3",
539 CRATES_IO
540 ),
541 Err(CrateError::Read { path: _, source: _ })
542 ));
543 }
544
545 #[test]
546 fn dependency_registries_follow_cargo_publish() {
547 let manifest = concat!(
548 "[package]\nname = \"core\"\nversion = \"1.2.3\"\n",
549 "[dependencies]\nserde = \"1\"\n",
550 "[dependencies.same]\nversion = \"1\"\n",
551 "registry-index = \"sparse+https://forge.example/api/packages/acme/cargo\"\n",
552 "[dependencies.other]\nversion = \"1\"\n",
553 "registry-index = \"sparse+https://other.example/index/\"\n",
554 );
555 let directory = tempfile::tempdir().unwrap();
556 let path = crate_file(directory.path(), &[("core-1.2.3/Cargo.toml", manifest)]);
557 let registries = |target: &str| {
558 let package = CratePackage::read(&path, "core", "1.2.3", target).unwrap();
559 let json = String::from_utf8(package.metadata).unwrap();
560 json.split(r#""registry":"#)
561 .skip(1)
562 .map(|rest| rest.split([',', '}']).next().unwrap().to_owned())
563 .collect::<Vec<_>>()
564 };
565 assert_eq!(
566 registries(FORGE),
567 [
568 format!("\"{CRATES_IO_INDEX}\""),
569 "null".to_owned(),
570 r#""sparse+https://other.example/index/""#.to_owned(),
571 ]
572 );
573 assert_eq!(
574 registries(CRATES_IO),
575 [
576 "null".to_owned(),
577 r#""sparse+https://forge.example/api/packages/acme/cargo""#.to_owned(),
578 r#""sparse+https://other.example/index/""#.to_owned(),
579 ]
580 );
581 }
582}