1mod parse;
10
11use anyhow::{Context, Result, bail};
12use camino::{Utf8Path, Utf8PathBuf};
13use cap_std_ext::cap_std::fs::Dir;
14use std::collections::{BTreeMap, HashMap};
15use std::io::Read;
16use std::os::fd::AsRawFd;
17use std::path::Path;
18use std::process::Command;
19
20pub type Packages = HashMap<String, Package>;
22
23pub type Files = BTreeMap<Utf8PathBuf, FileInfo>;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum DigestAlgorithm {
29 Md5 = 1,
31 Sha1 = 2,
33 RipeMd160 = 3,
35 Md2 = 5,
37 Tiger192 = 6,
39 Haval5160 = 7,
41 Sha256 = 8,
43 Sha384 = 9,
45 Sha512 = 10,
47 Sha224 = 11,
49 Sha3_256 = 12,
51 Sha3_512 = 14,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
57pub struct FileFlags(u32);
58
59impl FileFlags {
60 pub const CONFIG: u32 = 1 << 0;
62 pub const DOC: u32 = 1 << 1;
64 pub const MISSINGOK: u32 = 1 << 3;
66 pub const NOREPLACE: u32 = 1 << 4;
68 pub const GHOST: u32 = 1 << 6;
70 pub const LICENSE: u32 = 1 << 7;
72 pub const README: u32 = 1 << 8;
74 pub const ARTIFACT: u32 = 1 << 12;
76
77 pub fn from_raw(value: u32) -> Self {
79 Self(value)
80 }
81
82 pub fn raw(&self) -> u32 {
84 self.0
85 }
86
87 pub fn is_config(&self) -> bool {
89 self.0 & Self::CONFIG != 0
90 }
91
92 pub fn is_doc(&self) -> bool {
94 self.0 & Self::DOC != 0
95 }
96
97 pub fn is_missingok(&self) -> bool {
99 self.0 & Self::MISSINGOK != 0
100 }
101
102 pub fn is_noreplace(&self) -> bool {
104 self.0 & Self::NOREPLACE != 0
105 }
106
107 pub fn is_ghost(&self) -> bool {
109 self.0 & Self::GHOST != 0
110 }
111
112 pub fn is_license(&self) -> bool {
114 self.0 & Self::LICENSE != 0
115 }
116
117 pub fn is_readme(&self) -> bool {
119 self.0 & Self::README != 0
120 }
121
122 pub fn is_artifact(&self) -> bool {
124 self.0 & Self::ARTIFACT != 0
125 }
126}
127
128#[derive(Debug, Clone)]
130pub struct FileInfo {
131 pub size: u64,
133 pub mode: u16,
135 pub mtime: u64,
137 pub digest: Option<String>,
139 pub flags: FileFlags,
141 pub user: String,
143 pub group: String,
145 pub linkto: Option<Utf8PathBuf>,
147}
148
149#[derive(Debug, Clone)]
151pub struct Package {
152 pub name: String,
154 pub version: String,
156 pub release: String,
158 pub epoch: Option<u32>,
160 pub arch: String,
163 pub license: String,
165 pub size: u64,
167 pub buildtime: u64,
169 pub installtime: u64,
171 pub sourcerpm: Option<String>,
173 pub digest_algo: Option<DigestAlgorithm>,
175 pub changelog_times: Vec<u64>,
177 pub files: Files,
179}
180
181pub fn load_from_reader<R: Read>(reader: R) -> Result<Packages> {
183 parse::load_from_reader_impl(reader)
184}
185
186pub fn load_from_str(s: &str) -> Result<Packages> {
188 parse::load_from_str_impl(s)
189}
190
191pub fn load_from_rootfs(rootfs: &Utf8Path) -> Result<Packages> {
193 run_rpm(rootfs.as_str())
194}
195
196pub fn load_from_rootfs_dir(rootfs: &Dir) -> Result<Packages> {
198 use rustix::io::dup;
199 let duped = dup(rootfs).context("failed to dup rootfs fd")?;
202 let rootfs_path = format!("/proc/self/fd/{}", duped.as_raw_fd());
203 run_rpm(&rootfs_path)
204}
205
206const RPMDB_PATHS: &[&str] = &["usr/lib/sysimage/rpm", "var/lib/rpm", "usr/share/rpm"];
211
212fn find_dbpath(rootfs: &Path) -> Result<Option<&'static str>> {
213 for dbpath in RPMDB_PATHS {
214 if std::fs::exists(rootfs.join(dbpath)).context("failed to probe rpmdb path")? {
215 return Ok(Some(dbpath));
216 }
217 }
218 Ok(None)
219}
220
221fn run_rpm(rootfs_path: &str) -> Result<Packages> {
222 let mut cmd = Command::new("rpm");
223 cmd.arg("--root").arg(rootfs_path);
224 if let Some(dbpath) = find_dbpath(Path::new(rootfs_path))? {
225 cmd.arg("--dbpath").arg(format!("/{dbpath}"));
226 }
227 cmd.args(["-qa", "--queryformat", parse::QUERYFORMAT]);
228 cmd.stdout(std::process::Stdio::piped());
229 let mut child = cmd.spawn().context("failed to run rpm")?;
230 let stdout = child
231 .stdout
232 .take()
233 .context("failed to capture rpm stdout")?;
234
235 let packages = load_from_reader(stdout);
236
237 let status = child.wait().context("failed to wait for rpm")?;
238 if !status.success() {
239 match status.code() {
240 Some(code) => bail!("rpm command failed (exit code {})", code),
241 None => {
242 use std::os::unix::process::ExitStatusExt;
243 bail!(
244 "rpm command killed by signal {}",
245 status.signal().unwrap_or(0)
246 )
247 }
248 }
249 }
250
251 packages
252}
253
254pub fn load() -> Result<Packages> {
256 load_from_rootfs(Utf8Path::new("/"))
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262
263 const FIXTURE: &str = include_str!("../tests/fixtures/fedora.qf");
264
265 fn setup_test_rootfs_at(rpmdb_relpath: &str) -> tempfile::TempDir {
266 let tmpdir = tempfile::tempdir().expect("failed to create tempdir");
267 let rpmdb_dir = tmpdir.path().join(rpmdb_relpath);
268 std::fs::create_dir_all(&rpmdb_dir).expect("failed to create rpmdb dir");
269 std::fs::copy(
270 "tests/fixtures/rpmdb.sqlite",
271 rpmdb_dir.join("rpmdb.sqlite"),
272 )
273 .expect("failed to copy rpmdb.sqlite");
274 tmpdir
275 }
276
277 fn setup_test_rootfs() -> tempfile::TempDir {
278 setup_test_rootfs_at("usr/lib/sysimage/rpm")
279 }
280
281 fn assert_has_test_packages(packages: &Packages) {
282 assert!(packages.contains_key("filesystem"));
283 assert!(packages.contains_key("setup"));
284 assert!(packages.contains_key("fedora-release"));
285 }
286
287 #[test]
288 fn test_load_from_rootfs() {
289 let tmpdir = setup_test_rootfs();
290 let rootfs = Utf8Path::from_path(tmpdir.path()).expect("non-utf8 path");
291 let packages = load_from_rootfs(rootfs).expect("failed to load packages");
292 assert_has_test_packages(&packages);
293 }
294
295 #[test]
296 fn test_load_from_rootfs_dir() {
297 let tmpdir = setup_test_rootfs();
298 let rootfs_dir =
299 Dir::open_ambient_dir(tmpdir.path(), cap_std_ext::cap_std::ambient_authority())
300 .expect("failed to open rootfs dir");
301 let packages = load_from_rootfs_dir(&rootfs_dir).expect("failed to load packages");
302 assert_has_test_packages(&packages);
303 }
304
305 #[test]
306 fn test_load_from_rootfs_legacy_dbpath() {
307 let tmpdir = setup_test_rootfs_at("var/lib/rpm");
308 let rootfs = Utf8Path::from_path(tmpdir.path()).expect("non-utf8 path");
309 let packages = load_from_rootfs(rootfs).expect("failed to load packages");
310 assert_has_test_packages(&packages);
311 }
312
313 #[test]
314 fn test_load_from_str() {
315 let packages = load_from_str(FIXTURE).expect("failed to load packages");
316 assert!(!packages.is_empty(), "expected at least one package");
317
318 for (name, pkg) in &packages {
319 assert_eq!(name, &pkg.name);
320 assert!(!pkg.version.is_empty());
321 assert!(!pkg.arch.is_empty());
322 }
323
324 assert!(packages.contains_key("glibc"));
326 assert!(packages.contains_key("bash"));
327 assert!(packages.contains_key("coreutils"));
328
329 assert_eq!(packages["bash"].epoch, None);
331 assert_eq!(packages["shadow-utils"].epoch, Some(2));
333 assert_eq!(packages["perl-POSIX"].epoch, Some(0));
335 }
336
337 #[test]
338 fn test_load_from_reader() {
339 let packages = load_from_reader(FIXTURE.as_bytes()).expect("failed to load packages");
340 assert!(!packages.is_empty(), "expected at least one package");
341 assert!(packages.get("rpm").is_some());
342 }
343
344 #[test]
345 fn test_file_parsing() {
346 let packages = load_from_str(FIXTURE).expect("failed to load packages");
347 let bash = packages.get("bash").expect("bash package not found");
348
349 assert!(!bash.files.is_empty(), "bash should have files");
351
352 let bash_bin = bash
354 .files
355 .get(Utf8Path::new("/usr/bin/bash"))
356 .expect("/usr/bin/bash not found");
357 assert!(bash_bin.size > 0, "bash binary should have non-zero size");
358 assert!(bash_bin.digest.is_some(), "bash binary should have digest");
359 assert_eq!(bash.digest_algo, Some(DigestAlgorithm::Sha256));
360 assert!(
361 !bash_bin.flags.is_config(),
362 "bash binary is not a config file"
363 );
364 assert_eq!(bash_bin.user, "root");
365 assert_eq!(bash_bin.group, "root");
366
367 let bashrc = bash
369 .files
370 .get(Utf8Path::new("/etc/skel/.bashrc"))
371 .expect("/etc/skel/.bashrc not found");
372 assert!(bashrc.flags.is_config(), ".bashrc should be a config file");
373 assert!(bashrc.flags.is_noreplace(), ".bashrc should be noreplace");
374
375 let sh = bash
377 .files
378 .get(Utf8Path::new("/usr/bin/sh"))
379 .expect("/usr/bin/sh not found");
380 assert!(sh.linkto.is_some(), "/usr/bin/sh should be a symlink");
381 assert_eq!(sh.linkto.as_ref().unwrap(), "bash");
382
383 let setup = packages.get("setup").expect("setup package not found");
385
386 let motd = setup
388 .files
389 .get(Utf8Path::new("/run/motd"))
390 .expect("/run/motd not found");
391 assert!(motd.flags.is_ghost(), "/run/motd should be a ghost");
392 assert!(!motd.flags.is_config(), "/run/motd is not a config file");
393 assert!(motd.digest.is_none(), "ghost files have no digest");
394
395 let fstab = setup
397 .files
398 .get(Utf8Path::new("/etc/fstab"))
399 .expect("/etc/fstab not found");
400 assert!(fstab.flags.is_ghost(), "/etc/fstab should be a ghost");
401 assert!(
402 fstab.flags.is_config(),
403 "/etc/fstab should be a config file"
404 );
405 assert!(fstab.flags.is_missingok(), "/etc/fstab should be missingok");
406 assert!(fstab.flags.is_noreplace(), "/etc/fstab should be noreplace");
407 }
408
409 #[test]
410 fn test_directory_ownership() {
411 let packages = load_from_str(FIXTURE).expect("failed to load packages");
416
417 let rpm = packages.get("rpm").expect("rpm package not found");
418 let fedora_release = packages
419 .get("fedora-release-common")
420 .expect("fedora-release-common package not found");
421
422 let macros_d = rpm
424 .files
425 .get(Utf8Path::new("/usr/lib/rpm/macros.d"))
426 .expect("/usr/lib/rpm/macros.d not found in rpm");
427 assert_eq!(
429 macros_d.mode & 0o170000,
430 0o040000,
431 "macros.d should be a directory"
432 );
433
434 assert!(
436 fedora_release
437 .files
438 .contains_key(Utf8Path::new("/usr/lib/rpm/macros.d/macros.dist")),
439 "/usr/lib/rpm/macros.d/macros.dist not found in fedora-release-common"
440 );
441
442 assert!(
444 rpm.files
445 .get(Utf8Path::new("/usr/lib/rpm/macros.d/macros.dist"))
446 .is_none(),
447 "macros.dist should not be owned by rpm"
448 );
449
450 assert!(
452 fedora_release
453 .files
454 .get(Utf8Path::new("/usr/lib/rpm/macros.d"))
455 .is_none(),
456 "macros.d directory should not be owned by fedora-release-common"
457 );
458 }
459
460 #[test]
461 fn test_single_file_scalar_values() {
462 let packages = load_from_str(FIXTURE).expect("failed to load packages");
464 let pkg = packages
465 .get("langpacks-core-en")
466 .expect("langpacks-core-en package not found");
467
468 assert_eq!(pkg.name, "langpacks-core-en");
469 assert_eq!(pkg.version, "4.2");
470 assert_eq!(pkg.release, "5.fc43");
471 assert_eq!(pkg.files.len(), 1);
472
473 let file = pkg
474 .files
475 .get(Utf8Path::new(
476 "/usr/share/metainfo/org.fedoraproject.LangPack-Core-en.metainfo.xml",
477 ))
478 .expect("metainfo.xml not found");
479 assert_eq!(file.size, 398);
480 assert_eq!(file.user, "root");
481 assert_eq!(file.group, "root");
482 assert_eq!(
483 file.digest.as_deref(),
484 Some("d0ba061c715c73b91d2be66ab40adfab510ed4e69cf5d40970733e211de38ce6")
485 );
486 }
487
488 #[test]
489 fn test_changelog_times() {
490 let packages = load_from_str(FIXTURE).expect("failed to load packages");
491
492 let bash = packages.get("bash").expect("bash package not found");
494 assert!(
495 !bash.changelog_times.is_empty(),
496 "bash should have changelog entries"
497 );
498
499 let min_valid_time = 1577836800u64; for &time in &bash.changelog_times {
502 assert!(time > min_valid_time, "changelog time {} is too old", time);
503 }
504 }
505}