Skip to main content

znippy_common/plugins/native/
gem_native.rs

1//! Native RubyGems plugin.
2//!
3//! A `.gem` is an **uncompressed `ustar` tar** whose members include
4//! `metadata.gz` (a gzipped YAML doc) and `data.tar.gz` (the actual files). The
5//! **authoritative** `name`, `version`, and `platform` live in that YAML — NOT in
6//! the filename. This matters for **platform-suffixed** gems: a native gem is
7//! published as `foo-1.2.3-java.gem`, whose filename "version" naively parses as
8//! `1.2.3-java`, while `metadata.gz` carries `version: 1.2.3` + `platform: java`.
9//! Parsing it (under the `host-decompressors` feature, via the `tar` crate for the
10//! outer tar + `lgz` for the inner `metadata.gz`) is what lets the read-side
11//! [`GemView`](crate::views::GemView) resolve gem coords correctly.
12//!
13//! When `host-decompressors` is off (or the gem can't be parsed), the plugin falls
14//! back to a best-effort `(name, version)` split from the filename — never panics,
15//! always returns a row.
16
17use crate::plugin::{ArchiveTypePlugin, ExtensionRow, ExtensionValue, HandlerCommand, HandlerMeta};
18use arrow::datatypes::{DataType, Field};
19use std::collections::HashMap;
20
21/// Native gem plugin. `name`/`version`/`platform` come from `metadata.gz` when the
22/// `.gem` is parseable, else `(name, version)` from the filename.
23pub struct GemPlugin;
24
25impl GemPlugin {
26    /// Best-effort `(name, version)` from a gem filename like `foo-1.2.3.gem` or
27    /// `foo-1.2.3-java.gem`: strip `.gem`, split at the last `-` immediately
28    /// followed by a digit (the conventional version start). The platform suffix
29    /// (`-java`) is NOT recoverable as a distinct field from the filename — that's
30    /// exactly why `metadata.gz` is preferred.
31    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    /// Parse the authoritative `(name, version, platform)` out of a `.gem`'s
51    /// `metadata.gz`. The outer `.gem` is a plain (uncompressed) tar; find the
52    /// `metadata.gz` member, gunzip it, then read the YAML fields. `None` on any
53    /// failure (caller falls back to the filename). Only compiled when host
54    /// decompressors are available.
55    #[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        // 1. The outer `.gem` is a plain ustar tar — read it with the `tar` crate
60        //    directly (no gzip wrapper on the outer layer).
61        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                // Cap the per-tar-entry read: a huge `metadata.gz` member can't
68                // blow up memory at ingest — bail to the filename fallback.
69                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        // 2. gunzip the YAML doc — capped so a compression-bomb metadata.gz
84        //    errors here (→ filename fallback) instead of OOM-aborting.
85        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    /// Minimal hand parse of the two (three) fields we need out of a gem
91    /// `metadata.gz` YAML doc. The relevant shape is:
92    ///
93    /// ```yaml
94    /// --- !ruby/object:Gem::Specification
95    /// name: foo
96    /// version: !ruby/object:Gem::Version
97    ///   version: 1.2.3
98    /// platform: java
99    /// ```
100    ///
101    /// `name` and `platform` are top-level scalars; `version` is nested one level
102    /// under a `version:` key (the top-level `version:` line itself has no scalar —
103    /// it introduces the `!ruby/object:Gem::Version` mapping). We therefore take the
104    /// FIRST indented `version: X` we see after the top-level `version:` key.
105    /// Platform defaults to `ruby` when absent. Returns `None` if name/version
106    /// can't be found (caller falls back to the filename).
107    #[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                // A new top-level key ends any version block.
125                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                    // The top-level `version:` key introduces the Gem::Version
139                    // mapping; the actual string is on an indented `version:` line.
140                    let rest = strip_quotes(&trimmed["version:".len()..]);
141                    if !rest.is_empty() && !rest.starts_with('!') {
142                        // Inline scalar form (rare, but accept it).
143                        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    /// Resolve `(name, version)`: authoritative `metadata.gz` first, filename
170    /// fallback otherwise.
171    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    /// Resolve the gem platform (`ruby` default). Only meaningful with the feature
180    /// on; filename mode can't recover it, so returns `ruby`.
181    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    /// Columns this handler contributes — the gem coords the read-side
237    /// [`GemView`](crate::views::GemView) resolves on, plus the authoritative
238    /// `platform` (defaults to `ruby`).
239    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        // The naive filename split folds the platform into the "version" — exactly
279        // the gap metadata.gz parsing closes.
280        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}