Skip to main content

aube_registry/
lib.rs

1use serde::de::{DeserializeSeed, IgnoredAny, MapAccess, SeqAccess, Visitor};
2use serde::{Deserialize, Deserializer, Serialize};
3use std::collections::BTreeMap;
4use std::fmt;
5
6// The registry client is https-only; without a TLS backend every request
7// would fail at runtime with an opaque scheme error. Fail at compile time
8// instead so embedders disabling default features re-enable `rustls`
9// (the only supported backend — see the TLS policy in CLAUDE.md).
10#[cfg(not(feature = "rustls"))]
11compile_error!("aube-registry requires the `rustls` feature");
12
13// Visitor helper macros — each tolerant deserializer below picks the
14// subset that matches its custom handlers. Splitting these granularly
15// is what lets `funding_url` keep its own `visit_seq` (array case)
16// while `FundingArrayEntry` keeps its own `visit_map` (object case),
17// without colliding on duplicate method definitions.
18
19/// Visit-primitives-as-default + `visit_some` re-entry. Covers the
20/// shapes that have no structural content: `null`, bool, integer,
21/// float. Every tolerant visitor in this file wants this.
22macro_rules! visit_primitives_to {
23    ($de:lifetime, $default:expr) => {
24        fn visit_unit<E: serde::de::Error>(self) -> Result<Self::Value, E> {
25            Ok($default)
26        }
27        fn visit_none<E: serde::de::Error>(self) -> Result<Self::Value, E> {
28            Ok($default)
29        }
30        fn visit_some<D2: Deserializer<$de>>(self, d: D2) -> Result<Self::Value, D2::Error> {
31            d.deserialize_any(self)
32        }
33        fn visit_bool<E: serde::de::Error>(self, _: bool) -> Result<Self::Value, E> {
34            Ok($default)
35        }
36        fn visit_i64<E: serde::de::Error>(self, _: i64) -> Result<Self::Value, E> {
37            Ok($default)
38        }
39        fn visit_u64<E: serde::de::Error>(self, _: u64) -> Result<Self::Value, E> {
40            Ok($default)
41        }
42        fn visit_f64<E: serde::de::Error>(self, _: f64) -> Result<Self::Value, E> {
43            Ok($default)
44        }
45    };
46}
47
48/// Drain a JSON array and return `$default`. Pulled into a macro because
49/// the `Visitor::visit_seq` signature varies by `'de` lifetime and
50/// `A: SeqAccess<'de>` bounds — same body, different traits.
51macro_rules! visit_seq_to {
52    ($de:lifetime, $default:expr) => {
53        fn visit_seq<A: SeqAccess<$de>>(self, mut access: A) -> Result<Self::Value, A::Error> {
54            while access.next_element::<IgnoredAny>()?.is_some() {}
55            Ok($default)
56        }
57    };
58}
59
60/// Drain a JSON object and return `$default`.
61macro_rules! visit_map_to {
62    ($de:lifetime, $default:expr) => {
63        fn visit_map<A: MapAccess<$de>>(self, mut access: A) -> Result<Self::Value, A::Error> {
64            while access.next_entry::<IgnoredAny, IgnoredAny>()?.is_some() {}
65            Ok($default)
66        }
67    };
68}
69
70/// Drop strings: `visit_str` / `visit_string` → `$default`. Used by the
71/// `*_to_none_via_any` visitors that consider strings non-applicable
72/// (e.g. `npm_user_tolerant`, which only accepts objects).
73macro_rules! visit_strings_to {
74    ($de:lifetime, $default:expr) => {
75        fn visit_str<E: serde::de::Error>(self, _: &str) -> Result<Self::Value, E> {
76            Ok($default)
77        }
78        fn visit_string<E: serde::de::Error>(self, _: String) -> Result<Self::Value, E> {
79            Ok($default)
80        }
81    };
82}
83
84/// Deserialize a `BTreeMap<String, String>` tolerant to any non-string
85/// value — both at the whole-map level (`"dist-tags": null` → empty
86/// map) and at the value level (`{"latest": null}` or
87/// `{"vows": {"version": "0.6.4", ...}}` → entry dropped).
88///
89/// Two real-world sources of non-string values:
90///
91/// 1. Registry proxies (notably JFrog Artifactory's npm remote) emit
92///    `null` in places where npmjs.org always emits a string: stripped
93///    / tombstoned `dist-tags` values, per-version `time` entries for
94///    deleted versions, or dep-map entries that were redacted by a
95///    mirroring filter.
96/// 2. Ancient publishes — some packages from the 2012–2013 era
97///    (`deep-diff@0.1.0`, for example) have `devDependencies` entries
98///    shaped like `{"version": "0.6.4", "dependencies": {...}}`
99///    instead of a plain version string, because an old npm client
100///    serialized a resolved tree into the manifest.
101///
102/// A strict `BTreeMap<String, String>` shape would fail these with
103/// `invalid type: ..., expected a string`, blocking an install of any
104/// package whose packument merely *lists* an affected version — even
105/// when the user's range doesn't select it. Drop the unparseable
106/// entries so the resolver sees the same shape npmjs would have served
107/// for a modern publish. pnpm and bun behave the same way.
108///
109/// Implemented as a direct serde `Visitor` rather than buffering
110/// through `serde_json::Value` — the dep-object case (Value 2 above)
111/// would otherwise allocate a nested `Map<String, Value>` per dropped
112/// entry. The proptest suite in `tests/tolerant_deserializers.rs`
113/// pins down parity with the old `Value`-based behavior.
114fn non_string_tolerant_map<'de, D>(de: D) -> Result<BTreeMap<String, String>, D::Error>
115where
116    D: Deserializer<'de>,
117{
118    struct V;
119
120    impl<'de> Visitor<'de> for V {
121        type Value = BTreeMap<String, String>;
122
123        fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124            f.write_str("null or an object mapping strings to strings")
125        }
126
127        fn visit_unit<E: serde::de::Error>(self) -> Result<Self::Value, E> {
128            Ok(BTreeMap::new())
129        }
130
131        fn visit_none<E: serde::de::Error>(self) -> Result<Self::Value, E> {
132            Ok(BTreeMap::new())
133        }
134
135        fn visit_some<D2: Deserializer<'de>>(self, d: D2) -> Result<Self::Value, D2::Error> {
136            d.deserialize_any(self)
137        }
138
139        fn visit_map<A: MapAccess<'de>>(self, mut access: A) -> Result<Self::Value, A::Error> {
140            let mut out = BTreeMap::new();
141            while let Some(key) = access.next_key::<String>()? {
142                let MaybeString(maybe) = access.next_value()?;
143                if let Some(s) = maybe {
144                    out.insert(key, s);
145                }
146            }
147            Ok(out)
148        }
149    }
150
151    de.deserialize_any(V)
152}
153
154pub mod client;
155pub mod config;
156pub mod jsr;
157pub mod osv_bloom_client;
158pub mod osv_mirror;
159pub mod slow_metadata;
160pub mod supply_chain;
161
162// Packuments and `package.json` files share the `bundledDependencies`
163// shape, so the registry crate borrows the type from `aube-manifest`
164// rather than defining its own copy. Re-exported for resolver callers
165// that already import this crate.
166pub use aube_manifest::BundledDependencies;
167
168/// Controls whether the registry client is allowed to hit the network.
169///
170/// Mirrors pnpm's `--offline` / `--prefer-offline`.
171#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
172pub enum NetworkMode {
173    /// Normal behavior: honor the packument TTL, revalidate with the
174    /// registry when the cache is stale, fetch tarballs over the network.
175    #[default]
176    Online,
177    /// Use the packument cache regardless of age; only hit the network on a
178    /// cache miss. Tarballs fall back to the network when the store doesn't
179    /// already have them.
180    PreferOffline,
181    /// Never hit the network. Packument and tarball fetches fail with
182    /// `Error::Offline` if the requested data isn't already on disk.
183    Offline,
184}
185
186/// A packument (package document) from the npm registry.
187/// This is the metadata for all versions of a package.
188#[derive(Debug, Clone, Deserialize, Serialize)]
189pub struct Packument {
190    pub name: String,
191    #[serde(default)]
192    pub modified: Option<String>,
193    #[serde(default)]
194    pub versions: BTreeMap<String, VersionMetadata>,
195    #[serde(
196        rename = "dist-tags",
197        default,
198        deserialize_with = "non_string_tolerant_map"
199    )]
200    pub dist_tags: BTreeMap<String, String>,
201    /// Per-version publish timestamps (ISO-8601). Populated
202    /// opportunistically: npmjs.org's corgi (abbreviated) packument
203    /// omits `time`, but Verdaccio v5.15.1+ includes it in corgi, and
204    /// the full-packument path used for `--resolution-mode=time-based`
205    /// and `minimumReleaseAge` always carries it. When present, the
206    /// resolver round-trips it into the lockfile's top-level `time:`
207    /// block — matching pnpm's `publishedAt` wiring — and, in
208    /// time-based mode, uses it to derive the publish-date cutoff.
209    #[serde(default, deserialize_with = "non_string_tolerant_map")]
210    pub time: BTreeMap<String, String>,
211}
212
213/// The subset of a full packument needed to enforce publish-time and
214/// trust-downgrade policies for an exact version. Deserializing this shape
215/// skips dependency maps and distribution metadata for every historical
216/// release, avoiding the large retained heap of a full [`Packument`].
217#[derive(Debug, Clone, Deserialize)]
218pub struct PackumentTrustHistory {
219    #[serde(default, deserialize_with = "non_string_tolerant_map")]
220    pub time: BTreeMap<String, String>,
221    #[serde(default)]
222    pub versions: BTreeMap<String, VersionTrustMetadata>,
223}
224
225/// One exact release plus the compact history needed for time and trust
226/// policy checks. The full registry document is decoded in a single pass:
227/// the selected release uses [`VersionMetadata`], while every other release
228/// uses [`VersionTrustMetadata`].
229#[derive(Debug)]
230pub struct ExactVersionPackument {
231    pub metadata: VersionMetadata,
232    pub history: PackumentTrustHistory,
233}
234
235pub(crate) struct ExactVersionPackumentSeed<'a> {
236    pub version: &'a str,
237}
238
239impl<'de> DeserializeSeed<'de> for ExactVersionPackumentSeed<'_> {
240    type Value = ExactVersionPackument;
241
242    fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
243    where
244        D: Deserializer<'de>,
245    {
246        deserializer.deserialize_map(ExactVersionPackumentVisitor {
247            version: self.version,
248        })
249    }
250}
251
252struct ExactVersionPackumentVisitor<'a> {
253    version: &'a str,
254}
255
256impl<'de> Visitor<'de> for ExactVersionPackumentVisitor<'_> {
257    type Value = ExactVersionPackument;
258
259    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
260        formatter.write_str("an npm packument containing the requested exact version")
261    }
262
263    fn visit_map<A>(self, mut access: A) -> Result<Self::Value, A::Error>
264    where
265        A: MapAccess<'de>,
266    {
267        let mut metadata = None;
268        let mut history = BTreeMap::new();
269        let mut time = BTreeMap::new();
270
271        while let Some(field) = access.next_key::<String>()? {
272            match field.as_str() {
273                "versions" => {
274                    let decoded = access.next_value_seed(ExactVersionMapSeed {
275                        version: self.version,
276                    })?;
277                    metadata = decoded.0;
278                    history = decoded.1;
279                }
280                "time" => {
281                    time = access.next_value::<TolerantStringMap>()?.0;
282                }
283                _ => {
284                    access.next_value::<IgnoredAny>()?;
285                }
286            }
287        }
288
289        let metadata = metadata.ok_or_else(|| {
290            serde::de::Error::custom(format!(
291                "packument does not contain requested version {}",
292                self.version
293            ))
294        })?;
295        Ok(ExactVersionPackument {
296            metadata,
297            history: PackumentTrustHistory {
298                time,
299                versions: history,
300            },
301        })
302    }
303}
304
305struct ExactVersionMapSeed<'a> {
306    version: &'a str,
307}
308
309impl<'de> DeserializeSeed<'de> for ExactVersionMapSeed<'_> {
310    type Value = (
311        Option<VersionMetadata>,
312        BTreeMap<String, VersionTrustMetadata>,
313    );
314
315    fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
316    where
317        D: Deserializer<'de>,
318    {
319        deserializer.deserialize_map(ExactVersionMapVisitor {
320            version: self.version,
321        })
322    }
323}
324
325struct ExactVersionMapVisitor<'a> {
326    version: &'a str,
327}
328
329impl<'de> Visitor<'de> for ExactVersionMapVisitor<'_> {
330    type Value = (
331        Option<VersionMetadata>,
332        BTreeMap<String, VersionTrustMetadata>,
333    );
334
335    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
336        formatter.write_str("an npm packument versions map")
337    }
338
339    fn visit_map<A>(self, mut access: A) -> Result<Self::Value, A::Error>
340    where
341        A: MapAccess<'de>,
342    {
343        let mut metadata = None;
344        let mut history = BTreeMap::new();
345        while let Some(version) = access.next_key::<String>()? {
346            if version == self.version {
347                metadata = Some(access.next_value::<VersionMetadata>()?);
348            } else {
349                history.insert(version, access.next_value::<VersionTrustMetadata>()?);
350            }
351        }
352        Ok((metadata, history))
353    }
354}
355
356#[derive(Deserialize)]
357#[serde(transparent)]
358struct TolerantStringMap(
359    #[serde(deserialize_with = "non_string_tolerant_map")] BTreeMap<String, String>,
360);
361
362#[derive(Debug, Clone, Deserialize)]
363#[serde(rename_all = "camelCase")]
364pub struct VersionTrustMetadata {
365    #[serde(default)]
366    pub approver: Option<serde_json::Value>,
367    #[serde(default, rename = "_npmUser", deserialize_with = "npm_user_tolerant")]
368    pub npm_user: Option<NpmUser>,
369    #[serde(default)]
370    pub dist: Option<VersionTrustDist>,
371}
372
373#[derive(Debug, Clone, Deserialize)]
374pub struct VersionTrustDist {
375    #[serde(default)]
376    pub attestations: Option<Attestations>,
377}
378
379/// Metadata for a specific version of a package.
380///
381/// Deserializes via `VersionMetadataRaw` (`#[serde(from = ...)]`) so
382/// that publishes carrying *both* `bundledDependencies` (canonical) and
383/// `bundleDependencies` (deprecated alias) parse cleanly. serde's plain
384/// `#[serde(alias = ...)]` rejects that as a duplicate field, which
385/// blocks installs of every version of every package that ships both
386/// keys (e.g. `@lingui/message-utils@>=5.2.0`).
387#[derive(Debug, Clone, Deserialize, Serialize)]
388#[serde(rename_all = "camelCase", from = "VersionMetadataRaw")]
389pub struct VersionMetadata {
390    pub name: String,
391    pub version: String,
392    #[serde(default, deserialize_with = "non_string_tolerant_map")]
393    pub dependencies: BTreeMap<String, String>,
394    #[serde(default, deserialize_with = "non_string_tolerant_map")]
395    pub dev_dependencies: BTreeMap<String, String>,
396    #[serde(default, deserialize_with = "non_string_tolerant_map")]
397    pub peer_dependencies: BTreeMap<String, String>,
398    #[serde(default)]
399    pub peer_dependencies_meta: BTreeMap<String, PeerDepMeta>,
400    #[serde(default, deserialize_with = "non_string_tolerant_map")]
401    pub optional_dependencies: BTreeMap<String, String>,
402    /// `bundledDependencies` from the packument. Either a list of dep
403    /// names or `true` (meaning "bundle every `dependencies` entry").
404    /// Packages listed here are shipped inside the parent tarball, so
405    /// the resolver must not recurse into them. npm serializes this
406    /// under both `bundledDependencies` and `bundleDependencies`; on
407    /// deserialize we accept either, and prefer the canonical when both
408    /// are present (handled in `VersionMetadataRaw`).
409    pub bundled_dependencies: Option<BundledDependencies>,
410    pub dist: Option<Dist>,
411    #[serde(default, deserialize_with = "aube_util::string_or_seq")]
412    pub os: Vec<String>,
413    #[serde(default, deserialize_with = "aube_util::string_or_seq")]
414    pub cpu: Vec<String>,
415    #[serde(default, deserialize_with = "aube_util::string_or_seq")]
416    pub libc: Vec<String>,
417    /// `engines:` from the package manifest (e.g. `{node: ">=8"}`).
418    /// Round-tripped into the lockfile so pnpm-compatible output can
419    /// emit `engines: {node: '>=8'}` on package entries without a
420    /// packument re-fetch.
421    ///
422    /// Uses `aube_manifest::engines_tolerant` so the legacy pre-npm-2.x
423    /// array shape (e.g. `madge@0.0.1` and `html-entities@1.x` ship
424    /// `"engines": ["node >= 0.8.0"]`) doesn't blow up the whole
425    /// packument — one such version would otherwise block install of
426    /// any range that touches the packument, even when the user's
427    /// selector doesn't pick that version. Array normalizes to an
428    /// empty map, matching the manifest and lockfile parsers.
429    #[serde(default, deserialize_with = "aube_manifest::engines_tolerant")]
430    pub engines: BTreeMap<String, String>,
431    /// `license:` field from the package manifest. npm's lockfile
432    /// keeps this per-package; other formats don't. Stored as
433    /// `Option<String>` because packuments can emit a bare string
434    /// (`"MIT"`), an SPDX object, or nothing at all — we only keep
435    /// the simple case for lockfile round-trip. Non-string shapes
436    /// degrade to `None` rather than failing to parse the packument.
437    #[serde(default, deserialize_with = "license_string")]
438    pub license: Option<String>,
439    /// `funding:` URL extracted from the manifest's `funding` field.
440    /// The field is documented as a string *or* an object with a
441    /// `url:` key *or* an array of either — npm's lockfile
442    /// normalizes to `{url: …}`, so we only keep the URL and let
443    /// the writer emit the wrapping object. Serde `rename` because
444    /// `rename_all = "camelCase"` would otherwise look for
445    /// `fundingUrl` in the JSON.
446    #[serde(default, rename = "funding", deserialize_with = "funding_url")]
447    pub funding_url: Option<String>,
448    /// `bin:` map from the packument, normalized to `name → path`.
449    ///
450    /// npm records `bin` in two shapes on a manifest: a string
451    /// (`"bin": "cli.js"` — implicitly named after the package) or a
452    /// map (`"bin": {"foo": "cli.js"}` — explicitly named). We
453    /// normalize to the map form at parse time so downstream callers
454    /// don't have to branch: an empty map means "no bins".
455    ///
456    /// pnpm collapses this to `hasBin: true` on its package entries;
457    /// bun preserves the full map on its per-package meta. Keeping
458    /// the map lets us feed both writers without an extra
459    /// tarball-level re-parse.
460    #[serde(default, rename = "bin", deserialize_with = "bin_map")]
461    pub bin: BTreeMap<String, String>,
462    #[serde(default)]
463    pub has_install_script: bool,
464    /// Deprecation message from the registry, if this version is deprecated.
465    #[serde(default, deserialize_with = "deprecated_string")]
466    pub deprecated: Option<String>,
467    /// npm staged-publish approval metadata. When present, the
468    /// resolver treats it as the strongest trust evidence because the
469    /// publish went through a registry-side approval flow.
470    #[serde(default)]
471    pub approver: Option<serde_json::Value>,
472    /// `_npmUser` block from the packument, when present. The
473    /// trust-policy check reads `_npmUser.trustedPublisher` as the
474    /// strongest trust-evidence signal (npm's "trusted publishers"
475    /// feature, OIDC-backed). Some old packuments emit `_npmUser` as
476    /// a `"name <email>"` string rather than an object — that shape
477    /// degrades to `None` instead of failing the whole packument.
478    #[serde(default, rename = "_npmUser", deserialize_with = "npm_user_tolerant")]
479    pub npm_user: Option<NpmUser>,
480}
481
482/// Deserialize-only mirror of [`VersionMetadata`] that splits the
483/// `bundled_dependencies` field into two name-distinct slots so a
484/// payload carrying *both* `bundledDependencies` and `bundleDependencies`
485/// (e.g. `@lingui/message-utils@5.2.0`+) doesn't trip serde's duplicate
486/// field check the way `#[serde(alias = ...)]` does. The canonical
487/// spelling wins on merge — keeps parity with what npm renders for the
488/// installed-tree view of the same package.
489///
490/// **Maintenance invariant:** every non-`bundled_dependencies` field
491/// here must mirror its counterpart on [`VersionMetadata`] *byte-for-byte*
492/// in serde attributes (`rename`, `deserialize_with`, `default`, etc.).
493/// The `From` impl below catches missing fields at compile time, but
494/// **attribute drift is silent** — e.g. dropping a `deserialize_with`
495/// here makes the deserialize path strict on shapes the public type
496/// silently tolerates. When adding or modifying a field on
497/// `VersionMetadata`, update this struct in lockstep.
498#[derive(Debug, Deserialize)]
499#[serde(rename_all = "camelCase")]
500struct VersionMetadataRaw {
501    name: String,
502    version: String,
503    #[serde(default, deserialize_with = "non_string_tolerant_map")]
504    dependencies: BTreeMap<String, String>,
505    #[serde(default, deserialize_with = "non_string_tolerant_map")]
506    dev_dependencies: BTreeMap<String, String>,
507    #[serde(default, deserialize_with = "non_string_tolerant_map")]
508    peer_dependencies: BTreeMap<String, String>,
509    #[serde(default)]
510    peer_dependencies_meta: BTreeMap<String, PeerDepMeta>,
511    #[serde(default, deserialize_with = "non_string_tolerant_map")]
512    optional_dependencies: BTreeMap<String, String>,
513    #[serde(default, rename = "bundledDependencies")]
514    bundled_dependencies: Option<BundledDependencies>,
515    #[serde(default, rename = "bundleDependencies")]
516    bundle_dependencies_alias: Option<BundledDependencies>,
517    dist: Option<Dist>,
518    #[serde(default, deserialize_with = "aube_util::string_or_seq")]
519    os: Vec<String>,
520    #[serde(default, deserialize_with = "aube_util::string_or_seq")]
521    cpu: Vec<String>,
522    #[serde(default, deserialize_with = "aube_util::string_or_seq")]
523    libc: Vec<String>,
524    #[serde(default, deserialize_with = "aube_manifest::engines_tolerant")]
525    engines: BTreeMap<String, String>,
526    #[serde(default, deserialize_with = "license_string")]
527    license: Option<String>,
528    #[serde(default, rename = "funding", deserialize_with = "funding_url")]
529    funding_url: Option<String>,
530    #[serde(default, rename = "bin", deserialize_with = "bin_map")]
531    bin: BTreeMap<String, String>,
532    #[serde(default)]
533    has_install_script: bool,
534    #[serde(default, deserialize_with = "deprecated_string")]
535    deprecated: Option<String>,
536    #[serde(default)]
537    approver: Option<serde_json::Value>,
538    #[serde(default, rename = "_npmUser", deserialize_with = "npm_user_tolerant")]
539    npm_user: Option<NpmUser>,
540}
541
542impl From<VersionMetadataRaw> for VersionMetadata {
543    fn from(raw: VersionMetadataRaw) -> Self {
544        Self {
545            name: raw.name,
546            version: raw.version,
547            dependencies: raw.dependencies,
548            dev_dependencies: raw.dev_dependencies,
549            peer_dependencies: raw.peer_dependencies,
550            peer_dependencies_meta: raw.peer_dependencies_meta,
551            optional_dependencies: raw.optional_dependencies,
552            bundled_dependencies: raw.bundled_dependencies.or(raw.bundle_dependencies_alias),
553            dist: raw.dist,
554            os: raw.os,
555            cpu: raw.cpu,
556            libc: raw.libc,
557            engines: raw.engines,
558            license: raw.license,
559            funding_url: raw.funding_url,
560            bin: raw.bin,
561            has_install_script: raw.has_install_script,
562            deprecated: raw.deprecated,
563            approver: raw.approver,
564            npm_user: raw.npm_user,
565        }
566    }
567}
568
569#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
570pub struct PeerDepMeta {
571    #[serde(default)]
572    pub optional: bool,
573}
574
575#[derive(Debug, Clone, Deserialize, Serialize, Default)]
576pub struct NpmUser {
577    /// Structured npm trusted-publisher evidence for publishes that came
578    /// through OIDC-backed automation (e.g. GitHub Actions). aube's
579    /// trust-policy check requires an object with a non-empty `id`.
580    #[serde(default, rename = "trustedPublisher")]
581    pub trusted_publisher: Option<serde_json::Value>,
582}
583
584#[derive(Debug, Clone, Deserialize, Serialize)]
585pub struct Dist {
586    pub tarball: String,
587    pub integrity: Option<String>,
588    pub shasum: Option<String>,
589    /// Unpacked tarball size in bytes (`dist.unpackedSize`). Present
590    /// on most modern packuments, absent on older ones — used as the
591    /// best-effort install-size estimate that the progress bar shows
592    /// as `4.2 MB / ~13.8 MB`. Decimal MB to match every other PM.
593    #[serde(default, rename = "unpackedSize")]
594    pub unpacked_size: Option<u64>,
595    /// Sigstore attestations block. The trust-policy check reads
596    /// `dist.attestations.provenance` as rank-1 trust evidence when
597    /// it is an object with an SLSA provenance `predicateType`. aube
598    /// validates this metadata shape during install; it does not
599    /// cryptographically verify the attached attestation bundle.
600    #[serde(default)]
601    pub attestations: Option<Attestations>,
602}
603
604#[derive(Debug, Clone, Deserialize, Serialize, Default)]
605pub struct Attestations {
606    #[serde(default)]
607    pub provenance: Option<serde_json::Value>,
608}
609
610fn deprecated_string<'de, D>(de: D) -> Result<Option<String>, D::Error>
611where
612    D: Deserializer<'de>,
613{
614    let MaybeString(maybe) = MaybeString::deserialize(de)?;
615    Ok(maybe.filter(|s| !s.is_empty()))
616}
617
618/// Accept the packument's `license:` field in any of its documented
619/// shapes (string, `{type, url}` object, or missing) and collapse to
620/// the simple string form npm emits in its lockfile. Non-string
621/// shapes degrade to `None`; we don't try to normalize SPDX
622/// expressions or license-file references here.
623fn license_string<'de, D>(de: D) -> Result<Option<String>, D::Error>
624where
625    D: Deserializer<'de>,
626{
627    struct V;
628    impl<'de> Visitor<'de> for V {
629        type Value = Option<String>;
630
631        fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
632            f.write_str("a license string, a {type, url} object, or null")
633        }
634
635        fn visit_str<E: serde::de::Error>(self, s: &str) -> Result<Self::Value, E> {
636            Ok((!s.is_empty()).then(|| s.to_owned()))
637        }
638
639        fn visit_string<E: serde::de::Error>(self, s: String) -> Result<Self::Value, E> {
640            Ok((!s.is_empty()).then_some(s))
641        }
642
643        fn visit_map<A: MapAccess<'de>>(self, mut access: A) -> Result<Self::Value, A::Error> {
644            let mut found: Option<String> = None;
645            while let Some(key) = access.next_key::<String>()? {
646                if key == "type" && found.is_none() {
647                    let MaybeString(maybe) = access.next_value()?;
648                    found = maybe.filter(|s| !s.is_empty());
649                } else {
650                    let _: IgnoredAny = access.next_value()?;
651                }
652            }
653            Ok(found)
654        }
655
656        visit_primitives_to!('de, None);
657        visit_seq_to!('de, None);
658    }
659    de.deserialize_any(V)
660}
661
662/// Extract the first `url:` out of a packument's `funding:` field.
663/// The field may be a URL string, a `{url: …}` object, or an array
664/// of either — npm's lockfile normalizes to `{"url": "…"}` on each
665/// package entry, so we only need the URL itself. Missing / empty
666/// / non-url-bearing shapes degrade to `None`.
667fn funding_url<'de, D>(de: D) -> Result<Option<String>, D::Error>
668where
669    D: Deserializer<'de>,
670{
671    struct V;
672    impl<'de> Visitor<'de> for V {
673        type Value = Option<String>;
674
675        fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
676            f.write_str("a funding URL string, a {url} object, or an array of either")
677        }
678
679        fn visit_str<E: serde::de::Error>(self, s: &str) -> Result<Self::Value, E> {
680            Ok((!s.is_empty()).then(|| s.to_owned()))
681        }
682
683        fn visit_string<E: serde::de::Error>(self, s: String) -> Result<Self::Value, E> {
684            Ok((!s.is_empty()).then_some(s))
685        }
686
687        fn visit_map<A: MapAccess<'de>>(self, access: A) -> Result<Self::Value, A::Error> {
688            extract_url_from_map(access)
689        }
690
691        fn visit_seq<A: SeqAccess<'de>>(self, mut access: A) -> Result<Self::Value, A::Error> {
692            // Walk the array via `FundingArrayEntry`, which performs the
693            // same string-or-{url} extraction per element. First non-empty
694            // hit wins — drain the rest with `IgnoredAny` so we don't
695            // re-parse elements we'll discard.
696            while let Some(FundingArrayEntry(maybe)) = access.next_element()? {
697                if let Some(s) = maybe {
698                    while access.next_element::<IgnoredAny>()?.is_some() {}
699                    return Ok(Some(s));
700                }
701            }
702            Ok(None)
703        }
704
705        visit_primitives_to!('de, None);
706    }
707    de.deserialize_any(V)
708}
709
710/// Shared map-walk for `funding`'s top-level object form and per-array-element
711/// object form: extract a non-empty `url` field, ignore everything else.
712fn extract_url_from_map<'de, A: MapAccess<'de>>(mut access: A) -> Result<Option<String>, A::Error> {
713    let mut found: Option<String> = None;
714    while let Some(key) = access.next_key::<String>()? {
715        if key == "url" && found.is_none() {
716            let MaybeString(maybe) = access.next_value()?;
717            found = maybe.filter(|s| !s.is_empty());
718        } else {
719            let _: IgnoredAny = access.next_value()?;
720        }
721    }
722    Ok(found)
723}
724
725/// Accept the packument's `_npmUser:` field in its documented shapes
726/// and degrade to `None` for anything else. Modern packuments emit an
727/// object (`{name, email, trustedPublisher?}`); pre-2010 publishes
728/// emit `"name <email>"` strings. We only care about
729/// `trustedPublisher`, so unparseable shapes don't fail the packument
730/// — they just lose the trusted-publisher signal for that version.
731fn npm_user_tolerant<'de, D>(de: D) -> Result<Option<NpmUser>, D::Error>
732where
733    D: Deserializer<'de>,
734{
735    struct V;
736    impl<'de> Visitor<'de> for V {
737        type Value = Option<NpmUser>;
738
739        fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
740            f.write_str("an _npmUser object or any other JSON value")
741        }
742
743        // Object case: walk the map manually and pluck `trustedPublisher`
744        // — the only `NpmUser` field anyone reads (see `aube-resolver`'s
745        // trust-policy check). Deferring to `NpmUser::deserialize` on the
746        // live `MapAccess` would propagate any deserialize error up and
747        // fail the whole packument parse, which silently breaks the
748        // tolerant contract the moment `NpmUser` gains a non-defaulted
749        // field. New fields that need reading from the packument get
750        // their extraction added here.
751        fn visit_map<A: MapAccess<'de>>(self, mut access: A) -> Result<Self::Value, A::Error> {
752            let mut trusted_publisher: Option<serde_json::Value> = None;
753            while let Some(key) = access.next_key::<String>()? {
754                if key == "trustedPublisher" && trusted_publisher.is_none() {
755                    trusted_publisher = access.next_value::<Option<serde_json::Value>>()?;
756                } else {
757                    let _: IgnoredAny = access.next_value()?;
758                }
759            }
760            Ok(Some(NpmUser { trusted_publisher }))
761        }
762
763        visit_primitives_to!('de, None);
764        visit_seq_to!('de, None);
765        visit_strings_to!('de, None);
766    }
767    de.deserialize_any(V)
768}
769
770/// Normalize `package.json` `bin` into a `name → path` map.
771///
772/// Two canonical shapes on the npm registry: a string
773/// (`"bin": "cli.js"` — implicitly keyed by the package name) and a
774/// map (`"bin": {"foo": "cli.js"}`). Older or odd packuments also
775/// surface `null` or an empty string; a missing `bin` field falls
776/// through to the default empty map.
777///
778/// The string-form needs the package name to emit a well-formed map
779/// — which we don't have here at deserialize time. We leave the key
780/// as an empty string; every call site that cares about bin names
781/// (`aube-linker`'s bin-symlink pass, the bun writer) already has
782/// the package name in scope and can patch it up.
783fn bin_map<'de, D>(de: D) -> Result<BTreeMap<String, String>, D::Error>
784where
785    D: Deserializer<'de>,
786{
787    struct V;
788    impl<'de> Visitor<'de> for V {
789        type Value = BTreeMap<String, String>;
790
791        fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
792            f.write_str("a bin string, a bin map, or null")
793        }
794
795        fn visit_str<E: serde::de::Error>(self, s: &str) -> Result<Self::Value, E> {
796            if s.is_empty() {
797                return Ok(BTreeMap::new());
798            }
799            let mut m = BTreeMap::new();
800            m.insert(String::new(), s.to_owned());
801            Ok(m)
802        }
803
804        fn visit_string<E: serde::de::Error>(self, s: String) -> Result<Self::Value, E> {
805            if s.is_empty() {
806                return Ok(BTreeMap::new());
807            }
808            let mut m = BTreeMap::new();
809            m.insert(String::new(), s);
810            Ok(m)
811        }
812
813        fn visit_map<A: MapAccess<'de>>(self, mut access: A) -> Result<Self::Value, A::Error> {
814            let mut out = BTreeMap::new();
815            while let Some(key) = access.next_key::<String>()? {
816                let MaybeString(maybe) = access.next_value()?;
817                if let Some(s) = maybe {
818                    out.insert(key, s);
819                }
820            }
821            Ok(out)
822        }
823
824        visit_primitives_to!('de, BTreeMap::new());
825        visit_seq_to!('de, BTreeMap::new());
826    }
827    de.deserialize_any(V)
828}
829
830/// Map value adapter: returns `Some(String)` for any JSON string, `None`
831/// for everything else. Drains seqs/maps without allocation so non-string
832/// values (including deeply nested dep-objects from ancient publishes)
833/// never get materialized as a `serde_json::Value`.
834struct MaybeString(Option<String>);
835
836impl<'de> Deserialize<'de> for MaybeString {
837    fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
838        struct V;
839        impl<'de> Visitor<'de> for V {
840            type Value = MaybeString;
841
842            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
843                f.write_str("any JSON value")
844            }
845
846            fn visit_str<E: serde::de::Error>(self, s: &str) -> Result<MaybeString, E> {
847                Ok(MaybeString(Some(s.to_owned())))
848            }
849
850            fn visit_string<E: serde::de::Error>(self, s: String) -> Result<MaybeString, E> {
851                Ok(MaybeString(Some(s)))
852            }
853
854            visit_primitives_to!('de, MaybeString(None));
855            visit_seq_to!('de, MaybeString(None));
856            visit_map_to!('de, MaybeString(None));
857        }
858        de.deserialize_any(V)
859    }
860}
861
862/// Array-element adapter for `funding`'s array case: yields `Some(url)`
863/// for a non-empty string or an object element with a non-empty `url`
864/// field; `None` otherwise. Mirrors the top-level `funding_url` shape.
865struct FundingArrayEntry(Option<String>);
866
867impl<'de> Deserialize<'de> for FundingArrayEntry {
868    fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
869        struct V;
870        impl<'de> Visitor<'de> for V {
871            type Value = FundingArrayEntry;
872
873            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
874                f.write_str("a funding URL string or a {url} object")
875            }
876
877            fn visit_str<E: serde::de::Error>(self, s: &str) -> Result<FundingArrayEntry, E> {
878                Ok(FundingArrayEntry((!s.is_empty()).then(|| s.to_owned())))
879            }
880
881            fn visit_string<E: serde::de::Error>(self, s: String) -> Result<FundingArrayEntry, E> {
882                Ok(FundingArrayEntry((!s.is_empty()).then_some(s)))
883            }
884
885            fn visit_map<A: MapAccess<'de>>(
886                self,
887                access: A,
888            ) -> Result<FundingArrayEntry, A::Error> {
889                Ok(FundingArrayEntry(extract_url_from_map(access)?))
890            }
891
892            visit_primitives_to!('de, FundingArrayEntry(None));
893            visit_seq_to!('de, FundingArrayEntry(None));
894        }
895        de.deserialize_any(V)
896    }
897}
898
899#[derive(Debug, thiserror::Error, miette::Diagnostic)]
900pub enum Error {
901    #[error("HTTP error: {0}")]
902    Http(#[from] reqwest::Error),
903    #[error("package not found: {0}")]
904    #[diagnostic(code(ERR_AUBE_PACKAGE_NOT_FOUND))]
905    NotFound(String),
906    #[error("access entity not found: {0}")]
907    #[diagnostic(code(ERR_AUBE_ACCESS_ENTITY_NOT_FOUND))]
908    AccessEntityNotFound(String),
909    #[error("registry identity endpoint is unavailable")]
910    #[diagnostic(code(ERR_AUBE_REGISTRY_ERROR))]
911    AccessIdentityUnavailable,
912    #[error("version not found: {0}@{1}")]
913    #[diagnostic(code(ERR_AUBE_VERSION_NOT_FOUND))]
914    VersionNotFound(String, String),
915    /// The registry rejected the request with 401/403 — either no auth
916    /// token was configured, it was invalid, or the account doesn't
917    /// have permission for this package. Callers should point the user
918    /// at `aube login`.
919    #[error("authentication required")]
920    #[diagnostic(code(ERR_AUBE_UNAUTHORIZED))]
921    Unauthorized,
922    #[error("I/O error: {0}")]
923    Io(#[from] std::io::Error),
924    #[error("registry rejected write: HTTP {status}: {body}")]
925    #[diagnostic(code(ERR_AUBE_REGISTRY_WRITE_REJECTED))]
926    RegistryWrite { status: u16, body: String },
927    #[error("offline: {0} is not available in the local cache")]
928    #[diagnostic(code(ERR_AUBE_OFFLINE))]
929    Offline(String),
930    /// The caller passed a package name that does not match the npm
931    /// name grammar. Returned eagerly (before any I/O) so a hostile
932    /// packument or manifest cannot use the cache-path builder as an
933    /// arbitrary-file-write primitive.
934    #[error("invalid package name: {0:?}")]
935    #[diagnostic(code(ERR_AUBE_INVALID_PACKAGE_NAME))]
936    InvalidName(String),
937}
938
939impl Error {
940    /// True when the error represents an upstream backpressure
941    /// signal worth feeding into [`aube_util::adaptive::AdaptiveLimit::record_throttle`].
942    /// HTTP 429 / 502 / 503 / 504 and request timeouts qualify.
943    /// Plain 4xx (NotFound, Unauthorized, ValidationError) and IO
944    /// errors don't — shrinking the concurrency cap won't help
945    /// those, and would over-react to transient hostile-input
946    /// failures (a typo'd package name shouldn't halve the limit).
947    pub fn is_throttle(&self) -> bool {
948        match self {
949            Error::Http(e) => {
950                if e.is_timeout() {
951                    return true;
952                }
953                matches!(
954                    e.status().map(|s| s.as_u16()),
955                    Some(429) | Some(502) | Some(503) | Some(504)
956                )
957            }
958            Error::RegistryWrite { status, .. } => matches!(*status, 429 | 502 | 503 | 504),
959            _ => false,
960        }
961    }
962}
963
964#[cfg(test)]
965mod tests {
966    use super::*;
967
968    fn parse(json: &str) -> VersionMetadata {
969        serde_json::from_str(json).unwrap()
970    }
971
972    #[test]
973    fn exact_version_packument_seed_keeps_only_selected_full_metadata() {
974        let json = br#"{
975            "name":"platform-package",
976            "versions":{
977                "1.0.0":{
978                    "name":"platform-package",
979                    "version":"1.0.0",
980                    "dependencies":{"discarded":"1"},
981                    "approver":{"name":"release-manager"}
982                },
983                "2.0.0":{
984                    "name":"platform-package",
985                    "version":"2.0.0",
986                    "dependencies":{"retained":"2"},
987                    "dist":{"tarball":"https://registry.example/pkg.tgz"}
988                }
989            },
990            "time":{"1.0.0":"2025-01-01T00:00:00.000Z","2.0.0":"2025-02-01T00:00:00.000Z"}
991        }"#;
992        let mut deserializer = sonic_rs::Deserializer::from_slice(json);
993        let decoded = serde::de::DeserializeSeed::deserialize(
994            ExactVersionPackumentSeed { version: "2.0.0" },
995            &mut deserializer,
996        )
997        .unwrap();
998
999        assert_eq!(decoded.metadata.version, "2.0.0");
1000        assert_eq!(
1001            decoded.metadata.dependencies.get("retained"),
1002            Some(&"2".to_string())
1003        );
1004        assert!(!decoded.history.versions.contains_key("2.0.0"));
1005        assert!(decoded.history.versions.contains_key("1.0.0"));
1006        assert_eq!(decoded.history.time.len(), 2);
1007    }
1008
1009    #[test]
1010    fn exact_version_packument_seed_rejects_missing_selected_version() {
1011        let json = br#"{"versions":{"1.0.0":{"name":"x","version":"1.0.0"}}}"#;
1012        let mut deserializer = sonic_rs::Deserializer::from_slice(json);
1013        let error = serde::de::DeserializeSeed::deserialize(
1014            ExactVersionPackumentSeed { version: "2.0.0" },
1015            &mut deserializer,
1016        )
1017        .unwrap_err();
1018
1019        assert!(error.to_string().contains("requested version 2.0.0"));
1020    }
1021
1022    #[test]
1023    fn libc_accepts_string() {
1024        let v = parse(r#"{"name":"x","version":"1.0.0","libc":"glibc"}"#);
1025        assert_eq!(v.libc, vec!["glibc"]);
1026    }
1027
1028    #[test]
1029    fn libc_accepts_array() {
1030        let v = parse(r#"{"name":"x","version":"1.0.0","libc":["glibc","musl"]}"#);
1031        assert_eq!(v.libc, vec!["glibc", "musl"]);
1032    }
1033
1034    #[test]
1035    fn os_and_cpu_accept_string() {
1036        let v = parse(r#"{"name":"x","version":"1.0.0","os":"linux","cpu":"x64"}"#);
1037        assert_eq!(v.os, vec!["linux"]);
1038        assert_eq!(v.cpu, vec!["x64"]);
1039    }
1040
1041    #[test]
1042    fn null_is_treated_as_empty() {
1043        let v = parse(r#"{"name":"x","version":"1.0.0","os":null,"cpu":null,"libc":null}"#);
1044        assert!(v.os.is_empty());
1045        assert!(v.cpu.is_empty());
1046        assert!(v.libc.is_empty());
1047    }
1048
1049    /// Napi-rs emits `"libc": [null]` on Windows/macOS native-binding
1050    /// publishes (e.g. `@oxc-parser/binding-win32-x64-msvc`), meaning
1051    /// "no libc constraint". Drop the null entry so the packument
1052    /// parses — otherwise every version with that shape blocks resolve.
1053    #[test]
1054    fn libc_array_containing_null_drops_null() {
1055        let v = parse(r#"{"name":"x","version":"1.0.0","libc":[null]}"#);
1056        assert!(v.libc.is_empty());
1057    }
1058
1059    #[test]
1060    fn os_cpu_libc_arrays_drop_non_string_entries() {
1061        let v = parse(
1062            r#"{
1063                "name":"x","version":"1.0.0",
1064                "os":["linux",null,42],
1065                "cpu":["x64",null],
1066                "libc":["glibc",{"x":1}]
1067            }"#,
1068        );
1069        assert_eq!(v.os, vec!["linux"]);
1070        assert_eq!(v.cpu, vec!["x64"]);
1071        assert_eq!(v.libc, vec!["glibc"]);
1072    }
1073
1074    #[test]
1075    fn approver_metadata_is_extracted() {
1076        let v = parse(
1077            r#"{
1078                "name":"x",
1079                "version":"1.0.0",
1080                "approver":{"name":"release-manager"}
1081            }"#,
1082        );
1083        assert_eq!(
1084            v.approver
1085                .as_ref()
1086                .and_then(|a| a.get("name"))
1087                .and_then(serde_json::Value::as_str),
1088            Some("release-manager")
1089        );
1090    }
1091
1092    #[test]
1093    fn bin_normalizes_packument_shapes() {
1094        let missing = parse(r#"{"name":"x","version":"1.0.0"}"#);
1095        assert!(missing.bin.is_empty(), "missing bin → empty map");
1096        let empty_string = parse(r#"{"name":"x","version":"1.0.0","bin":""}"#);
1097        assert!(empty_string.bin.is_empty(), "empty string bin → empty map");
1098        let null_bin = parse(r#"{"name":"x","version":"1.0.0","bin":null}"#);
1099        assert!(null_bin.bin.is_empty(), "null bin → empty map");
1100        let empty_map = parse(r#"{"name":"x","version":"1.0.0","bin":{}}"#);
1101        assert!(empty_map.bin.is_empty(), "empty map bin → empty map");
1102        // String bin leaves the name blank — callers patch it with the
1103        // package name before materializing a symlink / writing to
1104        // bun.lock.
1105        let string_bin = parse(r#"{"name":"x","version":"1.0.0","bin":"cli.js"}"#);
1106        assert_eq!(string_bin.bin.get(""), Some(&"cli.js".to_string()));
1107        let map_bin = parse(r#"{"name":"x","version":"1.0.0","bin":{"foo":"cli.js"}}"#);
1108        assert_eq!(map_bin.bin.get("foo"), Some(&"cli.js".to_string()));
1109    }
1110
1111    /// Round-trip the `bin` map through the on-disk cache format
1112    /// (serialize → parse). Regression: the disk cache round-trips
1113    /// the field under the name `bin`, so the deserializer *must*
1114    /// accept a map back (and not interpret the already-normalized
1115    /// map as an implicit-name string).
1116    #[test]
1117    fn bin_map_roundtrips_through_cache_serialization() {
1118        let mut bin = BTreeMap::new();
1119        bin.insert("semver".to_string(), "bin/semver.js".to_string());
1120        let v = VersionMetadata {
1121            name: "semver".to_string(),
1122            version: "7.7.4".to_string(),
1123            dependencies: BTreeMap::new(),
1124            dev_dependencies: BTreeMap::new(),
1125            peer_dependencies: BTreeMap::new(),
1126            peer_dependencies_meta: BTreeMap::new(),
1127            optional_dependencies: BTreeMap::new(),
1128            bundled_dependencies: None,
1129            dist: None,
1130            os: Vec::new(),
1131            cpu: Vec::new(),
1132            libc: Vec::new(),
1133            engines: BTreeMap::new(),
1134            license: None,
1135            funding_url: None,
1136            bin,
1137            has_install_script: false,
1138            deprecated: None,
1139            approver: None,
1140            npm_user: None,
1141        };
1142        let json = serde_json::to_string(&v).unwrap();
1143        let back: VersionMetadata = serde_json::from_str(&json).unwrap();
1144        assert_eq!(
1145            back.bin.get("semver"),
1146            Some(&"bin/semver.js".to_string()),
1147            "bin map must round-trip through cache serialization"
1148        );
1149    }
1150
1151    #[test]
1152    fn missing_fields_default_to_empty() {
1153        let v = parse(r#"{"name":"x","version":"1.0.0"}"#);
1154        assert!(v.os.is_empty());
1155        assert!(v.cpu.is_empty());
1156        assert!(v.libc.is_empty());
1157    }
1158
1159    #[test]
1160    fn attestations_provenance_is_extracted() {
1161        let v = parse(
1162            r#"{"name":"x","version":"1.0.0",
1163                "dist":{"tarball":"t","attestations":{"provenance":{"predicateType":"slsa"}}}}"#,
1164        );
1165        let dist = v.dist.expect("dist present");
1166        let att = dist.attestations.expect("attestations present");
1167        assert!(att.provenance.is_some(), "provenance present");
1168    }
1169
1170    #[test]
1171    fn attestations_missing_is_none() {
1172        let v = parse(r#"{"name":"x","version":"1.0.0","dist":{"tarball":"t"}}"#);
1173        let dist = v.dist.expect("dist present");
1174        assert!(dist.attestations.is_none());
1175    }
1176
1177    #[test]
1178    fn npm_user_object_with_trusted_publisher_is_parsed() {
1179        let v = parse(
1180            r#"{"name":"x","version":"1.0.0",
1181                "_npmUser":{"name":"u","email":"u@x","trustedPublisher":{"id":"gh"}}}"#,
1182        );
1183        let user = v.npm_user.expect("_npmUser present");
1184        assert!(user.trusted_publisher.is_some());
1185    }
1186
1187    #[test]
1188    fn npm_user_object_without_trusted_publisher_is_parsed() {
1189        let v = parse(r#"{"name":"x","version":"1.0.0","_npmUser":{"name":"u","email":"u@x"}}"#);
1190        let user = v.npm_user.expect("_npmUser present");
1191        assert!(user.trusted_publisher.is_none());
1192    }
1193
1194    /// Regression guard: the tolerant deserializer extracts
1195    /// `trustedPublisher` *manually* and ignores everything else, so
1196    /// payloads containing arbitrary garbage in the other `_npmUser`
1197    /// fields don't fail the whole packument parse. Pinning this means
1198    /// future contributors can't accidentally route through
1199    /// `NpmUser::deserialize` again — which would propagate any new
1200    /// non-defaulted-field error up and break tolerance for real-world
1201    /// packuments that pre-date the new field.
1202    #[test]
1203    fn npm_user_garbage_sibling_fields_still_extract_trusted_publisher() {
1204        let v = parse(
1205            r#"{"name":"x","version":"1.0.0",
1206                "_npmUser":{
1207                    "trustedPublisher":{"id":"gh"},
1208                    "name":42,
1209                    "email":[null,{"deep":{"nested":"garbage"}}],
1210                    "future_field_with_strict_type":"this would fail strict deserialize"
1211                }}"#,
1212        );
1213        let user = v
1214            .npm_user
1215            .expect("_npmUser present despite garbage siblings");
1216        assert!(user.trusted_publisher.is_some());
1217    }
1218
1219    /// Pre-2010 publishes serialize `_npmUser` as a `"name <email>"`
1220    /// string. Degrade to `None` instead of failing the whole packument.
1221    #[test]
1222    fn npm_user_string_form_degrades_to_none() {
1223        let v = parse(r#"{"name":"x","version":"1.0.0","_npmUser":"isaacs <i@npmjs.com>"}"#);
1224        assert!(v.npm_user.is_none());
1225    }
1226
1227    #[test]
1228    fn npm_user_null_or_missing_is_none() {
1229        let v_null = parse(r#"{"name":"x","version":"1.0.0","_npmUser":null}"#);
1230        assert!(v_null.npm_user.is_none());
1231        let v_missing = parse(r#"{"name":"x","version":"1.0.0"}"#);
1232        assert!(v_missing.npm_user.is_none());
1233    }
1234
1235    #[test]
1236    fn deprecated_string_is_preserved_and_false_is_empty() {
1237        let v = parse(r#"{"name":"x","version":"1.0.0","deprecated":"use y"}"#);
1238        assert_eq!(v.deprecated.as_deref(), Some("use y"));
1239
1240        let v = parse(r#"{"name":"x","version":"1.0.1","deprecated":false}"#);
1241        assert!(v.deprecated.is_none());
1242    }
1243
1244    /// Artifactory's npm remote proxies sometimes emit `null` entries
1245    /// in dep maps where stripped/redacted deps used to be. The
1246    /// resolver must not bail on that — the null dep is semantically
1247    /// "not present", same shape npmjs would have served.
1248    #[test]
1249    fn dependency_maps_drop_null_entries() {
1250        let v = parse(
1251            r#"{
1252                "name": "x",
1253                "version": "1.0.0",
1254                "dependencies": {"kept": "^1", "stripped": null},
1255                "devDependencies": {"dkept": "^2", "dstripped": null},
1256                "peerDependencies": {"pkept": "^3", "pstripped": null},
1257                "optionalDependencies": {"okept": "^4", "ostripped": null}
1258            }"#,
1259        );
1260        assert_eq!(v.dependencies.len(), 1);
1261        assert_eq!(v.dependencies["kept"], "^1");
1262        assert_eq!(v.dev_dependencies.len(), 1);
1263        assert_eq!(v.peer_dependencies.len(), 1);
1264        assert_eq!(v.optional_dependencies.len(), 1);
1265    }
1266
1267    /// Ancient publishes (e.g. `deep-diff@0.1.0`, published 2013) have
1268    /// dep-map entries where the value is an object
1269    /// (`{"version": "0.6.4", "dependencies": {...}}`) rather than a
1270    /// version string. That shape would fail a strict string-valued
1271    /// map — drop those entries, same as null ones, so the packument
1272    /// still parses and unaffected versions stay resolvable.
1273    #[test]
1274    fn dependency_maps_drop_object_valued_entries() {
1275        let v = parse(
1276            r#"{
1277                "name": "deep-diff",
1278                "version": "0.1.0",
1279                "devDependencies": {
1280                    "vows": {"version": "0.6.4", "dependencies": {"diff": {"version": "1.0.4"}}},
1281                    "extend": {"version": "1.1.1"},
1282                    "lodash": "0.9.2"
1283                }
1284            }"#,
1285        );
1286        assert_eq!(v.dev_dependencies.len(), 1);
1287        assert_eq!(v.dev_dependencies["lodash"], "0.9.2");
1288    }
1289
1290    #[test]
1291    fn dependency_maps_null_whole_field_is_empty() {
1292        let v = parse(
1293            r#"{
1294                "name": "x",
1295                "version": "1.0.0",
1296                "dependencies": null,
1297                "devDependencies": null,
1298                "peerDependencies": null,
1299                "optionalDependencies": null
1300            }"#,
1301        );
1302        assert!(v.dependencies.is_empty());
1303        assert!(v.dev_dependencies.is_empty());
1304        assert!(v.peer_dependencies.is_empty());
1305        assert!(v.optional_dependencies.is_empty());
1306    }
1307
1308    fn parse_packument(json: &str) -> Packument {
1309        serde_json::from_str(json).unwrap()
1310    }
1311
1312    #[test]
1313    fn packument_dist_tags_drops_null_tag() {
1314        let p = parse_packument(
1315            r#"{
1316                "name": "pkg",
1317                "dist-tags": {"latest": "1.2.3", "beta": null}
1318            }"#,
1319        );
1320        assert_eq!(p.dist_tags.len(), 1);
1321        assert_eq!(p.dist_tags["latest"], "1.2.3");
1322    }
1323
1324    #[test]
1325    fn packument_dist_tags_null_whole_field_is_empty() {
1326        let p = parse_packument(r#"{"name":"pkg","dist-tags":null}"#);
1327        assert!(p.dist_tags.is_empty());
1328    }
1329
1330    #[test]
1331    fn packument_preserves_modified_timestamp() {
1332        let p = parse_packument(
1333            r#"{
1334                "name": "pkg",
1335                "modified": "2026-04-14T14:26:11.557Z"
1336            }"#,
1337        );
1338        assert_eq!(p.modified.as_deref(), Some("2026-04-14T14:26:11.557Z"));
1339    }
1340
1341    #[test]
1342    fn packument_time_drops_null_entries() {
1343        let p = parse_packument(
1344            r#"{
1345                "name": "pkg",
1346                "time": {"1.0.0": "2024-01-01T00:00:00.000Z", "0.9.0": null}
1347            }"#,
1348        );
1349        assert_eq!(p.time.len(), 1);
1350        assert!(p.time.contains_key("1.0.0"));
1351    }
1352
1353    #[test]
1354    fn packument_time_null_whole_field_is_empty() {
1355        let p = parse_packument(r#"{"name":"pkg","time":null}"#);
1356        assert!(p.time.is_empty());
1357    }
1358
1359    /// Pre-npm-2.x publishes (e.g. `madge@0.0.1`, `html-entities@1.x`)
1360    /// ship `"engines": ["node >= 0.8.0"]` as an array, and some old
1361    /// entries (e.g. `qs`) ship a bare string. npmjs.org serves those
1362    /// shapes verbatim in packuments. A strict map-only deserializer
1363    /// fails the whole packument parse, blocking install of any range
1364    /// that even lists an affected version. Normalize legacy non-map
1365    /// forms to an empty map — same tolerance the manifest and
1366    /// lockfile parsers already apply.
1367    #[test]
1368    fn engines_accepts_legacy_array_shape() {
1369        let v = parse(r#"{"name":"madge","version":"0.0.1","engines":["node >= 0.8.0"]}"#);
1370        assert!(v.engines.is_empty());
1371    }
1372
1373    #[test]
1374    fn engines_accepts_legacy_string_shape() {
1375        let v = parse(r#"{"name":"qs","version":"0.6.0","engines":"node >= 0.4.0"}"#);
1376        assert!(v.engines.is_empty());
1377    }
1378
1379    #[test]
1380    fn engines_accepts_map_shape() {
1381        let v = parse(r#"{"name":"x","version":"1.0.0","engines":{"node":">=18"}}"#);
1382        assert_eq!(v.engines.get("node"), Some(&">=18".to_string()));
1383    }
1384
1385    #[test]
1386    fn engines_null_is_empty() {
1387        let v = parse(r#"{"name":"x","version":"1.0.0","engines":null}"#);
1388        assert!(v.engines.is_empty());
1389    }
1390
1391    /// Regression: `@lingui/message-utils@5.2.0`+ ships the full
1392    /// packument with both `bundledDependencies` (canonical) and
1393    /// `bundleDependencies` (deprecated alias) carrying the same value.
1394    /// serde's `#[serde(alias)]` rejects that as a duplicate field,
1395    /// which used to fail the packument parse and abort install.
1396    #[test]
1397    fn bundled_deps_accepts_both_canonical_and_alias() {
1398        let v = parse(
1399            r#"{
1400                "name":"x","version":"1.0.0",
1401                "bundledDependencies":["canonical"],
1402                "bundleDependencies":["legacy"]
1403            }"#,
1404        );
1405        let deps = BTreeMap::new();
1406        let names = v.bundled_dependencies.as_ref().unwrap().names(&deps);
1407        assert_eq!(names, vec!["canonical"]);
1408    }
1409
1410    #[test]
1411    fn bundled_deps_falls_back_to_alias_only() {
1412        let v = parse(r#"{"name":"x","version":"1.0.0","bundleDependencies":["legacy"]}"#);
1413        let deps = BTreeMap::new();
1414        let names = v.bundled_dependencies.as_ref().unwrap().names(&deps);
1415        assert_eq!(names, vec!["legacy"]);
1416    }
1417}