1use crate::plugin::{ArchiveTypePlugin, ExtensionRow, ExtensionValue, HandlerCommand, HandlerMeta};
16use arrow::datatypes::{DataType, Field};
17use std::collections::HashMap;
18
19pub struct RpmPlugin;
22
23const RPM_LEAD_MAGIC: [u8; 4] = [0xed, 0xab, 0xee, 0xdb];
25const RPM_HEADER_MAGIC: [u8; 3] = [0x8e, 0xad, 0xe8];
27
28const RPMTAG_NAME: u32 = 1000;
30const RPMTAG_VERSION: u32 = 1001;
31const RPMTAG_RELEASE: u32 = 1002;
32const RPMTAG_EPOCH: u32 = 1003;
33const RPMTAG_SUMMARY: u32 = 1004;
34const RPMTAG_VENDOR: u32 = 1011;
35const RPMTAG_LICENSE: u32 = 1014;
36const RPMTAG_URL: u32 = 1020;
37const RPMTAG_ARCH: u32 = 1022;
38const RPMTAG_SOURCERPM: u32 = 1044;
39const RPMTAG_PROVIDENAME: u32 = 1047;
40const RPMTAG_REQUIRENAME: u32 = 1049;
41
42const RPM_TYPE_INT32: u32 = 4;
44const RPM_TYPE_STRING: u32 = 6;
45const RPM_TYPE_STRING_ARRAY: u32 = 8;
46const RPM_TYPE_I18NSTRING: u32 = 9;
47
48#[derive(Debug, Default, Clone, PartialEq, Eq)]
52pub(crate) struct RpmMeta {
53 pub name: Option<String>,
54 pub epoch: Option<u32>,
55 pub version: Option<String>,
56 pub release: Option<String>,
57 pub arch: Option<String>,
58 pub summary: Option<String>,
59 pub license: Option<String>,
60 pub url: Option<String>,
61 pub vendor: Option<String>,
62 pub sourcerpm: Option<String>,
63 pub provides: Vec<String>,
64 pub requires: Vec<String>,
65}
66
67impl RpmPlugin {
68 fn be_u32(b: &[u8], off: usize) -> Option<u32> {
69 let s = b.get(off..off.checked_add(4)?)?;
70 Some(u32::from_be_bytes([s[0], s[1], s[2], s[3]]))
71 }
72
73 fn header_extent(b: &[u8], pos: usize) -> Option<(usize, usize, usize, usize, usize)> {
78 if b.get(pos..pos.checked_add(3)?)? != RPM_HEADER_MAGIC {
80 return None;
81 }
82 let nindex = Self::be_u32(b, pos + 8)? as usize;
83 let hsize = Self::be_u32(b, pos + 12)? as usize;
84 let index_bytes = nindex.checked_mul(16)?;
85 let entries_off = pos.checked_add(16)?;
86 let data_off = entries_off.checked_add(index_bytes)?;
87 let total = 16usize.checked_add(index_bytes)?.checked_add(hsize)?;
88 if pos.checked_add(total)? > b.len() {
89 return None;
90 }
91 Some((entries_off, nindex, data_off, hsize, total))
92 }
93
94 pub(crate) fn parse_meta(data: &[u8]) -> Option<RpmMeta> {
97 if data.get(0..4)? != RPM_LEAD_MAGIC {
99 return None;
100 }
101 let (_e, _n, _d, _h, sig_total) = Self::header_extent(data, 96)?;
104 let after_sig = 96usize.checked_add(sig_total)?;
105 let main_pos = after_sig.checked_add(7)? & !7;
106 let (entries_off, nindex, data_off, hsize, _total) = Self::header_extent(data, main_pos)?;
108 let store = data.get(data_off..data_off.checked_add(hsize)?)?;
109 let mut m = RpmMeta::default();
110 for i in 0..nindex {
111 let e = entries_off.checked_add(i.checked_mul(16)?)?;
112 let tag = Self::be_u32(data, e)?;
113 let typ = Self::be_u32(data, e + 4)?;
114 let off = Self::be_u32(data, e + 8)? as usize;
115 let count = Self::be_u32(data, e + 12)? as usize;
116 match tag {
117 RPMTAG_NAME if typ == RPM_TYPE_STRING => m.name = read_cstr(store, off),
118 RPMTAG_VERSION if typ == RPM_TYPE_STRING => m.version = read_cstr(store, off),
119 RPMTAG_RELEASE if typ == RPM_TYPE_STRING => m.release = read_cstr(store, off),
120 RPMTAG_ARCH if typ == RPM_TYPE_STRING => m.arch = read_cstr(store, off),
121 RPMTAG_LICENSE if typ == RPM_TYPE_STRING => m.license = read_cstr(store, off),
122 RPMTAG_URL if typ == RPM_TYPE_STRING => m.url = read_cstr(store, off),
123 RPMTAG_VENDOR if typ == RPM_TYPE_STRING => m.vendor = read_cstr(store, off),
124 RPMTAG_SOURCERPM if typ == RPM_TYPE_STRING => m.sourcerpm = read_cstr(store, off),
125 RPMTAG_SUMMARY if typ == RPM_TYPE_I18NSTRING || typ == RPM_TYPE_STRING => {
128 m.summary = read_cstr(store, off)
129 }
130 RPMTAG_EPOCH if typ == RPM_TYPE_INT32 => m.epoch = Self::be_u32(store, off),
131 RPMTAG_PROVIDENAME if typ == RPM_TYPE_STRING_ARRAY => {
132 m.provides = read_string_array(store, off, count)
133 }
134 RPMTAG_REQUIRENAME if typ == RPM_TYPE_STRING_ARRAY => {
135 m.requires = read_string_array(store, off, count)
136 }
137 _ => {}
138 }
139 }
140 (m.name.is_some() && m.version.is_some()).then_some(m)
142 }
143
144 fn parse_filename(path: &str) -> (String, Option<String>, Option<String>, Option<String>) {
148 let fname = path.rsplit('/').next().unwrap_or(path);
149 let Some(stem) = fname.strip_suffix(".rpm") else {
150 return (fname.to_string(), None, None, None);
151 };
152 let Some((rest, arch)) = stem.rsplit_once('.') else {
153 return (stem.to_string(), None, None, None);
154 };
155 let Some((name_ver, release)) = rest.rsplit_once('-') else {
156 return (stem.to_string(), None, None, Some(arch.to_string()));
157 };
158 let Some((name, version)) = name_ver.rsplit_once('-') else {
159 return (rest.to_string(), None, None, Some(arch.to_string()));
160 };
161 (
162 name.to_string(),
163 Some(version.to_string()),
164 Some(release.to_string()),
165 Some(arch.to_string()),
166 )
167 }
168
169}
170
171fn read_cstr(store: &[u8], off: usize) -> Option<String> {
173 let s = store.get(off..)?;
174 let end = s.iter().position(|&b| b == 0)?;
175 Some(String::from_utf8_lossy(&s[..end]).into_owned())
176}
177
178fn read_string_array(store: &[u8], off: usize, count: usize) -> Vec<String> {
183 let mut out = Vec::with_capacity(count.min(1024));
184 let mut pos = off;
185 for _ in 0..count.min(100_000) {
186 let Some(s) = store.get(pos..) else { break };
187 let Some(end) = s.iter().position(|&b| b == 0) else { break };
188 out.push(String::from_utf8_lossy(&s[..end]).into_owned());
189 pos = pos.saturating_add(end + 1);
190 }
191 out
192}
193
194fn join_lines(v: &[String]) -> Option<String> {
197 (!v.is_empty()).then(|| v.join("\n"))
198}
199
200impl ArchiveTypePlugin for RpmPlugin {
201 fn name(&self) -> &str {
202 "rpm"
203 }
204
205 fn type_id(&self) -> i8 {
206 8
207 }
208
209 fn meta(&self) -> HandlerMeta {
210 HandlerMeta {
211 name: "rpm".into(),
212 aliases: vec!["yum".into(), "dnf".into(), "redhat".into()],
213 type_id: 8,
214 ecosystem: "RPM / Red Hat family (dnf/yum)".into(),
215 extensions: vec![".rpm".into()],
216 description: "RPM packages — authoritative NEVRA (incl. epoch) from the header tag table"
217 .into(),
218 commands: vec![HandlerCommand::new(
219 "coords",
220 "Print rpm name + version (header if readable, else filename)",
221 )],
222 }
223 }
224
225 fn run_command(&self, cmd: &str, args: &[String]) -> anyhow::Result<()> {
226 match cmd {
227 "coords" => {
228 let path =
229 args.first().ok_or_else(|| anyhow::anyhow!("usage: rpm coords <file.rpm>"))?;
230 let (name, version, _r, _a) = Self::parse_filename(path);
231 match version {
232 Some(v) => println!("{} {}", name, v),
233 None => println!("{}", name),
234 }
235 Ok(())
236 }
237 other => anyhow::bail!("rpm: unknown subcommand '{}'", other),
238 }
239 }
240
241 fn matches_path(&self, path: &str) -> bool {
242 path.ends_with(".rpm")
243 }
244
245 fn schema_fields(&self) -> Vec<Field> {
250 ["name", "version", "release", "arch", "epoch", "summary", "license", "url", "vendor",
251 "sourcerpm", "provides", "requires"]
252 .iter()
253 .map(|n| Field::new(*n, DataType::Utf8, true))
254 .collect()
255 }
256
257 fn extract_metadata(&self, path: &str, data: &[u8]) -> Option<ExtensionRow> {
258 let m = Self::parse_meta(data);
261 let mut fields = HashMap::new();
262 let put = |fields: &mut HashMap<String, ExtensionValue>, k: &str, v: Option<String>| {
263 fields.insert(k.to_string(), ExtensionValue::OptStr(v));
264 };
265 match m {
266 Some(m) => {
267 fields.insert("name".into(), ExtensionValue::Str(m.name.unwrap_or_default()));
268 put(&mut fields, "version", m.version);
269 put(&mut fields, "release", m.release);
270 put(&mut fields, "arch", m.arch);
271 put(&mut fields, "epoch", m.epoch.map(|e| e.to_string()));
272 put(&mut fields, "summary", m.summary);
273 put(&mut fields, "license", m.license);
274 put(&mut fields, "url", m.url);
275 put(&mut fields, "vendor", m.vendor);
276 put(&mut fields, "sourcerpm", m.sourcerpm);
277 put(&mut fields, "provides", join_lines(&m.provides));
278 put(&mut fields, "requires", join_lines(&m.requires));
279 }
280 None => {
281 let (name, version, release, arch) = Self::parse_filename(path);
282 fields.insert("name".into(), ExtensionValue::Str(name));
283 put(&mut fields, "version", version);
284 put(&mut fields, "release", release);
285 put(&mut fields, "arch", arch);
286 for k in ["epoch", "summary", "license", "url", "vendor", "sourcerpm", "provides", "requires"] {
287 put(&mut fields, k, None);
288 }
289 }
290 }
291 Some(ExtensionRow { fields })
292 }
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 fn build_header(entries: &[(u32, u32, u32, u32)], store: &[u8]) -> Vec<u8> {
301 let mut h = Vec::new();
302 h.extend_from_slice(&RPM_HEADER_MAGIC);
303 h.push(0x01); h.extend_from_slice(&[0, 0, 0, 0]); h.extend_from_slice(&(entries.len() as u32).to_be_bytes());
306 h.extend_from_slice(&(store.len() as u32).to_be_bytes());
307 for (tag, typ, off, count) in entries {
308 h.extend_from_slice(&tag.to_be_bytes());
309 h.extend_from_slice(&typ.to_be_bytes());
310 h.extend_from_slice(&off.to_be_bytes());
311 h.extend_from_slice(&count.to_be_bytes());
312 }
313 h.extend_from_slice(store);
314 h
315 }
316
317 fn build_rpm(nevra_entries: &[(u32, u32, u32, u32)], store: &[u8]) -> Vec<u8> {
319 let mut rpm = vec![0u8; 96];
320 rpm[0..4].copy_from_slice(&RPM_LEAD_MAGIC);
321 let sig = build_header(&[], &[]);
323 rpm.extend_from_slice(&sig);
324 while rpm.len() % 8 != 0 {
326 rpm.push(0);
327 }
328 rpm.extend_from_slice(&build_header(nevra_entries, store));
329 rpm
330 }
331
332 #[test]
333 fn parses_real_nevra_including_epoch_from_header() {
334 let mut store = Vec::new();
336 let name_off = store.len() as u32;
337 store.extend_from_slice(b"bash\0");
338 let ver_off = store.len() as u32;
339 store.extend_from_slice(b"5.1.8\0");
340 let rel_off = store.len() as u32;
341 store.extend_from_slice(b"1.el9\0");
342 let arch_off = store.len() as u32;
343 store.extend_from_slice(b"x86_64\0");
344 let epoch_off = store.len() as u32;
345 store.extend_from_slice(&2u32.to_be_bytes()); let entries = [
348 (RPMTAG_NAME, RPM_TYPE_STRING, name_off, 1),
349 (RPMTAG_VERSION, RPM_TYPE_STRING, ver_off, 1),
350 (RPMTAG_RELEASE, RPM_TYPE_STRING, rel_off, 1),
351 (RPMTAG_ARCH, RPM_TYPE_STRING, arch_off, 1),
352 (RPMTAG_EPOCH, RPM_TYPE_INT32, epoch_off, 1),
353 ];
354 let rpm = build_rpm(&entries, &store);
355 let n = RpmPlugin::parse_meta(&rpm).expect("parses");
356 assert_eq!(n.name.as_deref(), Some("bash"));
357 assert_eq!(n.version.as_deref(), Some("5.1.8"));
358 assert_eq!(n.release.as_deref(), Some("1.el9"));
359 assert_eq!(n.arch.as_deref(), Some("x86_64"));
360 assert_eq!(n.epoch, Some(2), "epoch comes from the header — the filename omits it");
361 }
362
363 #[test]
364 fn falls_back_to_filename_when_not_an_rpm() {
365 assert!(RpmPlugin::parse_meta(b"not an rpm").is_none());
367 let row = RpmPlugin
368 .extract_metadata("Packages/zlib-1.2.11-31.el9.x86_64.rpm", b"garbage")
369 .expect("row");
370 assert_eq!(row.fields.get("name"), Some(&ExtensionValue::Str("zlib".into())));
371 assert_eq!(
372 row.fields.get("version"),
373 Some(&ExtensionValue::OptStr(Some("1.2.11".into())))
374 );
375 assert_eq!(
376 row.fields.get("arch"),
377 Some(&ExtensionValue::OptStr(Some("x86_64".into())))
378 );
379 assert_eq!(row.fields.get("epoch"), Some(&ExtensionValue::OptStr(None)));
381 }
382
383 #[test]
384 fn extract_prefers_header_over_filename() {
385 let mut store = Vec::new();
388 store.extend_from_slice(b"curl\0"); let ver_off = store.len() as u32;
390 store.extend_from_slice(b"9.9\0"); let epoch_off = store.len() as u32;
392 store.extend_from_slice(&7u32.to_be_bytes());
393 let entries = [
394 (RPMTAG_NAME, RPM_TYPE_STRING, 0, 1),
395 (RPMTAG_VERSION, RPM_TYPE_STRING, ver_off, 1),
396 (RPMTAG_EPOCH, RPM_TYPE_INT32, epoch_off, 1),
397 ];
398 let rpm = build_rpm(&entries, &store);
399 let row = RpmPlugin.extract_metadata("Packages/curl-1.0-1.noarch.rpm", &rpm).unwrap();
400 assert_eq!(row.fields.get("name"), Some(&ExtensionValue::Str("curl".into())));
401 assert_eq!(row.fields.get("version"), Some(&ExtensionValue::OptStr(Some("9.9".into()))));
402 assert_eq!(row.fields.get("epoch"), Some(&ExtensionValue::OptStr(Some("7".into()))));
403 }
404
405 #[test]
406 fn matches_rpm_only() {
407 assert!(RpmPlugin.matches_path("Packages/foo-1.0-1.x86_64.rpm"));
408 assert!(!RpmPlugin.matches_path("foo.deb"));
409 }
410
411 #[test]
412 fn schema_has_nevra_and_primary_columns() {
413 let f = RpmPlugin.schema_fields();
414 let names: Vec<&str> = f.iter().map(|x| x.name().as_str()).collect();
415 assert_eq!(
416 names,
417 vec![
418 "name", "version", "release", "arch", "epoch", "summary", "license", "url",
419 "vendor", "sourcerpm", "provides", "requires"
420 ]
421 );
422 }
423
424 #[test]
425 fn extracts_rich_primary_fields() {
426 let mut store = Vec::new();
429 store.extend_from_slice(b"curl\0"); let ver = store.len() as u32;
431 store.extend_from_slice(b"8.0\0");
432 let sum = store.len() as u32;
433 store.extend_from_slice(b"A URL transfer tool\0");
434 let lic = store.len() as u32;
435 store.extend_from_slice(b"MIT\0");
436 let url = store.len() as u32;
437 store.extend_from_slice(b"https://curl.se\0");
438 let src = store.len() as u32;
439 store.extend_from_slice(b"curl-8.0-1.src.rpm\0");
440 let prov = store.len() as u32;
441 store.extend_from_slice(b"curl\0libcurl\0"); let req = store.len() as u32;
443 store.extend_from_slice(b"/bin/sh\0libc.so.6\0"); let entries = [
445 (RPMTAG_NAME, RPM_TYPE_STRING, 0, 1),
446 (RPMTAG_VERSION, RPM_TYPE_STRING, ver, 1),
447 (RPMTAG_SUMMARY, RPM_TYPE_I18NSTRING, sum, 1),
448 (RPMTAG_LICENSE, RPM_TYPE_STRING, lic, 1),
449 (RPMTAG_URL, RPM_TYPE_STRING, url, 1),
450 (RPMTAG_SOURCERPM, RPM_TYPE_STRING, src, 1),
451 (RPMTAG_PROVIDENAME, RPM_TYPE_STRING_ARRAY, prov, 2),
452 (RPMTAG_REQUIRENAME, RPM_TYPE_STRING_ARRAY, req, 2),
453 ];
454 let rpm = build_rpm(&entries, &store);
455 let m = RpmPlugin::parse_meta(&rpm).unwrap();
456 assert_eq!(m.summary.as_deref(), Some("A URL transfer tool"));
457 assert_eq!(m.license.as_deref(), Some("MIT"));
458 assert_eq!(m.url.as_deref(), Some("https://curl.se"));
459 assert_eq!(m.sourcerpm.as_deref(), Some("curl-8.0-1.src.rpm"));
460 assert_eq!(m.provides, vec!["curl", "libcurl"]);
461 assert_eq!(m.requires, vec!["/bin/sh", "libc.so.6"]);
462 let row = RpmPlugin.extract_metadata("x.rpm", &rpm).unwrap();
464 assert_eq!(row.fields.get("license"), Some(&ExtensionValue::OptStr(Some("MIT".into()))));
465 assert_eq!(
466 row.fields.get("provides"),
467 Some(&ExtensionValue::OptStr(Some("curl\nlibcurl".into())))
468 );
469 assert_eq!(
470 row.fields.get("requires"),
471 Some(&ExtensionValue::OptStr(Some("/bin/sh\nlibc.so.6".into())))
472 );
473 }
474
475 #[test]
476 fn malformed_headers_never_panic() {
477 for bad in [
479 vec![0xed, 0xab, 0xee, 0xdb], {
481 let mut v = vec![0u8; 96];
482 v[0..4].copy_from_slice(&RPM_LEAD_MAGIC);
483 v.extend_from_slice(&RPM_HEADER_MAGIC); v
485 },
486 {
487 let mut v = vec![0u8; 96];
489 v[0..4].copy_from_slice(&RPM_LEAD_MAGIC);
490 v.extend_from_slice(&RPM_HEADER_MAGIC);
491 v.push(1);
492 v.extend_from_slice(&[0, 0, 0, 0]);
493 v.extend_from_slice(&u32::MAX.to_be_bytes()); v.extend_from_slice(&u32::MAX.to_be_bytes()); v
496 },
497 ] {
498 assert!(RpmPlugin::parse_meta(&bad).is_none());
499 }
500 }
501}