znippy_common/plugins/
npm_native.rs1use crate::plugin::{ArchiveTypePlugin, ExtensionRow, ExtensionValue, HandlerCommand, HandlerMeta};
16use arrow::datatypes::{DataType, Field};
17use std::collections::HashMap;
18
19#[cfg(feature = "host-decompressors")]
25pub(crate) const MAX_INGEST_DECOMPRESS: usize = 256 * 1024 * 1024; pub struct NpmPlugin;
30
31impl NpmPlugin {
32 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 #[cfg(feature = "host-decompressors")]
59 fn parse_package_json(data: &[u8]) -> Option<(String, String)> {
60 let entries =
63 lgz::decompress_tar_gz_filter_capped(data, "package.json", MAX_INGEST_DECOMPRESS)
64 .ok()?;
65 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 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 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 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}