cargo_util_schemas/
lockfile.rs

1//! `Cargo.lock` / Lockfile schema definition
2
3use std::collections::BTreeMap;
4use std::fmt;
5use std::{cmp::Ordering, str::FromStr};
6
7use serde::{Deserialize, Serialize, de, ser};
8use url::Url;
9
10use crate::core::{GitReference, SourceKind};
11
12/// Serialization of `Cargo.lock`
13#[derive(Serialize, Deserialize, Debug)]
14#[cfg_attr(feature = "unstable-schema", derive(schemars::JsonSchema))]
15pub struct TomlLockfile {
16    /// The lockfile format version (`version =` field).
17    ///
18    /// This field is optional for backward compatibility. Older lockfiles, i.e. V1 and V2, does
19    /// not have the version field serialized.
20    pub version: Option<u32>,
21    /// The list of `[[package]]` entries describing each resolved dependency.
22    pub package: Option<Vec<TomlLockfileDependency>>,
23    /// The `[root]` table describing the root package.
24    ///
25    /// This field is optional for backward compatibility. Older lockfiles have the root package
26    /// separated, whereas newer lockfiles have the root package as part of `[[package]]`.
27    pub root: Option<TomlLockfileDependency>,
28    /// The `[metadata]` table
29    ///
30    ///
31    /// In older lockfile versions, dependency checksums were stored here instead of alongside each
32    /// package entry.
33    pub metadata: Option<TomlLockfileMetadata>,
34    /// The `[patch]` table describing unused patches.
35    ///
36    /// The lockfile stores them as a list of `[[patch.unused]]` entries.
37    #[serde(default, skip_serializing_if = "TomlLockfilePatch::is_empty")]
38    pub patch: TomlLockfilePatch,
39}
40
41/// Serialization of lockfiles metadata
42///
43/// Older versions of lockfiles have their dependencies' checksums on this `[metadata]` table.
44pub type TomlLockfileMetadata = BTreeMap<String, String>;
45
46/// Serialization of unused patches
47///
48/// Cargo stores patches that were declared but not used during resolution.
49#[derive(Serialize, Deserialize, Debug, Default)]
50#[cfg_attr(feature = "unstable-schema", derive(schemars::JsonSchema))]
51pub struct TomlLockfilePatch {
52    /// The list of unused dependency patches.
53    pub unused: Vec<TomlLockfileDependency>,
54}
55
56impl TomlLockfilePatch {
57    fn is_empty(&self) -> bool {
58        self.unused.is_empty()
59    }
60}
61
62/// Serialization of lockfiles dependencies
63#[derive(Serialize, Deserialize, Debug, PartialOrd, Ord, PartialEq, Eq)]
64#[cfg_attr(feature = "unstable-schema", derive(schemars::JsonSchema))]
65pub struct TomlLockfileDependency {
66    /// The name of the dependency.
67    pub name: String,
68    /// The version of the dependency.
69    pub version: String,
70    /// The source of the dependency.
71    ///
72    /// Cargo does not serialize path dependencies.
73    pub source: Option<TomlLockfileSourceId>,
74    /// The checksum of the dependency.
75    ///
76    /// In older lockfiles, checksums were not stored here and instead on a separate `[metadata]`
77    /// table (see [`TomlLockfileMetadata`]).
78    pub checksum: Option<String>,
79    /// The transitive dependencies used by this dependency.
80    pub dependencies: Option<Vec<TomlLockfilePackageId>>,
81    /// The replace of the dependency.
82    pub replace: Option<TomlLockfilePackageId>,
83}
84
85/// Serialization of dependency's source
86#[derive(Debug, Clone)]
87#[cfg_attr(
88    feature = "unstable-schema",
89    derive(schemars::JsonSchema),
90    schemars(with = "String")
91)]
92pub struct TomlLockfileSourceId {
93    /// The string representation of the source as it appears in the lockfile.
94    source_str: String,
95    /// The parsed source type, e.g. `git`, `registry`.
96    ///
97    /// Used for sources ordering.
98    kind: SourceKind,
99    /// The parsed URL of the source.
100    ///
101    /// Used for sources ordering.
102    url: Url,
103}
104
105impl TomlLockfileSourceId {
106    pub fn new(source: String) -> Result<Self, TomlLockfileSourceIdError> {
107        let source_str = source.clone();
108        let (kind, url) = source
109            .split_once('+')
110            .ok_or_else(|| TomlLockfileSourceIdErrorKind::InvalidSource(source.clone()))?;
111
112        // Sparse URLs store the kind prefix (sparse+) in the URL. Therefore, for sparse kinds, we
113        // want to use the raw `source` instead of the splitted `url`.
114        let url = Url::parse(if kind == "sparse" { &source } else { url }).map_err(|msg| {
115            TomlLockfileSourceIdErrorKind::InvalidUrl {
116                url: url.to_string(),
117                msg: msg.to_string(),
118            }
119        })?;
120
121        let kind = match kind {
122            "git" => {
123                let reference = GitReference::from_query(url.query_pairs());
124                SourceKind::Git(reference)
125            }
126            "registry" => SourceKind::Registry,
127            "sparse" => SourceKind::SparseRegistry,
128            "path" => SourceKind::Path,
129            kind => {
130                return Err(
131                    TomlLockfileSourceIdErrorKind::UnsupportedSource(kind.to_string()).into(),
132                );
133            }
134        };
135
136        Ok(Self {
137            source_str,
138            kind,
139            url,
140        })
141    }
142
143    pub fn kind(&self) -> &SourceKind {
144        &self.kind
145    }
146
147    pub fn url(&self) -> &Url {
148        &self.url
149    }
150
151    pub fn source_str(&self) -> &String {
152        &self.source_str
153    }
154
155    pub fn as_url(&self) -> impl fmt::Display + '_ {
156        self.source_str.clone()
157    }
158}
159
160impl ser::Serialize for TomlLockfileSourceId {
161    fn serialize<S>(&self, s: S) -> Result<S::Ok, S::Error>
162    where
163        S: ser::Serializer,
164    {
165        s.collect_str(&self.as_url())
166    }
167}
168
169impl<'de> de::Deserialize<'de> for TomlLockfileSourceId {
170    fn deserialize<D>(d: D) -> Result<Self, D::Error>
171    where
172        D: de::Deserializer<'de>,
173    {
174        let s = String::deserialize(d)?;
175        Ok(TomlLockfileSourceId::new(s).map_err(de::Error::custom)?)
176    }
177}
178
179impl std::hash::Hash for TomlLockfileSourceId {
180    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
181        self.kind.hash(state);
182        self.url.hash(state);
183    }
184}
185
186impl std::cmp::PartialEq for TomlLockfileSourceId {
187    fn eq(&self, other: &Self) -> bool {
188        self.kind == other.kind && self.url == other.url
189    }
190}
191
192impl std::cmp::Eq for TomlLockfileSourceId {}
193
194impl PartialOrd for TomlLockfileSourceId {
195    fn partial_cmp(&self, other: &TomlLockfileSourceId) -> Option<Ordering> {
196        Some(self.cmp(other))
197    }
198}
199
200impl Ord for TomlLockfileSourceId {
201    fn cmp(&self, other: &TomlLockfileSourceId) -> Ordering {
202        self.kind
203            .cmp(&other.kind)
204            .then_with(|| self.url.cmp(&other.url))
205    }
206}
207
208/// Serialization of package IDs.
209///
210/// The version and source are only included when necessary to disambiguate between packages:
211/// - If multiple packages share the same name, the version is included.
212/// - If multiple packages share the same name and version, the source is included.
213#[derive(Debug, PartialOrd, Ord, PartialEq, Eq, Hash, Clone)]
214#[cfg_attr(feature = "unstable-schema", derive(schemars::JsonSchema))]
215pub struct TomlLockfilePackageId {
216    pub name: String,
217    pub version: Option<String>,
218    pub source: Option<TomlLockfileSourceId>,
219}
220
221impl fmt::Display for TomlLockfilePackageId {
222    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
223        write!(f, "{}", self.name)?;
224        if let Some(s) = &self.version {
225            write!(f, " {}", s)?;
226        }
227        if let Some(s) = &self.source {
228            write!(f, " ({})", s.as_url())?;
229        }
230        Ok(())
231    }
232}
233
234impl FromStr for TomlLockfilePackageId {
235    type Err = TomlLockfilePackageIdError;
236
237    fn from_str(s: &str) -> Result<TomlLockfilePackageId, Self::Err> {
238        let mut s = s.splitn(3, ' ');
239        let name = s.next().unwrap();
240        let version = s.next();
241        let source_id = match s.next() {
242            Some(s) => {
243                if let Some(s) = s.strip_prefix('(').and_then(|s| s.strip_suffix(')')) {
244                    Some(TomlLockfileSourceId::new(s.to_string())?)
245                } else {
246                    return Err(TomlLockfilePackageIdErrorKind::InvalidSerializedPackageId.into());
247                }
248            }
249            None => None,
250        };
251
252        Ok(TomlLockfilePackageId {
253            name: name.to_string(),
254            version: version.map(|v| v.to_string()),
255            source: source_id,
256        })
257    }
258}
259
260impl ser::Serialize for TomlLockfilePackageId {
261    fn serialize<S>(&self, s: S) -> Result<S::Ok, S::Error>
262    where
263        S: ser::Serializer,
264    {
265        s.collect_str(self)
266    }
267}
268
269impl<'de> de::Deserialize<'de> for TomlLockfilePackageId {
270    fn deserialize<D>(d: D) -> Result<TomlLockfilePackageId, D::Error>
271    where
272        D: de::Deserializer<'de>,
273    {
274        String::deserialize(d).and_then(|string| {
275            string
276                .parse::<TomlLockfilePackageId>()
277                .map_err(de::Error::custom)
278        })
279    }
280}
281
282#[derive(Debug, thiserror::Error)]
283#[error(transparent)]
284pub struct TomlLockfileSourceIdError(#[from] TomlLockfileSourceIdErrorKind);
285
286#[non_exhaustive]
287#[derive(Debug, thiserror::Error)]
288enum TomlLockfileSourceIdErrorKind {
289    #[error("invalid source `{0}`")]
290    InvalidSource(String),
291
292    #[error("invalid url `{url}`: {msg}")]
293    InvalidUrl { url: String, msg: String },
294
295    #[error("unsupported source protocol: {0}")]
296    UnsupportedSource(String),
297}
298
299#[derive(Debug, thiserror::Error)]
300#[error(transparent)]
301pub struct TomlLockfilePackageIdError(#[from] TomlLockfilePackageIdErrorKind);
302
303impl From<TomlLockfileSourceIdError> for TomlLockfilePackageIdError {
304    fn from(value: TomlLockfileSourceIdError) -> Self {
305        TomlLockfilePackageIdErrorKind::Source(value).into()
306    }
307}
308
309#[non_exhaustive]
310#[derive(Debug, thiserror::Error)]
311enum TomlLockfilePackageIdErrorKind {
312    #[error("invalid serialied PackageId")]
313    InvalidSerializedPackageId,
314
315    #[error(transparent)]
316    Source(#[from] TomlLockfileSourceIdError),
317}
318
319#[cfg(feature = "unstable-schema")]
320#[test]
321fn dump_lockfile_schema() {
322    let schema = schemars::schema_for!(crate::lockfile::TomlLockfile);
323    let dump = serde_json::to_string_pretty(&schema).unwrap();
324    snapbox::assert_data_eq!(dump, snapbox::file!("../lockfile.schema.json").raw());
325}
326
327#[cfg(test)]
328mod tests {
329    use crate::core::{GitReference, SourceKind};
330    use crate::lockfile::{TomlLockfileSourceId, TomlLockfileSourceIdErrorKind};
331
332    #[track_caller]
333    fn ok(source_str: &str, source_kind: SourceKind, url: &str) {
334        let source_str = source_str.to_owned();
335        let source_id = TomlLockfileSourceId::new(source_str).unwrap();
336        assert_eq!(source_id.kind, source_kind);
337        assert_eq!(source_id.url().to_string(), url);
338    }
339
340    macro_rules! err {
341        ($src:expr, $expected:pat) => {
342            let kind = TomlLockfileSourceId::new($src.to_owned()).unwrap_err().0;
343            assert!(
344                matches!(kind, $expected),
345                "`{}` parse error mismatch, got {kind:?}",
346                $src,
347            );
348        };
349    }
350
351    #[test]
352    fn good_sources() {
353        ok(
354            "sparse+https://my-crates.io",
355            SourceKind::SparseRegistry,
356            "sparse+https://my-crates.io",
357        );
358        ok(
359            "registry+https://github.com/rust-lang/crates.io-index",
360            SourceKind::Registry,
361            "https://github.com/rust-lang/crates.io-index",
362        );
363        ok(
364            "git+https://github.com/rust-lang/cargo",
365            SourceKind::Git(GitReference::DefaultBranch),
366            "https://github.com/rust-lang/cargo",
367        );
368        ok(
369            "git+https://github.com/rust-lang/cargo?branch=dev",
370            SourceKind::Git(GitReference::Branch("dev".to_owned())),
371            "https://github.com/rust-lang/cargo?branch=dev",
372        );
373        ok(
374            "git+https://github.com/rust-lang/cargo?tag=v1.0",
375            SourceKind::Git(GitReference::Tag("v1.0".to_owned())),
376            "https://github.com/rust-lang/cargo?tag=v1.0",
377        );
378        ok(
379            "git+https://github.com/rust-lang/cargo?rev=refs/pull/493/head",
380            SourceKind::Git(GitReference::Rev("refs/pull/493/head".to_owned())),
381            "https://github.com/rust-lang/cargo?rev=refs/pull/493/head",
382        );
383        ok(
384            "path+file:///path/to/root",
385            SourceKind::Path,
386            "file:///path/to/root",
387        );
388    }
389
390    #[test]
391    fn bad_sources() {
392        err!(
393            "unknown+https://my-crates.io",
394            TomlLockfileSourceIdErrorKind::UnsupportedSource(..)
395        );
396        err!(
397            "registry+https//github.com/rust-lang/crates.io-index",
398            TomlLockfileSourceIdErrorKind::InvalidUrl { .. }
399        );
400        err!(
401            "https//github.com/rust-lang/crates.io-index",
402            TomlLockfileSourceIdErrorKind::InvalidSource(..)
403        );
404    }
405}