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, EncodableSourceIdError> {
107        let source_str = source.clone();
108        let (kind, url) = source.split_once('+').ok_or_else(|| {
109            EncodableSourceIdError(EncodableSourceIdErrorKind::InvalidSource(source.clone()).into())
110        })?;
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            EncodableSourceIdErrorKind::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(EncodableSourceIdErrorKind::UnsupportedSource(kind.to_string()).into());
131            }
132        };
133
134        Ok(Self {
135            source_str,
136            kind,
137            url,
138        })
139    }
140
141    pub fn kind(&self) -> &SourceKind {
142        &self.kind
143    }
144
145    pub fn url(&self) -> &Url {
146        &self.url
147    }
148
149    pub fn source_str(&self) -> &String {
150        &self.source_str
151    }
152
153    pub fn as_url(&self) -> impl fmt::Display + '_ {
154        self.source_str.clone()
155    }
156}
157
158impl ser::Serialize for TomlLockfileSourceId {
159    fn serialize<S>(&self, s: S) -> Result<S::Ok, S::Error>
160    where
161        S: ser::Serializer,
162    {
163        s.collect_str(&self.as_url())
164    }
165}
166
167impl<'de> de::Deserialize<'de> for TomlLockfileSourceId {
168    fn deserialize<D>(d: D) -> Result<Self, D::Error>
169    where
170        D: de::Deserializer<'de>,
171    {
172        let s = String::deserialize(d)?;
173        Ok(TomlLockfileSourceId::new(s).map_err(de::Error::custom)?)
174    }
175}
176
177impl std::hash::Hash for TomlLockfileSourceId {
178    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
179        self.kind.hash(state);
180        self.url.hash(state);
181    }
182}
183
184impl std::cmp::PartialEq for TomlLockfileSourceId {
185    fn eq(&self, other: &Self) -> bool {
186        self.kind == other.kind && self.url == other.url
187    }
188}
189
190impl std::cmp::Eq for TomlLockfileSourceId {}
191
192impl PartialOrd for TomlLockfileSourceId {
193    fn partial_cmp(&self, other: &TomlLockfileSourceId) -> Option<Ordering> {
194        Some(self.cmp(other))
195    }
196}
197
198impl Ord for TomlLockfileSourceId {
199    fn cmp(&self, other: &TomlLockfileSourceId) -> Ordering {
200        self.kind
201            .cmp(&other.kind)
202            .then_with(|| self.url.cmp(&other.url))
203    }
204}
205
206/// Serialization of package IDs.
207///
208/// The version and source are only included when necessary to disambiguate between packages:
209/// - If multiple packages share the same name, the version is included.
210/// - If multiple packages share the same name and version, the source is included.
211#[derive(Debug, PartialOrd, Ord, PartialEq, Eq, Hash, Clone)]
212#[cfg_attr(feature = "unstable-schema", derive(schemars::JsonSchema))]
213pub struct TomlLockfilePackageId {
214    pub name: String,
215    pub version: Option<String>,
216    pub source: Option<TomlLockfileSourceId>,
217}
218
219impl fmt::Display for TomlLockfilePackageId {
220    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
221        write!(f, "{}", self.name)?;
222        if let Some(s) = &self.version {
223            write!(f, " {}", s)?;
224        }
225        if let Some(s) = &self.source {
226            write!(f, " ({})", s.as_url())?;
227        }
228        Ok(())
229    }
230}
231
232impl FromStr for TomlLockfilePackageId {
233    type Err = EncodablePackageIdError;
234
235    fn from_str(s: &str) -> Result<TomlLockfilePackageId, Self::Err> {
236        let mut s = s.splitn(3, ' ');
237        let name = s.next().unwrap();
238        let version = s.next();
239        let source_id = match s.next() {
240            Some(s) => {
241                if let Some(s) = s.strip_prefix('(').and_then(|s| s.strip_suffix(')')) {
242                    Some(TomlLockfileSourceId::new(s.to_string())?)
243                } else {
244                    return Err(EncodablePackageIdErrorKind::InvalidSerializedPackageId.into());
245                }
246            }
247            None => None,
248        };
249
250        Ok(TomlLockfilePackageId {
251            name: name.to_string(),
252            version: version.map(|v| v.to_string()),
253            source: source_id,
254        })
255    }
256}
257
258impl ser::Serialize for TomlLockfilePackageId {
259    fn serialize<S>(&self, s: S) -> Result<S::Ok, S::Error>
260    where
261        S: ser::Serializer,
262    {
263        s.collect_str(self)
264    }
265}
266
267impl<'de> de::Deserialize<'de> for TomlLockfilePackageId {
268    fn deserialize<D>(d: D) -> Result<TomlLockfilePackageId, D::Error>
269    where
270        D: de::Deserializer<'de>,
271    {
272        String::deserialize(d).and_then(|string| {
273            string
274                .parse::<TomlLockfilePackageId>()
275                .map_err(de::Error::custom)
276        })
277    }
278}
279
280#[derive(Debug, thiserror::Error)]
281#[error(transparent)]
282pub struct EncodableSourceIdError(#[from] EncodableSourceIdErrorKind);
283
284#[non_exhaustive]
285#[derive(Debug, thiserror::Error)]
286enum EncodableSourceIdErrorKind {
287    #[error("invalid source `{0}`")]
288    InvalidSource(String),
289
290    #[error("invalid url `{url}`: {msg}")]
291    InvalidUrl { url: String, msg: String },
292
293    #[error("unsupported source protocol: {0}")]
294    UnsupportedSource(String),
295}
296
297#[derive(Debug, thiserror::Error)]
298#[error(transparent)]
299pub struct EncodablePackageIdError(#[from] EncodablePackageIdErrorKind);
300
301impl From<EncodableSourceIdError> for EncodablePackageIdError {
302    fn from(value: EncodableSourceIdError) -> Self {
303        EncodablePackageIdErrorKind::Source(value).into()
304    }
305}
306
307#[non_exhaustive]
308#[derive(Debug, thiserror::Error)]
309enum EncodablePackageIdErrorKind {
310    #[error("invalid serialied PackageId")]
311    InvalidSerializedPackageId,
312
313    #[error(transparent)]
314    Source(#[from] EncodableSourceIdError),
315}
316
317#[cfg(feature = "unstable-schema")]
318#[test]
319fn dump_lockfile_schema() {
320    let schema = schemars::schema_for!(crate::lockfile::TomlLockfile);
321    let dump = serde_json::to_string_pretty(&schema).unwrap();
322    snapbox::assert_data_eq!(dump, snapbox::file!("../lockfile.schema.json").raw());
323}
324
325#[cfg(test)]
326mod tests {
327    use crate::core::{GitReference, SourceKind};
328    use crate::lockfile::{EncodableSourceIdErrorKind, TomlLockfileSourceId};
329
330    #[track_caller]
331    fn ok(source_str: &str, source_kind: SourceKind, url: &str) {
332        let source_str = source_str.to_owned();
333        let source_id = TomlLockfileSourceId::new(source_str).unwrap();
334        assert_eq!(source_id.kind, source_kind);
335        assert_eq!(source_id.url().to_string(), url);
336    }
337
338    macro_rules! err {
339        ($src:expr, $expected:pat) => {
340            let kind = TomlLockfileSourceId::new($src.to_owned()).unwrap_err().0;
341            assert!(
342                matches!(kind, $expected),
343                "`{}` parse error mismatch, got {kind:?}",
344                $src,
345            );
346        };
347    }
348
349    #[test]
350    fn good_sources() {
351        ok(
352            "sparse+https://my-crates.io",
353            SourceKind::SparseRegistry,
354            "sparse+https://my-crates.io",
355        );
356        ok(
357            "registry+https://github.com/rust-lang/crates.io-index",
358            SourceKind::Registry,
359            "https://github.com/rust-lang/crates.io-index",
360        );
361        ok(
362            "git+https://github.com/rust-lang/cargo",
363            SourceKind::Git(GitReference::DefaultBranch),
364            "https://github.com/rust-lang/cargo",
365        );
366        ok(
367            "git+https://github.com/rust-lang/cargo?branch=dev",
368            SourceKind::Git(GitReference::Branch("dev".to_owned())),
369            "https://github.com/rust-lang/cargo?branch=dev",
370        );
371        ok(
372            "git+https://github.com/rust-lang/cargo?tag=v1.0",
373            SourceKind::Git(GitReference::Tag("v1.0".to_owned())),
374            "https://github.com/rust-lang/cargo?tag=v1.0",
375        );
376        ok(
377            "git+https://github.com/rust-lang/cargo?rev=refs/pull/493/head",
378            SourceKind::Git(GitReference::Rev("refs/pull/493/head".to_owned())),
379            "https://github.com/rust-lang/cargo?rev=refs/pull/493/head",
380        );
381        ok(
382            "path+file:///path/to/root",
383            SourceKind::Path,
384            "file:///path/to/root",
385        );
386    }
387
388    #[test]
389    fn bad_sources() {
390        err!(
391            "unknown+https://my-crates.io",
392            EncodableSourceIdErrorKind::UnsupportedSource(..)
393        );
394        err!(
395            "registry+https//github.com/rust-lang/crates.io-index",
396            EncodableSourceIdErrorKind::InvalidUrl { .. }
397        );
398        err!(
399            "https//github.com/rust-lang/crates.io-index",
400            EncodableSourceIdErrorKind::InvalidSource(..)
401        );
402    }
403}