Skip to main content

ephemeris_release/adapters/cargo/
package.rs

1//! The registry upload of a packaged crate: its metadata and request body.
2
3use 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/// A `.crate` file read for upload.
24#[derive(Debug)]
25pub(crate) struct CratePackage {
26    metadata: Vec<u8>,
27    bytes: Vec<u8>,
28}
29
30/// A crate file cannot be read or does not describe the expected package.
31#[derive(Debug, thiserror::Error)]
32pub(crate) enum CrateError {
33    /// The crate file cannot be read.
34    #[error("cannot read {}", path.display())]
35    Read {
36        /// The crate file.
37        path: PathBuf,
38        /// Why reading failed.
39        #[source]
40        source: io::Error,
41    },
42    /// The crate file is not a readable gzip tar archive.
43    #[error("cannot unpack {}", path.display())]
44    Archive {
45        /// The crate file.
46        path: PathBuf,
47        /// Why unpacking failed.
48        #[source]
49        source: io::Error,
50    },
51    /// The archive has no file at an expected path.
52    #[error("{} has no {entry}", path.display())]
53    MissingEntry {
54        /// The crate file.
55        path: PathBuf,
56        /// The archive path.
57        entry: String,
58    },
59    /// The packaged manifest is not valid TOML.
60    #[error("invalid manifest in {}", path.display())]
61    Manifest {
62        /// The crate file.
63        path: PathBuf,
64        /// The parse failure.
65        #[source]
66        source: Box<toml_edit::TomlError>,
67    },
68    /// The packaged manifest lacks a string field the upload needs.
69    #[error("packaged manifest has no package.{0}")]
70    MissingField(&'static str),
71    /// The packaged manifest describes another package than the unit's.
72    #[error("crate holds {actual}, not {expected}")]
73    WrongPackage {
74        /// The unit's package and version.
75        expected: String,
76        /// The packaged package and version.
77        actual: String,
78    },
79    /// A dependency entry is neither a version string nor a table.
80    #[error("dependency {0:?} is malformed")]
81    Dependency(String),
82    /// A dependency version requirement does not parse.
83    #[error("dependency {name:?} has an invalid version requirement")]
84    VersionRequirement {
85        /// The dependency.
86        name: String,
87        /// The parse failure.
88        #[source]
89        source: semver::Error,
90    },
91    /// The metadata cannot be written as JSON.
92    #[error("cannot write the crate metadata")]
93    Metadata(#[source] Box<dyn StdError + Send + Sync>),
94    /// A part of the request body is longer than its length prefix allows.
95    #[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    /// Read the crate file of `name` at `version`.
141    ///
142    /// The metadata comes from the manifest Cargo wrote into the archive.
143    /// `target_index` is the index URL of the registry the crate goes to.
144    /// Dependencies from that registry get no registry of their own.
145    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    /// The upload body: each of the JSON metadata and the crate file after a
182    /// four byte little-endian length.
183    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
313/// The registry a dependency names, as `cargo publish` sends it.
314///
315/// A dependency without an index comes from crates.io. One from the registry
316/// the crate is published to has none.
317fn 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}