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::{file_name, ArchiveTypePlugin, Col, ExtensionRow, ExtensionValue, Claim, HandlerSpec};
18use std::collections::HashMap;
19
20/// Native gem plugin. `name`/`version`/`platform` come from `metadata.gz` when the
21/// `.gem` is parseable, else `(name, version)` from the filename.
22pub struct GemPlugin;
23
24impl GemPlugin {
25    /// Best-effort `(name, version)` from a gem filename like `foo-1.2.3.gem` or
26    /// `foo-1.2.3-java.gem`: strip `.gem`, split at the last `-` immediately
27    /// followed by a digit (the conventional version start). The platform suffix
28    /// (`-java`) is NOT recoverable as a distinct field from the filename — that's
29    /// exactly why `metadata.gz` is preferred.
30    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    /// Parse the authoritative `(name, version, platform)` out of a `.gem`'s
50    /// `metadata.gz`. The outer `.gem` is a plain (uncompressed) tar; find the
51    /// `metadata.gz` member, gunzip it, then read the YAML fields. `None` on any
52    /// failure (caller falls back to the filename). Only compiled when host
53    /// decompressors are available.
54    #[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        // 1. The outer `.gem` is a plain ustar tar — read it with the `tar` crate
59        //    directly (no gzip wrapper on the outer layer).
60        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                // Cap the per-tar-entry read: a huge `metadata.gz` member can't
67                // blow up memory at ingest — bail to the filename fallback.
68                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        // 2. gunzip the YAML doc — capped so a compression-bomb metadata.gz
83        //    errors here (→ filename fallback) instead of OOM-aborting.
84        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    /// Minimal hand parse of the two (three) fields we need out of a gem
90    /// `metadata.gz` YAML doc. The relevant shape is:
91    ///
92    /// ```yaml
93    /// --- !ruby/object:Gem::Specification
94    /// name: foo
95    /// version: !ruby/object:Gem::Version
96    ///   version: 1.2.3
97    /// platform: java
98    /// ```
99    ///
100    /// `name` and `platform` are top-level scalars; `version` is nested one level
101    /// under a `version:` key (the top-level `version:` line itself has no scalar —
102    /// it introduces the `!ruby/object:Gem::Version` mapping). We therefore take the
103    /// FIRST indented `version: X` we see after the top-level `version:` key.
104    /// Platform defaults to `ruby` when absent. Returns `None` if name/version
105    /// can't be found (caller falls back to the filename).
106    #[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                // A new top-level key ends any version block.
124                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                    // The top-level `version:` key introduces the Gem::Version
138                    // mapping; the actual string is on an indented `version:` line.
139                    let rest = strip_quotes(&trimmed["version:".len()..]);
140                    if !rest.is_empty() && !rest.starts_with('!') {
141                        // Inline scalar form (rare, but accept it).
142                        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    /// Resolve `(name, version)`: authoritative `metadata.gz` first, filename
169    /// fallback otherwise.
170    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    /// Resolve the gem platform (`ruby` default). Only meaningful with the feature
179    /// on; filename mode can't recover it, so returns `ruby`.
180    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
189/// The gem handler, stated once: the coords the read-side
190/// [`GemView`](crate::views::GemView) resolves on, plus `platform` (default `ruby`).
191pub 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        // The naive filename split folds the platform into the "version" — exactly
240        // the gap metadata.gz parsing closes.
241        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}