fetch_source/
source.rs

1//! Core types for interacting with sources declared in `Cargo.toml`.
2
3use super::error::FetchError;
4use super::git::Git;
5#[cfg(feature = "tar")]
6use super::tar::Tar;
7
8use derive_more::Deref;
9
10/// The name of a source
11pub type SourceName = String;
12
13/// Errors encountered when parsing sources from `Cargo.toml`
14#[derive(Debug, thiserror::Error)]
15pub enum SourceParseError {
16    /// An unknown source variant was encountered.
17    #[error("expected a valid source type for source '{source_name}': expected one of: {known}", known = SOURCE_VARIANTS.iter().map(|v| v.to_string()).collect::<Vec<_>>().join(", "))]
18    VariantUnknown {
19        /// The name of the source whose variant wasn't recognised
20        source_name: SourceName,
21    },
22
23    /// A source has multiple variants given.
24    #[error("multiple source types for source '{source_name}': expected exactly one of: {known}", known = SOURCE_VARIANTS.iter().map(|v| v.to_string()).collect::<Vec<_>>().join(", "))]
25    VariantMultiple {
26        /// The name of the source with multiple variants
27        source_name: SourceName,
28    },
29
30    /// A source has a variant which depends on a disabled feature.
31    #[error("source '{source_name}' has type '{variant}' but needs disabled feature '{requires}'")]
32    VariantDisabled {
33        /// The name of the source
34        source_name: SourceName,
35        /// The source type
36        variant: String,
37        /// The disabled feature
38        requires: String,
39    },
40
41    /// A toml value was expected to be a table.
42    #[error("expected value '{name}' to be a toml table")]
43    ValueNotTable {
44        /// The key for the value which was expected to be a table
45        name: String,
46    },
47
48    /// The `package.metadata.fetch-source` table was not found.
49    #[error("required table 'package.metadata.fetch-source' not found in string")]
50    SourceTableNotFound,
51
52    /// A toml deserialisation error occurred.
53    #[error(transparent)]
54    TomlInvalid(#[from] toml::de::Error),
55
56    /// A json error occurred.
57    #[error(transparent)]
58    JsonInvalid(#[from] serde_json::Error),
59}
60
61/// Represents the result of a fetch operation
62pub type FetchResult<T> = Result<T, crate::FetchError>;
63
64/// Represents a source that has been fetched from a remote location.
65///
66/// Notably implements [`AsRef<std::path::Path>`](std::path::Path) and [`AsRef<Source>`](Source).
67#[derive(Debug, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
68pub struct Artefact {
69    // This is a combination of the fetched artefact and the source it was fetched from.
70    // Note that the name associated with a source *must not* be stored in the cache. This avoids
71    // using one name for a source but then unexpectedly returning another.
72    /// The upstream source
73    source: Source,
74    /// The local copy
75    path: std::path::PathBuf,
76}
77
78impl Artefact {
79    /// Get the path to an artefact
80    pub fn path(&self) -> &std::path::Path {
81        &self.path
82    }
83}
84
85impl AsRef<std::path::Path> for Artefact {
86    fn as_ref(&self) -> &std::path::Path {
87        &self.path
88    }
89}
90
91impl AsRef<Source> for Artefact {
92    fn as_ref(&self) -> &Source {
93        &self.source
94    }
95}
96
97impl AsRef<Source> for Source {
98    fn as_ref(&self) -> &Source {
99        self
100    }
101}
102
103/// Allowed source variants.
104#[derive(Debug, PartialEq, Eq, Hash)]
105enum SourceVariant {
106    Tar,
107    Git,
108}
109
110const SOURCE_VARIANTS: &[SourceVariant] = &[SourceVariant::Tar, SourceVariant::Git];
111
112impl std::fmt::Display for SourceVariant {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        match self {
115            Self::Tar => write!(f, "tar"),
116            Self::Git => write!(f, "git"),
117        }
118    }
119}
120
121impl SourceVariant {
122    fn from<S: AsRef<str>>(name: S) -> Option<Self> {
123        match name.as_ref() {
124            "tar" => Some(Self::Tar),
125            "git" => Some(Self::Git),
126            _ => None,
127        }
128    }
129
130    fn is_enabled(&self) -> bool {
131        match self {
132            Self::Tar => cfg!(feature = "tar"),
133            Self::Git => true,
134        }
135    }
136
137    fn feature(&self) -> Option<&'static str> {
138        match self {
139            Self::Tar => Some("tar"),
140            Self::Git => None,
141        }
142    }
143}
144
145/// The digest associated with the definition of a [`Source`]
146#[derive(
147    Debug,
148    Default,
149    serde::Deserialize,
150    serde::Serialize,
151    PartialEq,
152    Eq,
153    PartialOrd,
154    Ord,
155    Clone,
156    Deref,
157)]
158pub struct Digest(String);
159
160impl AsRef<str> for Digest {
161    fn as_ref(&self) -> &str {
162        self.0.as_ref()
163    }
164}
165
166/// Represents an entry in the `package.metadata.fetch-source` table.
167#[derive(Debug, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
168#[serde(untagged)]
169pub enum Source {
170    #[cfg(feature = "tar")]
171    #[serde(rename = "tar")]
172    /// A remote tar archive
173    Tar(Tar),
174    #[serde(rename = "git")]
175    /// A remote git repo
176    Git(Git),
177}
178
179impl std::fmt::Display for Source {
180    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
181        match self {
182            #[cfg(feature = "tar")]
183            Source::Tar(tar) => write!(f, "tar source: {tar:?}"),
184            Source::Git(git) => write!(f, "git source: {git:?}"),
185        }
186    }
187}
188
189impl Source {
190    /// Calculate the digest of a source.
191    pub fn digest<S: AsRef<Self>>(value: S) -> Digest {
192        let json = serde_json::to_string(value.as_ref())
193            .expect("Serialisation of Source should never fail");
194        Digest(sha256::digest(json))
195    }
196
197    /// Fetch the remote source as declared in `Cargo.toml` and put the resulting [`Artefact`] in `dir`.
198    pub fn fetch<P: AsRef<std::path::Path>>(self, dir: P) -> FetchResult<Artefact> {
199        let dest = dir.as_ref();
200        let result = match self {
201            #[cfg(feature = "tar")]
202            Source::Tar(ref tar) => tar.fetch(dest),
203            Source::Git(ref git) => git.fetch(dest),
204        };
205        match result {
206            Ok(path) => Ok(Artefact { source: self, path }),
207            Err(err) => Err(FetchError::new(err, self)),
208        }
209    }
210
211    /// Convert a name into a partial path. Each `::`-separated component maps onto a subdirectory.
212    pub fn as_path_component<S: AsRef<str>>(name: S) -> std::path::PathBuf {
213        std::path::PathBuf::from_iter(name.as_ref().split("::"))
214    }
215
216    fn enforce_one_valid_variant<S: ToString>(
217        name: S,
218        source: &toml::Table,
219    ) -> Result<SourceVariant, SourceParseError> {
220        let mut detected_variant = None;
221        for key in source.keys() {
222            if let Some(variant) = SourceVariant::from(key) {
223                if detected_variant.is_some() {
224                    return Err(SourceParseError::VariantMultiple {
225                        source_name: name.to_string(),
226                    });
227                }
228                if !variant.is_enabled() {
229                    return Err(SourceParseError::VariantDisabled {
230                        source_name: name.to_string(),
231                        variant: variant.to_string(),
232                        requires: variant.feature().unwrap_or("?").to_string(),
233                    });
234                }
235                detected_variant = Some(variant);
236            }
237        }
238        detected_variant.ok_or(SourceParseError::VariantUnknown {
239            source_name: name.to_string(),
240        })
241    }
242
243    /// Parse a TOML table into a `Source` instance. Exactly one key in the table must identify
244    /// a valid, enabled source type, otherwise an error is returned.
245    pub fn parse<S: ToString>(name: S, source: toml::Table) -> Result<Self, SourceParseError> {
246        Self::enforce_one_valid_variant(name, &source)?;
247        Ok(toml::Value::Table(source).try_into::<Self>()?)
248    }
249}
250
251/// Represents the contents of the `package.metadata.fetch-source` table in a `Cargo.toml` file.
252pub type SourcesTable = std::collections::HashMap<SourceName, Source>;
253
254/// Parse a `package.metadata.fetch-source` table into a [`SourcesTable`](crate::source::SourcesTable) map
255pub fn try_parse(table: &toml::Table) -> Result<SourcesTable, SourceParseError> {
256    table
257        .iter()
258        .map(|(k, v)| match v.as_table() {
259            Some(t) => Source::parse(k, t.to_owned()).map(|s| (k.to_owned(), s)),
260            None => Err(SourceParseError::ValueNotTable { name: k.to_owned() }),
261        })
262        .collect()
263}
264
265/// Parse the contents of a Cargo.toml file containing the `package.metadata.fetch-source` table
266/// into a [`SourcesTable`](crate::source::SourcesTable) map.
267pub fn try_parse_toml<S: AsRef<str>>(toml_str: S) -> Result<SourcesTable, SourceParseError> {
268    let table = toml_str.as_ref().parse::<toml::Table>()?;
269    let sources_table = table
270        .get("package")
271        .and_then(|v| v.get("metadata"))
272        .and_then(|v| v.get("fetch-source"))
273        .and_then(|v| v.as_table())
274        .ok_or(SourceParseError::SourceTableNotFound)?;
275    try_parse(sources_table)
276}
277
278#[cfg(test)]
279use SourceParseError::*;
280
281#[cfg(test)]
282mod test_parsing_single_source_value {
283    use super::*;
284    use crate::build_from_json;
285
286    #[test]
287    fn parse_good_git_source() {
288        let source = build_from_json! {
289            Source,
290            "git": "git@github.com:foo/bar.git"
291        };
292        assert!(source.is_ok());
293    }
294
295    #[cfg(feature = "tar")]
296    #[test]
297    fn parse_good_tar_source() {
298        let source = build_from_json! {
299            Source,
300            "tar": "https://example.com/foo.tar.gz"
301        };
302        assert!(source.is_ok());
303    }
304
305    #[cfg(not(feature = "tar"))]
306    #[test]
307    fn parse_good_tar_source_fails_when_feature_disabled() {
308        let source = build_from_json! {
309            Source,
310            "tar": "https://example.com/foo.tar.gz"
311        };
312        assert!(
313            matches!(source, Err(VariantDisabled { source_name: _, variant, requires })
314                if variant == "tar" && requires == "tar"
315            )
316        );
317    }
318
319    #[test]
320    fn parse_multiple_types_fails() {
321        // NOTE: this test explicitly tests failure modes of Source::parse
322        let source = Source::parse(
323            "src",
324            toml::toml! {
325                tar = "https://example.com/foo.tar.gz"
326                git = "git@github.com:foo/bar.git"
327            },
328        );
329        assert!(matches!(source, Err(VariantMultiple { source_name })
330            if source_name == "src"
331        ));
332    }
333
334    #[test]
335    fn parse_missing_type_fails() {
336        // NOTE: this test explicitly tests failure modes of Source::parse
337        let source = Source::parse(
338            "src",
339            toml::toml! {
340                foo = "git@github.com:foo/bar.git"
341            },
342        );
343        assert!(matches!(source, Err(VariantUnknown { source_name })
344            if source_name == "src"
345        ));
346    }
347}
348
349#[cfg(test)]
350mod test_parsing_sources_table_failure_modes {
351    use super::*;
352
353    #[test]
354    fn parse_invalid_toml_str_fails() {
355        let document = "this is not a valid toml document :( uh-oh!";
356        let result = try_parse_toml(document);
357        assert!(matches!(result, Err(TomlInvalid(_))));
358    }
359
360    #[test]
361    fn parse_doc_missing_sources_table_fails() {
362        let document = r#"
363            [package]
364            name = "my_fun_test_suite"
365
366            [package.metadata.wrong-name]
367            foo = { git = "git@github.com:foo/bar.git" }
368            bar = { tar = "https://example.com/foo.tar.gz" }
369        "#;
370        assert!(matches!(try_parse_toml(document), Err(SourceTableNotFound)));
371    }
372
373    #[test]
374    fn parse_doc_source_value_not_a_table_fails() {
375        let document = r#"
376            [package]
377            name = "my_fun_test_suite"
378
379            [package.metadata.fetch-source]
380            not-a-table = "actually a string"
381        "#;
382        assert!(matches!(
383            try_parse_toml(document),
384            Err(ValueNotTable { name }) if name == "not-a-table"
385        ));
386    }
387
388    #[cfg(not(feature = "tar"))]
389    #[test]
390    fn parse_doc_source_variant_disabled_fails() {
391        let document = r#"
392            [package]
393            name = "my_fun_test_suite"
394
395            [package.metadata.fetch-source]
396            bar = { tar = "https://example.com/foo.tar.gz" }
397        "#;
398        assert!(matches!(
399            try_parse_toml(document),
400            Err(VariantDisabled {
401                source_name,
402                variant,
403                requires,
404            }) if source_name == "bar" && variant == "tar" && requires == "tar"
405        ));
406    }
407
408    #[test]
409    fn parse_doc_source_multiple_variants_fails() {
410        let document = r#"
411            [package]
412            name = "my_fun_test_suite"
413
414            [package.metadata.fetch-source]
415            bar = { tar = "https://example.com/foo.tar.gz", git = "git@github.com:foo/bar.git" }
416        "#;
417        assert!(matches!(
418            try_parse_toml(document),
419            Err(VariantMultiple { source_name }) if source_name == "bar"
420        ));
421    }
422
423    #[test]
424    fn parse_doc_source_unknown_variant_fails() {
425        let document = r#"
426            [package]
427            name = "my_fun_test_suite"
428
429            [package.metadata.fetch-source]
430            bar = { zim = "https://example.com/foo.tar.gz" }
431        "#;
432        assert!(matches!(
433            try_parse_toml(document),
434            Err(VariantUnknown { source_name }) if source_name == "bar"
435        ));
436    }
437}