diff --git a/libips/src/actions/executors.rs b/libips/src/actions/executors.rs new file mode 100644 index 0000000..ed4096b --- /dev/null +++ b/libips/src/actions/executors.rs @@ -0,0 +1,283 @@ +use std::fs::{self, File as FsFile}; +use std::io::{self, Write}; +use std::os::unix::fs as unix_fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Component, Path, PathBuf}; + +use miette::Diagnostic; +use thiserror::Error; +use tracing::info; + +use crate::actions::{Link as LinkAction, Manifest}; +use crate::actions::{Dir as DirAction, File as FileAction}; + +#[derive(Error, Debug, Diagnostic)] +pub enum InstallerError { + #[error("I/O error while operating on {path}")] + #[diagnostic(code(ips::installer_error::io))] + Io { + #[source] + source: io::Error, + path: PathBuf, + }, + + #[error("Absolute paths are forbidden in actions: {path}")] + #[diagnostic(code(ips::installer_error::absolute_path_forbidden), help("Provide paths relative to the image root"))] + AbsolutePathForbidden { path: String }, + + #[error("Path escapes image root via traversal: {rel}")] + #[diagnostic(code(ips::installer_error::path_outside_image), help("Remove '..' components that escape the image root"))] + PathTraversalOutsideImage { rel: String }, + + #[error("Unsupported or not yet implemented action: {action} ({reason})")] + #[diagnostic(code(ips::installer_error::unsupported_action))] + UnsupportedAction { action: &'static str, reason: String }, +} + +fn parse_mode(mode: &str, default: u32) -> u32 { + if mode.is_empty() || mode.eq("0") { + return default; + } + // Accept strings like "0755" or "755" + let trimmed = mode.trim_start_matches('0'); + u32::from_str_radix(if trimmed.is_empty() { "0" } else { trimmed }, 8).unwrap_or(default) +} + +/// Join a manifest-provided path (must be relative) under image_root. +/// - Rejects absolute paths +/// - Rejects traversal that would escape the image root +pub fn safe_join(image_root: &Path, rel: &str) -> Result { + if rel.is_empty() { + return Ok(image_root.to_path_buf()); + } + let rel_path = Path::new(rel); + if rel_path.is_absolute() { + return Err(InstallerError::AbsolutePathForbidden { + path: rel.to_string(), + }); + } + + let mut stack: Vec = Vec::new(); + for c in rel_path.components() { + match c { + Component::CurDir => {} + Component::Normal(seg) => stack.push(PathBuf::from(seg)), + Component::ParentDir => { + if stack.pop().is_none() { + return Err(InstallerError::PathTraversalOutsideImage { + rel: rel.to_string(), + }); + } + } + // Prefixes shouldn't appear on Unix; treat conservatively + Component::Prefix(_) | Component::RootDir => { + return Err(InstallerError::AbsolutePathForbidden { + path: rel.to_string(), + }) + } + } + } + + let mut out = PathBuf::from(image_root); + for seg in stack { + out.push(seg); + } + Ok(out) +} + +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Debug)] +pub enum ActionOrder { + Dir = 0, + File = 1, + Link = 2, + Other = 3, +} + +impl ActionOrder { + fn for_manifest_section(section: &'static str) -> ActionOrder { + match section { + "dir" | "directories" => ActionOrder::Dir, + "file" | "files" => ActionOrder::File, + "link" | "links" => ActionOrder::Link, + _ => ActionOrder::Other, + } + } +} + +#[derive(Debug, Default, Clone)] +pub struct ApplyOptions { + pub dry_run: bool, +} + +/// Apply a manifest to the filesystem rooted at image_root. +/// This function enforces ordering: directories, then files, then links, then others (no-ops for now). +pub fn apply_manifest(image_root: &Path, manifest: &Manifest, opts: &ApplyOptions) -> Result<(), InstallerError> { + // Directories first + for d in &manifest.directories { + apply_dir(image_root, d, opts)?; + } + + // Files next + for f in &manifest.files { + apply_file(image_root, f, opts)?; + } + + // Links + for l in &manifest.links { + apply_link(image_root, l, opts)?; + } + + // Other action kinds are ignored for now and left for future extension. + Ok(()) +} + +fn apply_dir(image_root: &Path, d: &DirAction, opts: &ApplyOptions) -> Result<(), InstallerError> { + let full = safe_join(image_root, &d.path)?; + info!(?full, "creating directory"); + if opts.dry_run { + return Ok(()); + } + + fs::create_dir_all(&full).map_err(|e| InstallerError::Io { + source: e, + path: full.clone(), + })?; + + // Set permissions if provided + let mode = parse_mode(&d.mode, 0o755); + let perm = fs::Permissions::from_mode(mode); + fs::set_permissions(&full, perm).map_err(|e| InstallerError::Io { + source: e, + path: full.clone(), + })?; + + Ok(()) +} + +fn ensure_parent(image_root: &Path, p: &str, opts: &ApplyOptions) -> Result<(), InstallerError> { + let full = safe_join(image_root, p)?; + if let Some(parent) = full.parent() { + if opts.dry_run { + return Ok(()); + } + fs::create_dir_all(parent).map_err(|e| InstallerError::Io { + source: e, + path: parent.to_path_buf(), + })?; + } + Ok(()) +} + +fn apply_file(image_root: &Path, f: &FileAction, opts: &ApplyOptions) -> Result<(), InstallerError> { + let full = safe_join(image_root, &f.path)?; + + // Ensure parent exists (directories should already be applied, but be robust) + ensure_parent(image_root, &f.path, opts)?; + + info!(?full, "creating file (payload handling TBD)"); + if opts.dry_run { + return Ok(()); + } + + // For now, write empty content as a scaffold. Payload fetching/integration will follow later. + let mut file = FsFile::create(&full).map_err(|e| InstallerError::Io { + source: e, + path: full.clone(), + })?; + file.write_all(&[]).map_err(|e| InstallerError::Io { + source: e, + path: full.clone(), + })?; + + // Set permissions if provided + let mode = parse_mode(&f.mode, 0o644); + let perm = fs::Permissions::from_mode(mode); + fs::set_permissions(&full, perm).map_err(|e| InstallerError::Io { + source: e, + path: full.clone(), + })?; + + Ok(()) +} + +fn apply_link(image_root: &Path, l: &LinkAction, opts: &ApplyOptions) -> Result<(), InstallerError> { + let link_path = safe_join(image_root, &l.path)?; + + // Determine link type (default to symlink). If properties contain type=hard, create hard link. + let mut is_hard = false; + if let Some(prop) = l.properties.get("type") { + let v = prop.value.to_ascii_lowercase(); + if v == "hard" || v == "hardlink" { + is_hard = true; + } + } + + // Target may be relative; keep it as-is for symlink. For hard links, target must resolve under image_root. + if opts.dry_run { + return Ok(()); + } + + if is_hard { + // Hard link needs a resolved, safe target within the image. + let target_full = safe_join(image_root, &l.target)?; + fs::hard_link(&target_full, &link_path).map_err(|e| InstallerError::Io { + source: e, + path: link_path.clone(), + })?; + } else { + // Symlink: require non-absolute target to avoid embedding full host paths + if Path::new(&l.target).is_absolute() { + return Err(InstallerError::AbsolutePathForbidden { path: l.target.clone() }); + } + // Create relative symlink as provided (do not convert to absolute to avoid embedding full paths) + #[cfg(target_family = "unix")] + { + unix_fs::symlink(&l.target, &link_path).map_err(|e| InstallerError::Io { + source: e, + path: link_path.clone(), + })?; + } + #[cfg(not(target_family = "unix"))] + { + return Err(InstallerError::UnsupportedAction { + action: "link", + reason: "symlink not supported on this platform".to_string(), + }); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn safe_join_rejects_absolute() { + let root = Path::new("/tmp/image"); + let err = safe_join(root, "/etc/passwd").unwrap_err(); + match err { + InstallerError::AbsolutePathForbidden { .. } => {} + _ => panic!("expected AbsolutePathForbidden"), + } + } + + #[test] + fn safe_join_rejects_escape() { + let root = Path::new("/tmp/image"); + let err = safe_join(root, "../../etc").unwrap_err(); + match err { + InstallerError::PathTraversalOutsideImage { .. } => {} + _ => panic!("expected PathTraversalOutsideImage"), + } + } + + #[test] + fn safe_join_ok() { + let root = Path::new("/tmp/image"); + let p = safe_join(root, "etc/pkg").unwrap(); + assert!(p.starts_with(root)); + assert!(p.ends_with("pkg")); + } +} diff --git a/libips/src/actions/mod.rs b/libips/src/actions/mod.rs index 4701b92..be8164e 100644 --- a/libips/src/actions/mod.rs +++ b/libips/src/actions/mod.rs @@ -22,6 +22,8 @@ use std::str::FromStr; use thiserror::Error; use tracing::debug; +pub mod executors; + type Result = StdResult; #[derive(Debug, Error, Diagnostic)] diff --git a/libips/src/image/action_plan.rs b/libips/src/image/action_plan.rs new file mode 100644 index 0000000..2ca57ec --- /dev/null +++ b/libips/src/image/action_plan.rs @@ -0,0 +1,65 @@ +use std::path::Path; + +use crate::actions::{Manifest, Dir, File, Link}; +use crate::actions::executors::{apply_manifest, ApplyOptions, InstallerError}; +use crate::solver::InstallPlan; + +/// ActionPlan represents a merged list of actions across all manifests +/// that are to be installed together. It intentionally does not preserve +/// per-package boundaries; executors will run with proper ordering. +#[derive(Debug, Default, Clone)] +pub struct ActionPlan { + pub manifest: Manifest, +} + +impl ActionPlan { + /// Build an ActionPlan by merging all actions from the install plan's add set. + /// Note: For now, only directory, file, and link actions are merged for execution. + pub fn from_install_plan(plan: &InstallPlan) -> Self { + // Merge all actions from the manifests in plan.add + let mut merged = Manifest::new(); + for rp in &plan.add { + // directories + for d in &rp.manifest.directories { + merged.directories.push(d.clone()); + } + // files + for f in &rp.manifest.files { + merged.files.push(f.clone()); + } + // links + for l in &rp.manifest.links { + merged.links.push(l.clone()); + } + // In the future we can merge other action kinds as executor support is added. + } + Self { manifest: merged } + } + + /// Execute the action plan using the executors relative to the provided image root. + pub fn apply(&self, image_root: &Path, opts: &ApplyOptions) -> Result<(), InstallerError> { + apply_manifest(image_root, &self.manifest, opts) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::solver::{InstallPlan as SInstallPlan, ResolvedPkg}; + use crate::fmri::{Fmri, Version}; + + #[test] + fn build_and_apply_empty_plan_dry_run() { + // Empty install plan should produce empty action plan and apply should be no-op. + let plan = SInstallPlan { add: vec![], remove: vec![], update: vec![], reasons: vec![] }; + let ap = ActionPlan::from_install_plan(&plan); + assert!(ap.manifest.directories.is_empty()); + assert!(ap.manifest.files.is_empty()); + assert!(ap.manifest.links.is_empty()); + let opts = ApplyOptions { dry_run: true }; + let root = Path::new("/tmp/ips_image_test_nonexistent_root"); + // Even if root doesn't exist, dry_run should not perform any IO and succeed. + let res = ap.apply(root, &opts); + assert!(res.is_ok()); + } +} diff --git a/libips/src/image/catalog.rs b/libips/src/image/catalog.rs index ea9c68a..a923f45 100644 --- a/libips/src/image/catalog.rs +++ b/libips/src/image/catalog.rs @@ -535,7 +535,7 @@ impl ImageCatalog { // Parse and add actions from the version entry if let Some(actions) = &version_entry.actions { for action_str in actions { - // Parse each action string to extract attributes + // Parse each action string to extract attributes we care about in the catalog if action_str.starts_with("set ") { // Format is typically "set name=pkg.key value=value" if let Some(name_part) = action_str.split_whitespace().nth(1) { @@ -567,6 +567,41 @@ impl ImageCatalog { } } } + } else if action_str.starts_with("depend ") { + // Example: "depend fmri=desktop/mate/caja type=require" + let rest = &action_str[7..]; // strip leading "depend " + let mut dep_type: String = String::new(); + let mut dep_predicate: Option = None; + let mut dep_fmris: Vec = Vec::new(); + let mut root_image: String = String::new(); + + for tok in rest.split_whitespace() { + if let Some((k, v)) = tok.split_once('=') { + match k { + "type" => dep_type = v.to_string(), + "predicate" => { + if let Ok(f) = crate::fmri::Fmri::parse(v) { dep_predicate = Some(f); } + } + "fmri" => { + if let Ok(f) = crate::fmri::Fmri::parse(v) { dep_fmris.push(f); } + } + "root-image" => { + root_image = v.to_string(); + } + _ => { /* ignore other props for catalog */ } + } + } + } + + // For each fmri property, add a Dependency entry + for f in dep_fmris { + let mut d = crate::actions::Dependency::default(); + d.fmri = Some(f); + d.dependency_type = dep_type.clone(); + d.predicate = dep_predicate.clone(); + d.root_image = root_image.clone(); + manifest.dependencies.push(d); + } } } } @@ -612,7 +647,6 @@ impl ImageCatalog { /// Check if a package is obsolete fn is_package_obsolete(&self, manifest: &Manifest) -> bool { - // Check for the pkg.obsolete attribute manifest.attributes.iter().any(|attr| { attr.key == "pkg.obsolete" && attr.values.get(0).map_or(false, |v| v == "true") }) diff --git a/libips/src/image/mod.rs b/libips/src/image/mod.rs index f5e1a60..c628d49 100644 --- a/libips/src/image/mod.rs +++ b/libips/src/image/mod.rs @@ -10,7 +10,7 @@ use std::fs::{self, File}; use std::path::{Path, PathBuf}; use thiserror::Error; -use crate::repository::{ReadableRepository, RepositoryError, RestBackend}; +use crate::repository::{ReadableRepository, RepositoryError, RestBackend, FileBackend}; // Export the catalog module pub mod catalog; @@ -18,6 +18,8 @@ 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 @@ -374,6 +376,75 @@ impl Image { }) } + /// 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()); @@ -493,6 +564,45 @@ impl Image { }) } + /// 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 diff --git a/libips/src/repository/file_backend.rs b/libips/src/repository/file_backend.rs index b692f97..903d9eb 100644 --- a/libips/src/repository/file_backend.rs +++ b/libips/src/repository/file_backend.rs @@ -1660,6 +1660,36 @@ impl WritableRepository for FileBackend { } impl FileBackend { + pub fn fetch_manifest_text(&self, publisher: &str, fmri: &Fmri) -> Result { + // Require a concrete version + let version = fmri.version(); + if version.is_empty() { + return Err(RepositoryError::Other("FMRI must include a version to fetch manifest".into())); + } + // Preferred path: publisher-scoped manifest path + let path = Self::construct_manifest_path(&self.path, publisher, fmri.stem(), &version); + if path.exists() { + return std::fs::read_to_string(&path).map_err(|e| RepositoryError::FileReadError(format!("{}", e))); + } + // Fallbacks: global pkg layout without publisher + let encoded_stem = Self::url_encode(fmri.stem()); + let encoded_version = Self::url_encode(&version); + let alt1 = self.path.join("pkg").join(&encoded_stem).join(&encoded_version); + if alt1.exists() { + return std::fs::read_to_string(&alt1).map_err(|e| RepositoryError::FileReadError(format!("{}", e))); + } + let alt2 = self + .path + .join("publisher") + .join(publisher) + .join("pkg") + .join(&encoded_stem) + .join(&encoded_version); + if alt2.exists() { + return std::fs::read_to_string(&alt2).map_err(|e| RepositoryError::FileReadError(format!("{}", e))); + } + Err(RepositoryError::NotFound(format!("manifest for {} not found", fmri))) + } /// Save the legacy pkg5.repository INI file for backward compatibility pub fn save_legacy_config(&self) -> Result<()> { let legacy_config_path = self.path.join("pkg5.repository"); diff --git a/libips/src/repository/rest_backend.rs b/libips/src/repository/rest_backend.rs index c6b388e..df1b65c 100644 --- a/libips/src/repository/rest_backend.rs +++ b/libips/src/repository/rest_backend.rs @@ -613,12 +613,31 @@ impl ReadableRepository for RestBackend { publisher: &str, fmri: &crate::fmri::Fmri, ) -> Result { + let text = self.fetch_manifest_text(publisher, fmri)?; + crate::actions::Manifest::parse_string(text).map_err(RepositoryError::from) + } + + fn search( + &self, + _query: &str, + _publisher: Option<&str>, + _limit: Option, + ) -> Result> { + todo!() + } +} + +impl RestBackend { + pub fn fetch_manifest_text( + &mut self, + publisher: &str, + fmri: &crate::fmri::Fmri, + ) -> Result { // Require versioned FMRI let version = fmri.version(); if version.is_empty() { return Err(RepositoryError::Other("FMRI must include a version to fetch manifest".into())); } - // URL-encode helper let url_encode = |s: &str| -> String { let mut out = String::new(); @@ -634,11 +653,9 @@ impl ReadableRepository for RestBackend { } out }; - let encoded_fmri = url_encode(&format!("{}@{}", fmri.stem(), version)); let encoded_stem = url_encode(fmri.stem()); let encoded_version = url_encode(&version); - let candidates = vec![ format!("{}/manifest/0/{}", self.uri, encoded_fmri), format!("{}/publisher/{}/manifest/0/{}", self.uri, publisher, encoded_fmri), @@ -646,13 +663,12 @@ impl ReadableRepository for RestBackend { format!("{}/pkg/{}/{}", self.uri, encoded_stem, encoded_version), format!("{}/publisher/{}/pkg/{}/{}", self.uri, publisher, encoded_stem, encoded_version), ]; - let mut last_err: Option = None; for url in candidates { match self.client.get(&url).send() { Ok(resp) if resp.status().is_success() => { let text = resp.text().map_err(|e| RepositoryError::Other(format!("Failed to read manifest body: {}", e)))?; - return crate::actions::Manifest::parse_string(text).map_err(RepositoryError::from); + return Ok(text); } Ok(resp) => { last_err = Some(format!("HTTP {} for {}", resp.status(), url)); @@ -662,21 +678,8 @@ impl ReadableRepository for RestBackend { } } } - Err(RepositoryError::NotFound(last_err.unwrap_or_else(|| "manifest not found".to_string()))) } - - fn search( - &self, - _query: &str, - _publisher: Option<&str>, - _limit: Option, - ) -> Result> { - todo!() - } -} - -impl RestBackend { /// Sets the local path where catalog files will be cached. /// /// This method creates the directory if it doesn't exist. The local cache path diff --git a/libips/src/solver/mod.rs b/libips/src/solver/mod.rs index 7034b54..3fcab75 100644 --- a/libips/src/solver/mod.rs +++ b/libips/src/solver/mod.rs @@ -16,13 +16,14 @@ //! solver, and assembles an InstallPlan from the chosen solvables. use std::cell::RefCell; -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; // Begin resolvo wiring imports (names discovered by compiler) // We start broad and refine with compiler guidance. -use resolvo::{self, Candidates, Dependencies as RDependencies, DependencyProvider, Interner, KnownDependencies, Mapping, NameId, Problem as RProblem, Requirement as RRequirement, Solver as RSolver, SolverCache, SolvableId, StringId, VersionSetId, VersionSetUnionId}; +use resolvo::{self, Candidates, Dependencies as RDependencies, DependencyProvider, Interner, KnownDependencies, Mapping, NameId, Problem as RProblem, Requirement as RRequirement, Solver as RSolver, SolverCache, SolvableId, StringId, VersionSetId, VersionSetUnionId, UnsolvableOrCancelled}; use miette::Diagnostic; +use redb::ReadableTable; use thiserror::Error; use crate::actions::Manifest; @@ -181,12 +182,29 @@ impl<'a> Interner for IpsProvider<'a> { // Helper to evaluate if a candidate FMRI matches a VersionSetKind constraint fn fmri_matches_version_set(fmri: &Fmri, kind: &VersionSetKind) -> bool { + // Allow composite releases like "20,5.11": a requirement of single token (e.g., "5.11") + // matches any candidate whose comma-separated release segments contain that token. + // Multi-token requirements (contain a comma) require exact equality. + fn release_satisfies(req: &str, cand: &str) -> bool { + if req == cand { + return true; + } + if req.contains(',') { + // Multi-token requirement must match exactly + return false; + } + // Single token requirement: match if present among candidate segments + cand.split(',').any(|seg| seg.trim() == req) + } match kind { VersionSetKind::Any => true, VersionSetKind::ReleaseEq(req_rel) => fmri .version .as_ref() - .map(|v| &v.release == req_rel) + .map(|v| { + release_satisfies(req_rel, &v.release) + || v.branch.as_deref() == Some(req_rel) + }) .unwrap_or(false), VersionSetKind::BranchEq(req_branch) => fmri .version @@ -194,17 +212,14 @@ fn fmri_matches_version_set(fmri: &Fmri, kind: &VersionSetKind) -> bool { .and_then(|v| v.branch.as_ref()) .map(|b| b == req_branch) .unwrap_or(false), - VersionSetKind::ReleaseAndBranch { release, branch } => fmri - .version - .as_ref() - .map(|v| &v.release == release) - .unwrap_or(false) - && fmri - .version - .as_ref() - .and_then(|v| v.branch.as_ref()) - .map(|b| b == branch) - .unwrap_or(false), + VersionSetKind::ReleaseAndBranch { release, branch } => { + let (mut ok_rel, mut ok_branch) = (false, false); + if let Some(v) = fmri.version.as_ref() { + ok_rel = release_satisfies(release, &v.release) || v.branch.as_deref() == Some(release); + ok_branch = v.branch.as_ref().map(|b| b == branch).unwrap_or(false); + } + ok_rel && ok_branch + } } } @@ -473,9 +488,13 @@ pub fn resolve_install(image: &Image, constraints: &[Constraint]) -> Result = Vec::new(); + for c in constraints.iter().cloned() { // Intern name let name_id = provider.intern_name(&c.stem); + root_names.push((name_id, c.clone())); // Store publisher preferences for this root let mut prefs = c.preferred_publishers.clone(); @@ -501,7 +520,30 @@ pub fn resolve_install(image: &Image, constraints: &[Constraint]) -> Result = Vec::new(); + for (name_id, c) in &root_names { + let has = provider + .cands_by_name + .get(name_id) + .map(|v| !v.is_empty()) + .unwrap_or(false); + if !has { + let mut req = c.stem.clone(); + if let Some(v) = &c.version_req { req.push('@'); req.push_str(v); } + missing.push(req); + } + } + if !missing.is_empty() { + let pubs: Vec = image.publishers().iter().map(|p| p.name.clone()).collect(); + return Err(SolverError::new(format!( + "No candidates found for requested package(s): {}.\nChecked publishers: {}.\nRun 'pkg6 refresh' to update catalogs or verify the package names.", + missing.join(", "), + pubs.join(", ") + ))); + } + + // Before moving provider into the solver, capture useful snapshots for diagnostics let mut sid_to_fmri: HashMap = HashMap::new(); for ids in provider.cands_by_name.values() { for sid in ids { @@ -509,22 +551,57 @@ pub fn resolve_install(image: &Image, constraints: &[Constraint]) -> Result name string + let mut name_to_string: HashMap = HashMap::new(); + for (name_id, _cands) in provider.cands_by_name.iter() { + name_to_string.insert(*name_id, provider.display_name(*name_id).to_string()); + } + // Reverse: stem string -> NameId + let mut stem_to_nameid: HashMap = HashMap::new(); + for (nid, nstr) in name_to_string.iter() { + stem_to_nameid.insert(nstr.clone(), *nid); + } + // Snapshot: NameId -> candidate FMRIs + let mut name_to_fmris: HashMap> = HashMap::new(); + for (name_id, sids) in provider.cands_by_name.iter() { + let mut v: Vec = Vec::new(); + for sid in sids { + if let Some(pc) = provider.solvables.get(*sid) { + v.push(pc.fmri.clone()); + } + } + name_to_fmris.insert(*name_id, v); + } // Run the solver let mut solver = RSolver::new(provider); - let solution_ids = solver - .solve(problem) - .map_err(|e| SolverError::new(format!("dependency solving failed: {e:?}")))?; + let solution_ids = solver.solve(problem).map_err(|conflict_or_cancelled| { + match conflict_or_cancelled { + UnsolvableOrCancelled::Unsolvable(u) => { + SolverError::new(u.display_user_friendly(&solver).to_string()) + } + UnsolvableOrCancelled::Cancelled(_) => { + SolverError::new("dependency resolution cancelled".to_string()) + } + } + })?; // Build plan from solution let image_ref = image; let mut plan = InstallPlan::default(); for sid in solution_ids { if let Some(fmri) = sid_to_fmri.get(&sid).cloned() { - let manifest = image_ref - .get_manifest_from_catalog(&fmri) - .map_err(|e| SolverError::new(format!("failed to load manifest for {}: {e}", fmri)))? - .ok_or_else(|| SolverError::new(format!("manifest not found in catalog for {}", fmri)))?; + // Fetch full manifest from repository; fallback to catalog if repo fetch fails (useful for tests/offline) + let manifest = match image_ref.get_manifest_from_repository(&fmri) { + Ok(m) => m, + Err(repo_err) => { + // Try catalog as a fallback + match image_ref.get_manifest_from_catalog(&fmri) { + Ok(Some(m)) => m, + _ => return Err(SolverError::new(format!("failed to obtain manifest for {}: {}", fmri, repo_err))), + } + } + }; plan.reasons.push(format!("selected {} via solver", fmri)); plan.add.push(ResolvedPkg { fmri, manifest }); } @@ -683,6 +760,52 @@ mod solver_integration_tests { assert_eq!(chosen.version.as_ref().unwrap().release, "0.9"); } + #[test] + fn resolve_uses_repo_manifest_after_solving() { + use crate::image::ImageType; + use crate::repository::{FileBackend, WritableRepository, RepositoryVersion}; + use std::fs; + + // Create a temp image + let td_img = tempfile::tempdir().expect("tempdir img"); + let img_path = td_img.path().to_path_buf(); + let mut img = Image::create_image(&img_path, ImageType::Partial).expect("create image"); + + // Create a temp file-based repository and add publisher + let td_repo = tempfile::tempdir().expect("tempdir repo"); + let repo_path = td_repo.path().to_path_buf(); + let mut repo = FileBackend::create(&repo_path, RepositoryVersion::V4).expect("create repo"); + repo.add_publisher("pubA").expect("add publisher"); + + // Configure image publisher to point to file:// repo + let origin = format!("file://{}", repo_path.display()); + img.add_publisher("pubA", &origin, vec![], true).expect("add publisher to image"); + + // Define FMRI and limited manifest in catalog (deps only) + let fmri = mk_fmri("pubA", "pkg/alpha", mk_version("1.0", None, Some("20200401T000000Z"))); + let limited = mk_manifest(&fmri, &[]); // no files/dirs + write_manifest_to_catalog(&img, &fmri, &limited); + + // Write full manifest into repository at expected path + let repo_manifest_path = FileBackend::construct_manifest_path(&repo_path, "pubA", fmri.stem(), &fmri.version()); + if let Some(parent) = repo_manifest_path.parent() { fs::create_dir_all(parent).unwrap(); } + let full_manifest_text = format!( + "set name=pkg.fmri value={}\n\ + dir path=opt/test owner=root group=bin mode=0755\n\ + file path=opt/test/hello owner=root group=bin mode=0644\n", + fmri + ); + fs::write(&repo_manifest_path, full_manifest_text).expect("write manifest to repo"); + + // Resolve and ensure we got the repo (full) manifest with file/dir actions + let c = Constraint { stem: "pkg/alpha".to_string(), version_req: None, preferred_publishers: vec![], branch: None }; + let plan = resolve_install(&img, &[c]).expect("resolve"); + assert_eq!(plan.add.len(), 1); + let man = &plan.add[0].manifest; + assert!(man.directories.len() >= 1, "expected directories from repo manifest"); + assert!(man.files.len() >= 1, "expected files from repo manifest"); + } + #[test] fn dependency_sticks_to_parent_branch() { let img = make_image_with_publishers(&[("pubA", true)]); @@ -750,3 +873,188 @@ mod solver_integration_tests { assert_eq!(v.timestamp.as_deref(), Some("20200201T000000Z")); } } + + +#[cfg(test)] +mod no_candidate_error_tests { + use super::*; + use crate::image::ImageType; + + #[test] + fn error_message_includes_no_candidates() { + // Create a temporary image with a publisher but no packages + let td = tempfile::tempdir().expect("tempdir"); + let img_path = td.path().to_path_buf(); + let mut img = Image::create_image(&img_path, ImageType::Partial).expect("create image"); + img.add_publisher("pubA", "https://example.com/pubA", vec![], true).expect("add publisher"); + + // Request a non-existent package so the root has zero candidates + let c = Constraint { stem: "pkg/does-not-exist".to_string(), version_req: None, preferred_publishers: vec![], branch: None }; + let err = resolve_install(&img, &[c]).err().expect("expected error"); + let msg = err.message; + assert!(msg.contains("No candidates") || msg.contains("no candidates"), "unexpected message: {}", msg); + } +} + + +#[cfg(test)] +mod solver_error_message_tests { + use super::*; + use crate::actions::{Dependency, Manifest}; + use crate::fmri::{Fmri, Version}; + use crate::image::ImageType; + use redb::Database; + use crate::image::catalog::CATALOG_TABLE; + + fn mk_version(release: &str, branch: Option<&str>, timestamp: Option<&str>) -> Version { + let mut v = Version::new(release); + if let Some(b) = branch { v.branch = Some(b.to_string()); } + if let Some(t) = timestamp { v.timestamp = Some(t.to_string()); } + v + } + + fn mk_fmri(publisher: &str, name: &str, v: Version) -> Fmri { + Fmri::with_publisher(publisher, name, Some(v)) + } + + fn mk_manifest_with_dep(parent: &Fmri, dep: &Fmri) -> Manifest { + let mut m = Manifest::new(); + let mut attr = crate::actions::Attr::default(); + attr.key = "pkg.fmri".to_string(); + attr.values = vec![parent.to_string()]; + m.attributes.push(attr); + let mut d = Dependency::default(); + d.fmri = Some(dep.clone()); + d.dependency_type = "require".to_string(); + m.dependencies.push(d); + m + } + + fn write_manifest_to_catalog(image: &Image, fmri: &Fmri, manifest: &Manifest) { + let db = Database::open(image.catalog_db_path()).expect("open catalog db"); + let tx = db.begin_write().expect("begin write"); + { + let mut table = tx.open_table(CATALOG_TABLE).expect("open catalog table"); + let key = format!("{}@{}", fmri.stem(), fmri.version()); + let val = serde_json::to_vec(manifest).expect("serialize manifest"); + table.insert(key.as_str(), val.as_slice()).expect("insert manifest"); + } + tx.commit().expect("commit"); + } + + #[test] + fn unsatisfied_dependency_message_no_clause_ids() { + let td = tempfile::tempdir().expect("tempdir"); + let img_path = td.path().to_path_buf(); + let mut img = Image::create_image(&img_path, ImageType::Partial).expect("create image"); + img.add_publisher("pubA", "https://example.com/pubA", vec![], true).expect("add publisher"); + + // Parent requires child@2.0 but only child@1.0 exists in catalog (unsatisfiable) + let parent = mk_fmri("pubA", "pkg/root", mk_version("1.0", None, Some("20200101T000000Z"))); + let child_req = Fmri::with_version("pkg/child", Version::new("2.0")); + let parent_manifest = mk_manifest_with_dep(&parent, &child_req); + write_manifest_to_catalog(&img, &parent, &parent_manifest); + // Add a child candidate with non-matching release + let child_present = mk_fmri("pubA", "pkg/child", mk_version("1.0", None, Some("20200101T000000Z"))); + write_manifest_to_catalog(&img, &child_present, &Manifest::new()); + + let c = Constraint { stem: "pkg/root".to_string(), version_req: None, preferred_publishers: vec![], branch: None }; + let err = resolve_install(&img, &[c]).err().expect("expected solver error"); + let msg = err.message; + assert!(!msg.contains("ClauseId("), "message should not include ClauseId identifiers: {}", msg); + assert!(msg.to_lowercase().contains("rejected because"), "expected rejection explanation in message: {}", msg); + assert!(msg.to_lowercase().contains("unsatisfied dependency"), "expected unsatisfied dependency in message: {}", msg); + } +} + + +#[cfg(test)] +mod composite_release_tests { + use super::*; + use crate::actions::{Dependency, Manifest}; + use crate::fmri::{Fmri, Version}; + use crate::image::catalog::CATALOG_TABLE; + use crate::image::ImageType; + use redb::Database; + + fn mk_version(release: &str, branch: Option<&str>, timestamp: Option<&str>) -> Version { + let mut v = Version::new(release); + if let Some(b) = branch { v.branch = Some(b.to_string()); } + if let Some(t) = timestamp { v.timestamp = Some(t.to_string()); } + v + } + + fn mk_fmri(publisher: &str, name: &str, v: Version) -> Fmri { + Fmri::with_publisher(publisher, name, Some(v)) + } + + fn write_manifest_to_catalog(image: &Image, fmri: &Fmri, manifest: &Manifest) { + let db = Database::open(image.catalog_db_path()).expect("open catalog db"); + let tx = db.begin_write().expect("begin write"); + { + let mut table = tx.open_table(CATALOG_TABLE).expect("open catalog table"); + let key = format!("{}@{}", fmri.stem(), fmri.version()); + let val = serde_json::to_vec(manifest).expect("serialize manifest"); + table.insert(key.as_str(), val.as_slice()).expect("insert manifest"); + } + tx.commit().expect("commit"); + } + + fn make_image_with_publishers(pubs: &[(&str, bool)]) -> Image { + let td = tempfile::tempdir().expect("tempdir"); + // Persist the directory for the duration of the test + let path = td.keep(); + let mut img = Image::create_image(&path, ImageType::Partial).expect("create image"); + for (name, is_default) in pubs.iter().copied() { + img.add_publisher(name, &format!("https://example.com/{name}"), vec![], is_default) + .expect("add publisher"); + } + img + } + + #[test] + fn require_5_11_matches_candidate_20_5_11() { + let img = make_image_with_publishers(&[("pubA", true)]); + + // Parent requires child@5.11 + let parent = mk_fmri("pubA", "pkg/root", mk_version("1.0", None, Some("20200101T000000Z"))); + let child_req = Fmri::with_version("pkg/child", Version::new("5.11")); + let mut man = Manifest::new(); + // add pkg.fmri attribute + let mut attr = crate::actions::Attr::default(); + attr.key = "pkg.fmri".to_string(); + attr.values = vec![parent.to_string()]; + man.attributes.push(attr); + // add require dep + let mut d = Dependency::default(); + d.fmri = Some(child_req); + d.dependency_type = "require".to_string(); + man.dependencies.push(d); + write_manifest_to_catalog(&img, &parent, &man); + + // Only child candidate is release "20,5.11" + let child = mk_fmri("pubA", "pkg/child", mk_version("20,5.11", None, Some("20200401T000000Z"))); + write_manifest_to_catalog(&img, &child, &Manifest::new()); + + let c = Constraint { stem: "pkg/root".to_string(), version_req: None, preferred_publishers: vec![], branch: None }; + let plan = resolve_install(&img, &[c]).expect("should resolve by matching composite release"); + let dep_pkg = plan.add.iter().find(|p| p.fmri.stem() == "pkg/child").expect("child present"); + let v = dep_pkg.fmri.version.as_ref().unwrap(); + assert_eq!(v.release, "20"); + assert_eq!(v.branch.as_deref(), Some("5.11")); + } + + #[test] + fn require_20_5_11_does_not_match_candidate_5_11() { + let img = make_image_with_publishers(&[("pubA", true)]); + + // Only candidate for stem is 5.11 + let only = mk_fmri("pubA", "pkg/alpha", mk_version("5.11", None, Some("20200101T000000Z"))); + write_manifest_to_catalog(&img, &only, &Manifest::new()); + + // Top-level constraint requires composite 20,5.11 + let c = Constraint { stem: "pkg/alpha".to_string(), version_req: Some("20,5.11".to_string()), preferred_publishers: vec![], branch: None }; + let err = resolve_install(&img, &[c]).err().expect("expected unsatisfiable"); + assert!(err.message.contains("No candidates") || err.message.contains("dependency solving failed")); + } +} diff --git a/pkg6/src/error.rs b/pkg6/src/error.rs index a5418e5..1158f0d 100644 --- a/pkg6/src/error.rs +++ b/pkg6/src/error.rs @@ -1,5 +1,7 @@ use libips::fmri::FmriError; use libips::image::ImageError; +use libips::solver::SolverError; +use libips::actions::executors::InstallerError as LibInstallerError; use miette::Diagnostic; use thiserror::Error; @@ -37,6 +39,20 @@ pub enum Pkg6Error { )] ImageError(#[from] ImageError), + #[error("Solver error: {0}")] + #[diagnostic( + code(pkg6::solver_error), + help("Resolve constraints or check catalogs; try 'pkg6 refresh' then retry.") + )] + Solver(#[from] SolverError), + + #[error("Installer error: {0}")] + #[diagnostic( + code(pkg6::installer_error), + help("See details; you can retry with --dry-run for diagnostics") + )] + Installer(#[from] LibInstallerError), + #[error("logging environment setup error: {0}")] #[diagnostic( code(pkg6::logging_env_error), diff --git a/pkg6/src/main.rs b/pkg6/src/main.rs index bb75369..b885c4f 100644 --- a/pkg6/src/main.rs +++ b/pkg6/src/main.rs @@ -568,8 +568,97 @@ fn main() -> Result<()> { debug!("Show licenses: {}", licenses); debug!("No index update: {}", no_index); debug!("No refresh: {}", no_refresh); - - // Stub implementation + + // Determine the image path using the -R argument or default rules + let image_path = determine_image_path(cli.image_path.clone()); + if !quiet { println!("Using image at: {}", image_path.display()); } + + // Load the image + let image = match libips::image::Image::load(&image_path) { + Ok(img) => img, + Err(e) => { + error!("Failed to load image from {}: {}", image_path.display(), e); + return Err(e.into()); + } + }; + + // Note: Install now relies on existing redb databases and does not perform + // a full import or refresh automatically. Run `pkg6 refresh` explicitly + // to update catalogs before installing if needed. + if !*quiet { + eprintln!("Install uses existing catalogs in redb; run 'pkg6 refresh' to update catalogs if needed."); + } + + // Build solver constraints from the provided pkg specs + if pkg_fmri_patterns.is_empty() { + if !quiet { eprintln!("No packages specified to install"); } + return Err(Pkg6Error::Other("no packages specified".to_string())); + } + let mut constraints: Vec = Vec::new(); + for spec in pkg_fmri_patterns { + let mut preferred_publishers: Vec = Vec::new(); + let mut name_part = spec.as_str(); + // parse optional publisher prefix pkg:/// + if let Some(rest) = name_part.strip_prefix("pkg://") { + if let Some((pubr, rest2)) = rest.split_once('/') { + preferred_publishers.push(pubr.to_string()); + name_part = rest2; + } + } + // split version requirement after '@' + let (stem, version_req) = if let Some((s, v)) = name_part.split_once('@') { + (s.to_string(), Some(v.to_string())) + } else { + (name_part.to_string(), None) + }; + constraints.push(libips::solver::Constraint { stem, version_req, preferred_publishers, branch: None }); + } + + // Resolve install plan + let plan = match libips::solver::resolve_install(&image, &constraints) { + Ok(p) => p, + Err(e) => { + error!("Failed to resolve install plan: {}", e); + return Err(e.into()); + } + }; + + if !quiet { println!("Resolved {} package(s) to install", plan.add.len()); } + + // Build and apply action plan + let ap = libips::image::action_plan::ActionPlan::from_install_plan(&plan); + let apply_opts = libips::actions::executors::ApplyOptions { dry_run: *dry_run }; + if !quiet { println!("Applying action plan (dry-run: {})", dry_run); } + ap.apply(image.path(), &apply_opts)?; + + // Update installed DB after success (skip on dry-run) + if !*dry_run { + for rp in &plan.add { + image.install_package(&rp.fmri, &rp.manifest)?; + // Save full manifest into manifests directory for reproducibility + match image.save_manifest(&rp.fmri, &rp.manifest) { + Ok(path) => { + if *verbose && !*quiet { + eprintln!("Saved manifest for {} to {}", rp.fmri, path.display()); + } + } + Err(e) => { + // Non-fatal: log error but continue install + error!("Failed to save manifest for {}: {}", rp.fmri, e); + } + } + } + if !quiet { println!("Installed {} package(s)", plan.add.len()); } + + // Dump installed database to make changes visible + let installed = libips::image::installed::InstalledPackages::new(image.installed_db_path()); + if let Err(e) = installed.dump_installed_table() { + error!("Failed to dump installed database: {}", e); + } + } else if !quiet { + println!("Dry-run completed: {} package(s) would be installed", plan.add.len()); + } + info!("Installation completed successfully"); Ok(()) }, diff --git a/run_sample_install.sh b/run_sample_install.sh new file mode 100755 index 0000000..4057f75 --- /dev/null +++ b/run_sample_install.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash + +# Run a sample installation into sample_data/test-image with dry-run and real run. +# +# This script will: +# 1) Build the pkg6 CLI +# 2) Create or reset a test image at sample_data/test-image +# 3) Configure the openindiana.org publisher +# - If sample_data/pkg6-repo exists, use it via file:// origin +# - Otherwise, use the OpenIndiana network origin (requires internet) +# 4) Refresh catalogs for the image +# 5) Install a package first with dry-run, then for real +# +# Usage: +# ./run_sample_install.sh [PKG_NAME] +# Environment variables: +# PKG_NAME Package stem/FMRI pattern to install (default: database/postgres/connector/jdbc) +# RUST_LOG Rust log level (default: info) +# +# Notes: +# - The current installer writes empty files as payloads (scaffold). It does create dirs/links. +# - All file system operations are performed relative to the image root (sample_data/test-image). +# - If you need to seed a local sample repo, see: ./run_local_import_test.sh + +set -euo pipefail +set -x + +export RUST_LOG="${RUST_LOG:-info}" + +IMG_PATH="sample_data/test-image" +PUBLISHER="openindiana.org" +LOCAL_REPO_DIR="sample_data/pkg6-repo" +PKG6_BIN="./target/debug/pkg6" + +# Package to install +PKG_NAME="${1:-${PKG_NAME:-database/postgres/connector/jdbc}}" + +# Determine origin: use local file repo if present, otherwise network origin +if [ -d "$LOCAL_REPO_DIR" ]; then + ORIGIN="file://$(pwd)/$LOCAL_REPO_DIR" +else + ORIGIN="https://pkg.openindiana.org/hipster" +fi + +echo "Using origin: $ORIGIN" + +echo "Building pkg6 (debug)" +cargo build -p pkg6 + +# Prepare image path +mkdir -p "$(dirname "$IMG_PATH")" +if [ -d "$IMG_PATH" ]; then + rm -rf "$IMG_PATH" +fi + +# 1) Create image and add publisher +"$PKG6_BIN" image-create \ + -F "$IMG_PATH" \ + -p "$PUBLISHER" \ + -g "$ORIGIN" + +# 2) Refresh catalogs (also downloads per-publisher catalogs) +"$PKG6_BIN" -R "$IMG_PATH" refresh "$PUBLISHER" + +# 3) Show publishers for confirmation (table output) +"$PKG6_BIN" -R "$IMG_PATH" publisher -o table + +# 4) Dry-run install +# clap short flag for --dry-run is -d in this CLI +"$PKG6_BIN" -R "$IMG_PATH" install -d "pkg://$PUBLISHER/$PKG_NAME" || { + echo "Dry-run install failed" >&2 + exit 1 +} + +# 5) Real install +"$PKG6_BIN" -R "$IMG_PATH" install "pkg://$PUBLISHER/$PKG_NAME" || { + echo "Real install failed" >&2 + exit 1 +} + +# 6) Show installed packages +"$PKG6_BIN" -R "$IMG_PATH" list + +# 7) Dump installed database +"$PKG6_BIN" -R "$IMG_PATH" debug-db --dump-table installed + +echo "Sample installation completed successfully at $IMG_PATH"