Skip to main content

znippy_common/plugins/native/
npm_native.rs

1//! Native npm registry plugin.
2//!
3//! Extracts the **authoritative** package `name` + `version` from the
4//! `package/package.json` inside an npm `.tgz` tarball — not from the filename.
5//! This matters for **scoped** packages: a registry tarball for `@scope/pkg` is
6//! served as `…/-/pkg-1.2.3.tgz`, i.e. the scope is *dropped from the filename*.
7//! Only `package.json` carries the real `"name": "@scope/pkg"`. Parsing it (under
8//! the `host-decompressors` feature, via `lgz`'s gzip+tar filter) is what lets the
9//! read-side [`NpmView`](crate::views::NpmView) resolve scoped coords correctly.
10//!
11//! When `host-decompressors` is off (or the tarball can't be parsed), the plugin
12//! falls back to a best-effort `(name, version)` split from the filename — never
13//! panics, always returns a row.
14
15use crate::plugin::{file_name, ArchiveTypePlugin, Col, ExtensionRow, ExtensionValue, Claim, HandlerSpec};
16use std::collections::HashMap;
17
18/// Hard cap on decompressed ingest output. Metadata files (package.json,
19/// metadata.gz YAML, info/index.json) are tiny; a real package is far under this.
20/// A small but highly compressible upload that would expand past this is rejected
21/// by the capped decompressors (returning `Err`), so the plugin degrades to the
22/// filename fallback instead of OOM-aborting the process.
23#[cfg(feature = "host-decompressors")]
24pub(crate) const MAX_INGEST_DECOMPRESS: usize = 256 * 1024 * 1024; // 256 MiB
25
26/// Native npm plugin. `name`/`version` come from `package.json` when the tarball
27/// is decompressable, else from the filename.
28pub struct NpmPlugin;
29
30impl NpmPlugin {
31    /// Best-effort `(name, version)` from a tarball filename like
32    /// `pkg-1.2.3.tgz`: strip `.tgz`, split at the last `-` immediately followed
33    /// by a digit (the conventional version start). Scope is **not** recoverable
34    /// from the filename — that's exactly why `package.json` is preferred.
35    fn parse_filename(path: &str) -> (String, Option<String>) {
36        let filename = file_name(path);
37        let stem = filename.strip_suffix(".tgz").unwrap_or(filename);
38        let mut split_pos = None;
39        for (i, c) in stem.char_indices() {
40            if c == '-' {
41                if let Some(next) = stem[i + 1..].chars().next() {
42                    if next.is_ascii_digit() {
43                        split_pos = Some(i);
44                    }
45                }
46            }
47        }
48        match split_pos {
49            Some(pos) => (stem[..pos].to_string(), Some(stem[pos + 1..].to_string())),
50            None => (stem.to_string(), None),
51        }
52    }
53
54    /// Parse the **top-level** `name` + `version` out of an npm tarball's
55    /// `package/package.json`. `None` on any failure (caller falls back to the
56    /// filename). Only compiled when a host gzip+tar decompressor is available.
57    #[cfg(feature = "host-decompressors")]
58    fn parse_package_json(data: &[u8]) -> Option<(String, String)> {
59        // Capped decompress: a bomb tarball errors here (→ `.ok()?` → filename
60        // fallback) instead of expanding to many GB and OOM-aborting at ingest.
61        let entries =
62            lgz::decompress_tar_gz_filter_capped(data, "package.json", MAX_INGEST_DECOMPRESS)
63                .ok()?;
64        // npm roots everything under `package/`; prefer that exact path, but
65        // accept a bare top-level `package.json` too.
66        let (_, bytes) = entries
67            .iter()
68            .find(|(p, _)| p.ends_with("package/package.json") || p.as_str() == "package.json")
69            .or_else(|| entries.first())?;
70        let v: serde_json::Value = serde_json::from_slice(bytes).ok()?;
71        let name = v.get("name")?.as_str()?.to_string();
72        let version = v.get("version")?.as_str()?.to_string();
73        if name.is_empty() || version.is_empty() {
74            return None;
75        }
76        Some((name, version))
77    }
78
79    /// Resolve `(name, version)`: authoritative `package.json` first, filename
80    /// fallback otherwise.
81    fn resolve_coords(path: &str, _data: &[u8]) -> (String, Option<String>) {
82        #[cfg(feature = "host-decompressors")]
83        if let Some((name, version)) = Self::parse_package_json(_data) {
84            return (name, Some(version));
85        }
86        Self::parse_filename(path)
87    }
88}
89
90/// The npm handler, stated once. `name`/`version` are the coords the read-side
91/// [`NpmView`](crate::views::NpmView) resolves on.
92pub static NPM_SPEC: HandlerSpec = HandlerSpec {
93    name: "npm",
94    aliases: &["node", "yarn", "pnpm"],
95    type_id: 6,
96    ecosystem: "JavaScript / npm (registry.npmjs.org)",
97    extensions: &[".tgz"],
98    claim: Claim::Extensions,
99    description: "npm package tarballs — authoritative name (incl. @scope) + version from package.json",
100    fields: &[("name", Col::Utf8), ("version", Col::Utf8)],
101    commands: &[],
102};
103
104impl ArchiveTypePlugin for NpmPlugin {
105    fn spec(&self) -> &HandlerSpec {
106        &NPM_SPEC
107    }
108
109    fn extract_metadata(&self, path: &str, data: &[u8]) -> Option<ExtensionRow> {
110        let (name, version) = Self::resolve_coords(path, data);
111        let mut fields = HashMap::new();
112        fields.insert("name".into(), ExtensionValue::Str(name));
113        fields.insert("version".into(), ExtensionValue::OptStr(version));
114        Some(ExtensionRow { fields })
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn matches_tgz_only() {
124        let p = NpmPlugin;
125        assert!(p.matches_path("react/-/react-18.2.0.tgz"));
126        assert!(!p.matches_path("react/-/react-18.2.0.tar.gz"));
127        assert!(!p.matches_path("foo.jar"));
128    }
129
130    #[test]
131    fn filename_fallback_splits_at_version() {
132        let (n, v) = NpmPlugin::parse_filename("@types/node/-/node-20.11.5.tgz");
133        // Scope is NOT recoverable from the filename — this is the documented gap
134        // that package.json parsing closes.
135        assert_eq!(n, "node");
136        assert_eq!(v.as_deref(), Some("20.11.5"));
137    }
138
139    #[test]
140    fn schema_is_name_version() {
141        let f = NpmPlugin.schema_fields();
142        assert_eq!(f.len(), 2);
143        assert_eq!(f[0].name(), "name");
144        assert_eq!(f[1].name(), "version");
145    }
146}