Skip to main content

znippy_common/plugins/
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::{ArchiveTypePlugin, ExtensionRow, ExtensionValue, HandlerCommand, HandlerMeta};
16use arrow::datatypes::{DataType, Field};
17use std::collections::HashMap;
18
19/// Hard cap on decompressed ingest output. Metadata files (package.json,
20/// metadata.gz YAML, info/index.json) are tiny; a real package is far under this.
21/// A small but highly compressible upload that would expand past this is rejected
22/// by the capped decompressors (returning `Err`), so the plugin degrades to the
23/// filename fallback instead of OOM-aborting the process.
24#[cfg(feature = "host-decompressors")]
25pub(crate) const MAX_INGEST_DECOMPRESS: usize = 256 * 1024 * 1024; // 256 MiB
26
27/// Native npm plugin. `name`/`version` come from `package.json` when the tarball
28/// is decompressable, else from the filename.
29pub struct NpmPlugin;
30
31impl NpmPlugin {
32    /// Best-effort `(name, version)` from a tarball filename like
33    /// `pkg-1.2.3.tgz`: strip `.tgz`, split at the last `-` immediately followed
34    /// by a digit (the conventional version start). Scope is **not** recoverable
35    /// from the filename — that's exactly why `package.json` is preferred.
36    fn parse_filename(path: &str) -> (String, Option<String>) {
37        let filename = path.rsplit('/').next().unwrap_or(path);
38        let stem = filename.strip_suffix(".tgz").unwrap_or(filename);
39        let mut split_pos = None;
40        for (i, c) in stem.char_indices() {
41            if c == '-' {
42                if let Some(next) = stem[i + 1..].chars().next() {
43                    if next.is_ascii_digit() {
44                        split_pos = Some(i);
45                    }
46                }
47            }
48        }
49        match split_pos {
50            Some(pos) => (stem[..pos].to_string(), Some(stem[pos + 1..].to_string())),
51            None => (stem.to_string(), None),
52        }
53    }
54
55    /// Parse the **top-level** `name` + `version` out of an npm tarball's
56    /// `package/package.json`. `None` on any failure (caller falls back to the
57    /// filename). Only compiled when a host gzip+tar decompressor is available.
58    #[cfg(feature = "host-decompressors")]
59    fn parse_package_json(data: &[u8]) -> Option<(String, String)> {
60        // Capped decompress: a bomb tarball errors here (→ `.ok()?` → filename
61        // fallback) instead of expanding to many GB and OOM-aborting at ingest.
62        let entries =
63            lgz::decompress_tar_gz_filter_capped(data, "package.json", MAX_INGEST_DECOMPRESS)
64                .ok()?;
65        // npm roots everything under `package/`; prefer that exact path, but
66        // accept a bare top-level `package.json` too.
67        let (_, bytes) = entries
68            .iter()
69            .find(|(p, _)| p.ends_with("package/package.json") || p.as_str() == "package.json")
70            .or_else(|| entries.first())?;
71        let v: serde_json::Value = serde_json::from_slice(bytes).ok()?;
72        let name = v.get("name")?.as_str()?.to_string();
73        let version = v.get("version")?.as_str()?.to_string();
74        if name.is_empty() || version.is_empty() {
75            return None;
76        }
77        Some((name, version))
78    }
79
80    /// Resolve `(name, version)`: authoritative `package.json` first, filename
81    /// fallback otherwise.
82    fn resolve_coords(path: &str, _data: &[u8]) -> (String, Option<String>) {
83        #[cfg(feature = "host-decompressors")]
84        if let Some((name, version)) = Self::parse_package_json(_data) {
85            return (name, Some(version));
86        }
87        Self::parse_filename(path)
88    }
89}
90
91impl ArchiveTypePlugin for NpmPlugin {
92    fn name(&self) -> &str {
93        "npm"
94    }
95
96    fn type_id(&self) -> i8 {
97        6
98    }
99
100    fn meta(&self) -> HandlerMeta {
101        HandlerMeta {
102            name: "npm".into(),
103            aliases: vec!["node".into(), "yarn".into(), "pnpm".into()],
104            type_id: 6,
105            ecosystem: "JavaScript / npm (registry.npmjs.org)".into(),
106            extensions: vec![".tgz".into()],
107            description:
108                "npm package tarballs — authoritative name (incl. @scope) + version from package.json"
109                    .into(),
110            commands: vec![HandlerCommand::new(
111                "coords",
112                "Print npm package name + version (package.json if readable, else filename)",
113            )],
114        }
115    }
116
117    fn run_command(&self, cmd: &str, args: &[String]) -> anyhow::Result<()> {
118        match cmd {
119            "coords" => {
120                let path =
121                    args.first().ok_or_else(|| anyhow::anyhow!("usage: npm coords <file.tgz>"))?;
122                let (name, version) = Self::parse_filename(path);
123                match version {
124                    Some(v) => println!("{} {}", name, v),
125                    None => println!("{}", name),
126                }
127                Ok(())
128            }
129            other => anyhow::bail!("npm: unknown subcommand '{}'", other),
130        }
131    }
132
133    fn matches_path(&self, path: &str) -> bool {
134        path.ends_with(".tgz")
135    }
136
137    /// Columns this handler contributes — the npm coords the read-side
138    /// [`NpmView`](crate::views::NpmView) resolves on.
139    fn schema_fields(&self) -> Vec<Field> {
140        vec![
141            Field::new("name", DataType::Utf8, true),
142            Field::new("version", DataType::Utf8, true),
143        ]
144    }
145
146    fn extract_metadata(&self, path: &str, data: &[u8]) -> Option<ExtensionRow> {
147        let (name, version) = Self::resolve_coords(path, data);
148        let mut fields = HashMap::new();
149        fields.insert("name".into(), ExtensionValue::Str(name));
150        fields.insert("version".into(), ExtensionValue::OptStr(version));
151        Some(ExtensionRow { fields })
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn matches_tgz_only() {
161        let p = NpmPlugin;
162        assert!(p.matches_path("react/-/react-18.2.0.tgz"));
163        assert!(!p.matches_path("react/-/react-18.2.0.tar.gz"));
164        assert!(!p.matches_path("foo.jar"));
165    }
166
167    #[test]
168    fn filename_fallback_splits_at_version() {
169        let (n, v) = NpmPlugin::parse_filename("@types/node/-/node-20.11.5.tgz");
170        // Scope is NOT recoverable from the filename — this is the documented gap
171        // that package.json parsing closes.
172        assert_eq!(n, "node");
173        assert_eq!(v.as_deref(), Some("20.11.5"));
174    }
175
176    #[test]
177    fn schema_is_name_version() {
178        let f = NpmPlugin.schema_fields();
179        assert_eq!(f.len(), 2);
180        assert_eq!(f[0].name(), "name");
181        assert_eq!(f[1].name(), "version");
182    }
183}