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