//! Packages Module - Package Manager Backend //! //! Provides abstraction layer for package management operations. //! Supports apt/dpkg (Debian/Ubuntu) and apk (Alpine Linux) with pluggable backend architecture. use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::process::Command; use tracing::info; /// Package status #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum PackageStatus { Installed, Available, Upgradable, NotInstalled, } /// Package information #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Package { pub name: String, pub version: String, pub status: PackageStatus, pub upgradable: bool, pub latest_version: Option, pub description: String, pub dependencies: Vec, pub reverse_dependencies: Vec, pub install_date: Option, pub size_installed: Option, } /// Package installation options #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct InstallOptions { pub force: bool, pub no_recommends: bool, } /// Patch information #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Patch { pub name: String, pub current_version: String, pub available_version: String, pub severity: String, pub description: String, pub cve_ids: Vec, pub requires_reboot: bool, } /// System information #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SystemInfo { pub hostname: String, pub os: String, pub os_version: String, pub kernel: String, pub architecture: String, pub last_update_check: Option, pub last_update_apply: Option, pub pending_reboot: bool, } /// Service status information #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ServiceStatus { pub name: String, pub display_name: String, pub active_state: String, pub sub_state: String, pub load_state: String, pub enabled_state: String, pub main_pid: Option, pub healthy: bool, } /// Package manager backend trait pub trait PackageManagerBackend: Send + Sync { fn list_packages(&self, filter: Option<&str>) -> Result>; fn get_package(&self, name: &str) -> Result>; fn install_packages(&self, packages: &[PackageSpec], options: &InstallOptions) -> Result<()>; fn update_package(&self, name: &str) -> Result<()>; fn remove_package(&self, name: &str, purge: bool) -> Result<()>; fn list_patches(&self) -> Result>; fn apply_patches(&self, packages: Option<&[String]>) -> Result<()>; fn get_system_info(&self) -> Result; fn reboot_system(&self, delay_seconds: u64) -> Result<()>; fn get_service_status(&self, name: &str) -> Result>; } /// Package specification for installation #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PackageSpec { pub name: String, pub version: Option, } /// APT package manager backend (Debian/Ubuntu) pub struct AptBackend { _marker: std::marker::PhantomData<()>, } impl AptBackend { pub fn new() -> Self { Self { _marker: std::marker::PhantomData, } } /// Run apt command and capture output fn run_apt(&self, args: &[&str]) -> Result { // Service runs as root - no sudo needed for apt commands let program = "apt"; let cmd_args: Vec<&str> = args.to_vec(); let output = Command::new(program) .args(&cmd_args) .output() .context("Failed to execute apt command")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(anyhow::anyhow!("apt command failed: {}", stderr)); } Ok(String::from_utf8_lossy(&output.stdout).to_string()) } /// Run dpkg command and capture output fn run_dpkg(&self, args: &[&str]) -> Result { let output = Command::new("dpkg") .args(args) .output() .context("Failed to execute dpkg command")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(anyhow::anyhow!("dpkg command failed: {}", stderr)); } Ok(String::from_utf8_lossy(&output.stdout).to_string()) } /// Parse package list from apt output fn parse_package_list(&self, output: &str) -> Vec { let mut packages = Vec::new(); for line in output.lines() { let parts: Vec<&str> = line.split_whitespace().collect(); if parts.len() >= 4 { let name = parts[0].to_string(); let status_str = parts[1]; let version = parts[2].to_string(); let status = if status_str.starts_with("ii") { PackageStatus::Installed } else if status_str.starts_with("iU") { PackageStatus::Upgradable } else { PackageStatus::Available }; let description = parts[4..].join(" "); let upgradable = status == PackageStatus::Upgradable; packages.push(Package { name, version: version.clone(), status: status.clone(), upgradable, latest_version: Some(version), description, dependencies: Vec::new(), reverse_dependencies: Vec::new(), install_date: None, size_installed: None, }); } } packages } } impl PackageManagerBackend for AptBackend { fn list_packages(&self, filter: Option<&str>) -> Result> { let args = match filter { Some(f) => vec!["list", f], None => vec!["list", "--installed"], }; let output = self.run_apt(&args)?; Ok(self.parse_package_list(&output)) } fn get_package(&self, name: &str) -> Result> { // Check if installed let dpkg_output = self.run_dpkg(&["-s", name]); if dpkg_output.is_err() { // Package not installed, check if available let list_output = self.run_apt(&["list", name])?; if list_output.contains(name) { let parts: Vec<&str> = list_output .lines() .find(|l| l.contains(name)) .unwrap_or("") .split_whitespace() .collect(); if parts.len() >= 3 { return Ok(Some(Package { name: name.to_string(), version: parts[1].to_string(), status: PackageStatus::Available, upgradable: false, latest_version: Some(parts[1].to_string()), description: String::new(), dependencies: Vec::new(), reverse_dependencies: Vec::new(), install_date: None, size_installed: None, })); } } return Ok(None); } let dpkg_info = dpkg_output?; // Parse dpkg status output let mut version = String::new(); let mut status = PackageStatus::Installed; let mut description = String::new(); let mut dependencies = Vec::new(); let install_date = None; let mut size_installed = None; for line in dpkg_info.lines() { if line.starts_with("Version:") { version = line.trim_start_matches("Version:").trim().to_string(); } else if line.starts_with("Status:") { if line.contains("install ok installed") { status = PackageStatus::Installed; } } else if line.starts_with("Description:") { description = line.trim_start_matches("Description:").trim().to_string(); } else if line.starts_with("Depends:") { dependencies = line .trim_start_matches("Depends:") .trim() .split(',') .map(|s| s.split_whitespace().next().unwrap_or("").to_string()) .collect(); } else if line.starts_with("Installed-Size:") { size_installed = Some(format!( "{} KB", line.trim_start_matches("Installed-Size:").trim() )); } } // Check if upgradable let upgradable = self .run_apt(&["list", "--upgradable", name]) .map(|o| o.contains(name)) .unwrap_or(false); let latest_version = if upgradable { self.run_apt(&["policy", name]).ok().and_then(|o| { o.lines() .find(|l| l.contains("Candidate")) .and_then(|l| l.split_whitespace().nth(1)) .map(|s| s.to_string()) }) } else { Some(version.clone()) }; Ok(Some(Package { name: name.to_string(), version, status, upgradable, latest_version, description, dependencies, reverse_dependencies: Vec::new(), install_date, size_installed, })) } fn install_packages(&self, packages: &[PackageSpec], options: &InstallOptions) -> Result<()> { let mut args: Vec = vec!["install".to_string(), "-y".to_string()]; if options.no_recommends { args.push("--no-install-recommends".to_string()); } if options.force { args.push("--force-yes".to_string()); } for pkg in packages { let pkg_arg = if let Some(version) = &pkg.version { format!("{}={}", pkg.name, version) } else { pkg.name.clone() }; args.push(pkg_arg); } let args_ref: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); self.run_apt(&args_ref)?; info!( "Installed packages: {:?}", packages.iter().map(|p| &p.name).collect::>() ); Ok(()) } fn update_package(&self, name: &str) -> Result<()> { self.run_apt(&["install", "-y", "--only-upgrade", name])?; info!("Updated package: {}", name); Ok(()) } fn remove_package(&self, name: &str, purge: bool) -> Result<()> { let args = if purge { vec!["purge", "-y", name] } else { vec!["remove", "-y", name] }; self.run_apt(&args)?; info!("Removed package: {} (purge={})", name, purge); Ok(()) } fn list_patches(&self) -> Result> { let output = self.run_apt(&["list", "--upgradable"])?; let mut patches = Vec::new(); for line in output.lines() { let parts: Vec<&str> = line.split_whitespace().collect(); if parts.len() >= 3 { // Strip release suffix from package name (e.g., "pkg/noble-updates,noble-security" → "pkg") let name = parts[0].split('/').next().unwrap_or(parts[0]).to_string(); let current_version = parts[1].to_string(); let available_version = parts[2].to_string(); // Determine severity based on package name heuristics let severity = if name.contains("kernel") || name.contains("ssl") || name.contains("security") { "critical".to_string() } else if name.contains("lib") { "high".to_string() } else { "medium".to_string() }; patches.push(Patch { name, current_version, available_version, severity, description: String::from("Package update available"), cve_ids: Vec::new(), requires_reboot: false, }); } } Ok(patches) } fn apply_patches(&self, packages: Option<&[String]>) -> Result<()> { let args = match packages { Some(pkgs) => { let mut a = vec!["install", "-y"]; for pkg in pkgs { a.push(pkg); } a } None => { vec!["upgrade", "-y"] } }; self.run_apt(&args)?; info!("Applied patches for packages: {:?}", packages); Ok(()) } fn get_system_info(&self) -> Result { let hostname = Command::new("hostname") .output() .ok() .and_then(|o| String::from_utf8(o.stdout).ok()) .map(|s| s.trim().to_string()) .unwrap_or_else(|| "unknown".to_string()); let os_info = std::fs::read_to_string("/etc/os-release") .ok() .map(|content| { let mut os = "Linux".to_string(); let mut version = "unknown".to_string(); for line in content.lines() { if line.starts_with("PRETTY_NAME=") { os = line .trim_start_matches("PRETTY_NAME=") .trim() .trim_matches('"') .to_string(); } else if line.starts_with("NAME=") { os = line .trim_start_matches("NAME=") .trim() .trim_matches('"') .to_string(); } else if line.starts_with("VERSION=") { version = line .trim_start_matches("VERSION=") .trim() .trim_matches('"') .to_string(); } } (os, version) }) .unwrap_or_else(|| ("Linux".to_string(), "unknown".to_string())); let kernel = Command::new("uname") .arg("-r") .output() .ok() .and_then(|o| String::from_utf8(o.stdout).ok()) .map(|s| s.trim().to_string()) .unwrap_or_else(|| "unknown".to_string()); let architecture = Command::new("uname") .arg("-m") .output() .ok() .and_then(|o| String::from_utf8(o.stdout).ok()) .map(|s| s.trim().to_string()) .unwrap_or_else(|| "unknown".to_string()); // Check if reboot is pending let pending_reboot = std::path::Path::new("/var/run/reboot-required").exists(); Ok(SystemInfo { hostname, os: os_info.0, os_version: os_info.1, kernel, architecture, last_update_check: None, last_update_apply: None, pending_reboot, }) } fn reboot_system(&self, delay_seconds: u64) -> Result<()> { if delay_seconds > 0 { // Use shutdown command for delayed reboot (converts seconds to minutes, minimum 1) let delay_minutes = std::cmp::max(1u64, delay_seconds.div_ceil(60)); info!( "Scheduling system reboot in {} minutes (requested {} seconds)", delay_minutes, delay_seconds ); Command::new("shutdown") .args(["-r", &format!("+{}", delay_minutes)]) .status() .context("Failed to schedule delayed reboot")?; info!("System reboot scheduled in {} minutes", delay_minutes); } else { // Immediate reboot using systemctl info!("Initiating immediate system reboot"); Command::new("systemctl") .arg("reboot") .status() .context("Failed to execute reboot command")?; info!("System reboot initiated"); } Ok(()) } fn get_service_status(&self, name: &str) -> Result> { // Validate service name to prevent shell injection if name.is_empty() || name.contains('/') || name.contains("..") { return Err(anyhow::anyhow!("Invalid service name: {}", name)); } // Determine init system and query accordingly let is_systemd = std::path::Path::new("/run/systemd/system").exists(); let is_openrc = std::path::Path::new("/sbin/openrc").exists(); if is_systemd { get_systemd_service_status(name) } else if is_openrc { get_openrc_service_status(name) } else { Err(anyhow::anyhow!( "No supported init system detected (systemd or OpenRC required)" )) } } } /// Query systemd service status via systemctl fn get_systemd_service_status(name: &str) -> Result> { let output = Command::new("systemctl") .args([ "show", name, "--property=Id,Description,ActiveState,SubState,LoadState,UnitFileState,MainPID", "--no-pager", ]) .output() .context("Failed to execute systemctl command")?; let stdout = String::from_utf8_lossy(&output.stdout); // If systemctl returns non-zero or empty output, service doesn't exist if !output.status.success() || stdout.trim().is_empty() { return Ok(None); } let mut id = String::new(); let mut description = String::new(); let mut active_state = String::new(); let mut sub_state = String::new(); let mut load_state = String::new(); let mut unit_file_state = String::new(); let mut main_pid: Option = None; for line in stdout.lines() { if let Some((key, value)) = line.split_once('=') { match key { "Id" => id = value.to_string(), "Description" => description = value.to_string(), "ActiveState" => active_state = value.to_string(), "SubState" => sub_state = value.to_string(), "LoadState" => load_state = value.to_string(), "UnitFileState" => unit_file_state = value.to_string(), "MainPID" => { main_pid = value.parse::().ok().filter(|&p| p > 0); } _ => {} } } } // If LoadState is not-found or bad-setting, service doesn't exist if load_state == "not-found" || load_state == "bad-setting" || id.is_empty() { return Ok(None); } let healthy = active_state == "active" && sub_state == "running"; // Check for socket activation: if service is inactive but enabled, // check if the corresponding .socket unit is active (listening) let healthy = if !healthy && active_state == "inactive" && unit_file_state == "enabled" { // Use the resolved service name (id) instead of input name, // so "sshd" resolves to "ssh.service" → "ssh.socket" correctly let socket_name = format!("{}.socket", id.trim_end_matches(".service")); if let Ok(socket_output) = Command::new("systemctl") .args(["show", &socket_name, "--property=ActiveState", "--no-pager"]) .output() { let socket_stdout = String::from_utf8_lossy(&socket_output.stdout); if socket_stdout.contains("ActiveState=active") { true } else { healthy } } else { healthy } } else { healthy }; Ok(Some(ServiceStatus { name: id, display_name: description, active_state, sub_state, load_state, enabled_state: unit_file_state, main_pid, healthy, })) } /// Query OpenRC service status via rc-service fn get_openrc_service_status(name: &str) -> Result> { let output = Command::new("rc-service") .args([name, "status"]) .output() .context("Failed to execute rc-service command")?; let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); // rc-service returns error if service doesn't exist if !output.status.success() { if stderr.contains("does not exist") || stdout.contains("does not exist") { return Ok(None); } return Err(anyhow::anyhow!("rc-service failed: {}", stderr)); } // Parse rc-service status output let status_line = stdout.lines().next().unwrap_or("").to_lowercase(); let (active_state, sub_state, healthy) = if status_line.contains("started") || status_line.contains("running") { ("active".to_string(), "running".to_string(), true) } else if status_line.contains("stopped") || status_line.contains("not running") { ("inactive".to_string(), "dead".to_string(), false) } else if status_line.contains("crashed") || status_line.contains("failed") { ("failed".to_string(), "failed".to_string(), false) } else { ("unknown".to_string(), "unknown".to_string(), false) }; // Check if service is enabled using rc-update let enabled_output = Command::new("rc-update") .args(["show", "default"]) .output() .ok(); let enabled_state = enabled_output .and_then(|o| String::from_utf8(o.stdout).ok()) .map(|s| { if s.lines().any(|l| l.trim().starts_with(name)) { "enabled".to_string() } else { "disabled".to_string() } }) .unwrap_or_else(|| "unknown".to_string()); Ok(Some(ServiceStatus { name: name.to_string(), display_name: name.to_string(), active_state, sub_state, load_state: "loaded".to_string(), enabled_state, main_pid: None, healthy, })) } impl Default for AptBackend { fn default() -> Self { Self::new() } } /// APK package manager backend (Alpine Linux) pub struct ApkBackend { _marker: std::marker::PhantomData<()>, } impl ApkBackend { pub fn new() -> Self { Self { _marker: std::marker::PhantomData, } } /// Run apk command and capture output fn run_apk(&self, args: &[&str]) -> Result { // Service runs as root on Alpine - no sudo needed for apk commands let output = Command::new("apk") .args(args) .output() .context("Failed to execute apk command")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(anyhow::anyhow!("apk command failed: {}", stderr)); } Ok(String::from_utf8_lossy(&output.stdout).to_string()) } /// Parse name and version from apk package identifier (name-version format). /// Alpine package names can contain hyphens (e.g., "gcc-gnat"), so we find /// the first hyphen followed by a digit to separate name from version. fn parse_name_version(&self, ident: &str) -> (String, String) { let bytes = ident.as_bytes(); for i in 0..bytes.len() { if bytes[i] == b'-' && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit() { return (ident[..i].to_string(), ident[i + 1..].to_string()); } } // Fallback: no version separator found (ident.to_string(), String::new()) } /// Parse package list from `apk list --installed` output. /// Format: {name}-{version} [{repo}] {description} fn parse_apk_package_list(&self, output: &str) -> Vec { let mut packages = Vec::new(); for line in output.lines() { if line.trim().is_empty() { continue; } // Split on " [" to separate package identifier from repo and description let (ident, rest) = if let Some(pos) = line.find(" [") { (&line[..pos], &line[pos + 2..]) } else if let Some(pos) = line.find(' ') { (&line[..pos], &line[pos + 1..]) } else { // No separator found, treat entire line as identifier let (name, version) = self.parse_name_version(line.trim()); packages.push(Package { name, version, status: PackageStatus::Installed, upgradable: false, latest_version: None, description: String::new(), dependencies: Vec::new(), reverse_dependencies: Vec::new(), install_date: None, size_installed: None, }); continue; }; let (name, version) = self.parse_name_version(ident); // Parse rest: "{repo}] {description}" or just "{description}" let description = if let Some(bracket_end) = rest.find("] ") { rest[bracket_end + 2..].to_string() } else if let Some(bracket_end) = rest.find(']') { rest[bracket_end + 1..].trim().to_string() } else { rest.to_string() }; packages.push(Package { name, version, status: PackageStatus::Installed, upgradable: false, latest_version: None, description, dependencies: Vec::new(), reverse_dependencies: Vec::new(), install_date: None, size_installed: None, }); } packages } /// Parse detailed package info from `apk info -a {name}` output. /// Output format has section headers like: /// {name}-{version} description: /// the description text /// {name}-{version} installed size: /// 32768 fn parse_apk_info( &self, output: &str, name: &str, status: PackageStatus, ) -> Result> { let mut version = String::new(); let mut description = String::new(); let mut dependencies = Vec::new(); let mut reverse_dependencies = Vec::new(); let mut size_installed = None; let mut current_field: Option<&str> = None; for line in output.lines() { if line.contains(" description:") { current_field = Some("description"); // Extract version from the header line let header = line.split(" description:").next().unwrap_or(""); let (parsed_name, v) = self.parse_name_version(header.trim()); if parsed_name == name || version.is_empty() { version = v; } } else if line.contains(" webpage:") { current_field = Some("webpage"); } else if line.contains(" installed size:") { current_field = Some("installed_size"); // Size might be on the same line after the header if let Some(pos) = line.find(" installed size:") { let size_str = line[pos + " installed size:".len()..].trim(); if !size_str.is_empty() { size_installed = Some(format!("{} bytes", size_str)); } } } else if line.contains(" dependencies:") { current_field = Some("dependencies"); } else if line.contains(" provides:") { current_field = Some("provides"); } else if line.contains(" required by:") { current_field = Some("required_by"); } else if !line.trim().is_empty() { match current_field { Some("description") if description.is_empty() => { description = line.trim().to_string(); } Some("dependencies") => { for dep in line.split_whitespace() { // APK dependencies use prefixes like "so:", "cmd:", "pc:" - strip them let dep_name = dep .trim_start_matches("so:") .trim_start_matches("cmd:") .trim_start_matches("pc:"); dependencies.push(dep_name.to_string()); } } Some("required_by") => { for req in line.split_whitespace() { let (req_name, _) = self.parse_name_version(req); reverse_dependencies.push(req_name); } } Some("installed_size") => { let size_str = line.trim(); if !size_str.is_empty() && size_installed.is_none() { size_installed = Some(format!("{} bytes", size_str)); } } _ => {} } } else { current_field = None; } } // Check if upgradable let upgradable = self .run_apk(&["list", "--upgradable", name]) .map(|o| !o.trim().is_empty() && o.contains(name)) .unwrap_or(false); let latest_version = if upgradable { // Try to get the candidate version from apk info self.run_apk(&["info", name]).ok().and_then(|o| { o.lines().next().and_then(|l| { let (_, v) = self.parse_name_version(l.trim()); if v.is_empty() { None } else { Some(v) } }) }) } else { Some(version.clone()) }; Ok(Some(Package { name: name.to_string(), version, status, upgradable, latest_version, description, dependencies, reverse_dependencies, install_date: None, size_installed, })) } } impl PackageManagerBackend for ApkBackend { fn list_packages(&self, filter: Option<&str>) -> Result> { let args = match filter { Some(f) => vec!["list", "--installed", f], None => vec!["list", "--installed"], }; let output = self.run_apk(&args)?; Ok(self.parse_apk_package_list(&output)) } fn get_package(&self, name: &str) -> Result> { // Validate package name to prevent shell injection if name.is_empty() || name.contains('/') || name.contains("..") || name.contains(' ') { return Err(anyhow::anyhow!("Invalid package name: {}", name)); } // Check if package is installed using apk list --installed let list_output = self.run_apk(&["list", "--installed", name])?; if !list_output.trim().is_empty() && list_output.contains(name) { // Package is installed, get detailed info let info_output = self.run_apk(&["info", "-a", name])?; return self.parse_apk_info(&info_output, name, PackageStatus::Installed); } // Check if package is available (not installed) using apk search let search_output = self.run_apk(&["search", name]); if let Ok(output) = search_output { if !output.trim().is_empty() && output.contains(name) { // Parse first matching line if let Some(first_line) = output.lines().next() { let (pkg_name, version) = self.parse_name_version(first_line.trim()); return Ok(Some(Package { name: pkg_name, version, status: PackageStatus::Available, upgradable: false, latest_version: None, description: String::new(), dependencies: Vec::new(), reverse_dependencies: Vec::new(), install_date: None, size_installed: None, })); } } } Ok(None) } fn install_packages(&self, packages: &[PackageSpec], options: &InstallOptions) -> Result<()> { let mut args: Vec = vec!["add".to_string()]; if options.force { args.push("--force".to_string()); } for pkg in packages { let pkg_arg = if let Some(version) = &pkg.version { format!("{}={}", pkg.name, version) } else { pkg.name.clone() }; args.push(pkg_arg); } let args_ref: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); self.run_apk(&args_ref)?; info!( "Installed packages: {:?}", packages.iter().map(|p| &p.name).collect::>() ); Ok(()) } fn update_package(&self, name: &str) -> Result<()> { self.run_apk(&["upgrade", name])?; info!("Updated package: {}", name); Ok(()) } fn remove_package(&self, name: &str, _purge: bool) -> Result<()> { // APK doesn't have a purge concept - just remove the package self.run_apk(&["del", name])?; info!("Removed package: {}", name); Ok(()) } fn list_patches(&self) -> Result> { let output = self.run_apk(&["list", "--upgradable"])?; let mut patches = Vec::new(); for line in output.lines() { if line.trim().is_empty() { continue; } // Parse upgradable package line // Format: {name}-{new_version} < {old_version} [{repo}] {description} // or fallback: {name}-{new_version} [{repo}] {description} let (ident, current_version) = if let Some(pos) = line.find(" < ") { let ident = &line[..pos]; let rest = &line[pos + 3..]; // Old version ends at the next space or bracket let cv = if let Some(space_pos) = rest.find(' ') { rest[..space_pos].to_string() } else { rest.to_string() }; (ident, cv) } else if let Some(pos) = line.find(' ') { (&line[..pos], String::new()) } else { continue; }; let (name, available_version) = self.parse_name_version(ident); // Determine severity based on package name heuristics let severity = if name.contains("kernel") || name.contains("ssl") || name.contains("security") { "critical".to_string() } else if name.contains("lib") { "high".to_string() } else { "medium".to_string() }; patches.push(Patch { name, current_version, available_version, severity, description: String::from("Package update available"), cve_ids: Vec::new(), requires_reboot: false, }); } Ok(patches) } fn apply_patches(&self, packages: Option<&[String]>) -> Result<()> { match packages { Some(pkgs) => { let mut args: Vec<&str> = vec!["upgrade"]; for pkg in pkgs { args.push(pkg); } self.run_apk(&args)?; info!("Applied patches for packages: {:?}", packages); } None => { self.run_apk(&["upgrade"])?; info!("Applied all available patches"); } } Ok(()) } fn get_system_info(&self) -> Result { let hostname = Command::new("hostname") .output() .ok() .and_then(|o| String::from_utf8(o.stdout).ok()) .map(|s| s.trim().to_string()) .unwrap_or_else(|| "unknown".to_string()); let os_info = std::fs::read_to_string("/etc/os-release") .ok() .map(|content| { let mut os = "Linux".to_string(); let mut version = "unknown".to_string(); for line in content.lines() { if line.starts_with("PRETTY_NAME=") { os = line .trim_start_matches("PRETTY_NAME=") .trim() .trim_matches('"') .to_string(); } else if line.starts_with("NAME=") { os = line .trim_start_matches("NAME=") .trim() .trim_matches('"') .to_string(); } else if line.starts_with("VERSION=") { version = line .trim_start_matches("VERSION=") .trim() .trim_matches('"') .to_string(); } else if line.starts_with("VERSION_ID=") { version = line .trim_start_matches("VERSION_ID=") .trim() .trim_matches('"') .to_string(); } } (os, version) }) .unwrap_or_else(|| ("Linux".to_string(), "unknown".to_string())); let kernel = Command::new("uname") .arg("-r") .output() .ok() .and_then(|o| String::from_utf8(o.stdout).ok()) .map(|s| s.trim().to_string()) .unwrap_or_else(|| "unknown".to_string()); let architecture = Command::new("uname") .arg("-m") .output() .ok() .and_then(|o| String::from_utf8(o.stdout).ok()) .map(|s| s.trim().to_string()) .unwrap_or_else(|| "unknown".to_string()); // Alpine uses /boot/.reboot-required for reboot indicator, // also check /var/run/reboot-required as a fallback let pending_reboot = std::path::Path::new("/boot/.reboot-required").exists() || std::path::Path::new("/var/run/reboot-required").exists(); Ok(SystemInfo { hostname, os: os_info.0, os_version: os_info.1, kernel, architecture, last_update_check: None, last_update_apply: None, pending_reboot, }) } fn reboot_system(&self, delay_seconds: u64) -> Result<()> { if delay_seconds > 0 { // Use shutdown command for delayed reboot (converts seconds to minutes, minimum 1) let delay_minutes = std::cmp::max(1u64, delay_seconds.div_ceil(60)); info!( "Scheduling system reboot in {} minutes (requested {} seconds)", delay_minutes, delay_seconds ); Command::new("shutdown") .args(["-r", &format!("+{}", delay_minutes)]) .status() .context("Failed to schedule delayed reboot")?; info!("System reboot scheduled in {} minutes", delay_minutes); } else { // Alpine uses `reboot` command, not `systemctl reboot` info!("Initiating immediate system reboot"); Command::new("reboot") .status() .context("Failed to execute reboot command")?; info!("System reboot initiated"); } Ok(()) } fn get_service_status(&self, name: &str) -> Result> { // Validate service name to prevent shell injection if name.is_empty() || name.contains('/') || name.contains("..") { return Err(anyhow::anyhow!("Invalid service name: {}", name)); } // Alpine uses OpenRC for service management get_openrc_service_status(name) } } impl Default for ApkBackend { fn default() -> Self { Self::new() } } /// Package manager factory pub fn create_backend() -> Result> { // Detect package manager and return appropriate backend if std::path::Path::new("/usr/bin/apt").exists() { Ok(Box::new(AptBackend::new())) } else if std::path::Path::new("/usr/bin/dnf").exists() { // TODO: Implement DnfBackend for RHEL/CentOS/Fedora Err(anyhow::anyhow!("DNF backend not yet implemented")) } else if std::path::Path::new("/usr/bin/apk").exists() || std::path::Path::new("/sbin/apk").exists() { Ok(Box::new(ApkBackend::new())) } else if std::path::Path::new("/usr/bin/pacman").exists() { // TODO: Implement PacmanBackend for Arch Err(anyhow::anyhow!("Pacman backend not yet implemented")) } else { Err(anyhow::anyhow!("No supported package manager found")) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_apt_backend_creation() { let _backend = AptBackend::new(); // Test passes if backend creation doesn't panic } #[test] fn test_package_status_serialization() { let status = PackageStatus::Installed; let json = serde_json::to_string(&status).unwrap(); assert!(json.contains("Installed")); } #[test] fn test_apk_backend_creation() { let _backend = ApkBackend::new(); // Test passes if backend creation doesn't panic } #[test] fn test_apk_parse_name_version_simple() { let backend = ApkBackend::new(); let (name, version) = backend.parse_name_version("bash-5.2.21-r0"); assert_eq!(name, "bash"); assert_eq!(version, "5.2.21-r0"); } #[test] fn test_apk_parse_name_version_hyphenated() { let backend = ApkBackend::new(); // Package names with hyphens like gcc-gnat let (name, version) = backend.parse_name_version("gcc-gnat-13.2.1-r0"); assert_eq!(name, "gcc-gnat"); assert_eq!(version, "13.2.1-r0"); } #[test] fn test_apk_parse_name_version_no_version() { let backend = ApkBackend::new(); let (name, version) = backend.parse_name_version("nohyphen"); assert_eq!(name, "nohyphen"); assert_eq!(version, ""); } #[test] fn test_apk_parse_name_version_multiple_hyphens() { let backend = ApkBackend::new(); let (name, version) = backend.parse_name_version("perl-net-ssleay-1.94-r0"); assert_eq!(name, "perl-net-ssleay"); assert_eq!(version, "1.94-r0"); } #[test] fn test_apk_parse_package_list() { let backend = ApkBackend::new(); let output = "bash-5.2.21-r0 [main] The GNU Bourne Again shell\nopenssl-3.1.4-r0 [main] Toolkit for SSL/TLS"; let packages = backend.parse_apk_package_list(output); assert_eq!(packages.len(), 2); assert_eq!(packages[0].name, "bash"); assert_eq!(packages[0].version, "5.2.21-r0"); assert_eq!(packages[1].name, "openssl"); assert_eq!(packages[1].version, "3.1.4-r0"); } }