Skip to main content

kellnr_common/
index_metadata.rs

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