znippy_common/plugins/native/
npm_native.rs1use crate::plugin::{file_name, ArchiveTypePlugin, Col, ExtensionRow, ExtensionValue, Claim, HandlerSpec};
16use std::collections::HashMap;
17
18#[cfg(feature = "host-decompressors")]
24pub(crate) const MAX_INGEST_DECOMPRESS: usize = 256 * 1024 * 1024; pub struct NpmPlugin;
29
30impl NpmPlugin {
31 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 #[cfg(feature = "host-decompressors")]
58 fn parse_package_json(data: &[u8]) -> Option<(String, String)> {
59 let entries =
62 lgz::decompress_tar_gz_filter_capped(data, "package.json", MAX_INGEST_DECOMPRESS)
63 .ok()?;
64 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 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
90pub 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 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}