mod properties; #[cfg(test)] mod tests; use miette::Diagnostic; use properties::*; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs::{self, File}; use std::path::{Path, PathBuf}; use thiserror::Error; use crate::repository::{ReadableRepository, RepositoryError, RestBackend, FileBackend}; // Export the catalog module pub mod catalog; use catalog::{ImageCatalog, PackageInfo}; // Export the installed packages module pub mod installed; // Export the action plan module pub mod action_plan; use installed::{InstalledPackageInfo, InstalledPackages}; // Include tests #[cfg(test)] mod installed_tests; #[derive(Debug, Error, Diagnostic)] pub enum ImageError { #[error("I/O error: {0}")] #[diagnostic( code(ips::image_error::io), help("Check system resources and permissions") )] IO(#[from] std::io::Error), #[error("JSON error: {0}")] #[diagnostic( code(ips::image_error::json), help("Check the JSON format and try again") )] Json(#[from] serde_json::Error), #[error("Invalid image path: {0}")] #[diagnostic( code(ips::image_error::invalid_path), help("Provide a valid path for the image") )] InvalidPath(String), #[error("Repository error: {0}")] #[diagnostic( code(ips::image_error::repository), help("Check the repository configuration and try again") )] Repository(#[from] RepositoryError), #[error("Database error: {0}")] #[diagnostic( code(ips::image_error::database), help("Check the database configuration and try again") )] Database(String), #[error("Publisher not found: {0}")] #[diagnostic( code(ips::image_error::publisher_not_found), help("Check the publisher name and try again") )] PublisherNotFound(String), #[error("No publishers configured")] #[diagnostic( code(ips::image_error::no_publishers), help("Configure at least one publisher before performing this operation") )] NoPublishers, } pub type Result = std::result::Result; /// Type of image, either Full (base path of "/") or Partial (attached to a full image) #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] pub enum ImageType { /// Full image with base path of "/" Full, /// Partial image attached to a full image Partial, } /// Represents a publisher configuration in an image #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] pub struct Publisher { /// Publisher name pub name: String, /// Publisher origin URL pub origin: String, /// Publisher mirror URLs pub mirrors: Vec, /// Whether this is the default publisher pub is_default: bool, } /// Represents an IPS image, which can be either a Full image or a Partial image #[derive(Clone, Debug, Deserialize, Serialize)] pub struct Image { /// Path to the image path: PathBuf, /// Type of image (Full or Partial) image_type: ImageType, /// Image properties props: Vec, /// Image version version: i32, /// Variants variants: HashMap, /// Mediators mediators: HashMap, /// Publishers publishers: Vec, } impl Image { /// Creates a new Full image at the specified path pub fn new_full>(path: P) -> Image { Image { path: path.into(), image_type: ImageType::Full, version: 5, variants: HashMap::new(), mediators: HashMap::new(), props: vec![], publishers: vec![], } } /// Creates a new Partial image at the specified path pub fn new_partial>(path: P) -> Image { Image { path: path.into(), image_type: ImageType::Partial, version: 5, variants: HashMap::new(), mediators: HashMap::new(), props: vec![], publishers: vec![], } } /// Add a publisher to the image pub fn add_publisher(&mut self, name: &str, origin: &str, mirrors: Vec, is_default: bool) -> Result<()> { // Check if publisher already exists if self.publishers.iter().any(|p| p.name == name) { // Update existing publisher for publisher in &mut self.publishers { if publisher.name == name { publisher.origin = origin.to_string(); publisher.mirrors = mirrors; publisher.is_default = is_default; // If this publisher is now the default, make sure no other publisher is default if is_default { for other_publisher in &mut self.publishers { if other_publisher.name != name { other_publisher.is_default = false; } } } break; } } } else { // Add new publisher let publisher = Publisher { name: name.to_string(), origin: origin.to_string(), mirrors, is_default, }; // If this publisher is the default, make sure no other publisher is default if is_default { for publisher in &mut self.publishers { publisher.is_default = false; } } self.publishers.push(publisher); } // Save the image to persist the changes self.save()?; Ok(()) } /// Remove a publisher from the image pub fn remove_publisher(&mut self, name: &str) -> Result<()> { let initial_len = self.publishers.len(); self.publishers.retain(|p| p.name != name); if self.publishers.len() == initial_len { return Err(ImageError::PublisherNotFound(name.to_string())); } // If we removed the default publisher, set the first remaining publisher as default if self.publishers.iter().all(|p| !p.is_default) && !self.publishers.is_empty() { self.publishers[0].is_default = true; } // Save the image to persist the changes self.save()?; Ok(()) } /// Get the default publisher pub fn default_publisher(&self) -> Result<&Publisher> { // Find the default publisher for publisher in &self.publishers { if publisher.is_default { return Ok(publisher); } } // If no publisher is marked as default, return the first one if !self.publishers.is_empty() { return Ok(&self.publishers[0]); } Err(ImageError::NoPublishers) } /// Get a publisher by name pub fn get_publisher(&self, name: &str) -> Result<&Publisher> { for publisher in &self.publishers { if publisher.name == name { return Ok(publisher); } } Err(ImageError::PublisherNotFound(name.to_string())) } /// Get all publishers pub fn publishers(&self) -> &[Publisher] { &self.publishers } /// Returns the path to the image pub fn path(&self) -> &Path { &self.path } /// Returns the type of the image pub fn image_type(&self) -> &ImageType { &self.image_type } /// Returns the path to the metadata directory for this image pub fn metadata_dir(&self) -> PathBuf { match self.image_type { ImageType::Full => self.path.join("var/pkg"), ImageType::Partial => self.path.join(".pkg"), } } /// Returns the path to the image JSON file pub fn image_json_path(&self) -> PathBuf { self.metadata_dir().join("pkg6.image.json") } /// Returns the path to the installed packages database pub fn installed_db_path(&self) -> PathBuf { self.metadata_dir().join("installed.redb") } /// Returns the path to the manifest directory pub fn manifest_dir(&self) -> PathBuf { self.metadata_dir().join("manifests") } /// Returns the path to the catalog directory pub fn catalog_dir(&self) -> PathBuf { self.metadata_dir().join("catalog") } /// Returns the path to the catalog database pub fn catalog_db_path(&self) -> PathBuf { self.metadata_dir().join("catalog.redb") } /// Creates the metadata directory if it doesn't exist pub fn create_metadata_dir(&self) -> Result<()> { let metadata_dir = self.metadata_dir(); fs::create_dir_all(&metadata_dir).map_err(|e| { ImageError::IO(std::io::Error::new( std::io::ErrorKind::Other, format!("Failed to create metadata directory at {:?}: {}", metadata_dir, e), )) }) } /// Creates the manifest directory if it doesn't exist pub fn create_manifest_dir(&self) -> Result<()> { let manifest_dir = self.manifest_dir(); fs::create_dir_all(&manifest_dir).map_err(|e| { ImageError::IO(std::io::Error::new( std::io::ErrorKind::Other, format!("Failed to create manifest directory at {:?}: {}", manifest_dir, e), )) }) } /// Creates the catalog directory if it doesn't exist pub fn create_catalog_dir(&self) -> Result<()> { let catalog_dir = self.catalog_dir(); fs::create_dir_all(&catalog_dir).map_err(|e| { ImageError::IO(std::io::Error::new( std::io::ErrorKind::Other, format!("Failed to create catalog directory at {:?}: {}", catalog_dir, e), )) }) } /// Initialize the installed packages database pub fn init_installed_db(&self) -> Result<()> { let db_path = self.installed_db_path(); // Create the installed packages database let installed = InstalledPackages::new(&db_path); installed.init_db().map_err(|e| { ImageError::Database(format!("Failed to initialize installed packages database: {}", e)) }) } /// Add a package to the installed packages database pub fn install_package(&self, fmri: &crate::fmri::Fmri, manifest: &crate::actions::Manifest) -> Result<()> { let installed = InstalledPackages::new(self.installed_db_path()); installed.add_package(fmri, manifest).map_err(|e| { ImageError::Database(format!("Failed to add package to installed database: {}", e)) }) } /// Remove a package from the installed packages database pub fn uninstall_package(&self, fmri: &crate::fmri::Fmri) -> Result<()> { let installed = InstalledPackages::new(self.installed_db_path()); installed.remove_package(fmri).map_err(|e| { ImageError::Database(format!("Failed to remove package from installed database: {}", e)) }) } /// Query the installed packages database for packages matching a pattern pub fn query_installed_packages(&self, pattern: Option<&str>) -> Result> { let installed = InstalledPackages::new(self.installed_db_path()); installed.query_packages(pattern).map_err(|e| { ImageError::Database(format!("Failed to query installed packages: {}", e)) }) } /// Get a manifest from the installed packages database pub fn get_manifest_from_installed(&self, fmri: &crate::fmri::Fmri) -> Result> { let installed = InstalledPackages::new(self.installed_db_path()); installed.get_manifest(fmri).map_err(|e| { ImageError::Database(format!("Failed to get manifest from installed database: {}", e)) }) } /// Check if a package is installed pub fn is_package_installed(&self, fmri: &crate::fmri::Fmri) -> Result { let installed = InstalledPackages::new(self.installed_db_path()); installed.is_installed(fmri).map_err(|e| { ImageError::Database(format!("Failed to check if package is installed: {}", e)) }) } /// Save a manifest into the metadata manifests directory for this image. /// /// The original, unprocessed manifest text is downloaded from the repository /// and stored under a flattened path: /// manifests//@.p5m /// Missing publisher will fall back to the image default publisher, then "unknown". pub fn save_manifest(&self, fmri: &crate::fmri::Fmri, _manifest: &crate::actions::Manifest) -> Result { // Determine publisher name let pub_name = if let Some(p) = &fmri.publisher { p.clone() } else if let Ok(def) = self.default_publisher() { def.name.clone() } else { "unknown".to_string() }; // Build directory path manifests/ (flattened, no stem subfolders) let dir_path = self.manifest_dir().join(&pub_name); std::fs::create_dir_all(&dir_path)?; // Encode helpers for filename parts fn url_encode(s: &str) -> String { let mut out = String::new(); for b in s.bytes() { match b { b'-' | b'_' | b'.' | b'~' | b'0'..=b'9' | b'a'..=b'z' | b'A'..=b'Z' => out.push(b as char), b' ' => out.push('+'), _ => { out.push('%'); out.push_str(&format!("{:02X}", b)); } } } out } let version = fmri.version(); let encoded_stem = url_encode(fmri.stem()); let encoded_version = url_encode(&version); let file_path = dir_path.join(format!("{}@{}.p5m", encoded_stem, encoded_version)); // Fetch raw manifest text from repository let publisher_name = pub_name.clone(); let raw_text = { // Look up publisher configuration let publisher = self.get_publisher(&publisher_name)?; let origin = &publisher.origin; if origin.starts_with("file://") { let path_str = origin.trim_start_matches("file://"); let path = std::path::PathBuf::from(path_str); let repo = crate::repository::FileBackend::open(&path)?; repo.fetch_manifest_text(&publisher_name, fmri)? } else { let mut repo = crate::repository::RestBackend::open(origin)?; // Set cache path for completeness let publisher_catalog_dir = self.catalog_dir().join(&publisher.name); repo.set_local_cache_path(&publisher_catalog_dir)?; repo.fetch_manifest_text(&publisher_name, fmri)? } }; // Write atomically let tmp_path = file_path.with_extension("p5m.tmp"); std::fs::write(&tmp_path, raw_text.as_bytes())?; std::fs::rename(&tmp_path, &file_path)?; Ok(file_path) } /// Initialize the catalog database pub fn init_catalog_db(&self) -> Result<()> { let catalog = ImageCatalog::new(self.catalog_dir(), self.catalog_db_path()); catalog.init_db().map_err(|e| { ImageError::Database(format!("Failed to initialize catalog database: {}", e)) }) } /// Download catalogs from all configured publishers and build the merged catalog pub fn download_catalogs(&self) -> Result<()> { // Create catalog directory if it doesn't exist self.create_catalog_dir()?; // Download catalogs for each publisher for publisher in &self.publishers { self.download_publisher_catalog(&publisher.name)?; } // Build the merged catalog self.build_catalog()?; Ok(()) } /// Refresh catalogs for specified publishers or all publishers if none specified /// /// # Arguments /// /// * `publishers` - Optional list of publishers to refresh. If empty, all publishers are refreshed. /// * `full` - If true, perform a full refresh by clearing existing catalog data before downloading. /// /// # Returns /// /// * `Result<()>` - Ok if all catalogs were refreshed successfully, Err otherwise pub fn refresh_catalogs(&self, publishers: &[String], full: bool) -> Result<()> { // Create catalog directory if it doesn't exist self.create_catalog_dir()?; // Determine which publishers to refresh let publishers_to_refresh: Vec<&Publisher> = if publishers.is_empty() { // If no publishers specified, refresh all self.publishers.iter().collect() } else { // Otherwise, filter publishers by name self.publishers.iter() .filter(|p| publishers.contains(&p.name)) .collect() }; // Check if we have any publishers to refresh if publishers_to_refresh.is_empty() { return Err(ImageError::NoPublishers); } // If full refresh is requested, clear the catalog directory for each publisher if full { for publisher in &publishers_to_refresh { let publisher_catalog_dir = self.catalog_dir().join(&publisher.name); if publisher_catalog_dir.exists() { fs::remove_dir_all(&publisher_catalog_dir) .map_err(|e| ImageError::IO(std::io::Error::new( std::io::ErrorKind::Other, format!("Failed to remove catalog directory for publisher {}: {}", publisher.name, e) )))?; } fs::create_dir_all(&publisher_catalog_dir) .map_err(|e| ImageError::IO(std::io::Error::new( std::io::ErrorKind::Other, format!("Failed to create catalog directory for publisher {}: {}", publisher.name, e) )))?; } } // Download catalogs for each publisher for publisher in publishers_to_refresh { self.download_publisher_catalog(&publisher.name)?; } // Build the merged catalog self.build_catalog()?; Ok(()) } /// Build the merged catalog from downloaded catalogs pub fn build_catalog(&self) -> Result<()> { // Initialize the catalog database if it doesn't exist self.init_catalog_db()?; // Get publisher names let publisher_names: Vec = self.publishers.iter() .map(|p| p.name.clone()) .collect(); // Create the catalog and build it let catalog = ImageCatalog::new(self.catalog_dir(), self.catalog_db_path()); catalog.build_catalog(&publisher_names).map_err(|e| { ImageError::Database(format!("Failed to build catalog: {}", e)) }) } /// Query the catalog for packages matching a pattern pub fn query_catalog(&self, pattern: Option<&str>) -> Result> { let catalog = ImageCatalog::new(self.catalog_dir(), self.catalog_db_path()); catalog.query_packages(pattern).map_err(|e| { ImageError::Database(format!("Failed to query catalog: {}", e)) }) } /// Get a manifest from the catalog pub fn get_manifest_from_catalog(&self, fmri: &crate::fmri::Fmri) -> Result> { let catalog = ImageCatalog::new(self.catalog_dir(), self.catalog_db_path()); catalog.get_manifest(fmri).map_err(|e| { ImageError::Database(format!("Failed to get manifest from catalog: {}", e)) }) } /// Fetch a full manifest for the given FMRI directly from its repository origin. /// /// This bypasses the local catalog database and retrieves the full manifest from /// the configured publisher origin (REST for http/https origins; File backend for /// file:// origins). A versioned FMRI is required. pub fn get_manifest_from_repository(&self, fmri: &crate::fmri::Fmri) -> Result { // Determine publisher: use FMRI's publisher if present, otherwise default publisher let publisher_name = if let Some(p) = &fmri.publisher { p.clone() } else { self.default_publisher()?.name.clone() }; // Look up publisher configuration let publisher = self.get_publisher(&publisher_name)?; let origin = &publisher.origin; // Require a concrete version in the FMRI if fmri.version().is_empty() { return Err(ImageError::Repository(RepositoryError::Other( "FMRI must include a version to fetch manifest".to_string(), ))); } // Choose backend based on origin scheme if origin.starts_with("file://") { let path_str = origin.trim_start_matches("file://"); let path = PathBuf::from(path_str); let mut repo = FileBackend::open(&path)?; repo.fetch_manifest(&publisher_name, fmri).map_err(Into::into) } else { let mut repo = RestBackend::open(origin)?; // Optionally set a per-publisher cache directory (used by other REST ops) let publisher_catalog_dir = self.catalog_dir().join(&publisher.name); repo.set_local_cache_path(&publisher_catalog_dir)?; repo.fetch_manifest(&publisher_name, fmri).map_err(Into::into) } } /// Download catalog for a specific publisher pub fn download_publisher_catalog(&self, publisher_name: &str) -> Result<()> { // Get the publisher let publisher = self.get_publisher(publisher_name)?; // Create a REST backend for the publisher let mut repo = RestBackend::open(&publisher.origin)?; // Set local cache path to the catalog directory for this publisher let publisher_catalog_dir = self.catalog_dir().join(&publisher.name); fs::create_dir_all(&publisher_catalog_dir)?; repo.set_local_cache_path(&publisher_catalog_dir)?; // Download the catalog repo.download_catalog(&publisher.name, None)?; Ok(()) } /// Create a new image with the basic directory structure /// /// This method only creates the image structure without adding publishers or downloading catalogs. /// Publisher addition and catalog downloading should be handled separately. /// /// # Arguments /// /// * `path` - The path where the image will be created /// * `image_type` - The type of image to create (Full or Partial) pub fn create_image>(path: P, image_type: ImageType) -> Result { // Create a new image based on the specified type let image = match image_type { ImageType::Full => Image::new_full(path.as_ref().to_path_buf()), ImageType::Partial => Image::new_partial(path.as_ref().to_path_buf()), }; // Create the directory structure image.create_metadata_dir()?; image.create_manifest_dir()?; image.create_catalog_dir()?; // Initialize the installed packages database image.init_installed_db()?; // Initialize the catalog database image.init_catalog_db()?; // Save the image image.save()?; Ok(image) } /// Saves the image data to the metadata directory pub fn save(&self) -> Result<()> { self.create_metadata_dir()?; let json_path = self.image_json_path(); let file = File::create(&json_path).map_err(|e| { ImageError::IO(std::io::Error::new( std::io::ErrorKind::Other, format!("Failed to create image JSON file at {:?}: {}", json_path, e), )) })?; serde_json::to_writer_pretty(file, self).map_err(ImageError::Json) } /// Loads an image from the specified path pub fn load>(path: P) -> Result { let path = path.as_ref(); // Check for both full and partial image JSON files let full_image = Image::new_full(path); let partial_image = Image::new_partial(path); let full_json_path = full_image.image_json_path(); let partial_json_path = partial_image.image_json_path(); // Determine which JSON file exists let json_path = if full_json_path.exists() { full_json_path } else if partial_json_path.exists() { partial_json_path } else { return Err(ImageError::InvalidPath(format!( "Image JSON file not found at either {:?} or {:?}", full_json_path, partial_json_path ))); }; let file = File::open(&json_path).map_err(|e| { ImageError::IO(std::io::Error::new( std::io::ErrorKind::Other, format!("Failed to open image JSON file at {:?}: {}", json_path, e), )) })?; serde_json::from_reader(file).map_err(ImageError::Json) } }