diff --git a/src/anolisa/crates/anolisa-cli/src/commands.rs b/src/anolisa/crates/anolisa-cli/src/commands.rs index 9432eb1cd..7afdf0de1 100644 --- a/src/anolisa/crates/anolisa-cli/src/commands.rs +++ b/src/anolisa/crates/anolisa-cli/src/commands.rs @@ -390,6 +390,13 @@ fn command_policy(command: &Commands) -> CommandPolicy { ComponentCommands::Doctor(_) => CommandPolicy::new("doctor", CommandScope::ReadOnly), ComponentCommands::Logs(_) => CommandPolicy::new("logs", CommandScope::ReadOnly), ComponentCommands::Restart(_) => mode_scoped("restart", false), + // `update --check` is read-only upgrade detection: it only runs + // read-only rpm/dnf queries (no package transaction, no state + // writes), so it must not be gated behind the mutating-update root + // requirement — the MOTD hook runs it unprivileged. + ComponentCommands::Update(args) if args.check => { + CommandPolicy::new("update", CommandScope::ReadOnly) + } ComponentCommands::Update(_) => mode_scoped("update", true), ComponentCommands::Repair(_) => system_only("repair", true), ComponentCommands::Forget(_) => mode_scoped("forget", true), diff --git a/src/anolisa/crates/anolisa-cli/src/commands/tier1/update.rs b/src/anolisa/crates/anolisa-cli/src/commands/tier1/update.rs index 3c21c0f21..33e3b99cd 100644 --- a/src/anolisa/crates/anolisa-cli/src/commands/tier1/update.rs +++ b/src/anolisa/crates/anolisa-cli/src/commands/tier1/update.rs @@ -71,6 +71,8 @@ use crate::context::CliContext; use crate::repo_config::{HostVars, RepoConfig}; use crate::response::{self, CliError}; +mod check; + /// Command label for JSON envelopes and error routing. const COMMAND: &str = "update"; @@ -94,6 +96,28 @@ pub struct UpdateArgs { /// single component. #[command(subcommand)] pub command: Option, + /// Report which RPM-backed components can be upgraded, read-only. + /// + /// `--check` only runs read-only rpm/dnf queries (it does run `dnf + /// repoquery` for candidates, but no mutating `dnf` transaction), never + /// writes ANOLISA state, and never persists repo/adapter configuration. It + /// is mutually exclusive with a component + /// argument and with the `self` / `all` subcommands (the latter is enforced + /// by `args_conflicts_with_subcommands`). `--motd`, `--refresh`, and + /// `--target` are only meaningful together with `--check`. + #[arg(long)] + pub check: bool, + /// With `--check`: emit a short, low-noise MOTD summary (silent when + /// nothing can be upgraded). + #[arg(long, requires = "check")] + pub motd: bool, + /// With `--check`: bypass the cached report and re-query. + #[arg(long, requires = "check")] + pub refresh: bool, + /// With `--check`: evaluate against a named target profile so its missing + /// default components are reported as installable. + #[arg(long, requires = "check", value_name = "TARGET")] + pub target: Option, } /// Update operations that intentionally keep CLI self-update and batch update @@ -117,6 +141,29 @@ pub enum UpdateCommands { /// Returns [`CliError`] when the selected update operation fails, no target is /// given, or the operation is not implemented yet. pub fn handle(args: UpdateArgs, ctx: &CliContext) -> Result<(), CliError> { + // `--check` is the read-only upgrade-detection entry (issue #1410). It is + // dispatched before the mutating forms so a stray component/subcommand is + // rejected instead of silently updating something. + if args.check { + if args.component.is_some() { + return Err(CliError::InvalidArgument { + command: check::CHECK_COMMAND.to_string(), + reason: "`--check` reports upgrades for every managed component and takes no component argument; run `anolisa update --check`".to_string(), + }); + } + // `args_conflicts_with_subcommands` already makes `update --check self` + // a parse error; this guard keeps the invariant explicit for any direct + // caller that bypasses clap. + if args.command.is_some() { + return Err(CliError::InvalidArgument { + command: check::CHECK_COMMAND.to_string(), + reason: "`--check` cannot be combined with the `self` or `all` subcommands" + .to_string(), + }); + } + return check::handle_update_check(&args, ctx); + } + // `args_conflicts_with_subcommands` guarantees `command` and `component` // are never both set, so a present subcommand always wins. match (args.command, args.component) { @@ -2928,6 +2975,10 @@ mod tests { UpdateArgs { component: None, command: None, + check: false, + motd: false, + refresh: false, + target: None, }, &c, ) @@ -3938,6 +3989,10 @@ packages = { rpm = "absent-tool", deb = "absent-tool" } let args = UpdateArgs { component: None, command: None, + check: false, + motd: false, + refresh: false, + target: None, }; let err = handle(args, &c).expect_err("must require a target"); assert_eq!(err.code(), "INVALID_ARGUMENT"); diff --git a/src/anolisa/crates/anolisa-cli/src/commands/tier1/update/check.rs b/src/anolisa/crates/anolisa-cli/src/commands/tier1/update/check.rs new file mode 100644 index 000000000..58317c026 --- /dev/null +++ b/src/anolisa/crates/anolisa-cli/src/commands/tier1/update/check.rs @@ -0,0 +1,1077 @@ +//! `anolisa update --check` — read-only RPM upgrade detection (issue #1410). +//! +//! Produces a report answering "can the installed toolchain be upgraded?" for +//! the RPM / system-image scenario. It is strictly read-only: it inspects the +//! host only through [`PackageQuery`], i.e. read-only `rpm -q` / `dnf repoquery` +//! lookups. It runs **no mutating package operation** (never a +//! [`PackageTransaction`](anolisa_platform::pkg_transaction::PackageTransaction), +//! so no `dnf install/update/remove`), never writes `installed.toml`, and never +//! persists repo/adapter state. Note the repo candidate lookup still calls +//! `dnf repoquery`, which touches the network like any read query — so the MOTD +//! path is cache-backed to keep that cost off the login hot path. Applying +//! upgrades is a separate command (`anolisa upgrade`, issue #1411). +//! +//! Three sources feed the report: +//! - **CLI**: whether the running `anolisa` binary is RPM-owned and has a newer +//! repo candidate. A non-RPM binary is `unsupported` here (self-update is +//! handled by `anolisa update self`). +//! - **Installed components**: each `rpm-managed` / `rpm-observed` component is +//! compared against its repo candidates; `raw-managed` components are reported +//! `unsupported_in_rpm_upgrade` and never touched. +//! - **Target profile** (`--target`, optional): default components the profile +//! declares but that are not installed are reported as installable. When +//! `--target` is omitted the release-owned default profile +//! ([`DEFAULT_TARGET_PROFILE_NAME`]) is used, so a plain check still surfaces +//! missing default components. +//! +//! Repo candidate ordering uses RPM's own EVR comparison +//! ([`rpm_evr_cmp`]), not semver, so real epochs/releases are ordered +//! correctly. A small on-disk cache under +//! `cache_dir/update-check.json` (keyed by the resolved target) backs the +//! low-noise MOTD path; `--refresh` bypasses it. +//! +//! Rendering lives in [`render`]; the cache and target-profile plumbing plus the +//! read-only repo-config load live here alongside the detection logic. + +mod render; + +#[cfg(test)] +mod tests; + +use std::cmp::Ordering; +use std::path::{Path, PathBuf}; + +use chrono::Utc; +use serde::{Deserialize, Serialize}; + +use anolisa_core::self_update; +use anolisa_core::state::{InstalledObject, InstalledState, ObjectKind, Ownership}; +use anolisa_platform::fs_layout::FsLayout; +use anolisa_platform::pkg_query::{PackageQuery, PackageQueryError, PackageVersion, rpm_evr_cmp}; +use anolisa_platform::rpm_query::RpmPackageQuery; + +use super::UpdateArgs; +use crate::commands::common; +use crate::context::CliContext; +use crate::repo_config::RepoConfig; +use crate::resolution::{ComponentIndex, rpm_component_provide}; +use crate::response::CliError; + +/// Command label for JSON envelopes and error routing on the check path. +pub(super) const CHECK_COMMAND: &str = "update --check"; + +/// Filename for the cached report under the layout's `cache_dir`. +const CACHE_FILE: &str = "update-check.json"; + +/// How long a cached report is considered fresh for the MOTD path. Off the +/// round hour so scheduled MOTD refreshes across hosts do not all land at once. +const CACHE_TTL_SECS: i64 = 6 * 3600 + 137; + +/// Release-owned default target profile evaluated when `--target` is omitted, so +/// a plain `update --check` (and the MOTD path) can report missing defaults. +const DEFAULT_TARGET_PROFILE_NAME: &str = "agentic_os-latest"; + +/// Built-in copy of the default target profile, compiled in as the final +/// fallback so the default check works even before any profile file is laid down +/// on disk. On-disk copies (`/profiles`, `/profiles`) still win. +const BUILTIN_DEFAULT_TARGET_PROFILE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../profiles/agentic_os-latest.toml" +)); + +/// Subdirectory under `etc_dir` / packaged datadir holding target profiles. +const PROFILES_SUBDIR: &str = "profiles"; + +// Stable action vocabulary shared by JSON, cache, and human/MOTD rendering. +const ACTION_UPDATE: &str = "update"; +const ACTION_NOOP: &str = "noop"; +const ACTION_INSTALL: &str = "install"; +const ACTION_UNSUPPORTED: &str = "unsupported"; +const ACTION_UNSUPPORTED_RPM: &str = "unsupported_in_rpm_upgrade"; +const ACTION_ERROR: &str = "error"; + +/// Wire shape for `update --check` (`--json`) and the on-disk cache. +/// +/// Owned `String`s (rather than `&'static str`) so the same struct round-trips +/// through the cache via `Deserialize`. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct UpdateCheckReport { + /// Target profile evaluated, when `--target` was given. + #[serde(default, skip_serializing_if = "Option::is_none")] + target: Option, + /// Upgrade backend this report covers. Always `rpm` in the first version. + backend: String, + /// True when at least one component/CLI `update` was found. Deliberately + /// narrow: a missing default is an *install*, not an upgrade, so it does not + /// set this — see [`action_required`](Self::action_required). + upgrade_available: bool, + /// True when there is anything to do: an upgrade **or** a missing default to + /// install. This is the signal machine callers should gate on when driving + /// the (future) `anolisa upgrade`; `upgrade_available` alone would report + /// "nothing to upgrade" on a fresh image that is only missing defaults. + action_required: bool, + cli: CliCheck, + components: Vec, + summary: CheckSummary, +} + +/// Upgrade status of the running `anolisa` CLI binary. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CliCheck { + /// RPM package that owns the CLI binary; `None` when it is not RPM-owned. + #[serde(default, skip_serializing_if = "Option::is_none")] + package: Option, + /// Installed EVR from rpmdb. + #[serde(default, skip_serializing_if = "Option::is_none")] + installed: Option, + /// Newest repo candidate EVR, when an upgrade is available. + #[serde(default, skip_serializing_if = "Option::is_none")] + available: Option, + action: String, + /// Item-level failure that did not abort the whole check. + #[serde(default, skip_serializing_if = "Option::is_none")] + error: Option, +} + +/// Upgrade status of one installed (or profile-declared) component. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ComponentCheck { + component: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + package: Option, + /// Provenance label (`rpm-managed` / `rpm-observed` / `raw-managed`); + /// `None` for a profile default that is not installed yet. + #[serde(default, skip_serializing_if = "Option::is_none")] + ownership: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + installed: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + available: Option, + action: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + error: Option, +} + +/// Aggregate counts used by the summary line, MOTD, and exit signalling. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct CheckSummary { + /// Upgrades found (CLI plus components). + updates: usize, + /// Profile default components absent from state. + missing_defaults: usize, + /// Items outside the RPM upgrade scope (raw-managed, non-RPM CLI). + unsupported: usize, + /// Item-level query failures that did not abort the check. + errors: usize, +} + +/// Cache envelope stored under `cache_dir/update-check.json`. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct UpdateCheckCache { + /// RFC3339 UTC timestamp used to decide cache freshness. + generated_at: String, + report: UpdateCheckReport, +} + +/// Minimal target-profile reader. +/// +/// The repository-side profile schema is not finalized, so this deliberately +/// reads only the one field the check needs (`default_components`) and tolerates +/// any other keys, letting a richer schema land later without breaking callers. +#[derive(Debug, Clone, Default, Deserialize)] +struct TargetProfile { + #[serde(default)] + default_components: Vec, +} + +impl TargetProfile { + fn from_toml_str(s: &str) -> Result { + toml::from_str(s) + } +} + +/// Read-only inputs for [`run_update_check`]; injected so tests drive the whole +/// report without a live rpmdb/dnf. +struct CheckInputs<'a> { + installed: &'a InstalledState, + query: &'a dyn PackageQuery, + /// Path of the running executable, used to find its owning RPM. + cli_exe_path: &'a str, + /// Installed architecture used to filter repo candidates. + arch: &'a str, + /// Echoed into the report's `target` field. + target_name: Option, + /// Loaded profile, when `--target` was given and resolved. + target: Option, + /// Repo-side component identity index, used to map a profile default's + /// component name to its RPM package so an installed-but-unadopted default is + /// not falsely reported as installable. `None` when the index is unavailable + /// (best-effort); the check then relies on the `anolisa-component(...)` + /// provide alone. + component_index: Option<&'a ComponentIndex>, +} + +/// Production entry point for `anolisa update --check`. +pub(super) fn handle_update_check(args: &UpdateArgs, ctx: &CliContext) -> Result<(), CliError> { + // `update --check` only understands the system / RPM-image scenario: it + // reasons about rpm-owned components and repo candidates. In user mode there + // is no rpmdb-backed toolchain to reason about, so refuse explicitly rather + // than emit a misleading "nothing to upgrade" RPM report. The MOTD path + // stays silent (a login banner must not error) — this runs before any cache + // lookup or rpm/dnf query. + if ctx.install_mode != crate::context::InstallMode::System { + if args.motd { + return Ok(()); + } + return Err(CliError::InvalidArgument { + command: CHECK_COMMAND.to_string(), + reason: "`update --check` currently supports only system/RPM image scenarios; run without `--install-mode user`".to_string(), + }); + } + + let layout = common::resolve_layout(ctx); + let cache_path = cache_path(&layout); + + // MOTD fast path: prefer a fresh cache for the *same* target so the login + // hook stays cheap and never blocks on the network. JSON output always + // recomputes so machine callers get the full, current envelope. + if args.motd + && !args.refresh + && !ctx.json + && let Some(cache) = read_cache(&cache_path) + && cache_is_usable(&cache, Some(effective_target_name(args.target.as_deref()))) + { + render::render_motd(ctx, &cache.report); + return Ok(()); + } + + let report = match compute_report(args, ctx, &layout) { + Ok(report) => report, + Err(err) => { + // MOTD must stay quiet and low-noise on failure; a JSON/human check + // surfaces the error as usual. + if args.motd { + return Ok(()); + } + return Err(err); + } + }; + + // The cache is not authoritative state; a write failure (e.g. a non-root + // MOTD probe against a root-owned cache dir) is non-fatal. + let _ = write_cache(&cache_path, &report); + + render::render_report(ctx, args, &report); + Ok(()) +} + +/// Build the report from live host state (repo config, rpmdb/dnf, target +/// profile). Split from [`run_update_check`] so the pure logic stays testable. +fn compute_report( + args: &UpdateArgs, + ctx: &CliContext, + layout: &FsLayout, +) -> Result { + let repo_config = load_repo_config_read_only(layout)?; + let env = anolisa_env::EnvService::detect(); + let repo = super::rpm_repo_source_for_update(&repo_config, &env, CHECK_COMMAND)?.ok_or_else( + || CliError::InvalidArgument { + command: CHECK_COMMAND.to_string(), + reason: "repo.toml has no [backends.rpm] table; `update --check` needs the configured ANOLISA RPM repository to look up upgrade candidates".to_string(), + }, + )?; + let query = RpmPackageQuery::system_with_repo(repo); + let installed = common::load_installed_state(ctx, CHECK_COMMAND)?; + + let exe = self_update::resolve_current_exe().map_err(|err| CliError::Runtime { + command: CHECK_COMMAND.to_string(), + reason: format!("cannot resolve the running executable: {err}"), + })?; + let exe_path = exe + .to_str() + .ok_or_else(|| CliError::Runtime { + command: CHECK_COMMAND.to_string(), + reason: format!( + "running executable path is not valid UTF-8: {}", + exe.display() + ), + })? + .to_string(); + + // An omitted `--target` resolves to the release default profile, so the + // report always carries a target and can surface missing defaults. + let (target_name, target) = load_effective_target_profile(layout, args.target.as_deref())?; + + // Best-effort component identity index so a profile default already present + // on the host (but not adopted into ANOLISA state) is checked against rpmdb + // via its package name rather than reported as installable. Loaded only on + // this (uncached) path, so the MOTD fast path is unaffected; a missing index + // is non-fatal (the check falls back to the `anolisa-component(...)` provide). + let component_index = + crate::resolution::load_optional_component_index(layout, &env, &repo_config); + + Ok(run_update_check(CheckInputs { + installed: &installed, + query: &query, + cli_exe_path: &exe_path, + arch: &env.arch, + target_name: Some(target_name), + target: Some(target), + component_index: component_index.as_ref(), + })) +} + +/// Load repo config without ever writing it, keeping `--check` read-only. +/// +/// The dry-run load path fetches a missing config into memory only (never +/// persisting `/repo.toml`), which is exactly the read-only guarantee +/// the check needs even on a host that has not provisioned repo config yet. The +/// no-write behaviour of the dry-run path is covered by +/// `repo_config::tests::load_dry_run_fetches_without_writing`. +fn load_repo_config_read_only(layout: &FsLayout) -> Result { + RepoConfig::load(layout, true) + .map(|loaded| loaded.config) + .map_err(|err| CliError::Runtime { + command: CHECK_COMMAND.to_string(), + reason: format!("failed to load repo config: {err}"), + }) +} + +/// Pure report builder over the injected read-only inputs. +fn run_update_check(inputs: CheckInputs<'_>) -> UpdateCheckReport { + let mut summary = CheckSummary::default(); + + let cli = build_cli_check(inputs.query, inputs.cli_exe_path, inputs.arch, &mut summary); + + let mut components = Vec::new(); + for obj in &inputs.installed.objects { + if obj.kind != ObjectKind::Component { + continue; + } + components.push(check_component( + inputs.query, + obj, + inputs.arch, + &mut summary, + )); + } + + // Profile defaults absent from ANOLISA state are surfaced as a gap (issue + // #1411 performs the install). Absence from `installed.toml` is not the same + // as absence from the host, though, so each candidate is cross-checked + // against rpmdb first — a default already installed as an RPM (but never + // adopted) is evaluated for upgrades instead of falsely reported as missing. + if let Some(profile) = &inputs.target { + for name in &profile.default_components { + if inputs + .installed + .find_object(ObjectKind::Component, name) + .is_some() + { + continue; + } + components.push(check_default_component( + inputs.query, + inputs.component_index, + name, + inputs.arch, + &mut summary, + )); + } + } + + let upgrade_available = summary.updates > 0; + let action_required = upgrade_available || summary.missing_defaults > 0; + UpdateCheckReport { + target: inputs.target_name, + backend: "rpm".to_string(), + upgrade_available, + action_required, + cli, + components, + summary, + } +} + +/// Determine whether the running CLI binary is RPM-owned and, if so, whether a +/// newer repo candidate exists. +fn build_cli_check( + query: &dyn PackageQuery, + exe_path: &str, + arch: &str, + summary: &mut CheckSummary, +) -> CliCheck { + let providers = match query.what_provides_installed(exe_path) { + Ok(providers) => providers, + // No rpm/dnf on the host: the CLI cannot be shown RPM-owned, so treat it + // as out of RPM upgrade scope rather than a hard failure. + Err(PackageQueryError::CommandMissing { .. }) => { + summary.unsupported += 1; + return cli_unsupported("rpm/dnf not found; cannot determine CLI package ownership"); + } + Err(err) => { + summary.errors += 1; + return cli_error( + None, + format!("cannot determine CLI package ownership: {err}"), + ); + } + }; + + let package = match providers.as_slice() { + [] => { + summary.unsupported += 1; + return cli_unsupported( + "anolisa CLI is not RPM-owned; use `anolisa update self` to update the binary", + ); + } + [only] => only.clone(), + _ => { + summary.errors += 1; + return cli_error( + None, + format!( + "CLI executable is provided by multiple RPM packages ({})", + providers.join(", ") + ), + ); + } + }; + + let installed = match query.query_installed(&package) { + Ok(Some(info)) => info.version, + Ok(None) => { + summary.errors += 1; + return cli_error( + Some(package), + "CLI package is reported as owner but is absent from rpmdb".to_string(), + ); + } + Err(PackageQueryError::UnexpectedOutput { .. }) => { + summary.errors += 1; + return cli_error( + Some(package), + "CLI package has multiple installed versions in rpmdb".to_string(), + ); + } + Err(err) => { + summary.errors += 1; + return cli_error(Some(package), format!("rpm query failed: {err}")); + } + }; + let installed_evr = installed.to_string(); + + match repo_upgrade(query, &package, arch, &installed) { + Ok(Some(available)) => { + summary.updates += 1; + CliCheck { + package: Some(package), + installed: Some(installed_evr), + available: Some(available), + action: ACTION_UPDATE.to_string(), + error: None, + } + } + Ok(None) => CliCheck { + package: Some(package), + installed: Some(installed_evr), + available: None, + action: ACTION_NOOP.to_string(), + error: None, + }, + Err(err) => { + summary.errors += 1; + cli_error(Some(package), format!("repo candidate query failed: {err}")) + } + } +} + +/// Evaluate one installed component against the RPM upgrade scope. +fn check_component( + query: &dyn PackageQuery, + obj: &InstalledObject, + arch: &str, + summary: &mut CheckSummary, +) -> ComponentCheck { + let component = obj.name.clone(); + let ownership = obj.effective_ownership(); + + // Raw-managed components are explicitly out of the RPM upgrade path. Nothing + // is queried, touched, or migrated — only reported. + if ownership == Ownership::RawManaged { + summary.unsupported += 1; + return ComponentCheck { + component, + package: obj.raw_package.clone(), + ownership: Some(ownership.label().to_string()), + installed: Some(obj.version.clone()), + available: None, + action: ACTION_UNSUPPORTED_RPM.to_string(), + error: None, + }; + } + + let ownership_label = ownership.label().to_string(); + let package = match obj + .rpm_metadata + .as_ref() + .map(|m| m.package_name.clone()) + .filter(|p| !p.is_empty()) + { + Some(package) => package, + None => { + summary.errors += 1; + return component_error( + component, + None, + ownership_label, + Some(obj.version.clone()), + "component is recorded as RPM-backed but has no package metadata; run `anolisa repair` to refresh it".to_string(), + ); + } + }; + + let installed = match query.query_installed(&package) { + Ok(Some(info)) => info.version, + // rpmdb no longer has the package (e.g. removed with `rpm -e`): item + // error, not a crash — the rest of the check continues. + Ok(None) => { + summary.errors += 1; + return component_error( + component, + Some(package), + ownership_label, + Some(obj.version.clone()), + "package recorded in ANOLISA state is not present in rpmdb; run `anolisa forget` or reinstall".to_string(), + ); + } + Err(PackageQueryError::UnexpectedOutput { .. }) => { + summary.errors += 1; + return component_error( + component, + Some(package), + ownership_label, + Some(obj.version.clone()), + "rpmdb reports multiple installed versions for this package".to_string(), + ); + } + Err(PackageQueryError::CommandMissing { .. }) => { + summary.errors += 1; + return component_error( + component, + Some(package), + ownership_label, + Some(obj.version.clone()), + "rpm/dnf not found; cannot query the installed version".to_string(), + ); + } + Err(err) => { + summary.errors += 1; + return component_error( + component, + Some(package), + ownership_label, + Some(obj.version.clone()), + format!("rpm query failed: {err}"), + ); + } + }; + let installed_evr = installed.to_string(); + + match repo_upgrade(query, &package, arch, &installed) { + Ok(Some(available)) => { + summary.updates += 1; + ComponentCheck { + component, + package: Some(package), + ownership: Some(ownership_label), + installed: Some(installed_evr), + available: Some(available), + action: ACTION_UPDATE.to_string(), + error: None, + } + } + Ok(None) => ComponentCheck { + component, + package: Some(package), + ownership: Some(ownership_label), + installed: Some(installed_evr), + available: None, + action: ACTION_NOOP.to_string(), + error: None, + }, + Err(err) => { + summary.errors += 1; + component_error( + component, + Some(package), + ownership_label, + Some(installed_evr), + format!("repo candidate query failed: {err}"), + ) + } + } +} + +/// Evaluate a profile default that is absent from ANOLISA state. +/// +/// A default missing from `installed.toml` may still be installed on the host — +/// e.g. baked into a system image and never adopted, which is common for +/// `legacy_adopt` packages predating the `anolisa-component(...)` provide. +/// Reporting such a default as `install` would be a false positive on the +/// default-profile MOTD, so rpmdb is consulted first (via the component provide +/// and its index-mapped package name). A present-but-unadopted default is +/// checked for upgrades like an rpm-observed component; only a default genuinely +/// absent from rpmdb too is reported installable. +fn check_default_component( + query: &dyn PackageQuery, + index: Option<&ComponentIndex>, + name: &str, + arch: &str, + summary: &mut CheckSummary, +) -> ComponentCheck { + match probe_default_package(query, index, name) { + // Present on the host but unadopted: evaluate for upgrades. + DefaultProbe::Installed(package) => { + check_present_default(query, name, &package, arch, summary) + } + // Genuinely absent from both ANOLISA state and rpmdb. + DefaultProbe::Missing => { + summary.missing_defaults += 1; + ComponentCheck { + component: name.to_string(), + package: None, + ownership: None, + installed: None, + available: None, + action: ACTION_INSTALL.to_string(), + error: None, + } + } + // Could not determine presence (query failure, ambiguous providers). + // This is an item error, not a missing default — reporting "install" + // here would be the false positive the rpmdb cross-check exists to + // avoid. + DefaultProbe::Indeterminate(reason) => { + summary.errors += 1; + ComponentCheck { + component: name.to_string(), + package: None, + ownership: None, + installed: None, + available: None, + action: ACTION_ERROR.to_string(), + error: Some(reason), + } + } + } +} + +/// Outcome of probing rpmdb for a profile default that is absent from ANOLISA +/// state. Kept distinct from a plain `Option` so a query failure or an ambiguous +/// provider set is never collapsed into "missing" (which would be reported as an +/// installable default — the very false positive this cross-check prevents). +enum DefaultProbe { + /// Installed on the host under this RPM package (an adopt/upgrade target). + Installed(String), + /// Not installed on the host — a genuine missing default. + Missing, + /// Presence could not be determined; carries a human-readable reason. + Indeterminate(String), +} + +/// Probe rpmdb for the RPM package backing `component` when it is absent from +/// ANOLISA state. +/// +/// Two signals are consulted, both read-only: +/// 1. A package that Provides `anolisa-component(name)`. An empty provider set +/// falls through to the index fallback; exactly one provider is the target; +/// multiple providers are ambiguous and reported as indeterminate (mirroring +/// the CLI-owner "multiple packages = error" rule) rather than silently +/// picking one. +/// 2. For `legacy_adopt` packages that may lack that provide, the RPM package +/// name(s) the component index maps to. +/// +/// Any query error (rpm/dnf missing, permission denied, malformed rpmdb output, +/// repoquery failure) yields [`DefaultProbe::Indeterminate`] — never `Missing`. +fn probe_default_package( + query: &dyn PackageQuery, + index: Option<&ComponentIndex>, + name: &str, +) -> DefaultProbe { + match query.what_provides_installed(&rpm_component_provide(name)) { + Ok(providers) => match providers.as_slice() { + [] => {} + [only] => return DefaultProbe::Installed(only.clone()), + many => { + return DefaultProbe::Indeterminate(format!( + "default component '{name}' is provided by multiple installed RPM packages ({}); cannot pick an upgrade target", + many.join(", ") + )); + } + }, + Err(err) => { + return DefaultProbe::Indeterminate(format!( + "cannot determine whether default component '{name}' is installed: {err}" + )); + } + } + + for package in index_rpm_packages(index, name) { + match query.query_installed(&package) { + Ok(Some(_)) => return DefaultProbe::Installed(package), + Ok(None) => continue, + Err(err) => { + return DefaultProbe::Indeterminate(format!( + "cannot query installed version of '{package}' for default component '{name}': {err}" + )); + } + } + } + DefaultProbe::Missing +} + +/// RPM package names the component index maps `component` to: the `rpm` backend +/// package plus any `rpm-package` aliases (historical names). Empty when there +/// is no index or no RPM mapping for the component. Duplicates (e.g. a backend +/// and an alias sharing a name) are collapsed so rpmdb is not queried twice for +/// the same package, order-preserving. +fn index_rpm_packages(index: Option<&ComponentIndex>, component: &str) -> Vec { + let Some(index) = index else { + return Vec::new(); + }; + let Some(entry) = index.components.iter().find(|e| e.name == component) else { + return Vec::new(); + }; + let mut packages: Vec = Vec::new(); + let mut push_unique = |package: &str| { + if !package.is_empty() && !packages.iter().any(|p| p == package) { + packages.push(package.to_string()); + } + }; + for backend in &entry.backends { + if backend.kind == "rpm" { + push_unique(&backend.package); + } + } + for alias in &entry.aliases { + if alias.kind == "rpm-package" { + push_unique(&alias.name); + } + } + packages +} + +/// Evaluate an installed-but-unadopted default against the RPM upgrade scope, +/// mirroring [`check_component`]'s query→candidate flow. Ownership is reported +/// as `rpm-observed`: present on the host, not managed through ANOLISA state. +fn check_present_default( + query: &dyn PackageQuery, + name: &str, + package: &str, + arch: &str, + summary: &mut CheckSummary, +) -> ComponentCheck { + let ownership_label = Ownership::RpmObserved.label().to_string(); + let installed = match query.query_installed(package) { + Ok(Some(info)) => info.version, + // The probe just resolved `package` as an installed provider, so an + // absent package here means it raced away mid-check. That is an + // inconsistency we cannot resolve, not evidence the default is missing — + // report an item error rather than flipping back to "install". + Ok(None) => { + summary.errors += 1; + return component_error( + name.to_string(), + Some(package.to_string()), + ownership_label, + None, + format!( + "default component '{name}' resolved to package '{package}' but it is absent from rpmdb" + ), + ); + } + Err(err) => { + summary.errors += 1; + return component_error( + name.to_string(), + Some(package.to_string()), + ownership_label, + None, + format!("rpm query failed: {err}"), + ); + } + }; + let installed_evr = installed.to_string(); + + match repo_upgrade(query, package, arch, &installed) { + Ok(Some(available)) => { + summary.updates += 1; + ComponentCheck { + component: name.to_string(), + package: Some(package.to_string()), + ownership: Some(ownership_label), + installed: Some(installed_evr), + available: Some(available), + action: ACTION_UPDATE.to_string(), + error: None, + } + } + Ok(None) => ComponentCheck { + component: name.to_string(), + package: Some(package.to_string()), + ownership: Some(ownership_label), + installed: Some(installed_evr), + available: None, + action: ACTION_NOOP.to_string(), + error: None, + }, + Err(err) => { + summary.errors += 1; + component_error( + name.to_string(), + Some(package.to_string()), + ownership_label, + Some(installed_evr), + format!("repo candidate query failed: {err}"), + ) + } + } +} + +/// Newest repo candidate strictly newer than `installed`, filtered to the +/// installed arch (plus `noarch`). Returns `None` when no candidate is a genuine +/// upgrade; ordering uses RPM's own EVR comparison +/// ([`rpm_evr_cmp`]) so epochs/releases/non-semver versions are ordered the way +/// `dnf` would, and a stale/downgrade candidate is never reported as available. +fn repo_upgrade( + query: &dyn PackageQuery, + package: &str, + arch: &str, + installed: &PackageVersion, +) -> Result, PackageQueryError> { + let mut best: Option = None; + for info in query.query_available(package)? { + if info.arch != arch && info.arch != "noarch" { + continue; + } + if rpm_evr_cmp(&info.version, installed) != Ordering::Greater { + continue; + } + best = match best { + Some(current) if rpm_evr_cmp(¤t, &info.version) != Ordering::Less => { + Some(current) + } + _ => Some(info.version), + }; + } + Ok(best.map(|version| version.to_string())) +} + +fn cli_unsupported(reason: &str) -> CliCheck { + CliCheck { + package: None, + installed: None, + available: None, + action: ACTION_UNSUPPORTED.to_string(), + error: Some(reason.to_string()), + } +} + +fn cli_error(package: Option, reason: String) -> CliCheck { + CliCheck { + package, + installed: None, + available: None, + action: ACTION_ERROR.to_string(), + error: Some(reason), + } +} + +fn component_error( + component: String, + package: Option, + ownership: String, + installed: Option, + reason: String, +) -> ComponentCheck { + ComponentCheck { + component, + package, + ownership: Some(ownership), + installed, + available: None, + action: ACTION_ERROR.to_string(), + error: Some(reason), + } +} + +/// Resolved profile name reported for a given `--target` value: the raw name +/// when supplied, otherwise the release default. Used both to build the report's +/// `target` field and to key the MOTD cache, so both agree on the same name. +fn effective_target_name(target: Option<&str>) -> &str { + target.unwrap_or(DEFAULT_TARGET_PROFILE_NAME) +} + +/// Resolve the effective target profile and its reported name. +/// +/// An omitted `--target` resolves to [`DEFAULT_TARGET_PROFILE_NAME`] through the +/// same lookup path as an explicit target, so disk profiles can override the +/// built-in bootstrap fallback consistently. +fn load_effective_target_profile( + layout: &FsLayout, + target: Option<&str>, +) -> Result<(String, TargetProfile), CliError> { + let name = effective_target_name(target); + Ok((name.to_string(), load_target_profile_by_name(layout, name)?)) +} + +/// Resolve and read a named target profile. The name is validated as a single +/// path segment so `--target` cannot escape the profiles directory. +/// +/// Lookup order (first hit wins): `/profiles/.toml`, then +/// `/profiles/.toml`, then — only for the release +/// default name — the built-in profile compiled into the binary. A missing +/// non-default profile is a hard [`CliError::InvalidArgument`] listing the +/// searched paths. +fn load_target_profile_by_name(layout: &FsLayout, name: &str) -> Result { + validate_target_name(name)?; + + let mut searched = Vec::new(); + + let etc_path = layout + .etc_dir + .join(PROFILES_SUBDIR) + .join(format!("{name}.toml")); + if let Some(profile) = try_read_profile(&etc_path, name)? { + return Ok(profile); + } + searched.push(etc_path); + + let packaged_root = + crate::packaged::packaged_datadir_root(layout).unwrap_or_else(|| layout.datadir.clone()); + let packaged_path = packaged_root + .join(PROFILES_SUBDIR) + .join(format!("{name}.toml")); + if let Some(profile) = try_read_profile(&packaged_path, name)? { + return Ok(profile); + } + searched.push(packaged_path); + + // Only the release default has a compiled-in fallback; any other name that + // reached here genuinely has no profile on disk. + if name == DEFAULT_TARGET_PROFILE_NAME { + return load_builtin_default_profile(); + } + + Err(CliError::InvalidArgument { + command: CHECK_COMMAND.to_string(), + reason: format!( + "cannot find target profile '{name}'; searched {}", + searched + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join(", ") + ), + }) +} + +/// Read one candidate profile path. Distinguishes "absent" (`Ok(None)`, so the +/// caller keeps looking) from "present but unreadable/invalid" (`Err`, a real +/// misconfiguration the caller must not paper over with a fallback). +fn try_read_profile(path: &Path, name: &str) -> Result, CliError> { + let body = match std::fs::read_to_string(path) { + Ok(body) => body, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => { + return Err(CliError::InvalidArgument { + command: CHECK_COMMAND.to_string(), + reason: format!( + "cannot read target profile '{name}' at {}: {err}", + path.display() + ), + }); + } + }; + TargetProfile::from_toml_str(&body) + .map(Some) + .map_err(|err| CliError::InvalidArgument { + command: CHECK_COMMAND.to_string(), + reason: format!("target profile '{name}' is invalid: {err}"), + }) +} + +/// Parse the compiled-in release default profile. A parse failure here is a +/// build-time bug in the packaged asset, not user input, so it is a +/// [`CliError::Runtime`]. +fn load_builtin_default_profile() -> Result { + TargetProfile::from_toml_str(BUILTIN_DEFAULT_TARGET_PROFILE).map_err(|err| CliError::Runtime { + command: CHECK_COMMAND.to_string(), + reason: format!("built-in default target profile is invalid: {err}"), + }) +} + +/// Reject target names that are not a single, safe path segment. +fn validate_target_name(target: &str) -> Result<(), CliError> { + if target.trim().is_empty() + || target == "." + || target == ".." + || target.contains('/') + || target.contains('\\') + { + return Err(CliError::InvalidArgument { + command: CHECK_COMMAND.to_string(), + reason: format!("target profile name '{target}' is not a valid profile identifier"), + }); + } + Ok(()) +} + +// ── cache ──────────────────────────────────────────────────────────────── + +/// Cache location under the active layout's `cache_dir` +/// (`/var/cache/anolisa/update-check.json` in system mode). +fn cache_path(layout: &FsLayout) -> PathBuf { + layout.cache_dir.join(CACHE_FILE) +} + +/// Read and parse a cached report; any error (missing, unreadable, malformed) +/// yields `None` so a bad cache never blocks a fresh query. +fn read_cache(path: &Path) -> Option { + let body = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&body).ok() +} + +/// Whether a cached report may be reused for a MOTD render: it must be fresh AND +/// have been computed for the same target, so a prior `--target` run never leaks +/// its report into a plain MOTD (or a different target's). +fn cache_is_usable(cache: &UpdateCheckCache, target: Option<&str>) -> bool { + is_fresh(&cache.generated_at) && cache.report.target.as_deref() == target +} + +/// Whether a cache timestamp is within [`CACHE_TTL_SECS`] of now (and not in the +/// future, which would indicate a corrupt/adversarial timestamp). +fn is_fresh(generated_at: &str) -> bool { + match chrono::DateTime::parse_from_rfc3339(generated_at) { + Ok(ts) => { + let age = Utc::now().signed_duration_since(ts.with_timezone(&Utc)); + age >= chrono::Duration::zero() && age <= chrono::Duration::seconds(CACHE_TTL_SECS) + } + Err(_) => false, + } +} + +/// Best-effort cache write; failures are swallowed by the caller. +fn write_cache(path: &Path, report: &UpdateCheckReport) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let cache = UpdateCheckCache { + generated_at: super::now_iso8601(), + report: report.clone(), + }; + let body = serde_json::to_string_pretty(&cache) + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?; + std::fs::write(path, body) +} diff --git a/src/anolisa/crates/anolisa-cli/src/commands/tier1/update/check/render.rs b/src/anolisa/crates/anolisa-cli/src/commands/tier1/update/check/render.rs new file mode 100644 index 000000000..ccc4e3f33 --- /dev/null +++ b/src/anolisa/crates/anolisa-cli/src/commands/tier1/update/check/render.rs @@ -0,0 +1,178 @@ +//! Rendering for `update --check`: JSON envelope, short MOTD, and the human +//! report. Kept separate from the detection logic in the parent module so the +//! output vocabulary lives in one place. + +use super::super::UpdateArgs; +use super::{ + ACTION_ERROR, ACTION_INSTALL, ACTION_NOOP, ACTION_UNSUPPORTED_RPM, ACTION_UPDATE, + CHECK_COMMAND, CliCheck, ComponentCheck, UpdateCheckReport, +}; +use crate::color::Palette; +use crate::context::CliContext; +use crate::response; + +/// Dispatch to the JSON, MOTD, or human renderer per the active flags. JSON +/// wins over `--motd` so `--check --json --motd` still yields the full envelope. +pub(super) fn render_report(ctx: &CliContext, args: &UpdateArgs, report: &UpdateCheckReport) { + if ctx.json { + // A plain Serialize struct never fails here; ignore the Result so a + // successful check is not misreported. + let _ = response::render_json(CHECK_COMMAND, report); + return; + } + if args.motd { + render_motd(ctx, report); + return; + } + render_human(ctx, report); +} + +/// Short, stable MOTD summary. Silent when there is nothing to do so a login +/// banner stays quiet on an up-to-date host. +pub(super) fn render_motd(ctx: &CliContext, report: &UpdateCheckReport) { + if ctx.quiet { + return; + } + if let Some(text) = build_motd(report) { + println!("{text}"); + } +} + +/// Build the MOTD text, or `None` when nothing can be upgraded or installed. +pub(super) fn build_motd(report: &UpdateCheckReport) -> Option { + let summary = &report.summary; + if summary.updates == 0 && summary.missing_defaults == 0 { + return None; + } + let mut parts = Vec::new(); + if summary.updates > 0 { + parts.push(format!( + "{} component{} can be upgraded", + summary.updates, + plural(summary.updates) + )); + } + if summary.missing_defaults > 0 { + parts.push(format!( + "{} new default component{} can be installed", + summary.missing_defaults, + plural(summary.missing_defaults) + )); + } + Some(format!( + "ANOLISA toolchain update is available.\n{}.\nRun: anolisa update --check for details", + parts.join("; ") + )) +} + +fn render_human(ctx: &CliContext, report: &UpdateCheckReport) { + if ctx.quiet { + return; + } + let color = Palette::new(ctx.no_color); + + let mut header = format!("update check (backend: {}", report.backend); + if let Some(target) = &report.target { + header.push_str(&format!(", target: {target}")); + } + header.push(')'); + println!("{}", color.command(header)); + + render_cli_line(&report.cli, &color); + for component in &report.components { + render_component_line(component, &color); + } + + let summary = &report.summary; + println!( + "{} {} update(s), {} new default(s), {} unsupported, {} error(s)", + color.label("summary:"), + summary.updates, + summary.missing_defaults, + summary.unsupported, + summary.errors, + ); +} + +fn render_cli_line(cli: &CliCheck, color: &Palette) { + let label = color.label("CLI:"); + match cli.action.as_str() { + ACTION_UPDATE => println!( + "{label} {} {} → {} {}", + cli.package.as_deref().unwrap_or("anolisa"), + cli.installed.as_deref().unwrap_or("-"), + cli.available.as_deref().unwrap_or("-"), + color.warn("(update)"), + ), + ACTION_NOOP => println!( + "{label} {} {} {}", + cli.package.as_deref().unwrap_or("anolisa"), + cli.installed.as_deref().unwrap_or("-"), + color.ok("(up to date)"), + ), + ACTION_ERROR => println!( + "{label} {} {}", + cli.package.as_deref().unwrap_or("anolisa"), + color.warn(format!( + "(error: {})", + cli.error.as_deref().unwrap_or("unknown") + )), + ), + _ => println!( + "{label} {}", + color.muted(format!( + "not RPM-managed ({})", + cli.error.as_deref().unwrap_or("use `anolisa update self`") + )), + ), + } +} + +fn render_component_line(component: &ComponentCheck, color: &Palette) { + let ownership = component.ownership.as_deref().unwrap_or(""); + let meta = if ownership.is_empty() { + String::new() + } else { + color.muted(format!(" ({ownership})")) + }; + match component.action.as_str() { + ACTION_UPDATE => println!( + " {}{} {} → {} {}", + component.component, + meta, + component.installed.as_deref().unwrap_or("-"), + component.available.as_deref().unwrap_or("-"), + color.warn("(update)"), + ), + ACTION_INSTALL => println!( + " {} {}", + component.component, + color.warn("(new default — can be installed)"), + ), + ACTION_UNSUPPORTED_RPM => println!( + " {}{} {}", + component.component, + meta, + color.muted("(not in RPM upgrade scope)"), + ), + ACTION_ERROR => println!( + " {}{} {}", + component.component, + meta, + color.warn(format!( + "(error: {})", + component.error.as_deref().unwrap_or("unknown") + )), + ), + _ => println!( + " {}{} {}", + component.component, + meta, + color.ok("(up to date)"), + ), + } +} + +fn plural(n: usize) -> &'static str { + if n == 1 { "" } else { "s" } +} diff --git a/src/anolisa/crates/anolisa-cli/src/commands/tier1/update/check/tests.rs b/src/anolisa/crates/anolisa-cli/src/commands/tier1/update/check/tests.rs new file mode 100644 index 000000000..a5d2ffffd --- /dev/null +++ b/src/anolisa/crates/anolisa-cli/src/commands/tier1/update/check/tests.rs @@ -0,0 +1,1046 @@ +//! Unit tests for `update --check`. Driven entirely through the injected +//! [`PackageQuery`] fake plus in-memory state, so no live rpmdb/dnf is required. + +use super::*; +use std::cell::Cell; +use std::collections::{HashMap, HashSet}; + +use anolisa_platform::pkg_query::{PackageInfo, PackageVersion}; +use anolisa_platform::pkg_transaction::{PackageTransaction, PackageTransactionError}; + +use anolisa_core::state::{ObjectStatus, RpmMetadata, SubscriptionScope}; + +use super::super::UpdateArgs; +use super::render::build_motd; + +/// In-memory host implementing both [`PackageQuery`] and [`PackageTransaction`]: +/// the check must only ever call the query side, so any transaction call is a +/// routing bug the counter surfaces. +#[derive(Default)] +struct FakeHost { + /// exe/capability path → owning package names. + provides: HashMap>, + /// package → installed EVR info. + installed: HashMap, + /// package → repo candidate infos. + available: HashMap>, + /// packages whose `query_available` returns an error. + available_errors: HashSet, + /// packages whose `query_installed` reports a multi-version drift. + installed_multi: HashSet, + /// capabilities whose `what_provides_installed` returns a query error. + provides_errors: HashSet, + txn_calls: Cell, +} + +impl FakeHost { + fn with_cli_noop() -> Self { + // A CLI that is RPM-owned and already current, so CLI status never + // perturbs component-focused assertions. + let mut host = FakeHost::default(); + host.provides + .insert("/usr/bin/anolisa".to_string(), vec!["anolisa".to_string()]); + host.installed.insert( + "anolisa".to_string(), + info("anolisa", "1.0.0", Some("1.al4")), + ); + host + } +} + +impl PackageQuery for FakeHost { + fn query_installed(&self, package: &str) -> Result, PackageQueryError> { + if self.installed_multi.contains(package) { + return Err(PackageQueryError::UnexpectedOutput { + command: "rpm".to_string(), + detail: "2 installed versions".to_string(), + }); + } + Ok(self.installed.get(package).cloned()) + } + + fn query_available(&self, package: &str) -> Result, PackageQueryError> { + if self.available_errors.contains(package) { + return Err(PackageQueryError::QueryFailed { + command: "dnf".to_string(), + code: Some(1), + stderr: "repo unreachable".to_string(), + }); + } + Ok(self.available.get(package).cloned().unwrap_or_default()) + } + + fn what_provides_installed(&self, capability: &str) -> Result, PackageQueryError> { + if self.provides_errors.contains(capability) { + return Err(PackageQueryError::QueryFailed { + command: "rpm".to_string(), + code: Some(1), + stderr: "rpmdb query failed".to_string(), + }); + } + Ok(self.provides.get(capability).cloned().unwrap_or_default()) + } +} + +impl PackageTransaction for FakeHost { + fn install(&self, _package: &str) -> Result<(), PackageTransactionError> { + self.txn_calls.set(self.txn_calls.get() + 1); + Ok(()) + } + fn update(&self, _package: &str) -> Result<(), PackageTransactionError> { + self.txn_calls.set(self.txn_calls.get() + 1); + Ok(()) + } + fn remove(&self, _package: &str) -> Result<(), PackageTransactionError> { + self.txn_calls.set(self.txn_calls.get() + 1); + Ok(()) + } +} + +fn info(name: &str, version: &str, release: Option<&str>) -> PackageInfo { + PackageInfo { + name: name.to_string(), + version: PackageVersion { + epoch: None, + version: version.to_string(), + release: release.map(str::to_string), + }, + arch: "x86_64".to_string(), + origin: None, + } +} + +fn rpm_component( + component: &str, + package: &str, + evr: &str, + ownership: Ownership, +) -> InstalledObject { + InstalledObject { + kind: ObjectKind::Component, + name: component.to_string(), + version: evr.to_string(), + status: ObjectStatus::Installed, + manifest_digest: None, + distribution_source: None, + raw_package: None, + install_backend: Some("rpm".to_string()), + ownership: Some(ownership), + rpm_metadata: Some(RpmMetadata { + package_name: package.to_string(), + evr: Some(evr.to_string()), + arch: Some("x86_64".to_string()), + source_repo: Some("@System".to_string()), + }), + installed_at: "2026-06-01T10:00:00Z".to_string(), + last_operation_id: None, + managed: !matches!(ownership, Ownership::RpmObserved), + adopted: matches!(ownership, Ownership::RpmObserved), + subscription_scope: SubscriptionScope::None, + enabled_features: Vec::new(), + component_refs: Vec::new(), + files: Vec::new(), + external_modified_files: Vec::new(), + services: Vec::new(), + health: Vec::new(), + provisioned_packages: Vec::new(), + } +} + +fn raw_component(component: &str, version: &str) -> InstalledObject { + InstalledObject { + kind: ObjectKind::Component, + name: component.to_string(), + version: version.to_string(), + status: ObjectStatus::Installed, + manifest_digest: None, + distribution_source: Some("https://example.com/x".to_string()), + raw_package: None, + install_backend: Some("raw".to_string()), + ownership: Some(Ownership::RawManaged), + rpm_metadata: None, + installed_at: "2026-06-01T10:00:00Z".to_string(), + last_operation_id: None, + managed: true, + adopted: false, + subscription_scope: SubscriptionScope::None, + enabled_features: Vec::new(), + component_refs: Vec::new(), + files: Vec::new(), + external_modified_files: Vec::new(), + services: Vec::new(), + health: Vec::new(), + provisioned_packages: Vec::new(), + } +} + +fn state_with(objects: Vec) -> InstalledState { + let mut state = InstalledState::default(); + for obj in objects { + state.upsert_object(obj); + } + state +} + +fn run( + host: &FakeHost, + installed: &InstalledState, + target: Option, + target_name: Option, +) -> UpdateCheckReport { + run_with_index(host, installed, target, target_name, None) +} + +fn run_with_index( + host: &FakeHost, + installed: &InstalledState, + target: Option, + target_name: Option, + component_index: Option<&crate::resolution::ComponentIndex>, +) -> UpdateCheckReport { + run_update_check(CheckInputs { + installed, + query: host, + cli_exe_path: "/usr/bin/anolisa", + arch: "x86_64", + target_name, + target, + component_index, + }) +} + +fn system_ctx() -> CliContext { + CliContext { + install_mode: crate::context::InstallMode::System, + prefix: None, + json: false, + dry_run: false, + verbose: false, + quiet: true, + no_color: true, + } +} + +// ── CLI parse surface ─────────────────────────────────────────────── + +use clap::Parser; + +#[test] +fn update_check_parse_accepts_check_flag() { + let args = UpdateArgs::try_parse_from(["update", "--check"]).expect("parse"); + assert!(args.check); + assert!(args.component.is_none()); + assert!(args.command.is_none()); +} + +#[test] +fn update_check_rejects_self_target() { + // With `--check` present, clap binds `self` to the positional rather than + // dispatching the subcommand, so the rejection lands in `handle`. + let args = UpdateArgs::try_parse_from(["update", "--check", "self"]).expect("parse"); + assert!(args.check); + let err = + super::super::handle(args, &system_ctx()).expect_err("`--check self` must be rejected"); + assert_eq!(err.code(), "INVALID_ARGUMENT"); +} + +#[test] +fn update_check_parse_motd_requires_check() { + UpdateArgs::try_parse_from(["update", "--motd"]) + .expect_err("--motd is only valid together with --check"); +} + +#[test] +fn update_check_parse_target_requires_check() { + UpdateArgs::try_parse_from(["update", "--target", "image-v1.0"]) + .expect_err("--target is only valid together with --check"); +} + +#[test] +fn update_check_rejects_component_argument() { + let args = UpdateArgs { + component: Some("cosh".to_string()), + command: None, + check: true, + motd: false, + refresh: false, + target: None, + }; + let err = super::super::handle(args, &system_ctx()) + .expect_err("component + --check must be rejected"); + assert_eq!(err.code(), "INVALID_ARGUMENT"); + assert!(err.reason().contains("no component argument")); +} + +// ── report shape and detection ────────────────────────────────────── + +#[test] +fn update_check_json_shape_has_cli_components_summary() { + let host = FakeHost::with_cli_noop(); + let state = state_with(vec![]); + let report = run(&host, &state, None, None); + let value = serde_json::to_value(&report).expect("serialize"); + assert!(value.get("cli").is_some(), "cli field present"); + assert!( + value.get("components").is_some(), + "components field present" + ); + assert!(value.get("summary").is_some(), "summary field present"); + assert_eq!(value["backend"], "rpm"); + // A current CLI and no components → nothing to do. + assert_eq!(value["upgrade_available"], false); + assert_eq!(value["action_required"], false); +} + +#[test] +fn update_check_rpm_component_update_candidate() { + let mut host = FakeHost::with_cli_noop(); + host.installed.insert( + "copilot-shell".to_string(), + info("copilot-shell", "1.0.0", Some("1.al4")), + ); + host.available.insert( + "copilot-shell".to_string(), + vec![info("copilot-shell", "1.1.0", Some("1.al4"))], + ); + let state = state_with(vec![rpm_component( + "cosh", + "copilot-shell", + "1.0.0-1.al4", + Ownership::RpmObserved, + )]); + + let report = run(&host, &state, None, None); + let item = report + .components + .iter() + .find(|c| c.component == "cosh") + .expect("component present"); + assert_eq!(item.action, ACTION_UPDATE); + assert_eq!(item.installed.as_deref(), Some("1.0.0-1.al4")); + assert_eq!(item.available.as_deref(), Some("1.1.0-1.al4")); + assert_eq!(item.ownership.as_deref(), Some("rpm-observed")); + assert_eq!(report.summary.updates, 1); + assert!(report.upgrade_available); + assert!(report.action_required); + assert_eq!( + host.txn_calls.get(), + 0, + "check must never run a transaction" + ); +} + +/// Regression for the P1 semver bug: real RPM EVRs that semver cannot parse +/// (two-segment version) must still be detected as upgradable. +#[test] +fn update_check_detects_non_semver_rpm_upgrade() { + let mut host = FakeHost::with_cli_noop(); + host.installed.insert( + "copilot-shell".to_string(), + info("copilot-shell", "0.5", Some("1.al4")), + ); + host.available.insert( + "copilot-shell".to_string(), + vec![info("copilot-shell", "1.0.0", Some("1.al4"))], + ); + let state = state_with(vec![rpm_component( + "cosh", + "copilot-shell", + "0.5-1.al4", + Ownership::RpmObserved, + )]); + + let report = run(&host, &state, None, None); + let item = &report.components[0]; + assert_eq!(item.action, ACTION_UPDATE); + assert_eq!(item.available.as_deref(), Some("1.0.0-1.al4")); + assert_eq!(report.summary.updates, 1); +} + +#[test] +fn update_check_rpm_component_up_to_date_is_noop() { + let mut host = FakeHost::with_cli_noop(); + host.installed.insert( + "copilot-shell".to_string(), + info("copilot-shell", "1.1.0", Some("1.al4")), + ); + // Repo only offers the same version. + host.available.insert( + "copilot-shell".to_string(), + vec![info("copilot-shell", "1.1.0", Some("1.al4"))], + ); + let state = state_with(vec![rpm_component( + "cosh", + "copilot-shell", + "1.1.0-1.al4", + Ownership::RpmManaged, + )]); + + let report = run(&host, &state, None, None); + let item = &report.components[0]; + assert_eq!(item.action, ACTION_NOOP); + assert_eq!(report.summary.updates, 0); +} + +#[test] +fn update_check_raw_component_is_unsupported() { + let host = FakeHost::with_cli_noop(); + let state = state_with(vec![raw_component("tokenless", "0.5.0")]); + + let report = run(&host, &state, None, None); + let item = &report.components[0]; + assert_eq!(item.action, ACTION_UNSUPPORTED_RPM); + assert_eq!(item.ownership.as_deref(), Some("raw-managed")); + assert_eq!(report.summary.unsupported, 1); + assert_eq!(report.summary.updates, 0); + assert_eq!( + host.txn_calls.get(), + 0, + "raw component must not run a transaction" + ); +} + +#[test] +fn update_check_missing_default_reports_install() { + let host = FakeHost::with_cli_noop(); + let state = state_with(vec![]); + let profile = TargetProfile { + default_components: vec!["cosh".to_string(), "sec-core".to_string()], + }; + + let report = run(&host, &state, Some(profile), Some("image-v1.0".to_string())); + assert_eq!(report.target.as_deref(), Some("image-v1.0")); + assert_eq!(report.summary.missing_defaults, 2); + assert!( + report.components.iter().all(|c| c.action == ACTION_INSTALL), + "absent defaults must be reported as installable" + ); + // Installs are not "upgrades", but they still require action. + assert!(!report.upgrade_available); + assert!(report.action_required); +} + +#[test] +fn update_check_present_default_is_not_reported() { + let mut host = FakeHost::with_cli_noop(); + host.installed.insert( + "copilot-shell".to_string(), + info("copilot-shell", "1.0.0", Some("1.al4")), + ); + let state = state_with(vec![rpm_component( + "cosh", + "copilot-shell", + "1.0.0-1.al4", + Ownership::RpmManaged, + )]); + let profile = TargetProfile { + default_components: vec!["cosh".to_string()], + }; + + let report = run(&host, &state, Some(profile), Some("image-v1.0".to_string())); + assert_eq!(report.summary.missing_defaults, 0); +} + +/// A default absent from ANOLISA state but installed on the host (declaring the +/// `anolisa-component(...)` provide) must not be reported as a missing install; +/// it is evaluated for upgrades instead. +#[test] +fn update_check_default_present_via_provide_is_not_missing() { + let mut host = FakeHost::with_cli_noop(); + host.provides.insert( + "anolisa-component(cosh)".to_string(), + vec!["copilot-shell".to_string()], + ); + host.installed.insert( + "copilot-shell".to_string(), + info("copilot-shell", "2.6.1", Some("1")), + ); + let state = state_with(vec![]); + let profile = TargetProfile { + default_components: vec!["cosh".to_string()], + }; + + let report = run( + &host, + &state, + Some(profile), + Some(DEFAULT_TARGET_PROFILE_NAME.to_string()), + ); + let item = report + .components + .iter() + .find(|c| c.component == "cosh") + .expect("cosh reported"); + assert_eq!(item.action, ACTION_NOOP); + assert_eq!(item.ownership.as_deref(), Some("rpm-observed")); + assert_eq!( + report.summary.missing_defaults, 0, + "a present-but-unadopted default is not missing" + ); +} + +/// A present-but-unadopted default with a newer repo candidate is reported as an +/// upgrade, not an install. +#[test] +fn update_check_default_present_via_provide_reports_upgrade() { + let mut host = FakeHost::with_cli_noop(); + host.provides.insert( + "anolisa-component(cosh)".to_string(), + vec!["copilot-shell".to_string()], + ); + host.installed.insert( + "copilot-shell".to_string(), + info("copilot-shell", "2.6.1", Some("1")), + ); + host.available.insert( + "copilot-shell".to_string(), + vec![info("copilot-shell", "2.7.0", Some("1"))], + ); + let state = state_with(vec![]); + let profile = TargetProfile { + default_components: vec!["cosh".to_string()], + }; + + let report = run( + &host, + &state, + Some(profile), + Some(DEFAULT_TARGET_PROFILE_NAME.to_string()), + ); + let item = &report.components[0]; + assert_eq!(item.action, ACTION_UPDATE); + assert_eq!(item.available.as_deref(), Some("2.7.0-1")); + assert_eq!(report.summary.missing_defaults, 0); + assert_eq!(report.summary.updates, 1); +} + +/// A legacy default installed under its package name but lacking the +/// `anolisa-component(...)` provide is still recognised via the component index +/// mapping, so it is not falsely reported as missing. +#[test] +fn update_check_default_present_via_index_package_without_provide() { + let mut host = FakeHost::with_cli_noop(); + host.installed.insert( + "copilot-shell".to_string(), + info("copilot-shell", "2.6.1", Some("1")), + ); + let index = crate::resolution::ComponentIndex::from_toml_str( + "schema_version = 1\n\n[[components]]\nname = \"cosh\"\n\n[[components.backends]]\nkind = \"rpm\"\npackage = \"copilot-shell\"\n", + "test-components.toml", + ) + .expect("index parses"); + let state = state_with(vec![]); + let profile = TargetProfile { + default_components: vec!["cosh".to_string()], + }; + + let report = run_with_index( + &host, + &state, + Some(profile), + Some(DEFAULT_TARGET_PROFILE_NAME.to_string()), + Some(&index), + ); + let item = &report.components[0]; + assert_eq!(item.action, ACTION_NOOP); + assert_eq!(item.ownership.as_deref(), Some("rpm-observed")); + assert_eq!(report.summary.missing_defaults, 0); +} + +/// A default absent from both ANOLISA state and rpmdb is still reported as an +/// install (the pre-fix behaviour for genuinely missing defaults). +#[test] +fn update_check_default_absent_from_rpmdb_reports_install() { + let host = FakeHost::with_cli_noop(); + let state = state_with(vec![]); + let profile = TargetProfile { + default_components: vec!["cosh".to_string()], + }; + + let report = run( + &host, + &state, + Some(profile), + Some(DEFAULT_TARGET_PROFILE_NAME.to_string()), + ); + let item = &report.components[0]; + assert_eq!(item.action, ACTION_INSTALL); + assert_eq!(report.summary.missing_defaults, 1); +} + +/// A failed rpmdb provide query for a default must not be reported as an +/// installable missing default — "cannot determine" is an item error. +#[test] +fn update_check_default_provide_query_error_is_item_error() { + let mut host = FakeHost::with_cli_noop(); + host.provides_errors + .insert("anolisa-component(cosh)".to_string()); + let state = state_with(vec![]); + let profile = TargetProfile { + default_components: vec!["cosh".to_string()], + }; + + let report = run( + &host, + &state, + Some(profile), + Some(DEFAULT_TARGET_PROFILE_NAME.to_string()), + ); + let item = &report.components[0]; + assert_eq!(item.action, ACTION_ERROR); + assert!(item.error.is_some()); + assert_eq!( + report.summary.missing_defaults, 0, + "an indeterminate probe must not count as a missing default" + ); + assert_eq!(report.summary.errors, 1); +} + +/// Multiple installed providers for a default's component capability is +/// ambiguous and must be an item error, not a silently-picked first package. +#[test] +fn update_check_default_multiple_providers_is_item_error() { + let mut host = FakeHost::with_cli_noop(); + host.provides.insert( + "anolisa-component(cosh)".to_string(), + vec!["copilot-shell".to_string(), "copilot-shell-ng".to_string()], + ); + let state = state_with(vec![]); + let profile = TargetProfile { + default_components: vec!["cosh".to_string()], + }; + + let report = run( + &host, + &state, + Some(profile), + Some(DEFAULT_TARGET_PROFILE_NAME.to_string()), + ); + let item = &report.components[0]; + assert_eq!(item.action, ACTION_ERROR); + assert!( + item.error.as_deref().unwrap().contains("multiple"), + "error should explain the ambiguity" + ); + assert_eq!(report.summary.missing_defaults, 0); + assert_eq!(report.summary.errors, 1); + assert_eq!( + host.txn_calls.get(), + 0, + "an ambiguous default must not run a transaction" + ); +} + +/// If the resolved provider package is absent from rpmdb (a mid-check race), the +/// default is an item error, not a missing install. +#[test] +fn update_check_default_resolved_package_absent_is_item_error() { + let mut host = FakeHost::with_cli_noop(); + // A provider is declared, but the package itself is not in the installed + // map, so `query_installed` returns `Ok(None)`. + host.provides.insert( + "anolisa-component(cosh)".to_string(), + vec!["copilot-shell".to_string()], + ); + let state = state_with(vec![]); + let profile = TargetProfile { + default_components: vec!["cosh".to_string()], + }; + + let report = run( + &host, + &state, + Some(profile), + Some(DEFAULT_TARGET_PROFILE_NAME.to_string()), + ); + let item = &report.components[0]; + assert_eq!(item.action, ACTION_ERROR); + assert_eq!(report.summary.missing_defaults, 0); + assert_eq!(report.summary.errors, 1); +} + +/// The index mapping is de-duplicated, so a backend and an alias sharing a name +/// are queried only once. +#[test] +fn update_check_index_rpm_packages_are_deduplicated() { + let index = crate::resolution::ComponentIndex::from_toml_str( + "schema_version = 1\n\n[[components]]\nname = \"cosh\"\n\n[[components.backends]]\nkind = \"rpm\"\npackage = \"copilot-shell\"\n\n[[components.aliases]]\nkind = \"rpm-package\"\nname = \"copilot-shell\"\n", + "test-components.toml", + ) + .expect("index parses"); + assert_eq!( + index_rpm_packages(Some(&index), "cosh"), + vec!["copilot-shell".to_string()], + ); +} + +#[test] +fn update_check_repo_query_failure_is_item_error() { + let mut host = FakeHost::with_cli_noop(); + host.installed.insert( + "copilot-shell".to_string(), + info("copilot-shell", "1.0.0", Some("1.al4")), + ); + host.available_errors.insert("copilot-shell".to_string()); + let state = state_with(vec![rpm_component( + "cosh", + "copilot-shell", + "1.0.0-1.al4", + Ownership::RpmObserved, + )]); + + let report = run(&host, &state, None, None); + let item = &report.components[0]; + assert_eq!(item.action, ACTION_ERROR); + assert!(item.error.is_some()); + assert_eq!(report.summary.errors, 1); +} + +#[test] +fn update_check_component_missing_from_rpmdb_is_item_error() { + let host = FakeHost::with_cli_noop(); + // No installed entry for the package → rpmdb miss. + let state = state_with(vec![rpm_component( + "cosh", + "copilot-shell", + "1.0.0-1.al4", + Ownership::RpmObserved, + )]); + + let report = run(&host, &state, None, None); + let item = &report.components[0]; + assert_eq!(item.action, ACTION_ERROR); + assert!(item.error.as_deref().unwrap().contains("forget")); + assert_eq!(report.summary.errors, 1); +} + +#[test] +fn update_check_cli_update_available() { + let mut host = FakeHost::default(); + host.provides + .insert("/usr/bin/anolisa".to_string(), vec!["anolisa".to_string()]); + host.installed.insert( + "anolisa".to_string(), + info("anolisa", "0.5.0", Some("1.al4")), + ); + host.available.insert( + "anolisa".to_string(), + vec![info("anolisa", "1.0.0", Some("1.al4"))], + ); + let state = state_with(vec![]); + + let report = run(&host, &state, None, None); + assert_eq!(report.cli.action, ACTION_UPDATE); + assert_eq!(report.cli.package.as_deref(), Some("anolisa")); + assert_eq!(report.cli.installed.as_deref(), Some("0.5.0-1.al4")); + assert_eq!(report.cli.available.as_deref(), Some("1.0.0-1.al4")); + assert_eq!(report.summary.updates, 1); + assert!(report.upgrade_available); +} + +#[test] +fn update_check_cli_not_rpm_owned_is_unsupported() { + // No provider for the exe path → not RPM-owned. + let host = FakeHost::default(); + let state = state_with(vec![]); + let report = run(&host, &state, None, None); + assert_eq!(report.cli.action, ACTION_UNSUPPORTED); + assert!(report.cli.package.is_none()); + assert_eq!(report.summary.unsupported, 1); +} + +// ── MOTD rendering ────────────────────────────────────────────────── + +#[test] +fn update_check_motd_text_lists_upgrades_and_installs() { + let report = UpdateCheckReport { + target: Some("image-v1.0".to_string()), + backend: "rpm".to_string(), + upgrade_available: true, + action_required: true, + cli: cli_unsupported("test"), + components: Vec::new(), + summary: CheckSummary { + updates: 1, + missing_defaults: 1, + unsupported: 0, + errors: 0, + }, + }; + let text = build_motd(&report).expect("motd text present"); + assert!(text.contains("ANOLISA toolchain update is available.")); + assert!(text.contains("1 component can be upgraded")); + assert!(text.contains("1 new default component can be installed")); + assert!(text.contains("Run: anolisa update --check for details")); + assert!( + !text.contains("anolisa upgrade"), + "MOTD must not reference the not-yet-existing `anolisa upgrade`" + ); +} + +#[test] +fn update_check_motd_is_silent_when_nothing_to_do() { + let report = UpdateCheckReport { + target: None, + backend: "rpm".to_string(), + upgrade_available: false, + action_required: false, + cli: CliCheck { + package: Some("anolisa".to_string()), + installed: Some("1.0.0-1.al4".to_string()), + available: None, + action: ACTION_NOOP.to_string(), + error: None, + }, + components: Vec::new(), + summary: CheckSummary::default(), + }; + assert!(build_motd(&report).is_none()); +} + +// ── cache ─────────────────────────────────────────────────────────── + +fn report_with_target(target: Option<&str>) -> UpdateCheckReport { + UpdateCheckReport { + target: target.map(str::to_string), + backend: "rpm".to_string(), + upgrade_available: true, + action_required: true, + cli: cli_unsupported("test"), + components: Vec::new(), + summary: CheckSummary { + updates: 1, + ..Default::default() + }, + } +} + +#[test] +fn update_check_cache_round_trips_and_respects_ttl() { + let tmp = tempfile::tempdir().expect("tmpdir"); + let layout = FsLayout::system(Some(tmp.path().to_path_buf())); + let path = cache_path(&layout); + + write_cache(&path, &report_with_target(None)).expect("write cache"); + + let cache = read_cache(&path).expect("cache readable"); + assert_eq!(cache.report.summary.updates, 1); + assert!(is_fresh(&cache.generated_at), "just-written cache is fresh"); + + // A far-past timestamp is stale. + assert!(!is_fresh("2000-01-01T00:00:00Z")); +} + +/// A cached report must not be reused for a different (or absent) target — the +/// MOTD fast path is keyed by target as well as freshness. +#[test] +fn update_check_cache_usable_requires_matching_target() { + let tmp = tempfile::tempdir().expect("tmpdir"); + let layout = FsLayout::system(Some(tmp.path().to_path_buf())); + let path = cache_path(&layout); + write_cache(&path, &report_with_target(Some("image-v1.0"))).expect("write cache"); + let cache = read_cache(&path).expect("cache readable"); + + assert!( + cache_is_usable(&cache, Some("image-v1.0")), + "same target reuses the cache" + ); + assert!( + !cache_is_usable(&cache, None), + "a plain MOTD must not reuse a targeted report" + ); + assert!( + !cache_is_usable(&cache, Some("image-v2.0")), + "a different target must not reuse the report" + ); +} + +// ── repo config read-only + target profile ────────────────────────── + +/// `--check` must load repo config without writing it. Here the config already +/// exists locally, so the read-only load returns it and touches nothing else; +/// the missing-config no-write guarantee is covered by +/// `repo_config::tests::load_dry_run_fetches_without_writing`. +#[test] +fn update_check_read_only_load_uses_existing_config_without_writing() { + let tmp = tempfile::tempdir().expect("tmpdir"); + let layout = FsLayout::system(Some(tmp.path().to_path_buf())); + std::fs::create_dir_all(&layout.etc_dir).expect("mkdir etc"); + let repo_toml = layout.etc_dir.join("repo.toml"); + std::fs::write( + &repo_toml, + "schema_version = 1\ndefault_backend = \"rpm\"\n\n[backends.rpm]\nbase_url = \"https://repo.example/$os/$arch/os\"\n", + ) + .expect("write repo.toml"); + + let cfg = load_repo_config_read_only(&layout).expect("read-only load"); + assert_eq!(cfg.default_backend, "rpm"); + // No scratch file was left behind by the read-only path. + assert!(!repo_toml.with_extension("toml.tmp").exists()); +} + +#[test] +fn update_check_target_profile_parses_default_components() { + let tmp = tempfile::tempdir().expect("tmpdir"); + let layout = FsLayout::system(Some(tmp.path().to_path_buf())); + let dir = layout.etc_dir.join("profiles"); + std::fs::create_dir_all(&dir).expect("mkdir profiles"); + std::fs::write( + dir.join("image-v1.0.toml"), + "schema_version = 1\nname = \"image-v1.0\"\ndefault_components = [\"cosh\", \"sec-core\"]\n", + ) + .expect("write profile"); + + let profile = load_target_profile_by_name(&layout, "image-v1.0").expect("profile loads"); + assert_eq!(profile.default_components, vec!["cosh", "sec-core"]); +} + +#[test] +fn update_check_target_profile_rejects_traversal() { + let tmp = tempfile::tempdir().expect("tmpdir"); + let layout = FsLayout::system(Some(tmp.path().to_path_buf())); + let err = load_target_profile_by_name(&layout, "../escape").expect_err("must reject traversal"); + assert_eq!(err.code(), "INVALID_ARGUMENT"); +} + +// ── default target profile + user-mode gating ─────────────────────── + +fn user_ctx() -> CliContext { + CliContext { + install_mode: crate::context::InstallMode::User, + prefix: None, + json: false, + dry_run: false, + verbose: false, + quiet: true, + no_color: true, + } +} + +/// A non-system install mode is out of scope for the RPM upgrade check and must +/// be rejected explicitly rather than emit a misleading RPM report. +#[test] +fn update_check_rejects_user_mode() { + let args = UpdateArgs { + component: None, + command: None, + check: true, + motd: false, + refresh: false, + target: None, + }; + let err = + super::handle_update_check(&args, &user_ctx()).expect_err("user mode must be rejected"); + assert_eq!(err.code(), "INVALID_ARGUMENT"); +} + +/// The MOTD path must stay silent in user mode: a login banner returns `Ok(())` +/// without touching rpm/dnf or erroring. +#[test] +fn update_check_motd_user_mode_is_silent() { + let args = UpdateArgs { + component: None, + command: None, + check: true, + motd: true, + refresh: false, + target: None, + }; + super::handle_update_check(&args, &user_ctx()).expect("motd in user mode is a silent Ok"); +} + +/// An omitted `--target` maps to the release default name for both the report +/// and the MOTD cache key. +#[test] +fn update_check_effective_target_defaults_to_release_profile() { + assert_eq!(effective_target_name(None), DEFAULT_TARGET_PROFILE_NAME); + assert_eq!(effective_target_name(Some("image-v1.0")), "image-v1.0"); +} + +/// The compiled-in default profile parses and declares at least one default. +#[test] +fn update_check_builtin_default_profile_parses() { + let profile = load_builtin_default_profile().expect("builtin default profile parses"); + assert!( + !profile.default_components.is_empty(), + "default profile must declare at least one default component" + ); +} + +/// With `--target` omitted, resolution yields the release default name and falls +/// back to the built-in profile when no disk profile exists. +#[test] +fn update_check_omitted_target_uses_builtin_default() { + let tmp = tempfile::tempdir().expect("tmpdir"); + let layout = FsLayout::system(Some(tmp.path().to_path_buf())); + let (name, profile) = + load_effective_target_profile(&layout, None).expect("default target resolves"); + assert_eq!(name, DEFAULT_TARGET_PROFILE_NAME); + assert!(!profile.default_components.is_empty()); +} + +/// Omitted `--target` and explicit `--target agentic_os-latest` share the same +/// lookup path, so an on-disk latest profile overrides the built-in fallback. +#[test] +fn update_check_omitted_target_uses_disk_default_profile() { + let tmp = tempfile::tempdir().expect("tmpdir"); + let layout = FsLayout::system(Some(tmp.path().to_path_buf())); + let dir = layout.etc_dir.join("profiles"); + std::fs::create_dir_all(&dir).expect("mkdir profiles"); + std::fs::write( + dir.join(format!("{DEFAULT_TARGET_PROFILE_NAME}.toml")), + format!( + "schema_version = 1\nname = \"{DEFAULT_TARGET_PROFILE_NAME}\"\ndefault_components = [\"disk-only\"]\n" + ), + ) + .expect("write default profile"); + + let (name, profile) = + load_effective_target_profile(&layout, None).expect("default target resolves"); + assert_eq!(name, DEFAULT_TARGET_PROFILE_NAME); + assert_eq!(profile.default_components, vec!["disk-only"]); +} + +/// An explicit `--target agentic_os-latest` with no on-disk profile falls back to +/// the compiled-in default rather than erroring. +#[test] +fn update_check_explicit_default_target_falls_back_to_builtin() { + let _guard = crate::packaged::DataDirEnvGuard::clear(); + let tmp = tempfile::tempdir().expect("tmpdir"); + let layout = FsLayout::system(Some(tmp.path().to_path_buf())); + let profile = load_target_profile_by_name(&layout, DEFAULT_TARGET_PROFILE_NAME) + .expect("explicit default falls back to builtin"); + assert!(!profile.default_components.is_empty()); +} + +/// A missing custom (non-default) profile is a hard invalid-argument error whose +/// message names the profile. +#[test] +fn update_check_explicit_custom_target_missing_is_invalid_argument() { + let _guard = crate::packaged::DataDirEnvGuard::clear(); + let tmp = tempfile::tempdir().expect("tmpdir"); + let layout = FsLayout::system(Some(tmp.path().to_path_buf())); + let err = load_target_profile_by_name(&layout, "no-such-profile") + .expect_err("missing custom target must error"); + assert_eq!(err.code(), "INVALID_ARGUMENT"); + assert!(err.reason().contains("no-such-profile")); +} + +/// The MOTD cache written for the default target is reused when `--target` is +/// omitted, since both resolve to the same effective name. +#[test] +fn update_check_cache_usable_for_omitted_target_matches_default() { + let tmp = tempfile::tempdir().expect("tmpdir"); + let layout = FsLayout::system(Some(tmp.path().to_path_buf())); + let path = cache_path(&layout); + write_cache( + &path, + &report_with_target(Some(DEFAULT_TARGET_PROFILE_NAME)), + ) + .expect("write cache"); + let cache = read_cache(&path).expect("cache readable"); + assert!( + cache_is_usable(&cache, Some(effective_target_name(None))), + "omitted target reuses the default-target cache" + ); +} diff --git a/src/anolisa/crates/anolisa-platform/src/pkg_query.rs b/src/anolisa/crates/anolisa-platform/src/pkg_query.rs index 92fe45d1b..7b400ea80 100644 --- a/src/anolisa/crates/anolisa-platform/src/pkg_query.rs +++ b/src/anolisa/crates/anolisa-platform/src/pkg_query.rs @@ -8,6 +8,7 @@ //! observe/repair/update consumers treat absence as expected control flow, so //! [`PackageQueryError`] is reserved for genuinely anomalous conditions. +use std::cmp::Ordering; use std::fmt; use thiserror::Error; @@ -42,6 +43,172 @@ impl fmt::Display for PackageVersion { } } +/// Compare two RPM versions by RPM's EVR ordering (epoch, then version, then +/// release), returning whether `a` sorts before/equal/after `b`. +/// +/// This is **not** semver: RPM EVRs routinely use shapes semver rejects +/// (numeric epochs like `1:...`, two-segment versions like `2.3`, `.al8` +/// releases, `~`/`^` markers). A semver comparison silently treats those as +/// unordered, so callers that need to know whether a repo candidate genuinely +/// upgrades an installed package must use this instead. Epoch is compared +/// numerically (absent epoch is `0`); version and release each use +/// [`rpmvercmp`]. +pub fn rpm_evr_cmp(a: &PackageVersion, b: &PackageVersion) -> Ordering { + let epoch_a = a.epoch.as_deref().unwrap_or("0"); + let epoch_b = b.epoch.as_deref().unwrap_or("0"); + let epoch_cmp = match (epoch_a.parse::(), epoch_b.parse::()) { + (Ok(x), Ok(y)) => x.cmp(&y), + // A non-numeric epoch is malformed; fall back to segment comparison + // rather than panicking so a weird rpmdb row still orders deterministically. + _ => rpmvercmp(epoch_a, epoch_b), + }; + if epoch_cmp != Ordering::Equal { + return epoch_cmp; + } + let version_cmp = rpmvercmp(&a.version, &b.version); + if version_cmp != Ordering::Equal { + return version_cmp; + } + rpmvercmp( + a.release.as_deref().unwrap_or(""), + b.release.as_deref().unwrap_or(""), + ) +} + +/// Compare a single RPM version or release segment using RPM's `rpmvercmp` +/// algorithm (a faithful port of `lib/rpmvercmp.c`). +/// +/// The string is walked in alternating runs of digits and letters separated by +/// any other bytes; numeric runs compare numerically (leading zeros stripped, +/// then longer-wins), alphabetic runs compare lexically, a digit run outranks +/// an alphabetic run, and `~` sorts before everything (including end of string) +/// while `^` sorts after a shorter version but before a longer one. Kept here in +/// the platform layer so version logic lives next to the RPM backend. +pub fn rpmvercmp(a: &str, b: &str) -> Ordering { + if a == b { + return Ordering::Equal; + } + let a = a.as_bytes(); + let b = b.as_bytes(); + let (mut i, mut j) = (0usize, 0usize); + + let is_sep = |c: u8| !c.is_ascii_alphanumeric() && c != b'~' && c != b'^'; + + loop { + while i < a.len() && is_sep(a[i]) { + i += 1; + } + while j < b.len() && is_sep(b[j]) { + j += 1; + } + + // `~` sorts before everything, even the empty string. + let a_tilde = i < a.len() && a[i] == b'~'; + let b_tilde = j < b.len() && b[j] == b'~'; + if a_tilde || b_tilde { + if !a_tilde { + return Ordering::Greater; + } + if !b_tilde { + return Ordering::Less; + } + i += 1; + j += 1; + continue; + } + + // `^` sorts after a shorter version but before a longer one. + let a_caret = i < a.len() && a[i] == b'^'; + let b_caret = j < b.len() && b[j] == b'^'; + if a_caret || b_caret { + if i >= a.len() { + return Ordering::Less; + } + if j >= b.len() { + return Ordering::Greater; + } + if !a_caret { + return Ordering::Greater; + } + if !b_caret { + return Ordering::Less; + } + i += 1; + j += 1; + continue; + } + + if i >= a.len() || j >= b.len() { + break; + } + + let (start_i, start_j) = (i, j); + let isnum = a[i].is_ascii_digit(); + if isnum { + while i < a.len() && a[i].is_ascii_digit() { + i += 1; + } + while j < b.len() && b[j].is_ascii_digit() { + j += 1; + } + } else { + while i < a.len() && a[i].is_ascii_alphabetic() { + i += 1; + } + while j < b.len() && b[j].is_ascii_alphabetic() { + j += 1; + } + } + + let seg_a = &a[start_i..i]; + let seg_b = &b[start_j..j]; + + // `seg_a` is always non-empty (we entered on an alnum byte of its type); + // an empty `seg_b` means the two runs are different types. A numeric run + // outranks an alphabetic one. + if seg_b.is_empty() { + return if isnum { + Ordering::Greater + } else { + Ordering::Less + }; + } + + if isnum { + let na = strip_leading_zeros(seg_a); + let nb = strip_leading_zeros(seg_b); + match na.len().cmp(&nb.len()) { + Ordering::Equal => match na.cmp(nb) { + Ordering::Equal => {} + other => return other, + }, + other => return other, + } + } else { + match seg_a.cmp(seg_b) { + Ordering::Equal => {} + other => return other, + } + } + } + + // Whichever string still has content sorts higher. + match (i >= a.len(), j >= b.len()) { + (true, true) => Ordering::Equal, + (true, false) => Ordering::Less, + (false, true) => Ordering::Greater, + // Unreachable: the loop only breaks when at least one side is exhausted. + (false, false) => Ordering::Equal, + } +} + +fn strip_leading_zeros(mut s: &[u8]) -> &[u8] { + while !s.is_empty() && s[0] == b'0' { + s = &s[1..]; + } + s +} + /// A package's identity, version, and origin (shared by installed/available queries). #[derive(Debug, Clone, PartialEq, Eq)] pub struct PackageInfo { @@ -194,3 +361,66 @@ pub trait PackageQuery { Ok(Vec::new()) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn ver(epoch: Option<&str>, version: &str, release: Option<&str>) -> PackageVersion { + PackageVersion { + epoch: epoch.map(str::to_string), + version: version.to_string(), + release: release.map(str::to_string), + } + } + + #[test] + fn rpmvercmp_matches_rpm_reference_cases() { + // Canonical cases from RPM's own rpmvercmp test suite. + assert_eq!(rpmvercmp("1.0", "1.0"), Ordering::Equal); + assert_eq!(rpmvercmp("1.0", "2.0"), Ordering::Less); + assert_eq!(rpmvercmp("2.0", "1.0"), Ordering::Greater); + // Numeric segments compare numerically, not lexically: 10 > 2. + assert_eq!(rpmvercmp("2", "10"), Ordering::Less); + assert_eq!(rpmvercmp("1.10", "1.9"), Ordering::Greater); + // Leading zeros are stripped before the length/lex compare. + assert_eq!(rpmvercmp("1.0010", "1.10"), Ordering::Equal); + // A numeric run outranks an alphabetic run at the same position. + assert_eq!(rpmvercmp("1.a", "1.1"), Ordering::Less); + // `~` sorts before everything, including a shorter release. + assert_eq!(rpmvercmp("1.0~rc1", "1.0"), Ordering::Less); + assert_eq!(rpmvercmp("1.0~rc1", "1.0~rc2"), Ordering::Less); + // `^` sorts after the base version. + assert_eq!(rpmvercmp("1.0^", "1.0"), Ordering::Greater); + } + + #[test] + fn rpm_evr_cmp_orders_epoch_first() { + // A higher epoch wins regardless of version — the classic reason plain + // version comparison is wrong. + let older = ver(None, "2.0", Some("1.al8")); + let newer = ver(Some("1"), "1.0", Some("1.al8")); + assert_eq!(rpm_evr_cmp(&older, &newer), Ordering::Less); + // Absent epoch is treated as 0, so it equals an explicit "0". + assert_eq!( + rpm_evr_cmp(&ver(None, "1.0", None), &ver(Some("0"), "1.0", None)), + Ordering::Equal + ); + } + + #[test] + fn rpm_evr_cmp_detects_non_semver_upgrade() { + // Real EVRs that semver cannot parse must still order correctly. + let installed = ver(None, "0.5", Some("1.al4")); + let candidate = ver(None, "1.0.0", Some("1.al4")); + assert_eq!(rpm_evr_cmp(&installed, &candidate), Ordering::Less); + // Release differences break upgrade ties. + assert_eq!( + rpm_evr_cmp( + &ver(None, "1.0.0", Some("1.al4")), + &ver(None, "1.0.0", Some("2.al4")) + ), + Ordering::Less + ); + } +} diff --git a/src/anolisa/profiles/agentic_os-latest.toml b/src/anolisa/profiles/agentic_os-latest.toml new file mode 100644 index 000000000..db414454a --- /dev/null +++ b/src/anolisa/profiles/agentic_os-latest.toml @@ -0,0 +1,25 @@ +# Release-owned latest target profile for `anolisa update --check`. +# +# When `--target` is omitted, `update --check` evaluates against this profile so +# a plain check (and the MOTD summary) can report default components that the +# current Agentic OS baseline expects but that are not yet installed. +# +# Entries are ANOLISA component names (as resolved by manifests/components.toml), +# not backend package names — the check matches them against installed component +# identities. The owning RPM package is noted in the trailing comment where it +# differs from the component name. The `anolisa` CLI package is intentionally +# omitted: its upgrade is handled by the CLI self-check path, not as a component +# default. +schema_version = 1 +name = "agentic_os-latest" + +default_components = [ + "cosh", + "os-skills", + "sec-core", + "agentsight", + "tokenless", + "ws-ckpt", + "skillfs", + "agent-memory", +]