Skip to main content

kellnr_common/
index_metadata.rs

1use std::collections::BTreeMap;
2use std::fmt::{Display, Formatter, Write};
3
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Deserializer, Serialize, Serializer};
6
7mod pubtime_format {
8    use chrono::{DateTime, Utc};
9    use serde::{self, Deserialize, Deserializer, Serializer};
10
11    const FORMAT: &str = "%Y-%m-%dT%H:%M:%SZ";
12
13    #[allow(clippy::ref_option)] // signature required by serde's `with` attribute
14    pub(super) fn serialize<S>(
15        date: &Option<DateTime<Utc>>,
16        serializer: S,
17    ) -> Result<S::Ok, S::Error>
18    where
19        S: Serializer,
20    {
21        match date {
22            Some(dt) => serializer.serialize_str(&dt.format(FORMAT).to_string()),
23            None => serializer.serialize_none(),
24        }
25    }
26
27    pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<Option<DateTime<Utc>>, D::Error>
28    where
29        D: Deserializer<'de>,
30    {
31        let s: Option<String> = Option::deserialize(deserializer)?;
32        match s {
33            Some(s) => DateTime::parse_from_rfc3339(&s)
34                .map(|dt| Some(dt.with_timezone(&Utc)))
35                .map_err(serde::de::Error::custom),
36            None => Ok(None),
37        }
38    }
39}
40
41use crate::publish_metadata::{PublishMetadata, RegistryDep};
42
43// This Metadata struct defined here is the one saved in the index.
44// It is different to the one sent by Cargo to the registry.
45// See: https://doc.rust-lang.org/cargo/reference/registries.html#index-format
46// Crates.io implementation: https://github.com/rust-lang/crates.io/blob/main/crates/crates_io_index/data.rs
47
48#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
49pub struct IndexMetadata {
50    // The name of the package.
51    // This must only contain alphanumeric, `-`, or `_` characters.
52    pub name: String,
53    // The version of the package this row is describing.
54    // This must be a valid version number according to the Semantic
55    // Versioning 2.0.0 spec at https://semver.org/.
56    pub vers: String,
57    // Array of direct dependencies of the package.
58    pub deps: Vec<IndexDep>,
59    // A SHA256 checksum of the `.crate` file.
60    pub cksum: String,
61    // Set of features defined for the package.
62    // Each feature maps to an array of features or dependencies it enables.
63    // #[serde(
64    //     skip_serializing_if = "Option::is_none",
65    //     serialize_with = "option_sorted_map"
66    // )]
67    pub features: BTreeMap<String, Vec<String>>,
68    // Boolean of whether or not this version has been yanked.
69    pub yanked: bool,
70    // The `links` string value from the package's manifest, or null if not
71    // specified. This field is optional and defaults to null.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub links: Option<String>,
74    // The time the package was published
75    #[serde(
76        default,
77        skip_serializing_if = "Option::is_none",
78        with = "pubtime_format"
79    )]
80    pub pubtime: Option<DateTime<Utc>>,
81    // An unsigned 32-bit integer value indicating the schema version of this
82    // entry.
83    //
84    // If this not specified, it should be interpreted as the default of 1.
85    //
86    // Cargo (starting with version 1.51) will ignore versions it does not
87    // recognize. This provides a method to safely introduce changes to index
88    // entries and allow older versions of cargo to ignore newer entries it
89    // doesn't understand. Versions older than 1.51 ignore this field, and
90    // thus may misinterpret the meaning of the index entry.
91    //
92    // The current values are:
93    //
94    // * 1: The schema as documented here, not including newer additions.
95    //      This is honored in Rust version 1.51 and newer.
96    // * 2: The addition of the `features2` field.
97    //      This is honored in Rust version 1.60 and newer.
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub v: Option<u32>,
100    // This optional field contains features with new, extended syntax.
101    // Specifically, namespaced features (`dep:`) and weak dependencies
102    // (`pkg?/feat`).
103    //
104    // This is separated from `features` because versions older than 1.19
105    // will fail to load due to not being able to parse the new syntax, even
106    // with a `Cargo.lock` file.
107    //
108    // Cargo will merge any values listed here with the "features" field.
109    //
110    // If this field is included, the "v" field should be set to at least 2.
111    //
112    // Registries are not required to use this field for extended feature
113    // syntax, they are allowed to include those in the "features" field.
114    // Using this is only necessary if the registry wants to support cargo
115    // versions older than 1.19, which in practice is only crates.io since
116    // those older versions do not support other registries.
117    // "features2": {
118    // "serde": ["dep:serde", "chrono?/serde"]
119    // }
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub features2: Option<BTreeMap<String, Vec<String>>>,
122    // The minimal supported Rust version of the package, or null if not
123    // specified. This field is optional and defaults to null.
124    //
125    // Note that the index spells this field `rust_version`, while the
126    // manifest key it originates from is `rust-version`.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub rust_version: Option<String>,
129}
130
131impl IndexMetadata {
132    pub fn to_json(&self) -> Result<String, serde_json::Error> {
133        serde_json::to_string(&self)
134    }
135
136    pub fn from_reg_meta(registry_metadata: &PublishMetadata, cksum: &str) -> Self {
137        let (features, features2) = split_features(registry_metadata.features.clone());
138        IndexMetadata {
139            name: registry_metadata.name.clone(),
140            vers: registry_metadata.vers.clone(),
141            deps: registry_metadata
142                .deps
143                .clone()
144                .into_iter()
145                .map(IndexDep::from)
146                .collect(),
147            cksum: cksum.to_string(),
148            pubtime: Some(Utc::now()),
149            v: Some(schema_version(features2.as_ref())),
150            features,
151            features2,
152            yanked: false,
153            links: registry_metadata.links.clone(),
154            rust_version: registry_metadata.rust_version.clone(),
155        }
156    }
157
158    pub fn minimal(name: &str, vers: &str, cksum: &str) -> Self {
159        Self {
160            name: name.to_string(),
161            vers: vers.to_string(),
162            cksum: cksum.to_string(),
163            deps: vec![],
164            features: BTreeMap::default(),
165            yanked: false,
166            links: None,
167            pubtime: None,
168            v: Some(1),
169            features2: None,
170            rust_version: None,
171        }
172    }
173
174    pub fn serialize_indices(indices: &[IndexMetadata]) -> Result<String, serde_json::Error> {
175        let mut index = String::new();
176        for ix in indices {
177            writeln!(&mut index, "{}", serde_json::to_string(ix)?).unwrap();
178        }
179        Ok(index)
180    }
181}
182
183/// Features of a crate with the features or dependencies each one enables.
184pub type Features = BTreeMap<String, Vec<String>>;
185
186/// Split the features like crates.io: features that use the newer `dep:` or
187/// `pkg?/feat` syntax go into the second map, as cargo older than 1.19 cannot
188/// parse them. `None` if there are no such features.
189pub fn split_features(features: Features) -> (Features, Option<Features>) {
190    let (features2, features): (BTreeMap<_, _>, BTreeMap<_, _>) =
191        features.into_iter().partition(|(_, values)| {
192            values
193                .iter()
194                .any(|v| v.starts_with("dep:") || v.contains("?/"))
195        });
196    let features2 = if features2.is_empty() {
197        None
198    } else {
199        Some(features2)
200    };
201    (features, features2)
202}
203
204/// The index schema version, 2 is required for `features2`.
205pub fn schema_version(features2: Option<&Features>) -> u32 {
206    if features2.is_some() { 2 } else { 1 }
207}
208
209#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
210pub struct IndexDep {
211    // Name of the dependency.
212    // If the dependency is renamed from the original package name,
213    // this is the new name. The original package name is stored in
214    // the `package` field.
215    pub name: String,
216    // The SemVer requirement for this dependency.
217    // This must be a valid version requirement defined at
218    // https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html.
219    pub req: String,
220    // Array of features (as strings) enabled for this dependency.
221    pub features: Vec<String>,
222    // Boolean of whether or not this is an optional dependency.
223    pub optional: bool,
224    // Boolean of whether or not default features are enabled.
225    pub default_features: bool,
226    // The target platform for the dependency.
227    // null if not a target dependency.
228    // Otherwise, a string such as "cfg(windows)".
229    pub target: Option<String>,
230    // The dependency kind.
231    // "dev", "build", or "normal".
232    // Note: this is a required field, but a small number of entries
233    // exist in the crates.io index with either a missing or null
234    // `kind` field due to implementation bugs.
235    pub kind: Option<DependencyKind>,
236    // The URL of the index of the registry where this dependency is
237    // from as a string. If not specified or null, it is assumed the
238    // dependency is in the current registry.
239    pub registry: Option<String>,
240    // If the dependency is renamed, this is a string of the actual
241    // package name. If not specified or null, this dependency is not
242    // renamed.
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub package: Option<String>,
245}
246
247#[derive(Clone, Debug, PartialEq, PartialOrd, Ord, Eq)]
248pub enum DependencyKind {
249    Normal,
250    Build,
251    Dev,
252    Other(String),
253}
254
255impl<'de> Deserialize<'de> for DependencyKind {
256    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
257    where
258        D: Deserializer<'de>,
259    {
260        let s = String::deserialize(deserializer)?;
261        match s.as_str() {
262            "normal" => Ok(DependencyKind::Normal),
263            "build" => Ok(DependencyKind::Build),
264            "dev" => Ok(DependencyKind::Dev),
265            _ => Ok(DependencyKind::Other(s)),
266        }
267    }
268}
269
270impl Serialize for DependencyKind {
271    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
272    where
273        S: Serializer,
274    {
275        match self {
276            DependencyKind::Normal => serializer.serialize_str("normal"),
277            DependencyKind::Build => serializer.serialize_str("build"),
278            DependencyKind::Dev => serializer.serialize_str("dev"),
279            DependencyKind::Other(s) => serializer.serialize_str(s),
280        }
281    }
282}
283
284impl Display for DependencyKind {
285    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
286        match self {
287            DependencyKind::Normal => write!(f, "normal"),
288            DependencyKind::Build => write!(f, "build"),
289            DependencyKind::Dev => write!(f, "dev"),
290            DependencyKind::Other(s) => write!(f, "{s}"),
291        }
292    }
293}
294
295impl From<String> for DependencyKind {
296    fn from(kind: String) -> Self {
297        match kind.as_str() {
298            "normal" => DependencyKind::Normal,
299            "build" => DependencyKind::Build,
300            "dev" => DependencyKind::Dev,
301            _ => DependencyKind::Other(kind),
302        }
303    }
304}
305
306impl From<RegistryDep> for IndexDep {
307    fn from(registry_dep: RegistryDep) -> Self {
308        IndexDep {
309            name: match registry_dep.explicit_name_in_toml {
310                Some(ref name) => name.clone(),
311                None => registry_dep.name.clone(),
312            },
313            req: registry_dep.version_req,
314            features: registry_dep.features.unwrap_or_default(),
315            optional: registry_dep.optional,
316            default_features: registry_dep.default_features,
317            target: registry_dep.target,
318            kind: registry_dep.kind.map(DependencyKind::from),
319            registry: registry_dep.registry,
320            package: match registry_dep.explicit_name_in_toml {
321                Some(_) => Some(registry_dep.name),
322                None => None,
323            },
324        }
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    fn features(entries: &[(&str, &[&str])]) -> BTreeMap<String, Vec<String>> {
333        entries
334            .iter()
335            .map(|(k, v)| {
336                (
337                    (*k).to_string(),
338                    v.iter().map(ToString::to_string).collect(),
339                )
340            })
341            .collect()
342    }
343
344    #[test]
345    fn new_feature_syntax_goes_into_features2() {
346        let reg_meta = PublishMetadata {
347            features: features(&[
348                ("default", &["std"]),
349                ("serde", &["dep:serde"]),
350                ("chrono", &["chrono?/serde", "std"]),
351            ]),
352            ..PublishMetadata::minimal("foo", "1.0.0")
353        };
354
355        let index = IndexMetadata::from_reg_meta(&reg_meta, "cksum");
356
357        assert_eq!(features(&[("default", &["std"])]), index.features);
358        assert_eq!(
359            Some(features(&[
360                ("chrono", &["chrono?/serde", "std"]),
361                ("serde", &["dep:serde"]),
362            ])),
363            index.features2
364        );
365        assert_eq!(Some(2), index.v);
366    }
367
368    #[test]
369    fn old_feature_syntax_stays_in_features() {
370        let reg_meta = PublishMetadata {
371            features: features(&[("default", &["std", "serde/std"])]),
372            ..PublishMetadata::minimal("foo", "1.0.0")
373        };
374
375        let index = IndexMetadata::from_reg_meta(&reg_meta, "cksum");
376
377        assert_eq!(reg_meta.features, index.features);
378        assert_eq!(None, index.features2);
379        assert_eq!(Some(1), index.v);
380    }
381
382    #[test]
383    fn transitive_dependency_rename() {
384        let reg_meta = PublishMetadata {
385            name: "foo".to_string(),
386            vers: "0.1.0".to_string(),
387            deps: vec![
388                RegistryDep {
389                    name: "bar".to_string(),
390                    version_req: "^0.1.0".to_string(),
391                    features: None,
392                    optional: false,
393                    default_features: true,
394                    target: None,
395                    kind: None,
396                    registry: None,
397                    explicit_name_in_toml: None,
398                },
399                RegistryDep {
400                    name: "baz".to_string(),
401                    version_req: "^0.1.0".to_string(),
402                    features: None,
403                    optional: false,
404                    default_features: true,
405                    target: None,
406                    kind: None,
407                    registry: None,
408                    explicit_name_in_toml: Some("qux".to_string()),
409                },
410            ],
411            features: BTreeMap::default(),
412            links: None,
413            description: None,
414            authors: None,
415            documentation: None,
416            homepage: None,
417            readme: None,
418            readme_file: None,
419            keywords: Vec::default(),
420            categories: Vec::default(),
421            license: None,
422            license_file: None,
423            repository: None,
424            badges: None,
425            rust_version: None,
426        };
427
428        let index_meta = IndexMetadata::from_reg_meta(&reg_meta, "1234");
429
430        assert_eq!(index_meta.deps.len(), 2);
431        assert_eq!(index_meta.deps[0].name, "bar");
432        assert_eq!(index_meta.deps[0].package, None);
433        assert_eq!(index_meta.deps[1].name, "qux");
434        assert_eq!(index_meta.deps[1].package, Some("baz".to_string()));
435    }
436
437    #[test]
438    fn pubtime_serializes_without_fractional_seconds() {
439        use chrono::TimeZone;
440
441        let pubtime = Utc.with_ymd_and_hms(2025, 1, 2, 9, 5, 7).unwrap();
442        let metadata = IndexMetadata {
443            name: "test".to_string(),
444            vers: "1.0.0".to_string(),
445            deps: vec![],
446            cksum: "abc123".to_string(),
447            features: BTreeMap::new(),
448            yanked: false,
449            links: None,
450            pubtime: Some(pubtime),
451            v: Some(1),
452            features2: None,
453            rust_version: None,
454        };
455
456        let json = metadata.to_json().unwrap();
457
458        // Verify format is exactly "2025-01-02T09:05:07Z" (zero-padded, no fractional seconds)
459        assert!(
460            json.contains(r#""pubtime":"2025-01-02T09:05:07Z""#),
461            "Expected pubtime to be serialized as '2025-01-02T09:05:07Z', got: {json}"
462        );
463    }
464
465    #[test]
466    fn pubtime_none_is_omitted_from_serialization() {
467        let metadata = IndexMetadata {
468            name: "test".to_string(),
469            vers: "1.0.0".to_string(),
470            deps: vec![],
471            cksum: "abc123".to_string(),
472            features: BTreeMap::new(),
473            yanked: false,
474            links: None,
475            pubtime: None,
476            v: Some(1),
477            features2: None,
478            rust_version: None,
479        };
480
481        let json = metadata.to_json().unwrap();
482
483        assert!(
484            !json.contains("pubtime"),
485            "Expected pubtime to be omitted when None, got: {json}"
486        );
487    }
488
489    #[test]
490    fn pubtime_deserializes_from_rfc3339() {
491        use chrono::{Datelike, Timelike};
492
493        let json = r#"{"name":"test","vers":"1.0.0","deps":[],"cksum":"abc","features":{},"yanked":false,"pubtime":"2025-01-02T09:05:07Z","v":1}"#;
494
495        let metadata: IndexMetadata = serde_json::from_str(json).unwrap();
496
497        assert!(metadata.pubtime.is_some());
498        let pubtime = metadata.pubtime.unwrap();
499        assert_eq!(pubtime.year(), 2025);
500        assert_eq!(pubtime.month(), 1);
501        assert_eq!(pubtime.day(), 2);
502        assert_eq!(pubtime.hour(), 9);
503        assert_eq!(pubtime.minute(), 5);
504        assert_eq!(pubtime.second(), 7);
505    }
506
507    #[test]
508    fn pubtime_deserializes_from_rfc3339_with_fractional_seconds() {
509        use chrono::{Datelike, Timelike};
510
511        // Should also handle input with fractional seconds (from crates.io or other sources)
512        let json = r#"{"name":"test","vers":"1.0.0","deps":[],"cksum":"abc","features":{},"yanked":false,"pubtime":"2025-01-02T09:05:07.123456Z","v":1}"#;
513
514        let metadata: IndexMetadata = serde_json::from_str(json).unwrap();
515
516        assert!(metadata.pubtime.is_some());
517        let pubtime = metadata.pubtime.unwrap();
518        assert_eq!(pubtime.year(), 2025);
519        assert_eq!(pubtime.month(), 1);
520        assert_eq!(pubtime.day(), 2);
521        assert_eq!(pubtime.hour(), 9);
522        assert_eq!(pubtime.minute(), 5);
523        assert_eq!(pubtime.second(), 7);
524    }
525
526    #[test]
527    fn rust_version_round_trips_losslessly() {
528        // The index spells the MSRV field `rust_version`, unlike the
529        // `rust-version` manifest key it is derived from.
530        let json = r#"{"name":"anyhow","vers":"1.0.100","deps":[],"cksum":"abc","features":{},"yanked":false,"v":2,"rust_version":"1.39"}"#;
531
532        let metadata: IndexMetadata = serde_json::from_str(json).unwrap();
533
534        assert_eq!(metadata.rust_version, Some("1.39".to_string()));
535        assert_eq!(
536            serde_json::from_str::<serde_json::Value>(&metadata.to_json().unwrap()).unwrap(),
537            serde_json::from_str::<serde_json::Value>(json).unwrap()
538        );
539    }
540
541    #[test]
542    fn rust_version_none_is_omitted_from_serialization() {
543        let metadata = IndexMetadata::minimal("test", "1.0.0", "abc123");
544
545        let json = metadata.to_json().unwrap();
546
547        assert!(
548            !json.contains("rust_version"),
549            "Expected rust_version to be omitted when None, got: {json}"
550        );
551    }
552
553    #[test]
554    fn serialize_indices_ends_with_newline_and_one_line_per_entry() {
555        let mk = |vers: &str| IndexMetadata {
556            name: "crate".to_string(),
557            vers: vers.to_string(),
558            deps: vec![],
559            cksum: "cksum".to_string(),
560            features: BTreeMap::default(),
561            yanked: false,
562            links: None,
563            pubtime: None,
564            v: Some(1),
565            features2: None,
566            rust_version: None,
567        };
568        let indices = vec![mk("1.0.0"), mk("2.0.0")];
569
570        let out = IndexMetadata::serialize_indices(&indices).unwrap();
571
572        // crates.io terminates the sparse-index body with a trailing newline,
573        // including after the final entry. Cargo tolerates a missing final
574        // newline, but `cargo install-update` / `cargo outdated` stream the body
575        // and treat an unterminated last line as "trailing garbage". Guard
576        // against regressing to `write!` (no newline) on the last line.
577        assert!(
578            out.ends_with('\n'),
579            "sparse index body must end with a newline, got: {out:?}"
580        );
581
582        // Exactly one newline-terminated JSON line per entry; no blank trailing line.
583        let lines: Vec<&str> = out.lines().collect();
584        assert_eq!(lines.len(), indices.len());
585        for (line, meta) in lines.iter().zip(&indices) {
586            let parsed: IndexMetadata = serde_json::from_str(line).unwrap();
587            assert_eq!(parsed.vers, meta.vers);
588        }
589    }
590}