Private
Public Access
1
0
Files
linux_patch_api/src/packages/mod.rs

1263 lines
44 KiB
Rust

//! 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<String>,
pub description: String,
pub dependencies: Vec<String>,
pub reverse_dependencies: Vec<String>,
pub install_date: Option<String>,
pub size_installed: Option<String>,
}
/// 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<String>,
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<String>,
pub last_update_apply: Option<String>,
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<u32>,
pub healthy: bool,
}
/// Package manager backend trait
pub trait PackageManagerBackend: Send + Sync {
fn list_packages(&self, filter: Option<&str>) -> Result<Vec<Package>>;
fn get_package(&self, name: &str) -> Result<Option<Package>>;
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<Vec<Patch>>;
fn apply_patches(&self, packages: Option<&[String]>) -> Result<()>;
fn get_system_info(&self) -> Result<SystemInfo>;
fn reboot_system(&self, delay_seconds: u64) -> Result<()>;
fn get_service_status(&self, name: &str) -> Result<Option<ServiceStatus>>;
}
/// Package specification for installation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackageSpec {
pub name: String,
pub version: Option<String>,
}
/// 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<String> {
// 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<String> {
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<Package> {
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<Vec<Package>> {
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<Option<Package>> {
// 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<String> = 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::<Vec<_>>()
);
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<Vec<Patch>> {
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<SystemInfo> {
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<Option<ServiceStatus>> {
// 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<Option<ServiceStatus>> {
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<u32> = 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::<u32>().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<Option<ServiceStatus>> {
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<String> {
// 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<Package> {
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<Option<Package>> {
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<Vec<Package>> {
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<Option<Package>> {
// 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<String> = 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::<Vec<_>>()
);
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<Vec<Patch>> {
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<SystemInfo> {
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<Option<ServiceStatus>> {
// 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<Box<dyn PackageManagerBackend>> {
// 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");
}
}