diff --git a/crates/pkg-cli/Cargo.toml b/crates/pkg-cli/Cargo.toml index fdc704c..0a7de7d 100644 --- a/crates/pkg-cli/Cargo.toml +++ b/crates/pkg-cli/Cargo.toml @@ -136,3 +136,8 @@ path = "../../tests/test_upgrade_apply.rs" [[test]] name = "test_curated_registry" path = "../../tests/test_curated_registry.rs" + +[[test]] +name = "test_cli_no_deps" +path = "../../tests/test_cli_no_deps.rs" + diff --git a/crates/pkg-cli/src/main.rs b/crates/pkg-cli/src/main.rs index a3c7a93..f1574ac 100644 --- a/crates/pkg-cli/src/main.rs +++ b/crates/pkg-cli/src/main.rs @@ -72,6 +72,10 @@ enum Commands { #[arg(long = "ignore-missing-libs")] ignore_missing_libs: bool, + /// Skip resolving and installing declared dependencies + #[arg(long = "no-deps", alias = "skip-deps")] + no_deps: bool, + /// Interactively select from matching package candidates #[arg(short = 'i', long = "interactive")] interactive: bool, @@ -348,6 +352,7 @@ async fn run() -> Result<()> { yes, ignore_missing_libs, interactive, + no_deps, } => { let config_path = engine.layout().base_dir().join("repositories.toml"); let config = if config_path.exists() { @@ -639,7 +644,7 @@ async fn run() -> Result<()> { // The planner will validate the resulting installed snapshot // again, so a failed dependency can never be reported as a // successful install. - if !dry_run { + if !dry_run && !no_deps { if let Err(e) = install_declared_dependencies( &engine, &artifact_path, @@ -684,27 +689,29 @@ async fn run() -> Result<()> { } let mut detected_deps = Vec::new(); - for dep in &preflight.package.dependencies { - let dep_name = dep.name.as_str(); - if preflight - .missing_libraries - .iter() - .any(|m| matches_missing_library(dep_name, m)) - { - if let Ok(RemoteResolution::Exact(p)) = engine - .resolve_dependency_package( - dep_name, - preferred_repo.as_deref(), - preferred_format.as_deref(), - config.as_ref(), - ) + if !no_deps { + for dep in &preflight.package.dependencies { + let dep_name = dep.name.as_str(); + if preflight + .missing_libraries + .iter() + .any(|m| matches_missing_library(dep_name, m)) { - if !detected_deps.iter().any( - |d: &pkg_core::domain::package::RemotePackage| { - d.name == p.name - }, - ) { - detected_deps.push(p); + if let Ok(RemoteResolution::Exact(p)) = engine + .resolve_dependency_package( + dep_name, + preferred_repo.as_deref(), + preferred_format.as_deref(), + config.as_ref(), + ) + { + if !detected_deps.iter().any( + |d: &pkg_core::domain::package::RemotePackage| { + d.name == p.name + }, + ) { + detected_deps.push(p); + } } } } @@ -740,6 +747,7 @@ async fn run() -> Result<()> { false, InstallOptions { allow_missing_libraries: false, + skip_dependencies: false, }, ) { Ok(_) => { @@ -820,6 +828,7 @@ async fn run() -> Result<()> { dry_run, InstallOptions { allow_missing_libraries: allow_missing, + skip_dependencies: no_deps, }, ); @@ -1262,7 +1271,9 @@ async fn run() -> Result<()> { r.id, r.distro, r.format, status, desc ); } - println!("\nTip: Run `pkg repo add ` to enable an official repository."); + println!( + "\nTip: Run `pkg repo add ` to enable an official repository." + ); } return Ok(()); } @@ -2434,6 +2445,10 @@ async fn mcp_call_tool( .get("allow_missing_libs") .and_then(serde_json::Value::as_bool) .unwrap_or(false); + let no_deps = arguments + .get("no_deps") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); let config_path = engine.layout().base_dir().join("repositories.toml"); let config = config_path .is_file() @@ -2453,22 +2468,24 @@ async fn mcp_call_tool( } }; let artifact = download_with_progress(engine, &remote, true).await?; - install_declared_dependencies( - engine, - &artifact, - profile, - Some(&remote.repository_id), - Some(&remote.format), - config.as_ref(), - true, - true, - &[], - 1, - ) - .await?; + if !no_deps { + install_declared_dependencies( + engine, + &artifact, + profile, + Some(&remote.repository_id), + Some(&remote.format), + config.as_ref(), + true, + true, + &[], + 1, + ) + .await?; + } artifact }; - if std::path::Path::new(target).is_file() { + if !no_deps && std::path::Path::new(target).is_file() { install_declared_dependencies( engine, &artifact, @@ -2489,6 +2506,7 @@ async fn mcp_call_tool( false, InstallOptions { allow_missing_libraries: allow_missing, + skip_dependencies: no_deps, }, )?)?) } diff --git a/crates/pkg-core/src/engine.rs b/crates/pkg-core/src/engine.rs index 82c1e17..5cedbb5 100644 --- a/crates/pkg-core/src/engine.rs +++ b/crates/pkg-core/src/engine.rs @@ -53,6 +53,8 @@ pub enum PackageInfo { pub struct InstallOptions { /// If true, missing shared libraries detected in ELF binaries will not abort installation. pub allow_missing_libraries: bool, + /// If true, package dependency resolution is skipped. + pub skip_dependencies: bool, } /// Report returned by preflight inspection of an uninstalled package artifact. @@ -422,13 +424,31 @@ impl Engine { profile: &str, replaced_packages: &[crate::domain::package::PackageName], ) -> Result { - Planner::plan_install_with_replacements( + self.plan_install_with_options_replacing( + artifact_path, + profile, + replaced_packages, + &InstallOptions::default(), + ) + } + + /// Plans an install while treating the listed installed package names + /// as replacements in the same upgrade transaction and applying the specified options. + pub fn plan_install_with_options_replacing( + &self, + artifact_path: &Path, + profile: &str, + replaced_packages: &[crate::domain::package::PackageName], + options: &InstallOptions, + ) -> Result { + Planner::plan_install_with_replacements_and_options( artifact_path, &self.layout, &self.db, profile, true, replaced_packages, + options, ) } @@ -498,7 +518,12 @@ impl Engine { replaced_packages: &[crate::domain::package::PackageName], ) -> Result { if is_dry_run { - return self.plan_install_with_replacements(artifact_path, profile, replaced_packages); + return self.plan_install_with_options_replacing( + artifact_path, + profile, + replaced_packages, + &options, + ); } // Acquire process lock to prevent concurrent state mutations (INV-012) @@ -508,13 +533,14 @@ impl Engine { let _ = Recovery::reconcile(&self.layout, &self.db)?; // Plan installation - let mut plan = Planner::plan_install_with_replacements( + let mut plan = Planner::plan_install_with_replacements_and_options( artifact_path, &self.layout, &self.db, profile, false, replaced_packages, + &options, )?; let old_pkg = self.db.get_package(profile, plan.package.name.as_str())?; let old_binaries = self @@ -1616,7 +1642,9 @@ impl Engine { Ok(Ok((repo, packages))) => successes.push((repo, packages)), Ok(Err(err)) => failures.push(err), Err(join_err) => { - failures.push(Error::Internal(format!("Repository task join error: {join_err}"))); + failures.push(Error::Internal(format!( + "Repository task join error: {join_err}" + ))); } } } diff --git a/crates/pkg-core/src/host/mod.rs b/crates/pkg-core/src/host/mod.rs index 07f9cde..97ee66f 100644 --- a/crates/pkg-core/src/host/mod.rs +++ b/crates/pkg-core/src/host/mod.rs @@ -143,7 +143,6 @@ impl HostFacts { } } - #[cfg(test)] mod tests { use super::*; diff --git a/crates/pkg-core/src/planner/mod.rs b/crates/pkg-core/src/planner/mod.rs index 61f0868..9c04073 100644 --- a/crates/pkg-core/src/planner/mod.rs +++ b/crates/pkg-core/src/planner/mod.rs @@ -42,6 +42,28 @@ impl Planner { profile: &str, is_dry_run: bool, replaced_packages: &[PackageName], + ) -> Result { + Self::plan_install_with_replacements_and_options( + artifact_path, + layout, + db, + profile, + is_dry_run, + replaced_packages, + &crate::engine::InstallOptions::default(), + ) + } + + /// Plans an install while treating the listed installed package names as + /// replacements in the same transaction and respecting customized install options. + pub fn plan_install_with_replacements_and_options( + artifact_path: &Path, + layout: &StoreLayout, + db: &StateDatabase, + profile: &str, + is_dry_run: bool, + replaced_packages: &[PackageName], + options: &crate::engine::InstallOptions, ) -> Result { StoreLayout::validate_profile(profile)?; let format = crate::format::detect_format(artifact_path)?; @@ -58,10 +80,9 @@ impl Planner { } // Resolve declared package/capability constraints before producing an - // install plan. The resolver receives only immutable snapshots and - // evidence derived from the host and pkg-owned installed payloads. + // install plan, unless dependency resolution is explicitly skipped. let mut resolved_dependencies = Vec::new(); - if !package.constraints.is_empty() { + if !options.skip_dependencies && !package.constraints.is_empty() { let mut host_evidence = HostEvidence::detect(&host); let profile_lib_dir = layout.profile_lib_dir(profile); if profile_lib_dir.is_dir() diff --git a/crates/pkg-core/src/repository/config.rs b/crates/pkg-core/src/repository/config.rs index 305734b..63fcc90 100644 --- a/crates/pkg-core/src/repository/config.rs +++ b/crates/pkg-core/src/repository/config.rs @@ -172,7 +172,11 @@ impl CuratedRegistry { .timeout(std::time::Duration::from_secs(4)) .build(); if let Ok(client) = client { - if let Ok(resp) = client.get("https://pkg.atlantic.sh/repositories").send().await { + if let Ok(resp) = client + .get("https://pkg.atlantic.sh/repositories") + .send() + .await + { if resp.status().is_success() { if let Ok(bytes) = resp.bytes().await { if let Ok(registry) = serde_json::from_slice::(&bytes) { @@ -188,7 +192,9 @@ impl CuratedRegistry { /// Finds a repository by ID (case-insensitive). #[must_use] pub fn find_by_id(&self, id: &str) -> Option<&CuratedRepository> { - self.repositories.iter().find(|r| r.id.eq_ignore_ascii_case(id)) + self.repositories + .iter() + .find(|r| r.id.eq_ignore_ascii_case(id)) } /// Selects the repositories that should be enabled by default for a host distribution. @@ -340,7 +346,9 @@ impl RepositoriesConfig { pub fn default_for_host_with_registry(registry: &CuratedRegistry) -> Self { let (distro_id, distro_id_like) = crate::host::HostFacts::detect_distro(); let metadata = crate::host::HostFacts::release_metadata(); - let codename = metadata.get("os_release_VERSION_CODENAME").map(|s| s.as_str()); + let codename = metadata + .get("os_release_VERSION_CODENAME") + .map(|s| s.as_str()); let version_id = metadata.get("os_release_VERSION_ID").map(|s| s.as_str()); let mut matched = if let Some(ref d) = distro_id { @@ -360,7 +368,9 @@ impl RepositoriesConfig { } let repos = matched.into_iter().map(RepositoryConfig::from).collect(); - RepositoriesConfig { repositories: repos } + RepositoriesConfig { + repositories: repos, + } } /// Loads the repository configuration from the specified TOML file. diff --git a/crates/pkg-core/src/repository/deb.rs b/crates/pkg-core/src/repository/deb.rs index 06ef2f6..fc57931 100644 --- a/crates/pkg-core/src/repository/deb.rs +++ b/crates/pkg-core/src/repository/deb.rs @@ -64,7 +64,7 @@ pub async fn update_debian_repository( ); let checksums = parse_release_checksums(&release)?; let host_arch = get_debian_architecture(); - let fetches = futures::stream::iter(components.to_vec().into_iter().map(|component| { + let fetches = futures::stream::iter(components.iter().cloned().map(|component| { let client = &client; let checksums = &checksums; async move { diff --git a/crates/pkg-core/src/resolver/evidence.rs b/crates/pkg-core/src/resolver/evidence.rs index 298d8e7..8d6ade7 100644 --- a/crates/pkg-core/src/resolver/evidence.rs +++ b/crates/pkg-core/src/resolver/evidence.rs @@ -8,7 +8,9 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use crate::domain::capability::Capability; +use crate::domain::constraint::VersionConstraint; use crate::domain::package::{Architecture, PackageVersion}; +use crate::domain::version::VersionEcosystem; use crate::host::HostFacts; /// Standard host Linux library search paths for 64-bit systems. @@ -34,6 +36,19 @@ pub struct CapabilityEvidence { pub symbols: Vec, } +/// Verified evidence of a native package installed on the host operating system. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HostPackageEvidence { + /// Nominal name of the native package. + pub name: String, + /// Host package version. + pub version: PackageVersion, + /// Host packaging ecosystem (e.g. `debian`, `alpm`, `rpm`). + pub ecosystem: String, + /// Explicit capabilities provided by the host package (e.g. features, virtual names, libraries). + pub provides: Vec, +} + /// Verified evidence about the host environment used for capability and ABI evaluation. #[derive(Debug, Clone, PartialEq, Eq)] pub struct HostEvidence { @@ -43,6 +58,8 @@ pub struct HostEvidence { pub provided_capabilities: HashMap, /// Verified linker search paths on the host. pub library_search_paths: Vec, + /// Native packages discovered on the host system. + pub host_packages: HashMap, } impl HostEvidence { @@ -63,23 +80,8 @@ impl HostEvidence { } } - // Detect common host executables - for bin in &[ - "sh", - "bash", - "coreutils", - "tar", - "gzip", - "python", - "python3", - "perl", - "ruby", - "node", - ] { - if Path::new("/bin").join(bin).exists() || Path::new("/usr/bin").join(bin).exists() { - builder = builder.add_executable(bin); - } - } + // Broad dynamic discovery of host executables + detect_executables(&mut builder); // Detect libc SONAME on host if present for &dir in STANDARD_LIB_SEARCH_DIRS { @@ -90,12 +92,6 @@ impl HostEvidence { .flatten() .map(|inspection| inspection.defined_symbol_versions) .unwrap_or_default(); - // The host's libc package version is not exposed through a - // portable API, but glibc's exported symbol versions provide - // a conservative capability floor (for example GLIBC_2.34). - // Recording the highest numeric GLIBC symbol lets a Debian - // `libc6 (>= ...)` requirement use real ABI evidence instead - // of treating an unversioned host capability as a match. let inferred_version = symbols .iter() .filter_map(|symbol| symbol.strip_prefix("GLIBC_")) @@ -113,6 +109,13 @@ impl HostEvidence { } } + // Broad detection of standard desktop and system subsystems + detect_subsystems(&mut builder); + + // Native host package manager inspection + detect_pacman_local(&mut builder); + detect_dpkg_status(&mut builder); + builder.build() } @@ -121,6 +124,37 @@ impl HostEvidence { self.provided_capabilities.get(cap_str) } + /// Checks whether the host provides the requested feature/token. + pub fn provides_feature(&self, name: &str) -> Option<&CapabilityEvidence> { + self.provided_capabilities + .get(&format!("feature:{name}")) + .or_else(|| self.provided_capabilities.get(name)) + } + + /// Checks whether the host provides the requested native package matching ecosystem and version constraint. + pub fn provides_package( + &self, + name: &str, + version: &VersionConstraint, + target_ecosystem: &str, + ) -> Option<&HostPackageEvidence> { + if let Some(pkg) = self.host_packages.get(name) { + if target_ecosystem.is_empty() || target_ecosystem.eq_ignore_ascii_case(&pkg.ecosystem) + { + let eco = match pkg.ecosystem.as_str() { + "debian" | "ubuntu" => VersionEcosystem::Debian, + "rpm" | "fedora" | "rhel" | "suse" | "centos" => VersionEcosystem::Rpm, + "alpm" | "arch" => VersionEcosystem::Alpm, + _ => VersionEcosystem::Debian, + }; + if version.matches(pkg.version.as_str(), eco) { + return Some(pkg); + } + } + } + None + } + /// Checks if a dynamic library SONAME exists in any verified host library search path. pub fn has_soname(&self, soname: &str) -> bool { let lib_key = format!("lib:{soname}"); @@ -181,6 +215,7 @@ pub struct HostEvidenceBuilder { architecture: Option, provided_capabilities: HashMap, library_search_paths: Vec, + host_packages: HashMap, } impl HostEvidenceBuilder { @@ -196,8 +231,8 @@ impl HostEvidenceBuilder { self } - /// Registers a shared library capability with optional version and exported symbol versions. - pub fn add_library(mut self, soname: &str, version: Option<&str>, symbols: &[&str]) -> Self { + /// In-place registration of a shared library capability. + pub fn add_library_in_place(&mut self, soname: &str, version: Option<&str>, symbols: &[&str]) { let key = format!("lib:{soname}"); let evidence = CapabilityEvidence { capability: Capability::SharedLibrary(soname.to_string()), @@ -206,11 +241,16 @@ impl HostEvidenceBuilder { symbols: symbols.iter().map(|s| s.to_string()).collect(), }; self.provided_capabilities.insert(key, evidence); + } + + /// Registers a shared library capability with optional version and exported symbol versions. + pub fn add_library(mut self, soname: &str, version: Option<&str>, symbols: &[&str]) -> Self { + self.add_library_in_place(soname, version, symbols); self } - /// Registers an executable binary capability. - pub fn add_executable(mut self, command: &str) -> Self { + /// In-place registration of an executable binary capability. + pub fn add_executable_in_place(&mut self, command: &str) { let key = format!("bin:{command}"); let evidence = CapabilityEvidence { capability: Capability::Executable(command.to_string()), @@ -219,19 +259,76 @@ impl HostEvidenceBuilder { symbols: Vec::new(), }; self.provided_capabilities.insert(key, evidence); + } + + /// Registers an executable binary capability. + pub fn add_executable(mut self, command: &str) -> Self { + self.add_executable_in_place(command); self } + /// In-place registration of a feature capability. + pub fn add_feature_in_place(&mut self, feature: &str) { + let key = format!("feature:{feature}"); + let evidence = CapabilityEvidence { + capability: Capability::Feature(feature.to_string()), + version: None, + provider_origin: "host:system".to_string(), + symbols: Vec::new(), + }; + self.provided_capabilities.insert(key, evidence); + } + /// Registers a generic or virtual feature capability. pub fn add_feature(mut self, feature: &str) -> Self { + self.add_feature_in_place(feature); + self + } + + /// In-place registration of a versioned feature capability. + pub fn add_feature_with_version_in_place(&mut self, feature: &str, version: Option<&str>) { let key = format!("feature:{feature}"); let evidence = CapabilityEvidence { capability: Capability::Feature(feature.to_string()), - version: None, + version: version.map(PackageVersion::new), provider_origin: "host:system".to_string(), symbols: Vec::new(), }; self.provided_capabilities.insert(key, evidence); + } + + /// Registers a versioned feature capability. + pub fn add_feature_with_version(mut self, feature: &str, version: Option<&str>) -> Self { + self.add_feature_with_version_in_place(feature, version); + self + } + + /// In-place registration of native package evidence. + pub fn add_host_package_in_place( + &mut self, + name: &str, + version: &str, + ecosystem: &str, + provides: Vec, + ) { + let evidence = HostPackageEvidence { + name: name.to_string(), + version: PackageVersion::new(version), + ecosystem: ecosystem.to_string(), + provides, + }; + self.host_packages.insert(name.to_string(), evidence); + } + + /// Registers native package evidence. + pub fn add_host_package( + mut self, + name: &str, + version: &str, + ecosystem: &str, + provides: Vec, + ) -> Self { + self.add_host_package_in_place(name, version, ecosystem, provides); self } @@ -241,10 +338,257 @@ impl HostEvidenceBuilder { architecture: self.architecture.unwrap_or(Architecture::X86_64), provided_capabilities: self.provided_capabilities, library_search_paths: self.library_search_paths, + host_packages: self.host_packages, } } } +/// Dynamically discovers executables in standard system binary directories. +fn detect_executables(builder: &mut HostEvidenceBuilder) { + for bin_dir in &["/bin", "/usr/bin"] { + let path = Path::new(bin_dir); + let Ok(entries) = std::fs::read_dir(path) else { + continue; + }; + for entry in entries.flatten() { + if let Ok(file_type) = entry.file_type() { + if file_type.is_file() || file_type.is_symlink() { + if let Some(name) = entry.file_name().to_str() { + builder.add_executable_in_place(name); + } + } + } + } + } +} + +/// Detects standard Linux desktop and system subsystems. +fn detect_subsystems(builder: &mut HostEvidenceBuilder) { + // 1. D-Bus & IPC + let has_dbus = Path::new("/usr/bin/dbus-daemon").exists() + || Path::new("/usr/bin/dbus-send").exists() + || Path::new("/usr/share/dbus-1").exists() + || Path::new("/run/dbus/system_bus_socket").exists() + || std::env::var_os("DBUS_SESSION_BUS_ADDRESS").is_some(); + if has_dbus { + builder.add_feature_in_place("dbus"); + builder.add_feature_in_place("dbus-session-bus"); + builder.add_feature_in_place("default-dbus-session-bus"); + builder.add_feature_in_place("dbus-user-session"); + builder.add_feature_in_place("dbus-system-bus"); + } + + // 2. GSettings & DConf desktop configuration + let has_gsettings = Path::new("/usr/share/glib-2.0/schemas").exists() + || Path::new("/usr/bin/dconf").exists() + || Path::new("/usr/lib/dconf").exists() + || Path::new("/usr/lib64/dconf").exists() + || Path::new("/usr/lib/x86_64-linux-gnu/dconf").exists(); + if has_gsettings { + builder.add_feature_in_place("gsettings-backend"); + builder.add_feature_in_place("dconf-gsettings-backend"); + builder.add_feature_in_place("dconf-service"); + builder.add_feature_in_place("gsettings-desktop-schemas"); + } + + // 3. Display & Windowing + let has_x11 = Path::new("/usr/share/X11").exists() + || Path::new("/usr/bin/Xorg").exists() + || Path::new("/tmp/.X11-unix").exists() + || std::env::var_os("DISPLAY").is_some(); + if has_x11 { + builder.add_feature_in_place("x11-common"); + builder.add_feature_in_place("x11"); + builder.add_feature_in_place("xserver-xorg"); + } + + let has_wayland = std::env::var_os("WAYLAND_DISPLAY").is_some() + || Path::new("/usr/bin/wayland-scanner").exists() + || Path::new("/usr/share/wayland").exists(); + if has_wayland { + builder.add_feature_in_place("wayland"); + builder.add_feature_in_place("wayland-client"); + } + + // 4. Audio Subsystems + if Path::new("/usr/bin/pipewire").exists() { + builder.add_feature_in_place("pipewire"); + builder.add_feature_in_place("pipewire-audio"); + builder.add_feature_in_place("pipewire-session-manager"); + } + if Path::new("/usr/bin/pulseaudio").exists() { + builder.add_feature_in_place("pulseaudio"); + builder.add_feature_in_place("pulse"); + } + if Path::new("/usr/share/alsa").exists() + || Path::new("/etc/asound.conf").exists() + || Path::new("/proc/asound").exists() + { + builder.add_feature_in_place("alsa"); + builder.add_feature_in_place("alsa-utils"); + builder.add_feature_in_place("alsa-lib"); + } + + // 5. PKI & CA Certificates + if Path::new("/etc/ssl/certs/ca-certificates.crt").exists() + || Path::new("/etc/pki/tls/certs/ca-bundle.crt").exists() + || Path::new("/etc/ssl/certs").exists() + { + builder.add_feature_in_place("ca-certificates"); + } + + // 6. Desktop Integration & MIME & Fonts + if Path::new("/usr/share/mime").exists() { + builder.add_feature_in_place("shared-mime-info"); + } + if Path::new("/usr/share/applications").exists() { + builder.add_feature_in_place("desktop-file-utils"); + } + if Path::new("/usr/share/icons/hicolor").exists() { + builder.add_feature_in_place("hicolor-icon-theme"); + } + if Path::new("/usr/bin/xdg-open").exists() { + builder.add_feature_in_place("xdg-utils"); + } + if Path::new("/usr/share/fonts").exists() || Path::new("/etc/fonts").exists() { + builder.add_feature_in_place("fontconfig"); + } +} + +/// Inspects pacman local database on Arch Linux hosts. +fn detect_pacman_local(builder: &mut HostEvidenceBuilder) { + detect_pacman_local_at(Path::new("/var/lib/pacman/local"), builder); +} + +fn detect_pacman_local_at(pacman_dir: &Path, builder: &mut HostEvidenceBuilder) { + let Ok(entries) = std::fs::read_dir(pacman_dir) else { + return; + }; + + for entry in entries.flatten() { + let desc_path = entry.path().join("desc"); + if !desc_path.is_file() { + continue; + } + let Ok(content) = std::fs::read_to_string(&desc_path) else { + continue; + }; + + let mut current_section = ""; + let mut pkg_name = None; + let mut pkg_version = None; + let mut provides = Vec::new(); + + for line in content.lines() { + let line = line.trim(); + if line.starts_with('%') && line.ends_with('%') { + current_section = line; + continue; + } + if line.is_empty() { + continue; + } + match current_section { + "%NAME%" if pkg_name.is_none() => { + pkg_name = Some(line.to_string()); + } + "%VERSION%" if pkg_version.is_none() => { + pkg_version = Some(line.to_string()); + } + "%PROVIDES%" => { + let prov = line.split('=').next().unwrap_or(line).trim(); + if !prov.is_empty() { + provides.push(prov.to_string()); + } + } + _ => {} + } + } + + if let (Some(name), Some(version)) = (pkg_name, pkg_version) { + let mut provides_caps = Vec::new(); + for prov in provides { + if prov.contains(".so") { + builder.add_library_in_place(&prov, None, &[]); + provides_caps.push(Capability::SharedLibrary(prov)); + } else { + builder.add_feature_in_place(&prov); + provides_caps.push(Capability::Feature(prov)); + } + } + builder.add_host_package_in_place(&name, &version, "alpm", provides_caps); + } + } +} + +/// Inspects dpkg status file on Debian and Ubuntu hosts. +fn detect_dpkg_status(builder: &mut HostEvidenceBuilder) { + detect_dpkg_status_at(Path::new("/var/lib/dpkg/status"), builder); +} + +fn detect_dpkg_status_at(status_path: &Path, builder: &mut HostEvidenceBuilder) { + let Ok(file) = std::fs::File::open(status_path) else { + return; + }; + use std::io::{BufRead, BufReader}; + let reader = BufReader::new(file); + + let mut pkg_name: Option = None; + let mut pkg_version: Option = None; + let mut is_installed = false; + let mut provides_raw: Vec = Vec::new(); + + let mut commit_package = + |name: Option, version: Option, installed: bool, provides: &[String]| { + if installed && let (Some(name), Some(version)) = (name, version) { + let mut provides_caps = Vec::new(); + for prov in provides { + let clean = prov.split('(').next().unwrap_or(prov).trim(); + if clean.is_empty() { + continue; + } + if clean.contains(".so") { + builder.add_library_in_place(clean, None, &[]); + provides_caps.push(Capability::SharedLibrary(clean.to_string())); + } else { + builder.add_feature_in_place(clean); + provides_caps.push(Capability::Feature(clean.to_string())); + } + } + builder.add_host_package_in_place(&name, &version, "debian", provides_caps); + } + }; + + for line in reader.lines().map_while(Result::ok) { + if line.is_empty() { + commit_package( + pkg_name.take(), + pkg_version.take(), + is_installed, + &provides_raw, + ); + is_installed = false; + provides_raw.clear(); + continue; + } + + if let Some(rest) = line.strip_prefix("Package: ") { + pkg_name = Some(rest.trim().to_string()); + } else if let Some(rest) = line.strip_prefix("Status: ") { + if rest.contains("installed") && !rest.contains("not-installed") { + is_installed = true; + } + } else if let Some(rest) = line.strip_prefix("Version: ") { + pkg_version = Some(rest.trim().to_string()); + } else if let Some(rest) = line.strip_prefix("Provides: ") { + for item in rest.split(',') { + provides_raw.push(item.trim().to_string()); + } + } + } + commit_package(pkg_name, pkg_version, is_installed, &provides_raw); +} + #[cfg(test)] mod tests { use super::*; @@ -255,11 +599,83 @@ mod tests { .architecture(Architecture::X86_64) .add_library("libc.so.6", Some("2.38"), &["GLIBC_2.34", "GLIBC_2.38"]) .add_executable("sh") + .add_feature("gsettings-backend") + .add_host_package( + "dconf", + "0.40.0", + "alpm", + vec![Capability::Feature("gsettings-backend".to_string())], + ) .build(); assert!(host.has_soname("libc.so.6")); let cap = host.provides_capability("lib:libc.so.6").unwrap(); assert_eq!(cap.version.as_ref().map(|v| v.as_str()), Some("2.38")); assert!(cap.symbols.contains(&"GLIBC_2.38".to_string())); + + assert!(host.provides_feature("gsettings-backend").is_some()); + assert!( + host.provides_package("dconf", &VersionConstraint::Any, "alpm") + .is_some() + ); + // Cross-ecosystem nominal match without capability evidence rejected (INV-007) + assert!( + host.provides_package("dconf", &VersionConstraint::Any, "debian") + .is_none() + ); + } + + #[test] + fn test_broad_host_evidence_detection() { + let facts = HostFacts::detect(); + let host = HostEvidence::detect(&facts); + + // System binaries should be discovered + assert!(host.provides_capability("bin:sh").is_some()); + + // Basic subsystems should be detected if on standard Linux + if Path::new("/etc/ssl/certs").exists() { + assert!(host.provides_feature("ca-certificates").is_some()); + } + } + + #[test] + fn test_pacman_local_parsing() { + let temp = tempfile::tempdir().unwrap(); + let pkg_dir = temp.path().join("dconf-0.40.0-2"); + std::fs::create_dir_all(&pkg_dir).unwrap(); + let desc_content = "%NAME%\ndconf\n\n%VERSION%\n0.40.0-2\n\n%PROVIDES%\ngsettings-backend\ndconf-service\n"; + std::fs::write(pkg_dir.join("desc"), desc_content).unwrap(); + + let mut builder = HostEvidence::builder(); + detect_pacman_local_at(temp.path(), &mut builder); + let host = builder.build(); + + assert!(host.provides_feature("gsettings-backend").is_some()); + assert!(host.provides_feature("dconf-service").is_some()); + assert!( + host.provides_package("dconf", &VersionConstraint::Any, "alpm") + .is_some() + ); + } + + #[test] + fn test_dpkg_status_parsing() { + let temp = tempfile::tempdir().unwrap(); + let status_path = temp.path().join("status"); + let status_content = "Package: dbus-user-session\nStatus: install ok installed\nVersion: 1.14.10-4ubuntu4\nProvides: default-dbus-session-bus, dbus-session-bus\n\nPackage: broken-pkg\nStatus: deinstall ok config-files\nVersion: 1.0\nProvides: should-not-be-included\n"; + std::fs::write(&status_path, status_content).unwrap(); + + let mut builder = HostEvidence::builder(); + detect_dpkg_status_at(&status_path, &mut builder); + let host = builder.build(); + + assert!(host.provides_feature("default-dbus-session-bus").is_some()); + assert!(host.provides_feature("dbus-session-bus").is_some()); + assert!(host.provides_feature("should-not-be-included").is_none()); + assert!( + host.provides_package("dbus-user-session", &VersionConstraint::Any, "debian") + .is_some() + ); } } diff --git a/crates/pkg-core/src/resolver/mod.rs b/crates/pkg-core/src/resolver/mod.rs index 17ef4be..e7e785a 100644 --- a/crates/pkg-core/src/resolver/mod.rs +++ b/crates/pkg-core/src/resolver/mod.rs @@ -465,6 +465,11 @@ impl Resolver { ) -> Result<(), ResolutionError> { let current_id = format!("{}-{}", current_pkg.name, current_pkg.version); + // Check if already in closure or resolved + if resolved_names.contains(name) { + return Ok(()); + } + // Interpreter adapters may intentionally use a host interpreter. This // is capability evidence, not a general package-name equivalence: only // the reviewed interpreter names and unconstrained requirements qualify. @@ -489,12 +494,39 @@ impl Resolver { } } - // Check if already in closure or resolved - if resolved_names.contains(name) { + // Check host evidence for virtual feature capability (e.g. gsettings-backend, dbus-session-bus) + if let Some(evidence) = self.host.provides_feature(name.as_str()) { + let version_ok = match version_constraint { + VersionConstraint::Any => true, + VersionConstraint::Relational(_, _) => { + evidence.version.as_ref().is_some_and(|host_ver| { + version_constraint.matches(host_ver.as_str(), VersionEcosystem::Debian) + }) + } + }; + if version_ok { + host_satisfied.push(evidence.clone()); + resolved_names.insert(name.clone()); + return Ok(()); + } + } + + // Check host native packages matching ecosystem + if let Some(host_pkg) = + self.host + .provides_package(name.as_str(), version_constraint, ecosystem) + { + host_satisfied.push(CapabilityEvidence { + capability: Capability::Feature(name.to_string()), + version: Some(host_pkg.version.clone()), + provider_origin: format!("host:pkg:{}", host_pkg.name), + symbols: Vec::new(), + }); + resolved_names.insert(name.clone()); return Ok(()); } - // Check installed packages + // Check installed packages (direct package match) if let Some(installed) = self.installed_packages.get(name) && !self.replaced_packages.contains(name) { @@ -532,7 +564,32 @@ impl Resolver { } } - // Search repository candidates + // Check installed packages (virtual capability provider) + let cap_feature = CapabilityConstraint { + identifier: format!("feature:{name}"), + version: version_constraint.clone(), + original_expression: original_expr.to_string(), + }; + for installed in self.installed_packages.values() { + if self.replaced_packages.contains(&installed.name) { + continue; + } + if self.package_provides_capability(installed, &cap_feature) { + installed_satisfied.push(format!("{}:{name}", installed.name)); + resolved_names.insert(name.clone()); + return Ok(()); + } + } + + // Check already selected closure packages + for pkg in closure.iter() { + if self.package_provides_capability(pkg, &cap_feature) { + resolved_names.insert(name.clone()); + return Ok(()); + } + } + + // Search repository candidates (direct name match) let mut last_false_equivalence = None; let mut last_arch_mismatch = None; let mut last_version_mismatch = None; @@ -598,27 +655,82 @@ impl Resolver { closure.push(cand.clone()); return Ok(()); } + } - if let Some(reason) = last_false_equivalence { - chain.add_step(¤t_id, original_expr.to_string(), reason); - return Err(ResolutionError { - chain: chain.clone(), - }); + // Search repository candidates for a virtual package/capability provider (Provides: ) + let mut virtual_candidates: Vec<&NormalizedPackage> = self + .repository_packages + .values() + .flat_map(|candidates| candidates.iter()) + .filter(|cand| { + if !cand.architecture.matches_host(&self.host.architecture) { + return false; + } + let format_str = package_ecosystem(cand.format); + if !ecosystem.is_empty() && format_str != ecosystem { + return false; + } + self.package_provides_capability(cand, &cap_feature) + }) + .collect(); + virtual_candidates.sort_by(|a, b| package_candidate_cmp(a, b)); + + for cand in virtual_candidates { + if resolved_names.contains(&cand.name) { + resolved_names.insert(name.clone()); + return Ok(()); } - if let Some(reason) = last_arch_mismatch { - chain.add_step(¤t_id, original_expr.to_string(), reason); - return Err(ResolutionError { - chain: chain.clone(), - }); + if in_progress.contains(&cand.name) { + return Ok(()); } - if let Some(reason) = last_version_mismatch { - chain.add_step(¤t_id, original_expr.to_string(), reason); - return Err(ResolutionError { - chain: chain.clone(), - }); + + in_progress.insert(cand.name.clone()); + let mut sub_chain = chain.clone(); + let mut sub_ok = true; + for sub_c in &cand.constraints { + if let Err(_e) = self.resolve_constraint( + cand, + sub_c, + closure, + host_satisfied, + installed_satisfied, + in_progress, + resolved_names, + &mut sub_chain, + ) { + sub_ok = false; + break; + } + } + in_progress.remove(&cand.name); + + if sub_ok { + resolved_names.insert(cand.name.clone()); + resolved_names.insert(name.clone()); + closure.push(cand.clone()); + return Ok(()); } } + if let Some(reason) = last_false_equivalence { + chain.add_step(¤t_id, original_expr.to_string(), reason); + return Err(ResolutionError { + chain: chain.clone(), + }); + } + if let Some(reason) = last_arch_mismatch { + chain.add_step(¤t_id, original_expr.to_string(), reason); + return Err(ResolutionError { + chain: chain.clone(), + }); + } + if let Some(reason) = last_version_mismatch { + chain.add_step(¤t_id, original_expr.to_string(), reason); + return Err(ResolutionError { + chain: chain.clone(), + }); + } + chain.add_step( ¤t_id, original_expr.to_string(), @@ -1329,4 +1441,81 @@ mod tests { .expect_err("host evidence without a version is incomplete"); assert!(error.to_string().contains("does not satisfy"), "{error}"); } + + #[test] + fn test_virtual_package_resolved_via_repository_provides() { + let host = HostEvidence::builder() + .architecture(Architecture::X86_64) + .build(); + // Repository has dconf-gsettings-backend which provides "gsettings-backend" + let dconf = make_pkg( + "dconf-gsettings-backend", + "0.40.0-4", + PackageFormat::Deb, + vec![], + vec![Capability::Feature("gsettings-backend".to_string())], + ); + // Target app depends on virtual package "gsettings-backend" + let app = make_pkg( + "desktop-app", + "1.0.0", + PackageFormat::Deb, + vec![Constraint::Package { + name: PackageName::new("gsettings-backend").unwrap(), + version: VersionConstraint::Any, + ecosystem: "debian".into(), + original_expression: "gsettings-backend".into(), + }], + vec![], + ); + + let plan = Resolver::new(host) + .with_repository_packages(vec![dconf]) + .resolve(&app) + .expect("virtual package requirement should be satisfied by repository package with Provides"); + + assert_eq!(plan.packages_to_install.len(), 1); + assert_eq!( + plan.packages_to_install[0].name.as_str(), + "dconf-gsettings-backend" + ); + } + + #[test] + fn test_virtual_package_satisfied_via_host_feature_evidence() { + let host = HostEvidence::builder() + .architecture(Architecture::X86_64) + .add_feature("gsettings-backend") + .add_feature("default-dbus-session-bus") + .build(); + + // Target app depends on virtual package "gsettings-backend" and "default-dbus-session-bus" + let app = make_pkg( + "desktop-app", + "1.0.0", + PackageFormat::Deb, + vec![ + Constraint::Package { + name: PackageName::new("gsettings-backend").unwrap(), + version: VersionConstraint::Any, + ecosystem: "debian".into(), + original_expression: "gsettings-backend".into(), + }, + Constraint::Package { + name: PackageName::new("default-dbus-session-bus").unwrap(), + version: VersionConstraint::Any, + ecosystem: "debian".into(), + original_expression: "default-dbus-session-bus".into(), + }, + ], + vec![], + ); + + let plan = Resolver::new(host) + .resolve(&app) + .expect("virtual package requirements should be satisfied by host feature evidence"); + + assert!(plan.packages_to_install.is_empty()); + assert_eq!(plan.host_satisfied_capabilities.len(), 2); + } } diff --git a/tests/test_cli_no_deps.rs b/tests/test_cli_no_deps.rs new file mode 100644 index 0000000..1276703 --- /dev/null +++ b/tests/test_cli_no_deps.rs @@ -0,0 +1,110 @@ +mod common; + +use assert_cmd::Command; +use common::deb_builder::DebPackageBuilder; +use predicates::prelude::*; +use tempfile::tempdir; + +#[test] +fn test_cli_install_no_deps_help() { + let mut cmd = Command::cargo_bin("pkg").expect("pkg binary should exist"); + cmd.args(["install", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--no-deps")) + .stdout(predicate::str::contains( + "Skip resolving and installing declared dependencies", + )); +} + +#[test] +fn test_cli_install_with_no_deps_flag() { + let temp = tempdir().unwrap(); + let store_dir = temp.path().join("store"); + let artifact = temp.path().join("standalone-app.deb"); + + DebPackageBuilder::new("standalone-app") + .architecture("amd64") + .depends("missing-lib-dependency-xyz (>= 2.0.0)") + .file( + "usr/bin/standalone-tool", + b"#!/bin/sh\necho standalone\n", + 0o755, + ) + .write_to(&artifact) + .unwrap(); + + // 1. Without --no-deps: fails due to declared dependency resolution failure + let mut cmd_fail = Command::cargo_bin("pkg").unwrap(); + cmd_fail + .args([ + "--data-dir", + store_dir.to_str().unwrap(), + "install", + "--yes", + artifact.to_str().unwrap(), + ]) + .assert() + .failure(); + + // 2. With --no-deps: succeeds and activates binary + let mut cmd_pass = Command::cargo_bin("pkg").unwrap(); + cmd_pass + .args([ + "--data-dir", + store_dir.to_str().unwrap(), + "install", + "--yes", + "--no-deps", + artifact.to_str().unwrap(), + ]) + .assert() + .success() + .stdout(predicate::str::contains("Installed standalone-app")); + + // Verify binary execution + let mut cmd_run = Command::cargo_bin("pkg").unwrap(); + cmd_run + .args([ + "--data-dir", + store_dir.to_str().unwrap(), + "run", + "standalone-tool", + ]) + .assert() + .success() + .stdout(predicate::str::contains("standalone")); +} + +#[test] +fn test_cli_install_with_skip_deps_alias() { + let temp = tempdir().unwrap(); + let store_dir = temp.path().join("store"); + let artifact = temp.path().join("standalone-app2.deb"); + + DebPackageBuilder::new("standalone-app2") + .architecture("amd64") + .depends("missing-lib-dependency-xyz (>= 2.0.0)") + .file( + "usr/bin/standalone-tool2", + b"#!/bin/sh\necho standalone2\n", + 0o755, + ) + .write_to(&artifact) + .unwrap(); + + // With alias --skip-deps: succeeds + let mut cmd_pass = Command::cargo_bin("pkg").unwrap(); + cmd_pass + .args([ + "--data-dir", + store_dir.to_str().unwrap(), + "install", + "--yes", + "--skip-deps", + artifact.to_str().unwrap(), + ]) + .assert() + .success() + .stdout(predicate::str::contains("Installed standalone-app2")); +} diff --git a/tests/test_curated_registry.rs b/tests/test_curated_registry.rs index 0af4faf..3a4e93c 100644 --- a/tests/test_curated_registry.rs +++ b/tests/test_curated_registry.rs @@ -210,9 +210,9 @@ fn test_cli_repo_add_curated_id() { let assert = cmd .args(["--data-dir", data_dir, "repo", "add", "arch-multilib"]) .assert(); - assert - .success() - .stdout(predicates::str::contains("Successfully added repository 'arch-multilib'")); + assert.success().stdout(predicates::str::contains( + "Successfully added repository 'arch-multilib'", + )); // 2. Verify repositories.toml was created and contains arch-multilib let config_path = temp.path().join("repositories.toml"); @@ -234,7 +234,13 @@ fn test_cli_repo_add_curated_id() { // 4. Adding non-existent curated ID should fail informatively let mut invalid_cmd = Command::cargo_bin("pkg").unwrap(); invalid_cmd - .args(["--data-dir", data_dir, "repo", "add", "non-existent-distro-repo"]) + .args([ + "--data-dir", + data_dir, + "repo", + "add", + "non-existent-distro-repo", + ]) .assert() .failure() .stderr(predicates::str::contains("not in the curated registry")); @@ -288,7 +294,10 @@ async fn test_resilient_parallel_sync() { }; // Parallel update should NOT fail the entire batch: working-arch must be saved - let total = engine.update(&config).await.expect("Resilient sync should succeed when at least one repo succeeds"); + let total = engine + .update(&config) + .await + .expect("Resilient sync should succeed when at least one repo succeeds"); assert_eq!(total, 1); // Search verifies working repo was committed into SQLite @@ -299,17 +308,15 @@ async fn test_resilient_parallel_sync() { // Now test fail-closed behavior: when ALL repos fail, engine.update must return Err let all_broken_config = RepositoriesConfig { - repositories: vec![ - RepositoryConfig { - id: "broken-1".to_string(), - format: "alpm".to_string(), - url: "http://127.0.0.1:1".to_string(), - distribution: "core".to_string(), - components: vec![], - public_key_path: None, - priority: None, - }, - ], + repositories: vec![RepositoryConfig { + id: "broken-1".to_string(), + format: "alpm".to_string(), + url: "http://127.0.0.1:1".to_string(), + distribution: "core".to_string(), + components: vec![], + public_key_path: None, + priority: None, + }], }; let fail_result = engine.update(&all_broken_config).await; diff --git a/tests/test_dependency_contextual_resolution.rs b/tests/test_dependency_contextual_resolution.rs index 8746105..c2efad6 100644 --- a/tests/test_dependency_contextual_resolution.rs +++ b/tests/test_dependency_contextual_resolution.rs @@ -318,6 +318,7 @@ fn test_preflight_and_install_resolves_store_libraries() { false, InstallOptions { allow_missing_libraries: false, + skip_dependencies: false, }, ) .unwrap(); diff --git a/tests/test_install_options.rs b/tests/test_install_options.rs index 9ab0500..14d9bae 100644 --- a/tests/test_install_options.rs +++ b/tests/test_install_options.rs @@ -82,6 +82,7 @@ fn test_preflight_and_install_options() { false, InstallOptions { allow_missing_libraries: true, + skip_dependencies: false, }, ) .unwrap(); @@ -118,3 +119,51 @@ fn test_preflight_and_install_options() { ); assert!(engine.run_command("default", "missing-cmd", &[]).is_err()); } + +#[test] +fn test_install_options_skip_dependencies() { + let temp = tempdir().unwrap(); + let artifact = temp.path().join("pkg-with-deps.deb"); + + DebPackageBuilder::new("app-with-deps") + .architecture("amd64") + .depends("non-existent-dependency-xyz (>= 1.0.0)") + .file("usr/bin/my-app", b"#!/bin/sh\necho hello\n", 0o755) + .write_to(&artifact) + .unwrap(); + + let layout = StoreLayout::new(temp.path().join("data")); + let engine = Engine::open(layout).unwrap(); + + // Default install: fails due to unresolvable dependency + let err = engine.install_with_options( + &artifact, + "default", + false, + InstallOptions { + allow_missing_libraries: false, + skip_dependencies: false, + }, + ); + assert!(err.is_err(), "should fail resolving missing dependency"); + + // With skip_dependencies: true, installation succeeds + let plan = engine + .install_with_options( + &artifact, + "default", + false, + InstallOptions { + allow_missing_libraries: false, + skip_dependencies: true, + }, + ) + .expect("should succeed when skipping dependencies"); + + assert_eq!(plan.package.name.as_str(), "app-with-deps"); + assert_eq!(plan.resolved_dependencies.len(), 0); + + let list = engine.list("default").unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].name.as_str(), "app-with-deps"); +} diff --git a/tests/test_rpm_noarch.rs b/tests/test_rpm_noarch.rs index 5c76ffc..6f84d01 100644 --- a/tests/test_rpm_noarch.rs +++ b/tests/test_rpm_noarch.rs @@ -40,6 +40,7 @@ fn test_rpm_noarch_metadata_and_installation() { false, InstallOptions { allow_missing_libraries: false, + skip_dependencies: false, }, ) .unwrap(); diff --git a/tests/test_runtime_launcher.rs b/tests/test_runtime_launcher.rs index 4fdfe98..f5fb141 100644 --- a/tests/test_runtime_launcher.rs +++ b/tests/test_runtime_launcher.rs @@ -35,6 +35,7 @@ fn test_python_runtime_launcher_and_scoped_pythonpath() { false, InstallOptions { allow_missing_libraries: false, + skip_dependencies: false, }, ) .unwrap(); @@ -235,6 +236,7 @@ fn test_native_runner_uses_promoted_package_library_view() { false, InstallOptions { allow_missing_libraries: false, + skip_dependencies: false, }, ) .unwrap(); @@ -573,6 +575,7 @@ fn package_rpath_cannot_escape_staging_root() { false, InstallOptions { allow_missing_libraries: false, + skip_dependencies: false, }, ) .unwrap_err(); diff --git a/tests/test_text_relocation.rs b/tests/test_text_relocation.rs index ffcef72..54f0631 100644 --- a/tests/test_text_relocation.rs +++ b/tests/test_text_relocation.rs @@ -69,6 +69,7 @@ Type=Application false, InstallOptions { allow_missing_libraries: false, + skip_dependencies: false, }, ) .unwrap();