znippy_common/plugins/native/
gem_native.rs1use crate::plugin::{file_name, ArchiveTypePlugin, Col, ExtensionRow, ExtensionValue, Claim, HandlerSpec};
18use std::collections::HashMap;
19
20pub struct GemPlugin;
23
24impl GemPlugin {
25 fn parse_filename(path: &str) -> (String, Option<String>) {
31 let filename = file_name(path);
32 let stem = filename.strip_suffix(".gem").unwrap_or(filename);
33 let mut split_pos = None;
34 for (i, c) in stem.char_indices() {
35 if c == '-' {
36 if let Some(next) = stem[i + 1..].chars().next() {
37 if next.is_ascii_digit() {
38 split_pos = Some(i);
39 }
40 }
41 }
42 }
43 match split_pos {
44 Some(pos) => (stem[..pos].to_string(), Some(stem[pos + 1..].to_string())),
45 None => (stem.to_string(), None),
46 }
47 }
48
49 #[cfg(feature = "host-decompressors")]
55 fn parse_metadata(data: &[u8]) -> Option<(String, String, String)> {
56 use crate::plugins::npm_native::MAX_INGEST_DECOMPRESS;
57 use std::io::Read;
58 let mut archive = tar::Archive::new(data);
61 let mut meta_gz: Option<Vec<u8>> = None;
62 for entry in archive.entries().ok()? {
63 let mut entry = entry.ok()?;
64 let path = entry.path().ok()?.to_string_lossy().to_string();
65 if path == "metadata.gz" || path.ends_with("/metadata.gz") {
66 let mut buf = Vec::new();
69 let read = entry
70 .by_ref()
71 .take(MAX_INGEST_DECOMPRESS as u64)
72 .read_to_end(&mut buf)
73 .ok()?;
74 if read >= MAX_INGEST_DECOMPRESS {
75 return None;
76 }
77 meta_gz = Some(buf);
78 break;
79 }
80 }
81 let meta_gz = meta_gz?;
82 let yaml = lgz::decompress_gz_capped(&meta_gz, MAX_INGEST_DECOMPRESS).ok()?;
85 let yaml = String::from_utf8_lossy(&yaml);
86 Self::parse_metadata_yaml(&yaml)
87 }
88
89 #[cfg(feature = "host-decompressors")]
107 fn parse_metadata_yaml(yaml: &str) -> Option<(String, String, String)> {
108 let mut name: Option<String> = None;
109 let mut version: Option<String> = None;
110 let mut platform = "ruby".to_string();
111 let mut in_version_block = false;
112
113 let strip_quotes = |s: &str| -> String {
114 let t = s.trim();
115 t.trim_matches('"').trim_matches('\'').to_string()
116 };
117
118 for line in yaml.lines() {
119 let trimmed = line.trim_start();
120 let indent = line.len() - trimmed.len();
121
122 if indent == 0 {
123 if let Some(rest) = trimmed.strip_prefix("name:") {
125 let v = strip_quotes(rest);
126 if !v.is_empty() && name.is_none() {
127 name = Some(v);
128 }
129 in_version_block = false;
130 } else if let Some(rest) = trimmed.strip_prefix("platform:") {
131 let v = strip_quotes(rest);
132 if !v.is_empty() {
133 platform = v;
134 }
135 in_version_block = false;
136 } else if trimmed.starts_with("version:") {
137 let rest = strip_quotes(&trimmed["version:".len()..]);
140 if !rest.is_empty() && !rest.starts_with('!') {
141 version.get_or_insert(rest);
143 in_version_block = false;
144 } else {
145 in_version_block = true;
146 }
147 } else {
148 in_version_block = false;
149 }
150 } else if in_version_block && version.is_none() {
151 if let Some(rest) = trimmed.strip_prefix("version:") {
152 let v = strip_quotes(rest);
153 if !v.is_empty() && !v.starts_with('!') {
154 version = Some(v);
155 }
156 }
157 }
158 }
159
160 let name = name?;
161 let version = version?;
162 if name.is_empty() || version.is_empty() {
163 return None;
164 }
165 Some((name, version, platform))
166 }
167
168 fn resolve_coords(path: &str, _data: &[u8]) -> (String, Option<String>) {
171 #[cfg(feature = "host-decompressors")]
172 if let Some((name, version, _platform)) = Self::parse_metadata(_data) {
173 return (name, Some(version));
174 }
175 Self::parse_filename(path)
176 }
177
178 fn resolve_platform(_data: &[u8]) -> String {
181 #[cfg(feature = "host-decompressors")]
182 if let Some((_n, _v, platform)) = Self::parse_metadata(_data) {
183 return platform;
184 }
185 "ruby".to_string()
186 }
187}
188
189pub static GEM_SPEC: HandlerSpec = HandlerSpec {
192 name: "gem",
193 aliases: &["ruby", "rubygems"],
194 type_id: 11,
195 ecosystem: "Ruby / RubyGems (rubygems.org)",
196 extensions: &[".gem"],
197 claim: Claim::Extensions,
198 description: "RubyGems packages — authoritative name/version/platform from metadata.gz",
199 fields: &[("name", Col::Utf8), ("version", Col::Utf8), ("platform", Col::Utf8)],
200 commands: &[],
201};
202
203impl ArchiveTypePlugin for GemPlugin {
204 fn spec(&self) -> &HandlerSpec {
205 &GEM_SPEC
206 }
207
208 fn extract_metadata(&self, path: &str, data: &[u8]) -> Option<ExtensionRow> {
209 let (name, version) = Self::resolve_coords(path, data);
210 let platform = Self::resolve_platform(data);
211 let mut fields = HashMap::new();
212 fields.insert("name".into(), ExtensionValue::Str(name));
213 fields.insert("version".into(), ExtensionValue::OptStr(version));
214 fields.insert("platform".into(), ExtensionValue::Str(platform));
215 Some(ExtensionRow { fields })
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 #[test]
224 fn matches_gem_only() {
225 let p = GemPlugin;
226 assert!(p.matches_path("gems/rails-7.0.0.gem"));
227 assert!(!p.matches_path("foo.tgz"));
228 }
229
230 #[test]
231 fn filename_fallback_splits_at_version() {
232 let (n, v) = GemPlugin::parse_filename("gems/nokogiri-1.15.0.gem");
233 assert_eq!(n, "nokogiri");
234 assert_eq!(v.as_deref(), Some("1.15.0"));
235 }
236
237 #[test]
238 fn filename_platform_suffix_is_not_a_distinct_field() {
239 let (n, v) = GemPlugin::parse_filename("foo-1.2.3-java.gem");
242 assert_eq!(n, "foo");
243 assert_eq!(v.as_deref(), Some("1.2.3-java"));
244 }
245
246 #[test]
247 fn schema_has_name_version_platform() {
248 let f = GemPlugin.schema_fields();
249 assert_eq!(f.len(), 3);
250 assert_eq!(f[0].name(), "name");
251 assert_eq!(f[1].name(), "version");
252 assert_eq!(f[2].name(), "platform");
253 }
254
255 #[cfg(feature = "host-decompressors")]
256 #[test]
257 fn parse_metadata_yaml_reads_name_version_platform() {
258 let yaml = "--- !ruby/object:Gem::Specification\n\
259 name: foo\n\
260 version: !ruby/object:Gem::Version\n version: 1.2.3\n\
261 platform: java\n";
262 let (n, v, p) = GemPlugin::parse_metadata_yaml(yaml).unwrap();
263 assert_eq!(n, "foo");
264 assert_eq!(v, "1.2.3");
265 assert_eq!(p, "java");
266 }
267
268 #[cfg(feature = "host-decompressors")]
269 #[test]
270 fn parse_metadata_yaml_defaults_platform_to_ruby() {
271 let yaml = "name: bar\nversion: !ruby/object:Gem::Version\n version: 2.0.0\n";
272 let (n, v, p) = GemPlugin::parse_metadata_yaml(yaml).unwrap();
273 assert_eq!(n, "bar");
274 assert_eq!(v, "2.0.0");
275 assert_eq!(p, "ruby");
276 }
277}