archmeld 1.3.0

Secure, memory-safe, type-safe CLI for multi-format archive extraction, inspection and decompression
Documentation
//! In-place self-update for the archmeld binary.
//!
//! This module is compiled into **every** build. Per `skills/rust-self-update`
//! there is no cargo feature, config key or build profile that removes it: the
//! capability is always present and `--no-self-update` (or
//! `ARCHMELD_NO_SELF_UPDATE`) is the runtime opt-out for package-managed,
//! air-gapped and otherwise locked-down installs.
//!
//! Two properties are deliberate and should not be "simplified" away:
//!
//! * **A network failure is not an error.** `--check-update` on a machine with
//!   no route to GitHub, or against a rate-limited API, reports
//!   [`UpdateOutcome::Unreachable`] and exits `0`. A checker that fails closed
//!   would break every wrapper script the moment GitHub has a bad afternoon.
//! * **Checksum verification stays on.** The `checksums` feature is enabled and
//!   the release-published digest is verified; disabling it is a policy
//!   violation, not a tuning knob.

use std::env;

use self_update::cargo_crate_version;

/// GitHub coordinates of the published release stream.
const REPO_OWNER: &str = "ndaal";
const REPO_NAME: &str = "archmeld";
const BIN_NAME: &str = "archmeld";

/// Environment fallback for `--no-self-update`.
pub const NO_SELF_UPDATE_ENV: &str = "ARCHMELD_NO_SELF_UPDATE";

/// Result of an update check or an update attempt.
///
/// Modelled as an enum so the CLI's printing and the tests share one source of
/// truth for both the message and the exit code.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UpdateOutcome {
    /// Running the newest published release.
    UpToDate {
        /// The version already installed.
        current: String,
    },
    /// A newer release exists (`--check-update` only).
    Available {
        /// The version currently installed.
        current: String,
        /// The newest version published upstream.
        latest: String,
    },
    /// A newer release was installed (`--self-update` only).
    Updated {
        /// The version that was replaced.
        from: String,
        /// The version now installed.
        to: String,
    },
    /// The update host could not be reached. Explicitly **not** a failure.
    Unreachable {
        /// Transport-level explanation, for the log — never a hard error.
        reason: String,
    },
    /// `--no-self-update` (or the env var) refused the operation.
    DisabledByPolicy,
}

impl UpdateOutcome {
    /// Process exit code for this outcome.
    ///
    /// The contract is fixed by `skills/rust-self-update` so wrapper scripts can
    /// branch on it without parsing text:
    /// `0` nothing to do, `10` an update is available, `3` refused by policy,
    /// `1` the update itself failed.
    #[must_use]
    pub const fn exit_code(&self) -> u8 {
        match *self {
            Self::UpToDate { .. } | Self::Updated { .. } | Self::Unreachable { .. } => 0,
            Self::Available { .. } => 10,
            Self::DisabledByPolicy => 3,
        }
    }

    /// One-line human-readable rendering, written to stdout by the CLI.
    #[must_use]
    pub fn message(&self) -> String {
        match *self {
            Self::UpToDate { ref current } => {
                format!("archmeld {current} is up to date")
            },
            Self::Available {
                ref current,
                ref latest,
            } => format!(
                "archmeld {latest} is available (running {current}); run `archmeld --self-update`"
            ),
            Self::Updated { ref from, ref to } => {
                format!("archmeld updated {from} -> {to}")
            },
            Self::Unreachable { ref reason } => {
                format!("update check skipped: update host unreachable ({reason})")
            },
            Self::DisabledByPolicy => format!(
                "self-update is disabled by policy (--no-self-update / {NO_SELF_UPDATE_ENV})"
            ),
        }
    }
}

/// Whether a raw environment value counts as "on".
///
/// Accepts `1`, `true`, `yes`, `on` (case-insensitive); everything else,
/// including an empty value, is false. Kept separate from [`env_flag_on`] so it
/// can be tested exhaustively as a pure function — `std::env::set_var` is
/// `unsafe` in edition 2024 and this crate is `unsafe_code = "forbid"`.
#[must_use]
pub fn flag_value_is_on(raw: &str) -> bool {
    matches!(
        raw.trim().to_ascii_lowercase().as_str(),
        "1" | "true" | "yes" | "on"
    )
}

/// Parse a boolean environment variable.
///
/// An absent variable is false.
#[must_use]
pub fn env_flag_on(name: &str) -> bool {
    // WHY: disallowed-methods bans std::env::var to force deliberate config
    // reads. This IS the deliberate read: ARCHMELD_NO_SELF_UPDATE is the one
    // documented environment variable archmeld honours, and refusing to read
    // it would remove the only runtime opt-out package maintainers have.
    // A missing variable is correctly false -- see flag_value_is_on.
    #[allow(clippy::disallowed_methods)]
    env::var(name).is_ok_and(|raw| flag_value_is_on(&raw))
}

/// Whether self-update is refused, combining the flag and the env fallback.
///
/// The CLI flag always wins over the environment; passing `true` here short-
/// circuits regardless of what the environment says.
#[must_use]
pub fn is_disabled(flag: bool) -> bool {
    flag || env_flag_on(NO_SELF_UPDATE_ENV)
}

/// Build the configured GitHub updater.
///
/// `no_confirm` is driven by the caller: `--self-update` from an interactive
/// terminal may prompt, but anything non-interactive must not block on stdin.
fn build_updater(
    no_confirm: bool,
) -> Result<self_update::backends::github::Update, self_update::Error> {
    self_update::backends::github::Update::configure()
        .repo_owner(REPO_OWNER)
        .repo_name(REPO_NAME)
        .bin_name(BIN_NAME)
        .current_version(cargo_crate_version!())
        .show_download_progress(true)
        .show_output(false)
        .no_confirm(no_confirm)
        .build()
}

/// Report whether a newer release exists, without installing anything.
///
/// Never returns `Err` for a transport problem — that becomes
/// [`UpdateOutcome::Unreachable`].
#[must_use]
pub fn check_update() -> UpdateOutcome {
    let current = cargo_crate_version!().to_owned();

    let updater = match build_updater(true) {
        Ok(u) => u,
        Err(e) => {
            return UpdateOutcome::Unreachable {
                reason: e.to_string(),
            };
        },
    };

    // `get_latest_release` returns a one-element `Releases` list; the crate
    // does the version comparison against `current_version` for us.
    match updater.get_latest_release() {
        Ok(releases) => match releases.is_update_available() {
            Ok(true) => releases.latest().map_or_else(
                || UpdateOutcome::UpToDate {
                    current: current.clone(),
                },
                |release| UpdateOutcome::Available {
                    current: current.clone(),
                    latest: release.version().to_owned(),
                },
            ),
            Ok(false) => UpdateOutcome::UpToDate { current },
            Err(e) => UpdateOutcome::Unreachable {
                reason: e.to_string(),
            },
        },
        Err(e) => UpdateOutcome::Unreachable {
            reason: e.to_string(),
        },
    }
}

/// Download and install the newest release, replacing the running executable.
///
/// # Errors
///
/// Returns the underlying `self_update` error when no asset matches this target
/// triple, when the checksum does not verify, or when the install fails. Those
/// are genuine failures and map to exit code `1` — unlike a failed *check*.
pub fn self_update(disabled: bool, interactive: bool) -> Result<UpdateOutcome, self_update::Error> {
    if disabled {
        return Ok(UpdateOutcome::DisabledByPolicy);
    }

    let from = cargo_crate_version!().to_owned();
    let updater = build_updater(!interactive)?;
    let status = updater.update()?;

    let to = status.version().to_owned();
    if to == from {
        Ok(UpdateOutcome::UpToDate { current: from })
    } else {
        Ok(UpdateOutcome::Updated { from, to })
    }
}

#[cfg(test)]
mod tests {
    use super::{NO_SELF_UPDATE_ENV, UpdateOutcome, env_flag_on, flag_value_is_on, is_disabled};

    #[test]
    fn exit_codes_match_the_documented_contract() {
        // These constants are a published interface: wrapper scripts branch on
        // them, so a change here is a breaking change, not a refactor.
        assert_eq!(
            UpdateOutcome::UpToDate {
                current: "1.2.3".to_owned()
            }
            .exit_code(),
            0
        );
        assert_eq!(
            UpdateOutcome::Available {
                current: "1.2.3".to_owned(),
                latest: "1.3.0".to_owned(),
            }
            .exit_code(),
            10
        );
        assert_eq!(
            UpdateOutcome::Updated {
                from: "1.2.3".to_owned(),
                to: "1.3.0".to_owned(),
            }
            .exit_code(),
            0
        );
        assert_eq!(UpdateOutcome::DisabledByPolicy.exit_code(), 3);
    }

    #[test]
    fn unreachable_is_not_a_failure() {
        // The whole point: `archmeld --check-update` in a CI job with no
        // network must not break the pipeline.
        let outcome = UpdateOutcome::Unreachable {
            reason: "dns error".to_owned(),
        };
        assert_eq!(outcome.exit_code(), 0);
        assert!(outcome.message().contains("unreachable"));
    }

    #[test]
    fn flag_value_accepts_only_the_documented_truthy_values() {
        // The accepted set is part of the operator-facing contract: an
        // air-gapped site sets ARCHMELD_NO_SELF_UPDATE and must get the
        // behaviour the help text promises, whichever spelling they picked.
        for truthy in ["1", "true", "TRUE", "yes", "On", " on "] {
            assert!(flag_value_is_on(truthy), "{truthy:?} should be true");
        }
        // "" matters: an exported-but-empty variable must NOT disable updates,
        // or `export ARCHMELD_NO_SELF_UPDATE=` would silently turn them off.
        for falsy in ["0", "false", "no", "off", "", "maybe", "2"] {
            assert!(!flag_value_is_on(falsy), "{falsy:?} should be false");
        }
    }

    #[test]
    fn cli_flag_wins_over_environment() {
        // The flag short-circuits, so this holds regardless of the ambient
        // environment the test process happens to have inherited.
        assert!(is_disabled(true), "--no-self-update must always disable");
    }

    #[test]
    fn env_name_is_the_documented_one() {
        // Renaming this silently would strand every operator's config.
        assert_eq!(NO_SELF_UPDATE_ENV, "ARCHMELD_NO_SELF_UPDATE");
        assert!(!env_flag_on("ARCHMELD_DEFINITELY_NOT_SET_XYZ"));
    }
}