znippy_common/plugins/native/
gem_native.rs1use crate::plugin::{ArchiveTypePlugin, ExtensionRow, ExtensionValue, HandlerCommand, HandlerMeta};
18use arrow::datatypes::{DataType, Field};
19use std::collections::HashMap;
20
21pub struct GemPlugin;
24
25impl GemPlugin {
26 fn parse_filename(path: &str) -> (String, Option<String>) {
32 let filename = path.rsplit('/').next().unwrap_or(path);
33 let stem = filename.strip_suffix(".gem").unwrap_or(filename);
34 let mut split_pos = None;
35 for (i, c) in stem.char_indices() {
36 if c == '-' {
37 if let Some(next) = stem[i + 1..].chars().next() {
38 if next.is_ascii_digit() {
39 split_pos = Some(i);
40 }
41 }
42 }
43 }
44 match split_pos {
45 Some(pos) => (stem[..pos].to_string(), Some(stem[pos + 1..].to_string())),
46 None => (stem.to_string(), None),
47 }
48 }
49
50 #[cfg(feature = "host-decompressors")]
56 fn parse_metadata(data: &[u8]) -> Option<(String, String, String)> {
57 use crate::plugins::npm_native::MAX_INGEST_DECOMPRESS;
58 use std::io::Read;
59 let mut archive = tar::Archive::new(data);
62 let mut meta_gz: Option<Vec<u8>> = None;
63 for entry in archive.entries().ok()? {
64 let mut entry = entry.ok()?;
65 let path = entry.path().ok()?.to_string_lossy().to_string();
66 if path == "metadata.gz" || path.ends_with("/metadata.gz") {
67 let mut buf = Vec::new();
70 let read = entry
71 .by_ref()
72 .take(MAX_INGEST_DECOMPRESS as u64)
73 .read_to_end(&mut buf)
74 .ok()?;
75 if read >= MAX_INGEST_DECOMPRESS {
76 return None;
77 }
78 meta_gz = Some(buf);
79 break;
80 }
81 }
82 let meta_gz = meta_gz?;
83 let yaml = lgz::decompress_gz_capped(&meta_gz, MAX_INGEST_DECOMPRESS).ok()?;
86 let yaml = String::from_utf8_lossy(&yaml);
87 Self::parse_metadata_yaml(&yaml)
88 }
89
90 #[cfg(feature = "host-decompressors")]
108 fn parse_metadata_yaml(yaml: &str) -> Option<(String, String, String)> {
109 let mut name: Option<String> = None;
110 let mut version: Option<String> = None;
111 let mut platform = "ruby".to_string();
112 let mut in_version_block = false;
113
114 let strip_quotes = |s: &str| -> String {
115 let t = s.trim();
116 t.trim_matches('"').trim_matches('\'').to_string()
117 };
118
119 for line in yaml.lines() {
120 let trimmed = line.trim_start();
121 let indent = line.len() - trimmed.len();
122
123 if indent == 0 {
124 if let Some(rest) = trimmed.strip_prefix("name:") {
126 let v = strip_quotes(rest);
127 if !v.is_empty() && name.is_none() {
128 name = Some(v);
129 }
130 in_version_block = false;
131 } else if let Some(rest) = trimmed.strip_prefix("platform:") {
132 let v = strip_quotes(rest);
133 if !v.is_empty() {
134 platform = v;
135 }
136 in_version_block = false;
137 } else if trimmed.starts_with("version:") {
138 let rest = strip_quotes(&trimmed["version:".len()..]);
141 if !rest.is_empty() && !rest.starts_with('!') {
142 version.get_or_insert(rest);
144 in_version_block = false;
145 } else {
146 in_version_block = true;
147 }
148 } else {
149 in_version_block = false;
150 }
151 } else if in_version_block && version.is_none() {
152 if let Some(rest) = trimmed.strip_prefix("version:") {
153 let v = strip_quotes(rest);
154 if !v.is_empty() && !v.starts_with('!') {
155 version = Some(v);
156 }
157 }
158 }
159 }
160
161 let name = name?;
162 let version = version?;
163 if name.is_empty() || version.is_empty() {
164 return None;
165 }
166 Some((name, version, platform))
167 }
168
169 fn resolve_coords(path: &str, _data: &[u8]) -> (String, Option<String>) {
172 #[cfg(feature = "host-decompressors")]
173 if let Some((name, version, _platform)) = Self::parse_metadata(_data) {
174 return (name, Some(version));
175 }
176 Self::parse_filename(path)
177 }
178
179 fn resolve_platform(_data: &[u8]) -> String {
182 #[cfg(feature = "host-decompressors")]
183 if let Some((_n, _v, platform)) = Self::parse_metadata(_data) {
184 return platform;
185 }
186 "ruby".to_string()
187 }
188}
189
190impl ArchiveTypePlugin for GemPlugin {
191 fn name(&self) -> &str {
192 "gem"
193 }
194
195 fn type_id(&self) -> i8 {
196 11
197 }
198
199 fn meta(&self) -> HandlerMeta {
200 HandlerMeta {
201 name: "gem".into(),
202 aliases: vec!["ruby".into(), "rubygems".into()],
203 type_id: 11,
204 ecosystem: "Ruby / RubyGems (rubygems.org)".into(),
205 extensions: vec![".gem".into()],
206 description:
207 "RubyGems packages — authoritative name/version/platform from metadata.gz"
208 .into(),
209 commands: vec![HandlerCommand::new(
210 "coords",
211 "Print gem name + version (metadata.gz if readable, else filename)",
212 )],
213 }
214 }
215
216 fn run_command(&self, cmd: &str, args: &[String]) -> anyhow::Result<()> {
217 match cmd {
218 "coords" => {
219 let path =
220 args.first().ok_or_else(|| anyhow::anyhow!("usage: gem coords <file.gem>"))?;
221 let (name, version) = Self::parse_filename(path);
222 match version {
223 Some(v) => println!("{} {}", name, v),
224 None => println!("{}", name),
225 }
226 Ok(())
227 }
228 other => anyhow::bail!("gem: unknown subcommand '{}'", other),
229 }
230 }
231
232 fn matches_path(&self, path: &str) -> bool {
233 path.ends_with(".gem")
234 }
235
236 fn schema_fields(&self) -> Vec<Field> {
240 vec![
241 Field::new("name", DataType::Utf8, true),
242 Field::new("version", DataType::Utf8, true),
243 Field::new("platform", DataType::Utf8, true),
244 ]
245 }
246
247 fn extract_metadata(&self, path: &str, data: &[u8]) -> Option<ExtensionRow> {
248 let (name, version) = Self::resolve_coords(path, data);
249 let platform = Self::resolve_platform(data);
250 let mut fields = HashMap::new();
251 fields.insert("name".into(), ExtensionValue::Str(name));
252 fields.insert("version".into(), ExtensionValue::OptStr(version));
253 fields.insert("platform".into(), ExtensionValue::Str(platform));
254 Some(ExtensionRow { fields })
255 }
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 #[test]
263 fn matches_gem_only() {
264 let p = GemPlugin;
265 assert!(p.matches_path("gems/rails-7.0.0.gem"));
266 assert!(!p.matches_path("foo.tgz"));
267 }
268
269 #[test]
270 fn filename_fallback_splits_at_version() {
271 let (n, v) = GemPlugin::parse_filename("gems/nokogiri-1.15.0.gem");
272 assert_eq!(n, "nokogiri");
273 assert_eq!(v.as_deref(), Some("1.15.0"));
274 }
275
276 #[test]
277 fn filename_platform_suffix_is_not_a_distinct_field() {
278 let (n, v) = GemPlugin::parse_filename("foo-1.2.3-java.gem");
281 assert_eq!(n, "foo");
282 assert_eq!(v.as_deref(), Some("1.2.3-java"));
283 }
284
285 #[test]
286 fn schema_has_name_version_platform() {
287 let f = GemPlugin.schema_fields();
288 assert_eq!(f.len(), 3);
289 assert_eq!(f[0].name(), "name");
290 assert_eq!(f[1].name(), "version");
291 assert_eq!(f[2].name(), "platform");
292 }
293
294 #[cfg(feature = "host-decompressors")]
295 #[test]
296 fn parse_metadata_yaml_reads_name_version_platform() {
297 let yaml = "--- !ruby/object:Gem::Specification\n\
298 name: foo\n\
299 version: !ruby/object:Gem::Version\n version: 1.2.3\n\
300 platform: java\n";
301 let (n, v, p) = GemPlugin::parse_metadata_yaml(yaml).unwrap();
302 assert_eq!(n, "foo");
303 assert_eq!(v, "1.2.3");
304 assert_eq!(p, "java");
305 }
306
307 #[cfg(feature = "host-decompressors")]
308 #[test]
309 fn parse_metadata_yaml_defaults_platform_to_ruby() {
310 let yaml = "name: bar\nversion: !ruby/object:Gem::Version\n version: 2.0.0\n";
311 let (n, v, p) = GemPlugin::parse_metadata_yaml(yaml).unwrap();
312 assert_eq!(n, "bar");
313 assert_eq!(v, "2.0.0");
314 assert_eq!(p, "ruby");
315 }
316}