mod properties; #[cfg(test)] mod tests; use miette::Diagnostic; use properties::*; use rusqlite::{Connection, OpenFlags}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs::{self, File}; use std::path::{Path, PathBuf}; use thiserror::Error; use crate::repository::{FileBackend, ReadableRepository, RepositoryError, RestBackend}; // 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, } impl From for ImageError { fn from(e: rusqlite::Error) -> Self { ImageError::Database(format!("SQLite error: {}", e)) } } 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.db") } /// 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 active catalog database (packages and dependencies) pub fn active_db_path(&self) -> PathBuf { self.metadata_dir().join("active.db") } /// Returns the path to the obsoleted packages database pub fn obsolete_db_path(&self) -> PathBuf { self.metadata_dir().join("obsolete.db") } /// Returns the path to the full-text search database pub fn fts_db_path(&self) -> PathBuf { self.metadata_dir().join("fts.db") } /// Deprecated: Use active_db_path() instead #[deprecated(note = "Use active_db_path() instead")] pub fn catalog_db_path(&self) -> PathBuf { self.active_db_path() } /// Deprecated: Use obsolete_db_path() instead #[deprecated(note = "Use obsolete_db_path() instead")] pub fn obsoleted_db_path(&self) -> PathBuf { self.obsolete_db_path() } /// 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<()> { // Precheck incorporation dependencies: fail if any stem already has a lock for d in &manifest.dependencies { if d.dependency_type == "incorporate" { if let Some(df) = &d.fmri { let stem = df.stem(); if let Some(_) = self.get_incorporated_release(stem)? { return Err(ImageError::Database(format!( "Incorporation lock already exists for stem {}", stem ))); } } } } // Add to installed database 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 )) })?; // Write incorporation locks for any incorporate dependencies for d in &manifest.dependencies { if d.dependency_type == "incorporate" { if let Some(df) = &d.fmri { let stem = df.stem(); let ver = df.version(); if !ver.is_empty() { // Store the full version string (release[,branch][-build][:timestamp]) // Ignore errors here? Better to propagate to ensure consistency self.add_incorporation_lock(stem, &ver)?; } } } } Ok(()) } /// 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 mut 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<()> { use crate::repository::sqlite_catalog::ACTIVE_SCHEMA; let path = self.active_db_path(); if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let conn = Connection::open(&path)?; conn.execute_batch(ACTIVE_SCHEMA) .map_err(|e| ImageError::Database(format!("Failed to initialize catalog database: {}", e)))?; Ok(()) } /// 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(), self.obsoleted_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(), self.obsoleted_db_path(), ); catalog .query_packages(pattern) .map_err(|e| ImageError::Database(format!("Failed to query catalog: {}", e))) } /// Look up an incorporation lock for a given stem. /// Returns Some(release) if a lock exists, otherwise None. pub fn get_incorporated_release(&self, stem: &str) -> Result> { let conn = Connection::open_with_flags( &self.active_db_path(), OpenFlags::SQLITE_OPEN_READ_ONLY, ) .map_err(|e| ImageError::Database(format!("Failed to open catalog database: {}", e)))?; let result = conn.query_row( "SELECT release FROM incorporate_locks WHERE stem = ?1", rusqlite::params![stem], |row| row.get(0), ); match result { Ok(release) => Ok(Some(release)), Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), Err(e) => Err(ImageError::Database(format!( "Failed to read incorporate lock: {}", e ))), } } /// Add an incorporation lock for a stem to a specific release. /// Uses INSERT OR REPLACE, so will update if a lock already exists. pub fn add_incorporation_lock(&self, stem: &str, release: &str) -> Result<()> { let mut conn = Connection::open(&self.active_db_path()) .map_err(|e| ImageError::Database(format!("Failed to open catalog database: {}", e)))?; let tx = conn.transaction().map_err(|e| { ImageError::Database(format!("Failed to begin write transaction: {}", e)) })?; tx.execute( "INSERT OR REPLACE INTO incorporate_locks (stem, release) VALUES (?1, ?2)", rusqlite::params![stem, release], ) .map_err(|e| ImageError::Database(format!("Failed to insert incorporate lock: {}", e)))?; tx.commit().map_err(|e| { ImageError::Database(format!("Failed to commit incorporate lock: {}", e)) })?; Ok(()) } /// Get a manifest from the catalog. /// First checks the local manifest cache on disk, then falls back to repository fetch. /// Note: active.db does NOT store manifest blobs - manifests are served from the repository. pub fn get_manifest_from_catalog( &self, fmri: &crate::fmri::Fmri, ) -> Result> { // Helper to URL-encode filename components fn url_encode(s: &str) -> String { s.chars() .map(|c| match c { 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' | '~' => c.to_string(), ' ' => "+".to_string(), _ => { let mut buf = [0u8; 4]; let bytes = c.encode_utf8(&mut buf).as_bytes(); bytes.iter().map(|b| format!("%{:02X}", b)).collect() } }) .collect() } // Check local manifest cache on disk let publisher = fmri.publisher.as_deref().unwrap_or(""); let manifest_dir = self.manifest_dir().join(publisher); let stem_encoded = url_encode(fmri.stem()); let version_encoded = url_encode(&fmri.version()); let manifest_path = manifest_dir.join(format!("{}@{}.p5m", stem_encoded, version_encoded)); if manifest_path.exists() { let content = fs::read_to_string(&manifest_path)?; let manifest = crate::actions::Manifest::parse_string(content) .map_err(|e| ImageError::Database(format!("Failed to parse manifest: {}", e)))?; return Ok(Some(manifest)); } // Check catalog shards for a minimal manifest let catalog = crate::image::catalog::ImageCatalog::new( self.catalog_dir(), self.active_db_path(), self.obsolete_db_path(), ); if let Ok(Some(manifest)) = catalog.get_manifest(fmri) { return Ok(Some(manifest)); } // Fall back to repository fetch match self.get_manifest_from_repository(fmri) { Ok(manifest) => Ok(Some(manifest)), Err(_) => Ok(None), } } /// 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) } }