diff --git a/src/cli/bootupctl.rs b/src/cli/bootupctl.rs index 38bb9a54..0f58c174 100644 --- a/src/cli/bootupctl.rs +++ b/src/cli/bootupctl.rs @@ -73,6 +73,8 @@ pub enum CtlBackend { Generate(super::bootupd::GenerateOpts), #[clap(name = "install", hide = true)] Install(super::bootupd::InstallOpts), + #[clap(name = "generate-manifest", hide = true)] + GenerateManifest(super::bootupd::GenerateManifestOpts), #[clap(hide = true)] #[cfg(efi_arch)] SetDefaultBootloader(super::bootupd::DefaultBootloaderOpts), @@ -112,6 +114,9 @@ impl CtlCommand { CtlVerb::Backend(CtlBackend::Install(opts)) => { super::bootupd::DCommand::run_install(opts) } + CtlVerb::Backend(CtlBackend::GenerateManifest(opts)) => { + super::bootupd::DCommand::run_generate_manifest(opts) + } #[cfg(efi_arch)] CtlVerb::Backend(CtlBackend::SetDefaultBootloader(opts)) => { super::bootupd::DCommand::set_default_bootloader(opts) diff --git a/src/cli/bootupd.rs b/src/cli/bootupd.rs index a9b8951b..3fede3e8 100644 --- a/src/cli/bootupd.rs +++ b/src/cli/bootupd.rs @@ -1,5 +1,6 @@ use crate::bootloader::Bootloader; use crate::bootupd::{self, ConfigMode}; +use crate::manifest::{self, generate_manifest}; use anyhow::{Context, Result}; use camino::Utf8Path; use cap_std::ambient_authority; @@ -7,6 +8,7 @@ use cap_std::fs::Dir; use cap_std_ext::cap_std; use clap::Parser; use log::LevelFilter; +use std::path::{Path, PathBuf}; /// `bootupd` sub-commands. #[derive(Debug, Parser)] @@ -40,6 +42,11 @@ pub enum DVerb { GenerateUpdateMetadata(GenerateOpts), #[clap(name = "install", about = "Install components")] Install(InstallOpts), + #[clap( + name = "generate-manifest", + about = "Generate manifest for grub and shim packages (supported distros: Ubuntu/Debian, Arch Linux, Fedora/CentOS, OpenSuse and Alpine Linux" + )] + GenerateManifest(GenerateManifestOpts), #[cfg(efi_arch)] SetDefaultBootloader(DefaultBootloaderOpts), } @@ -99,6 +106,17 @@ pub struct GenerateOpts { sysroot: Option, } +#[derive(Debug, Parser)] +pub struct GenerateManifestOpts { + /// Physical root mountpoint + #[clap(value_parser)] + sysroot: Option, + + /// Grub or shim filepaths + #[clap(value_parser)] + files: Vec, +} + #[derive(Debug, Parser)] pub struct DefaultBootloaderOpts { /// Physical root mountpoint @@ -114,6 +132,7 @@ impl DCommand { match self.cmd { DVerb::Install(opts) => Self::run_install(opts), DVerb::GenerateUpdateMetadata(opts) => Self::run_generate_meta(opts), + DVerb::GenerateManifest(opts) => Self::run_generate_manifest(opts), #[cfg(efi_arch)] DVerb::SetDefaultBootloader(opts) => Self::set_default_bootloader(opts), } @@ -129,6 +148,17 @@ impl DCommand { Ok(()) } + /// Runner for 'generate-manifest' verb + pub(crate) fn run_generate_manifest(opts: GenerateManifestOpts) -> Result<()> { + let sysroot = opts.sysroot.as_deref().unwrap_or("/"); + let path_refs: Vec<&Path> = opts.files.iter().map(|p| p.as_path()).collect(); + if sysroot != "/" { + anyhow::bail!("Using a non-default sysroot is not supported: {}", sysroot); + } + manifest::generate_manifest(sysroot, &path_refs)?; + Ok(()) + } + /// Runner for `install` verb. pub(crate) fn run_install(opts: InstallOpts) -> Result<()> { let configmode = if opts.write_uuid { diff --git a/src/efi.rs b/src/efi.rs index fceecd98..6cc03ec4 100644 --- a/src/efi.rs +++ b/src/efi.rs @@ -881,7 +881,7 @@ fn generate_meta_from_usr_efi(sysroot_path: &Utf8Path) -> Result bool { + path.metadata().map(|m| m.len() > 0).unwrap_or(false) +} + +fn is_nonempty_dir(path: &Path) -> bool { + path.read_dir() + .map(|mut d| d.next().is_some()) + .unwrap_or(false) +} + +fn find_rpm_dbpath(sysroot_path: &str) -> Option { + let sysroot = Path::new(sysroot_path); + for dbpath in [SYSIMAGE_RPM_DBPATH, LEGACY_RPMOSTREE_DBPATH] { + let p = sysroot.join(dbpath); + if is_nonempty_dir(&p) { + return Some(p); + } + } + None +} + +fn find_dpkg_dbpath(sysroot_path: &str) -> Option { + let sysroot = Path::new(sysroot_path); + for dbpath in [SYSIMAGE_APT_DBPATH, LEGACY_APT_DBPATH] { + let p = sysroot.join(dbpath); + if is_nonempty_file(&p) { + return Some(p); + } + } + None +} + +fn find_pacman_dbpath(sysroot_path: &str) -> Option { + let sysroot = Path::new(sysroot_path); + for dbpath in [SYSIMAGE_PACMAN_DBPATH, LEGACY_PACMAN_DBPATH] { + let p = sysroot.join(dbpath); + if p.exists() { + return Some(p); + } + } + None +} + +fn detect_package_manager(sysroot_path: &str) -> Result { + if let Some(p) = find_rpm_dbpath(sysroot_path) { + debug!("Detected RPM (dbpath: {})", p.display()); + return Ok(PackageManager::Rpm); + } + + if let Some(p) = find_dpkg_dbpath(sysroot_path) { + debug!("Detected DPKG (dbpath: {})", p.display()); + return Ok(PackageManager::Dpkg); + } + + if let Some(p) = find_pacman_dbpath(sysroot_path) { + debug!("Detected Pacman (dbpath: {})", p.display()); + return Ok(PackageManager::Pacman); + } + + if Path::new(sysroot_path).join(APK_DBPATH).exists() { + debug!("Detected APK"); + return Ok(PackageManager::Apk); + } + + bail!( + "No supported package manager found in sysroot '{}' \ + (checked: rpm, dpkg, pacman, apk)", + sysroot_path + ) +} + +fn query_rpm(sysroot_path: &str, file: &Path) -> Result { + let dbpath = find_rpm_dbpath(sysroot_path) + .ok_or_else(|| anyhow::anyhow!("RPM database not found in sysroot '{}'", sysroot_path))?; + + let out = Command::new("rpm") + .env("LC_ALL", "C") + .arg(format!("--dbpath={}", dbpath.display())) + .args(["-qf", "--queryformat", "%{nevra},%{buildtime}"]) + .arg(file) + .output() + .context("Failed to run rpm")?; + + if !out.status.success() { + bail!( + "rpm -qf failed for {}: {}", + file.display(), + String::from_utf8_lossy(&out.stderr).trim() + ); + } + + let line = std::str::from_utf8(&out.stdout) + .context("rpm output is not valid UTF-8")? + .trim() + .to_string(); + + parse_manifest_entry(&line).with_context(|| format!("Failed to parse rpm output: '{}'", line)) +} + +fn query_dpkg(sysroot_path: &str, file: &Path) -> Result { + let dbpath = find_dpkg_dbpath(sysroot_path) + .ok_or_else(|| anyhow::anyhow!("DPKG database not found in sysroot '{}'", sysroot_path))?; + + let out = Command::new("dpkg") + .env("LC_ALL", "C") + .arg(format!("--admindir={}", dbpath.parent().unwrap().display())) + .args(["-S", &file.to_string_lossy()]) + .output() + .context("Failed to run dpkg -S")?; + + if !out.status.success() { + bail!("dpkg -S found no package owning {}", file.display()); + } + + // Format: "package: /path" + let stdout = String::from_utf8_lossy(&out.stdout); + let pkg = stdout + .lines() + .next() + .and_then(|l| l.split(':').next()) + .map(|s| s.trim().to_string()) + .ok_or_else(|| anyhow::anyhow!("Failed to parse dpkg -S output: '{}'", stdout.trim()))?; + + let ver_out = Command::new("dpkg-query") + .env("LC_ALL", "C") + .arg(format!("--admindir={}", dbpath.parent().unwrap().display())) + .args(["-W", "-f=${Package}-${Version}", &pkg]) + .output() + .context("Failed to run dpkg-query")?; + + let package = std::str::from_utf8(&ver_out.stdout) + .context("dpkg-query output is not valid UTF-8")? + .trim() + .to_string(); + + // dpkg dont use buildtime, use installtime instead + let time = std::fs::metadata(file) + .and_then(|m| m.modified()) + .map(|t| { + t.duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 + }) + .unwrap_or(0); + + Ok(ManifestEntry { package, time }) +} + +fn query_pacman(sysroot_path: &str, file: &Path) -> Result { + let dbpath = find_pacman_dbpath(sysroot_path).ok_or_else(|| { + anyhow::anyhow!("Pacman database not found in sysroot '{}'", sysroot_path) + })?; + + let out = Command::new("pacman") + .env("LC_ALL", "C") + .arg(format!("--dbpath={}", dbpath.display())) + .args(["-Qo", &file.to_string_lossy()]) + .output() + .context("Failed to run pacman -Qo")?; + + if !out.status.success() { + bail!("pacman -Qo found no package owning {}", file.display()); + } + + // Format: "/usr/sbin/grub-install is owned by grub 2:2.12-1" + let stdout = String::from_utf8_lossy(&out.stdout); + let words: Vec<&str> = stdout.trim().split_whitespace().collect(); + if words.len() < 2 { + bail!("Unexpected pacman -Qo output: '{}'", stdout.trim()); + } + let pkg = words[words.len() - 2]; + let ver = words[words.len() - 1]; + let package = format!("{}-{}", pkg, ver); + + // BUILDDATE z /local/-/desc is Unix timestamp + // Use the same base after detecting + let desc_path = dbpath + .join("local") + .join(format!("{}-{}", pkg, ver)) + .join("desc"); + + let time = if desc_path.exists() { + let content = std::fs::read_to_string(&desc_path) + .with_context(|| format!("Failed to read {}", desc_path.display()))?; + let mut found = false; + let mut ts = 0i64; + for line in content.lines() { + if line == "%BUILDDATE%" { + found = true; + continue; + } + if found { + ts = line.parse().unwrap_or(0); + break; + } + } + ts + } else { + std::fs::metadata(file) + .and_then(|m| m.modified()) + .map(|t| { + t.duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 + }) + .unwrap_or(0) + }; + + Ok(ManifestEntry { package, time }) +} + +fn query_apk(sysroot_path: &str, file: &Path) -> Result { + let out = Command::new("apk") + .env("LC_ALL", "C") + .arg(format!("--root={}", sysroot_path)) + .args(["info", "--who-owns", &file.to_string_lossy()]) + .output() + .context("Failed to run apk info --who-owns")?; + + if !out.status.success() { + bail!("apk found no package owning {}", file.display()); + } + + // Format: "/usr/sbin/grub-install is owned by grub-2.12-r0" + let stdout = String::from_utf8_lossy(&out.stdout); + let pkg_ver = stdout + .trim() + .split(" is owned by ") + .nth(1) + .map(|s| s.trim().to_string()) + .ok_or_else(|| anyhow::anyhow!("Unexpected apk output: '{}'", stdout.trim()))?; + + let pkg_name = pkg_ver + .rsplitn(3, '-') + .last() + .unwrap_or(&pkg_ver) + .to_string(); + + let info_out = Command::new("apk") + .env("LC_ALL", "C") + .arg(format!("--root={}", sysroot_path)) + .args(["info", "-a", &pkg_name]) + .output() + .context("Failed to run apk info -a")?; + + let time = if info_out.status.success() { + let stdout = String::from_utf8_lossy(&info_out.stdout); + + stdout + .lines() + .find(|l| l.to_ascii_lowercase().contains("build")) + .and_then(|l| l.split_once(':')) + .map(|(_, v)| v.trim()) + .and_then(|v| { + v.parse::().ok().or_else(|| { + chrono::DateTime::parse_from_rfc2822(v) + .ok() + .map(|dt| dt.timestamp()) + }) + }) + .unwrap_or(0) + } else { + 0 + }; + + Ok(ManifestEntry { + package: pkg_ver, + time, + }) +} + +fn parse_manifest_entry(entry: &str) -> Result { + let mut parts = entry.splitn(2, ','); + let package = parts + .next() + .filter(|s| !s.is_empty()) + .ok_or_else(|| anyhow::anyhow!("Missing package name"))? + .to_string(); + let time = parts + .next() + .ok_or_else(|| anyhow::anyhow!("Missing time"))? + .trim() + .parse::() + .context("Failed to parse time as integer")?; + Ok(ManifestEntry { package, time }) +} + +fn query_file_owner(sysroot_path: &str, pm: &PackageManager, file: &Path) -> Result { + match pm { + PackageManager::Rpm => query_rpm(sysroot_path, file), + PackageManager::Dpkg => query_dpkg(sysroot_path, file), + PackageManager::Pacman => query_pacman(sysroot_path, file), + PackageManager::Apk => query_apk(sysroot_path, file), + } +} + +pub(crate) fn generate_manifest(sysroot_path: &str, files: &[&Path]) -> Result<()> { + if files.is_empty() { + bail!("No files specified for manifest generation"); + } + + let pm = detect_package_manager(sysroot_path).context("Failed to detect package manager")?; + debug!("Detected package manager: {:?}", pm); + + let mut entries: BTreeMap = BTreeMap::new(); + + for file in files { + if !file.exists() { + println!("File not found, skipping: {}", file.display()); + continue; + } + match query_file_owner(sysroot_path, &pm, file) { + Ok(entry) => { + println!( + " {} -> {} (buildtime: {})", + file.display(), + entry.package, + entry.time + ); + entries + .entry(entry.package) + .and_modify(|ts| { + if entry.time > *ts { + *ts = entry.time; + } + }) + .or_insert(entry.time); + } + Err(e) => { + println!( + "Warning: failed to query owner of {}: {:#}", + file.display(), + e + ); + } + } + } + + if entries.is_empty() { + bail!("No packages found for the given files, manifest not written"); + } + + let content: String = entries + .iter() + .map(|(pkg, ts)| format!("{},{} ", pkg, ts)) + .collect(); + + let manifest_path = Path::new(sysroot_path).join(MANIFEST_PATH); + std::fs::create_dir_all(manifest_path.parent().expect("manifest has parent dir")) + .with_context(|| format!("Failed to create manifest directory"))?; + std::fs::write(&manifest_path, content.trim_end()) + .with_context(|| format!("Failed to write manifest: {}", manifest_path.display()))?; + + println!("Manifest written to: {}", manifest_path.display()); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn make_sysroot_with_rpm(dir: &TempDir) { + let dbpath = dir.path().join(SYSIMAGE_RPM_DBPATH); + std::fs::create_dir_all(&dbpath).unwrap(); + std::fs::write(dbpath.join("Packages"), b"dummy").unwrap(); + } + + fn make_sysroot_with_dpkg(dir: &TempDir) { + let dbpath = dir.path().join(LEGACY_APT_DBPATH); + std::fs::create_dir_all(dbpath.parent().unwrap()).unwrap(); + std::fs::write(&dbpath, b"Package: grub-efi-amd64\n").unwrap(); + } + + fn make_sysroot_with_pacman(dir: &TempDir) { + let dbpath = dir.path().join(LEGACY_PACMAN_DBPATH); + std::fs::create_dir_all(&dbpath).unwrap(); + // Must be empty + std::fs::write(dbpath.join("dummy"), b"dummy").unwrap(); + } + + fn make_sysroot_with_apk(dir: &TempDir) { + let dbpath = dir.path().join(APK_DBPATH); + std::fs::create_dir_all(dbpath.parent().unwrap()).unwrap(); + std::fs::write(&dbpath, b"P:grub\n").unwrap(); + } + + #[test] + fn test_detect_rpm() { + let dir = TempDir::new().unwrap(); + make_sysroot_with_rpm(&dir); + assert_eq!( + detect_package_manager(dir.path().to_str().unwrap()).unwrap(), + PackageManager::Rpm + ); + } + + #[test] + fn test_detect_dpkg() { + let dir = TempDir::new().unwrap(); + make_sysroot_with_dpkg(&dir); + assert_eq!( + detect_package_manager(dir.path().to_str().unwrap()).unwrap(), + PackageManager::Dpkg + ); + } + + #[test] + fn test_detect_pacman() { + let dir = TempDir::new().unwrap(); + make_sysroot_with_pacman(&dir); + assert_eq!( + detect_package_manager(dir.path().to_str().unwrap()).unwrap(), + PackageManager::Pacman + ); + } + + #[test] + fn test_detect_apk() { + let dir = TempDir::new().unwrap(); + make_sysroot_with_apk(&dir); + assert_eq!( + detect_package_manager(dir.path().to_str().unwrap()).unwrap(), + PackageManager::Apk + ); + } + + #[test] + fn test_detect_none() { + let dir = TempDir::new().unwrap(); + assert!(detect_package_manager(dir.path().to_str().unwrap()).is_err()); + } + + #[test] + fn test_find_rpm_dbpath_sysimage_wins() { + let dir = TempDir::new().unwrap(); + // Two exists, but sysimage is first one + let sysimage = dir.path().join(SYSIMAGE_RPM_DBPATH); + std::fs::create_dir_all(&sysimage).unwrap(); + std::fs::write(sysimage.join("Packages"), b"dummy").unwrap(); + let legacy = dir.path().join(LEGACY_RPMOSTREE_DBPATH); + std::fs::create_dir_all(&legacy).unwrap(); + std::fs::write(legacy.join("Packages"), b"dummy").unwrap(); + + let found = find_rpm_dbpath(dir.path().to_str().unwrap()).unwrap(); + assert_eq!(found, sysimage); + } + + #[test] + fn test_find_rpm_dbpath_falls_back_to_legacy() { + let dir = TempDir::new().unwrap(); + // Only legacy exists + let legacy = dir.path().join(LEGACY_RPMOSTREE_DBPATH); + std::fs::create_dir_all(&legacy).unwrap(); + std::fs::write(legacy.join("Packages"), b"dummy").unwrap(); + + let found = find_rpm_dbpath(dir.path().to_str().unwrap()).unwrap(); + assert_eq!(found, legacy); + } + + #[test] + fn test_find_dpkg_dbpath_sysimage_wins() { + let dir = TempDir::new().unwrap(); + let sysimage = dir.path().join(SYSIMAGE_APT_DBPATH); + std::fs::create_dir_all(sysimage.parent().unwrap()).unwrap(); + std::fs::write(&sysimage, b"Package: grub\n").unwrap(); + let legacy = dir.path().join(LEGACY_APT_DBPATH); + std::fs::create_dir_all(legacy.parent().unwrap()).unwrap(); + std::fs::write(&legacy, b"Package: grub\n").unwrap(); + + let found = find_dpkg_dbpath(dir.path().to_str().unwrap()).unwrap(); + assert_eq!(found, sysimage); + } + + #[test] + fn test_parse_manifest_entry() { + let e = parse_manifest_entry("grub2-1:2.12-28.fc42,1710000000").unwrap(); + assert_eq!(e.package, "grub2-1:2.12-28.fc42"); + assert_eq!(e.time, 1710000000); + + let e = parse_manifest_entry("shim-x64-15.8-3.x86_64,1700000000").unwrap(); + assert_eq!(e.package, "shim-x64-15.8-3.x86_64"); + assert_eq!(e.time, 1700000000); + } + + #[test] + fn test_generate_manifest_no_files() { + let dir = TempDir::new().unwrap(); + make_sysroot_with_rpm(&dir); + assert!(generate_manifest(dir.path().to_str().unwrap(), &[]).is_err()); + } +} diff --git a/src/packagesystem.rs b/src/packagesystem.rs index c3ee154a..c10b125e 100644 --- a/src/packagesystem.rs +++ b/src/packagesystem.rs @@ -1,6 +1,5 @@ use std::cmp::Ordering; use std::collections::{BTreeMap, BTreeSet}; -use std::io::Write; use std::path::Path; use anyhow::{bail, Context, Result}; @@ -9,7 +8,74 @@ use serde::{Deserialize, Serialize}; use uapi_version::Version; use crate::model::*; -use crate::ostreeutil; + +use crate::manifest::*; + +//Manifest file is a alternative to rpm structure, its compatiable with rpm -q --qwertyformat structure. This file need to be generated first and it looks like this: +// grub2-1:2.12-28.fc42,1710000000 shim-15.8-3,170000000 +const MANIFEST_PATH: &str = "usr/lib/bootupd/manifest"; + +//If any package starts with grub** shim**, this will make in one name in ANY distros +const CANONICAL_NAMES: &[&str] = &["grub", "shim"]; + +fn is_grub_cc_name(name: &str) -> bool { + // Handle raw package names and full NEVRA strings. + if !(name.starts_with("grub-") || name.starts_with("grub2-")) { + return false; + } + + let main_name = if let Some((main, arch)) = name.rsplit_once('.') { + if arch == std::env::consts::ARCH { + main + } else { + name + } + } else { + name + }; + + let cc_pos = match main_name.find("-cc") { + Some(pos) => pos, + None => return false, + }; + + let version_boundary = main_name + .char_indices() + .find(|(idx, ch)| { + *ch == '-' + && main_name[*idx + 1..] + .chars() + .next() + .map(|c| c.is_ascii_digit()) + .unwrap_or(false) + }) + .map(|(idx, _)| idx) + .unwrap_or(main_name.len()); + + cc_pos < version_boundary +} + +pub fn normalize_package_name(name: &str) -> &str { + if is_grub_cc_name(name) { + return "grub-cc"; + } + + for canonical in CANONICAL_NAMES { + if name == *canonical { + return canonical; + } + + // if package name is grub-efi -> grub, grub2-efi -> grub + if let Some(rest) = name.strip_prefix(canonical) { + let next = rest.chars().next(); + match next { + Some(c) if c.is_ascii_digit() || !c.is_ascii_alphabetic() => return canonical, + _ => {} + } + } + } + name +} #[derive(Serialize, Deserialize, Clone, Debug, Eq, Hash, PartialEq)] pub(crate) struct Module { @@ -19,142 +85,169 @@ pub(crate) struct Module { impl Module { pub(crate) fn rpm_evr(&self) -> Version { - Version::from(&self.rpm_evr) + if self.rpm_evr.is_empty() { + Version::from("0") + } else { + Version::from(&self.rpm_evr) + } + } + + fn canonical_name(&self) -> &str { + normalize_package_name(&self.name) } } impl Ord for Module { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { - self.name - .cmp(&other.name) // Compare names first - .then_with(|| self.rpm_evr().cmp(&other.rpm_evr())) // If names equal, compare versions + fn cmp(&self, other: &Self) -> Ordering { + self.canonical_name() + .cmp(&other.canonical_name()) + .then_with(|| self.rpm_evr().cmp(&other.rpm_evr())) } } impl PartialOrd for Module { - fn partial_cmp(&self, other: &Self) -> Option { + fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } -/// Parse the output of `rpm -q` -fn rpm_parse_metadata(stdout: &[u8]) -> Result { - let pkgs = std::str::from_utf8(stdout)? +fn parse_manifest(data: &[u8]) -> Result { + let pkgs = std::str::from_utf8(data) + .context("Manifest is not valid UTF-8")? .split_whitespace() .map(|s| -> Result<_> { - let parts: Vec<_> = s.splitn(2, ',').collect(); - let name = parts[0]; - if let Some(ts) = parts.get(1) { - let nt = DateTime::parse_from_str(ts, "%s") - .context("Failed to parse rpm buildtime")? - .with_timezone(&chrono::Utc); - Ok((name, nt)) - } else { - bail!("Failed to parse: {}", s); - } + let mut parts = s.splitn(2, ','); + let name = parts + .next() + .filter(|s| !s.is_empty()) + .ok_or_else(|| anyhow::anyhow!("Missing package name in entry: {}", s))?; + let ts_str = parts + .next() + .ok_or_else(|| anyhow::anyhow!("Missing buildtime in entry: {}", s))?; + let ts = DateTime::parse_from_str(ts_str, "%s") + .with_context(|| format!("Invalid buildtime in entry: {}", s))? + .with_timezone(&Utc); + Ok((name, ts)) }) .collect::>>>()?; + if pkgs.is_empty() { - bail!("Failed to find any RPM packages matching files in source efidir"); + bail!("Manifest contains no entries"); } - let timestamps: BTreeSet<&DateTime> = pkgs.values().collect(); - // Unwrap safety: We validated pkgs has at least one value above - let largest_timestamp = timestamps.iter().last().unwrap(); - let version = pkgs.keys().fold("".to_string(), |mut s, n| { - if !s.is_empty() { - s.push(','); - } - s.push_str(n); - s - }); - // Map the version into Module struct - let mut modules_vec: Vec = pkgs.keys().map(|pkg_str| parse_evr(pkg_str)).collect(); - modules_vec.sort_unstable(); + let largest_timestamp = pkgs + .values() + .collect::>() + .into_iter() + .last() + .expect("pkgs is non-empty"); + + let version = pkgs.keys().cloned().collect::>().join(","); + + let mut modules: Vec = pkgs.keys().map(|s| parse_evr(s)).collect(); + modules.sort_unstable(); + modules.dedup(); + Ok(ContentMetadata { - timestamp: **largest_timestamp, + timestamp: *largest_timestamp, version, - versions: Some(modules_vec), + versions: Some(modules), #[cfg(efi_arch)] default_bootloader: None, }) } -/// Query the rpm database and list the package and build times. pub(crate) fn query_files( sysroot_path: &str, - paths: impl IntoIterator, + _paths: impl IntoIterator, ) -> Result where T: AsRef, { - let mut c = ostreeutil::rpm_cmd(sysroot_path)?; - c.args(["-q", "--queryformat", "%{nevra},%{buildtime} ", "-f"]); - for arg in paths { - c.arg(arg.as_ref()); - } + let manifest_path = Path::new(sysroot_path).join(MANIFEST_PATH); + + let paths: Vec<_> = _paths.into_iter().collect(); - let rpmout = c.output()?; - if !rpmout.status.success() { - std::io::stderr().write_all(&rpmout.stderr)?; - bail!("Failed to invoke rpm -qf"); + if !paths.is_empty() { + // If theres a files, generate manifest + let path_refs: Vec<&Path> = paths.iter().map(|p| p.as_ref()).collect(); + generate_manifest(sysroot_path, &path_refs).context("Failed to generate manifest")?; } - rpm_parse_metadata(&rpmout.stdout) + let manifest_path = Path::new(sysroot_path).join(MANIFEST_PATH); + let data = std::fs::read(&manifest_path) + .with_context(|| format!("Failed to read manifest: {}", manifest_path.display()))?; + + parse_manifest(&data) } fn split_name_version(input: &str) -> Option<(String, String)> { - // assume it is "grub2-tools-1:2.06-110.el9.x86_64" - // strip .arch - let main = input.rsplit_once('.')?.0; - - // find last two '-' + let main = if input.ends_with(std::env::consts::ARCH) { + input + .rsplit_once('.') + .map(|(main, _)| main) + .unwrap_or(input) + } else { + input + }; let mut parts = main.rsplitn(3, '-'); - let release = parts.next()?; // after last '-' - let version = parts.next()?; // between last two '-' - let name = parts.next()?; // the rest (may contain '-') - + let release = parts.next()?; + let version = parts.next()?; + let name = parts.next()?; Some((name.to_string(), format!("{version}-{release}"))) } +//In this function if it using rpm use rpm_rs fn parse_evr(pkg: &str) -> Module { - // assume it is "grub2-1:2.12-28.fc42" (from usr/lib/efi) - if !pkg.ends_with(std::env::consts::ARCH) { - let (name, evr) = pkg.split_once('-').unwrap_or((pkg, "")); - return Module { - name: name.to_string(), - rpm_evr: evr.to_string(), + let (name_str, rpm_evr) = if is_grub_cc_name(pkg) { + let pkg_no_arch = if let Some((main, arch)) = pkg.rsplit_once('.') { + if arch == std::env::consts::ARCH { + main + } else { + pkg + } + } else { + pkg }; - } - let (name_str, rpm_evr) = { - #[cfg(not(feature = "rpm"))] - { - split_name_version(pkg).unwrap() - } - #[cfg(feature = "rpm")] - { - let nevra = rpm_rs::Nevra::parse(pkg); - (nevra.name().to_string(), nevra.evr().to_string()) + if pkg_no_arch.ends_with("-cc") { + (pkg_no_arch.to_string(), String::new()) + } else { + split_name_version(pkg).unwrap_or_else(|| { + let (name, evr) = pkg.split_once('-').unwrap_or((pkg, "")); + (name.to_string(), evr.to_string()) + }) } + } else if !pkg.ends_with(std::env::consts::ARCH) { + split_name_version(pkg).unwrap_or_else(|| { + let (name, evr) = pkg.split_once('-').unwrap_or((pkg, "")); + (name.to_string(), evr.to_string()) + }) + } else { + split_name_version(pkg).unwrap() }; - let (name, _) = name_str.split_once('-').unwrap_or((&name_str, "")); - Module { - name: name.to_string(), - rpm_evr, - } + // Exceptions for grub**-cc and systemd-boot + let name = if is_grub_cc_name(&name_str) { + "grub-cc".to_string() + } else if name_str.contains("systemd-boot") { + "systemd-boot".to_string() + } else { + normalize_package_name( + name_str + .split_once('-') + .map(|(name, _)| name) + .unwrap_or(&name_str), + ) + .to_string() + }; + + Module { name, rpm_evr } } fn parse_evr_vec(input: &str) -> Vec { - let mut pkgs: Vec = input - .split(',') - .map(|pkg| parse_evr(pkg)) // parse_evr returns owned Package - .collect(); - // Sort packages to ensure a consistent order for comparison, which is - // required by `compare_package_slices`. + let mut pkgs: Vec = input.split(',').map(|pkg| parse_evr(pkg)).collect(); pkgs.sort_unstable(); - // Now that it's sorted, we can efficiently remove duplicates. pkgs.dedup(); pkgs } @@ -162,21 +255,21 @@ fn parse_evr_vec(input: &str) -> Vec { pub(crate) fn compare_package_slices(a: &[Module], b: &[Module]) -> Ordering { let mut has_greater = false; - // Assume it is in order for (pkg_a, pkg_b) in a.iter().zip(b.iter()) { + // Compare only versions - names are already normalized via canonical_name() + // in Ord so sort order is consistent across distros. match pkg_a.cmp(pkg_b) { - Ordering::Less => return Ordering::Less, // upgradable - Ordering::Greater => has_greater = true, // downgrade + Ordering::Less => return Ordering::Less, + Ordering::Greater => has_greater = true, Ordering::Equal => {} } } - // If all compared equal, longer slice wins if a.len() < b.len() { - return Ordering::Less; // extra packages in b → upgrade + return Ordering::Less; } if a.len() > b.len() { - return Ordering::Greater; // extra packages in a → downgrade + return Ordering::Greater; } if has_greater { @@ -186,12 +279,7 @@ pub(crate) fn compare_package_slices(a: &[Module], b: &[Module]) -> Ordering { } } -// Compare package versions: -// If any package is Ordering::Less, return Ordering::Less, means upgradable, -// Else if any package is Ordering::Greater, return Ordering::Greater, -// Else (all equal), return Ordering::Equal. pub(crate) fn compare_package_versions(a: &str, b: &str) -> Ordering { - // Fast path: if the two values are equal, skip detailed comparison if a == b { return Ordering::Equal; } @@ -203,27 +291,141 @@ pub(crate) fn compare_package_versions(a: &str, b: &str) -> Ordering { #[cfg(test)] mod tests { use super::*; + use tempfile::TempDir; + + fn write_manifest(dir: &TempDir, content: &str) { + let manifest_dir = dir.path().join("usr/lib/bootupd"); + std::fs::create_dir_all(&manifest_dir).unwrap(); + std::fs::write(manifest_dir.join("manifest"), content).unwrap(); + } + + #[test] + fn test_normalize_package_name() { + // Canonic names without change + assert_eq!(normalize_package_name("grub"), "grub"); + assert_eq!(normalize_package_name("shim"), "shim"); + // Grub variants + assert_eq!(normalize_package_name("grub2"), "grub"); + assert_eq!(normalize_package_name("grub2-tools"), "grub"); + assert_eq!(normalize_package_name("grub2-efi-x64"), "grub"); + assert_eq!(normalize_package_name("grub2-efi-ia32"), "grub"); + assert_eq!(normalize_package_name("grub2-common"), "grub"); + assert_eq!(normalize_package_name("grub-efi-amd64"), "grub"); + assert_eq!(normalize_package_name("grub-efi-arm64"), "grub"); + assert_eq!(normalize_package_name("grub-pc"), "grub"); + // Grub CC variants should all canonicalize to grub-cc + assert_eq!(normalize_package_name("grub-cc"), "grub-cc"); + assert_eq!(normalize_package_name("grub-cc-1:2.12-28.fc42"), "grub-cc"); + assert_eq!(normalize_package_name("grub2-efi-x64-cc"), "grub-cc"); + assert_eq!( + normalize_package_name("grub2-efi-x64-cc-1:2.12-28.fc42.x86_64"), + "grub-cc" + ); + assert_eq!(normalize_package_name("grub2-efi-ia32-cc"), "grub-cc"); + assert_eq!( + normalize_package_name("grub2-efi-ia32-cc-1:2.12-28.fc42.x86_64"), + "grub-cc" + ); + assert_eq!(normalize_package_name("grub-efi-x64-cc"), "grub-cc"); + assert_eq!(normalize_package_name("grub-efi-x64-cc-1:2.12"), "grub-cc"); + // Should not be treated as grub-cc + assert_eq!(normalize_package_name("grubby"), "grubby"); + assert_eq!(normalize_package_name("shimmer"), "shimmer"); + assert_eq!(normalize_package_name("unknown-pkg"), "unknown-pkg"); + } #[test] - fn test_parse_rpmout() { - let testdata = "grub2-efi-x64-1:2.06-95.fc38.x86_64,1681321788 grub2-efi-x64-1:2.06-95.fc38.x86_64,1681321788 shim-x64-15.6-2.x86_64,1657222566 shim-x64-15.6-2.x86_64,1657222566 shim-x64-15.6-2.x86_64,1657222566"; - let parsed = rpm_parse_metadata(testdata.as_bytes()).unwrap(); + fn test_parse_manifest() { + let data = + b"grub2-efi-x64-1:2.06-95.fc38.x86_64,1681321788 shim-x64-15.6-2.x86_64,1657222566 "; + let parsed = parse_manifest(data).unwrap(); assert_eq!( parsed.version, "grub2-efi-x64-1:2.06-95.fc38.x86_64,shim-x64-15.6-2.x86_64" ); - let expected_modules = vec![ + let modules = parsed.versions.unwrap(); + assert_eq!(modules[0].name, "grub"); + assert_eq!(modules[0].rpm_evr, "1:2.06-95.fc38"); + assert_eq!(modules[1].name, "shim"); + assert_eq!(modules[1].rpm_evr, "15.6-2"); + } + + #[test] + fn test_query_files_reads_manifest() { + let dir = TempDir::new().unwrap(); + write_manifest( + &dir, + "grub2-1:2.12-28.fc42,1710000000 shim-15.8-3,1700000000 ", + ); + let meta = query_files(dir.path().to_str().unwrap(), std::iter::empty::<&Path>()).unwrap(); + let modules = meta.versions.unwrap(); + assert_eq!(modules[0].name, "grub"); + assert_eq!(modules[1].name, "shim"); + } + + #[test] + fn test_parse_evr_grub_cc() { + let module = parse_evr("grub-cc-1:2.12-28.fc42.x86_64"); + assert_eq!(module.name, "grub-cc"); + assert_eq!(module.rpm_evr, "1:2.12-28.fc42"); + } + + #[test] + fn test_parse_evr_grub2_efi_x64_cc() { + let module = parse_evr("grub2-efi-x64-cc-1:2.12-28.fc42.x86_64"); + assert_eq!(module.name, "grub-cc"); + assert_eq!(module.rpm_evr, "1:2.12-28.fc42"); + } + + #[test] + fn test_parse_evr_grub2_efi_x64_cc_name_only() { + let module = parse_evr("grub2-efi-x64-cc"); + assert_eq!(module.name, "grub-cc"); + assert_eq!(module.rpm_evr, ""); + } + + #[test] + fn test_query_files_missing_manifest() { + let dir = TempDir::new().unwrap(); + let result = query_files(dir.path().to_str().unwrap(), std::iter::empty::<&Path>()); + assert!(result.is_err()); + } + + #[test] + fn test_compare_cross_distro() { + // grub2-efi-x64 (Fedora) vs grub (Arch) - that same version + let fedora = vec![ Module { - name: "grub2".to_string(), - rpm_evr: "1:2.06-95.fc38".to_string(), + name: "grub2-efi-x64".into(), + rpm_evr: "1:2.12-28.fc42".into(), }, Module { - name: "shim".to_string(), - rpm_evr: "15.6-2".to_string(), + name: "shim-x64".into(), + rpm_evr: "15.8-3".into(), }, ]; - - assert_eq!(parsed.versions, Some(expected_modules)); + let arch = vec![ + Module { + name: "grub".into(), + rpm_evr: "1:2.12-28.fc42".into(), + }, + Module { + name: "shim-signed".into(), + rpm_evr: "15.8-3".into(), + }, + ]; + assert_eq!(compare_package_slices(&fedora, &arch), Ordering::Equal); + + // grub2-tools (fedora) vs Arch (grub) + let rhel = vec![Module { + name: "grub2-tools".into(), + rpm_evr: "1:2.06-86.el9".into(), + }]; + let arch_newer = vec![Module { + name: "grub".into(), + rpm_evr: "1:2.12-28.fc42".into(), + }]; + assert_eq!(compare_package_slices(&rhel, &arch_newer), Ordering::Less); } #[test] @@ -248,99 +450,62 @@ mod tests { rpm_evr: "15.8-3".into(), }, ]; - let ord = compare_package_slices(&a, &b); - assert_eq!(ord, Ordering::Less); - - let ord = compare_package_slices(&b, &a); - assert_eq!(ord, Ordering::Greater); - - let ord = compare_package_slices(&a, &a); - assert_eq!(ord, Ordering::Equal); + assert_eq!(compare_package_slices(&a, &b), Ordering::Less); + assert_eq!(compare_package_slices(&b, &a), Ordering::Greater); + assert_eq!(compare_package_slices(&a, &a), Ordering::Equal); } #[test] fn test_compare_package_versions() { let current = "grub2-efi-x64-1:2.12-28.fc42.x86_64,shim-x64-15.8-3.x86_64"; let target = "grub2-efi-x64-1:2.12-29.fc42.x86_64,shim-x64-15.8-3.x86_64"; - let ord = compare_package_versions(current, target); - assert_eq!(ord, Ordering::Less); // current < target - - let ord = compare_package_versions(target, current); - assert_eq!(ord, Ordering::Greater); + assert_eq!(compare_package_versions(current, target), Ordering::Less); + assert_eq!(compare_package_versions(target, current), Ordering::Greater); let current = "grub2-efi-x64-1:2.12-28.fc42.x86_64,shim-x64-15.8-3.x86_64"; let target = "grub2-1:2.12-29.fc42,shim-15.8-3"; - let ord = compare_package_versions(current, target); - assert_eq!(ord, Ordering::Less); // current < target - - let ord = compare_package_versions(target, current); - assert_eq!(ord, Ordering::Greater); + assert_eq!(compare_package_versions(current, target), Ordering::Less); + assert_eq!(compare_package_versions(target, current), Ordering::Greater); let current = "grub2-1:2.12-28.fc42,shim-15.8-3"; let target = "grub2-1:2.12-28.fc42,shim-15.8-4"; - let ord = compare_package_versions(current, target); - assert_eq!(ord, Ordering::Less); // current < target - - let ord = compare_package_versions(target, current); - assert_eq!(ord, Ordering::Greater); + assert_eq!(compare_package_versions(current, target), Ordering::Less); + assert_eq!(compare_package_versions(target, current), Ordering::Greater); - // The target includes new package, should upgrade let current = "grub2-efi-x64-1:2.12-28.fc42.x86_64,shim-x64-15.8-3.x86_64"; let target = "grub2-efi-x64-1:2.12-28.fc42.x86_64,shim-x64-15.8-3.x86_64,test"; - let ord = compare_package_versions(current, target); - assert_eq!(ord, Ordering::Less); - - // The target missed some package - let ord = compare_package_versions(target, current); - assert_eq!(ord, Ordering::Greater); + assert_eq!(compare_package_versions(current, target), Ordering::Less); + assert_eq!(compare_package_versions(target, current), Ordering::Greater); - // Not sure if this would happen - // current_grub2 > target_grub2 - // current_shim < target_shim - // In this case there is Ordering::Less, return Ordering::Less { let current = "grub2-1:2.12-28.fc42,shim-15.8-3"; let target = "grub2-1:2.12-27.fc42,shim-15.8-4"; - let ord = compare_package_versions(current, target); - assert_eq!(ord, Ordering::Less); - - let ord = compare_package_versions(target, current); - assert_eq!(ord, Ordering::Less); + assert_eq!(compare_package_versions(current, target), Ordering::Less); + assert_eq!(compare_package_versions(target, current), Ordering::Less); } - // Test Equal { let current = "grub2-efi-x64-1:2.12-28.fc42.x86_64,shim-x64-15.8-3.x86_64"; let target = "grub2-efi-x64-1:2.12-28.fc42.x86_64,shim-x64-15.8-3.x86_64"; - let ord = compare_package_versions(current, target); - assert_eq!(ord, Ordering::Equal); + assert_eq!(compare_package_versions(current, target), Ordering::Equal); let current = "grub2-efi-x64-1:2.12-28.fc42.x86_64,shim-x64-15.8-3.x86_64"; let target = "grub2-1:2.12-28.fc42,shim-15.8-3"; - let ord = compare_package_versions(current, target); - assert_eq!(ord, Ordering::Equal); + assert_eq!(compare_package_versions(current, target), Ordering::Equal); let current = "grub2-1:2.12-28.fc42,shim-15.8-3"; let target = "grub2-1:2.12-28.fc42,shim-15.8-3"; - let ord = compare_package_versions(current, target); - assert_eq!(ord, Ordering::Equal); + assert_eq!(compare_package_versions(current, target), Ordering::Equal); } - // Test only grub2 let current = "grub2-tools-1:2.06-86.el9_4.3.x86_64"; let target = "grub2-tools-1:2.06-110.el9.x86_64"; - let ord = compare_package_versions(current, target); - assert_eq!(ord, Ordering::Less); - - let ord = compare_package_versions(target, current); - assert_eq!(ord, Ordering::Greater); + assert_eq!(compare_package_versions(current, target), Ordering::Less); + assert_eq!(compare_package_versions(target, current), Ordering::Greater); let current = "grub2-efi-ia32-1:2.12-21.fc41.x86_64,grub2-efi-x64-1:2.12-21.fc41.x86_64,shim-ia32-15.8-3.x86_64,shim-x64-15.8-3.x86_64"; let target = "grub2-1:2.12-28.fc42,shim-15.8-3"; - let ord = compare_package_versions(current, target); - assert_eq!(ord, Ordering::Less); - - let ord = compare_package_versions(target, current); - assert_eq!(ord, Ordering::Greater); + assert_eq!(compare_package_versions(current, target), Ordering::Less); + assert_eq!(compare_package_versions(target, current), Ordering::Greater); } } diff --git a/src/packagesystem_legacy.rs b/src/packagesystem_legacy.rs new file mode 100644 index 00000000..f20e5e81 --- /dev/null +++ b/src/packagesystem_legacy.rs @@ -0,0 +1,344 @@ +use std::cmp::Ordering; +use std::collections::{BTreeMap, BTreeSet}; +use std::io::Write; +use std::path::Path; + +use anyhow::{bail, Context, Result}; +use chrono::prelude::*; +use serde::{Deserialize, Serialize}; +use uapi_version::Version; + +use crate::model::*; +use crate::ostreeutil; + +#[derive(Serialize, Deserialize, Clone, Debug, Eq, Hash, PartialEq)] +pub(crate) struct Module { + pub(crate) name: String, + pub(crate) rpm_evr: String, +} + +impl Module { + pub(crate) fn rpm_evr(&self) -> Version { + Version::from(&self.rpm_evr) + } +} + +impl Ord for Module { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.name + .cmp(&other.name) // Compare names first + .then_with(|| self.rpm_evr().cmp(&other.rpm_evr())) // If names equal, compare versions + } +} + +impl PartialOrd for Module { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +/// Parse the output of `rpm -q` +fn rpm_parse_metadata(stdout: &[u8]) -> Result { + let pkgs = std::str::from_utf8(stdout)? + .split_whitespace() + .map(|s| -> Result<_> { + let parts: Vec<_> = s.splitn(2, ',').collect(); + let name = parts[0]; + if let Some(ts) = parts.get(1) { + let nt = DateTime::parse_from_str(ts, "%s") + .context("Failed to parse rpm buildtime")? + .with_timezone(&chrono::Utc); + Ok((name, nt)) + } else { + bail!("Failed to parse: {}", s); + } + }) + .collect::>>>()?; + if pkgs.is_empty() { + bail!("Failed to find any RPM packages matching files in source efidir"); + } + let timestamps: BTreeSet<&DateTime> = pkgs.values().collect(); + // Unwrap safety: We validated pkgs has at least one value above + let largest_timestamp = timestamps.iter().last().unwrap(); + let version = pkgs.keys().fold("".to_string(), |mut s, n| { + if !s.is_empty() { + s.push(','); + } + s.push_str(n); + s + }); + + // Map the version into Module struct + let mut modules_vec: Vec = pkgs.keys().map(|pkg_str| parse_evr(pkg_str)).collect(); + modules_vec.sort_unstable(); + Ok(ContentMetadata { + timestamp: **largest_timestamp, + version, + versions: Some(modules_vec), + }) +} + +/// Query the rpm database and list the package and build times. +pub(crate) fn query_files( + sysroot_path: &str, + paths: impl IntoIterator, +) -> Result +where + T: AsRef, +{ + let mut c = ostreeutil::rpm_cmd(sysroot_path)?; + c.args(["-q", "--queryformat", "%{nevra},%{buildtime} ", "-f"]); + for arg in paths { + c.arg(arg.as_ref()); + } + + let rpmout = c.output()?; + if !rpmout.status.success() { + std::io::stderr().write_all(&rpmout.stderr)?; + bail!("Failed to invoke rpm -qf"); + } + + rpm_parse_metadata(&rpmout.stdout) +} + +fn split_name_version(input: &str) -> Option<(String, String)> { + // assume it is "grub2-tools-1:2.06-110.el9.x86_64" + // strip .arch + let main = input.rsplit_once('.')?.0; + + // find last two '-' + let mut parts = main.rsplitn(3, '-'); + let release = parts.next()?; // after last '-' + let version = parts.next()?; // between last two '-' + let name = parts.next()?; // the rest (may contain '-') + + Some((name.to_string(), format!("{version}-{release}"))) +} + +fn parse_evr(pkg: &str) -> Module { + // assume it is "grub2-1:2.12-28.fc42" (from usr/lib/efi) + if !pkg.ends_with(std::env::consts::ARCH) { + let (name, evr) = pkg.split_once('-').unwrap_or((pkg, "")); + return Module { + name: name.to_string(), + rpm_evr: evr.to_string(), + }; + } + + let (name_str, rpm_evr) = { + #[cfg(not(feature = "rpm"))] + { + split_name_version(pkg).unwrap() + } + #[cfg(feature = "rpm")] + { + let nevra = rpm_rs::Nevra::parse(pkg); + (nevra.name().to_string(), nevra.evr().to_string()) + } + }; + + let (name, _) = name_str.split_once('-').unwrap_or((&name_str, "")); + Module { + name: name.to_string(), + rpm_evr, + } +} + +fn parse_evr_vec(input: &str) -> Vec { + let mut pkgs: Vec = input + .split(',') + .map(|pkg| parse_evr(pkg)) // parse_evr returns owned Package + .collect(); + // Sort packages to ensure a consistent order for comparison, which is + // required by `compare_package_slices`. + pkgs.sort_unstable(); + // Now that it's sorted, we can efficiently remove duplicates. + pkgs.dedup(); + pkgs +} + +pub(crate) fn compare_package_slices(a: &[Module], b: &[Module]) -> Ordering { + let mut has_greater = false; + + // Assume it is in order + for (pkg_a, pkg_b) in a.iter().zip(b.iter()) { + match pkg_a.cmp(pkg_b) { + Ordering::Less => return Ordering::Less, // upgradable + Ordering::Greater => has_greater = true, // downgrade + Ordering::Equal => {} + } + } + + // If all compared equal, longer slice wins + if a.len() < b.len() { + return Ordering::Less; // extra packages in b → upgrade + } + if a.len() > b.len() { + return Ordering::Greater; // extra packages in a → downgrade + } + + if has_greater { + Ordering::Greater + } else { + Ordering::Equal + } +} + +// Compare package versions: +// If any package is Ordering::Less, return Ordering::Less, means upgradable, +// Else if any package is Ordering::Greater, return Ordering::Greater, +// Else (all equal), return Ordering::Equal. +pub(crate) fn compare_package_versions(a: &str, b: &str) -> Ordering { + // Fast path: if the two values are equal, skip detailed comparison + if a == b { + return Ordering::Equal; + } + let pkg_a = parse_evr_vec(a); + let pkg_b = parse_evr_vec(b); + compare_package_slices(&pkg_a, &pkg_b) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_rpmout() { + let testdata = "grub2-efi-x64-1:2.06-95.fc38.x86_64,1681321788 grub2-efi-x64-1:2.06-95.fc38.x86_64,1681321788 shim-x64-15.6-2.x86_64,1657222566 shim-x64-15.6-2.x86_64,1657222566 shim-x64-15.6-2.x86_64,1657222566"; + let parsed = rpm_parse_metadata(testdata.as_bytes()).unwrap(); + assert_eq!( + parsed.version, + "grub2-efi-x64-1:2.06-95.fc38.x86_64,shim-x64-15.6-2.x86_64" + ); + let expected_modules = vec![ + Module { + name: "grub2".to_string(), + rpm_evr: "1:2.06-95.fc38".to_string(), + }, + Module { + name: "shim".to_string(), + rpm_evr: "15.6-2".to_string(), + }, + ]; + + assert_eq!(parsed.versions, Some(expected_modules)); + } + + #[test] + fn test_compare_package_slices() { + let a = vec![ + Module { + name: "grub2".into(), + rpm_evr: "1:2.12-21.fc41".into(), + }, + Module { + name: "shim".into(), + rpm_evr: "15.8-3".into(), + }, + ]; + let b = vec![ + Module { + name: "grub2".into(), + rpm_evr: "1:2.12-28.fc41".into(), + }, + Module { + name: "shim".into(), + rpm_evr: "15.8-3".into(), + }, + ]; + let ord = compare_package_slices(&a, &b); + assert_eq!(ord, Ordering::Less); + + let ord = compare_package_slices(&b, &a); + assert_eq!(ord, Ordering::Greater); + + let ord = compare_package_slices(&a, &a); + assert_eq!(ord, Ordering::Equal); + } + + #[test] + fn test_compare_package_versions() { + let current = "grub2-efi-x64-1:2.12-28.fc42.x86_64,shim-x64-15.8-3.x86_64"; + let target = "grub2-efi-x64-1:2.12-29.fc42.x86_64,shim-x64-15.8-3.x86_64"; + let ord = compare_package_versions(current, target); + assert_eq!(ord, Ordering::Less); // current < target + + let ord = compare_package_versions(target, current); + assert_eq!(ord, Ordering::Greater); + + let current = "grub2-efi-x64-1:2.12-28.fc42.x86_64,shim-x64-15.8-3.x86_64"; + let target = "grub2-1:2.12-29.fc42,shim-15.8-3"; + let ord = compare_package_versions(current, target); + assert_eq!(ord, Ordering::Less); // current < target + + let ord = compare_package_versions(target, current); + assert_eq!(ord, Ordering::Greater); + + let current = "grub2-1:2.12-28.fc42,shim-15.8-3"; + let target = "grub2-1:2.12-28.fc42,shim-15.8-4"; + let ord = compare_package_versions(current, target); + assert_eq!(ord, Ordering::Less); // current < target + + let ord = compare_package_versions(target, current); + assert_eq!(ord, Ordering::Greater); + + // The target includes new package, should upgrade + let current = "grub2-efi-x64-1:2.12-28.fc42.x86_64,shim-x64-15.8-3.x86_64"; + let target = "grub2-efi-x64-1:2.12-28.fc42.x86_64,shim-x64-15.8-3.x86_64,test"; + let ord = compare_package_versions(current, target); + assert_eq!(ord, Ordering::Less); + + // The target missed some package + let ord = compare_package_versions(target, current); + assert_eq!(ord, Ordering::Greater); + + // Not sure if this would happen + // current_grub2 > target_grub2 + // current_shim < target_shim + // In this case there is Ordering::Less, return Ordering::Less + { + let current = "grub2-1:2.12-28.fc42,shim-15.8-3"; + let target = "grub2-1:2.12-27.fc42,shim-15.8-4"; + let ord = compare_package_versions(current, target); + assert_eq!(ord, Ordering::Less); + + let ord = compare_package_versions(target, current); + assert_eq!(ord, Ordering::Less); + } + + // Test Equal + { + let current = "grub2-efi-x64-1:2.12-28.fc42.x86_64,shim-x64-15.8-3.x86_64"; + let target = "grub2-efi-x64-1:2.12-28.fc42.x86_64,shim-x64-15.8-3.x86_64"; + let ord = compare_package_versions(current, target); + assert_eq!(ord, Ordering::Equal); + + let current = "grub2-efi-x64-1:2.12-28.fc42.x86_64,shim-x64-15.8-3.x86_64"; + let target = "grub2-1:2.12-28.fc42,shim-15.8-3"; + let ord = compare_package_versions(current, target); + assert_eq!(ord, Ordering::Equal); + + let current = "grub2-1:2.12-28.fc42,shim-15.8-3"; + let target = "grub2-1:2.12-28.fc42,shim-15.8-3"; + let ord = compare_package_versions(current, target); + assert_eq!(ord, Ordering::Equal); + } + + // Test only grub2 + let current = "grub2-tools-1:2.06-86.el9_4.3.x86_64"; + let target = "grub2-tools-1:2.06-110.el9.x86_64"; + let ord = compare_package_versions(current, target); + assert_eq!(ord, Ordering::Less); + + let ord = compare_package_versions(target, current); + assert_eq!(ord, Ordering::Greater); + + let current = "grub2-efi-ia32-1:2.12-21.fc41.x86_64,grub2-efi-x64-1:2.12-21.fc41.x86_64,shim-ia32-15.8-3.x86_64,shim-x64-15.8-3.x86_64"; + let target = "grub2-1:2.12-28.fc42,shim-15.8-3"; + let ord = compare_package_versions(current, target); + assert_eq!(ord, Ordering::Less); + + let ord = compare_package_versions(target, current); + assert_eq!(ord, Ordering::Greater); + } +}