csaf-core 1.4.9

CSAF storage, validation, sidecar generation, import/export
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2026 ndaal Gesellschaft für Sicherheit in der Informationstechnik mbH & Co KG, Cologne
// SPDX-FileCopyrightText: Author: Pierre Gronau <Pierre.Gronau@ndaal.eu>

//! The shared `--check-update` / `--self-update` / `--no-self-update` CLI
//! contract, identical in every binary of this workspace.
//!
//! `csaf-crud` parses its arguments by hand and `ndaal-csaf-cli` uses clap.
//! The contract is the flag *names, semantics and exit codes* — not the
//! parsing library — so both funnel into [`dispatch`] here and therefore
//! cannot drift apart. A user who knows one binary knows the other.
//!
//! This lives in the library rather than in either `main.rs` so it can carry
//! unit tests and proptest invariants over every flag combination.
//!
//! Fixed semantics (see `skills/rust-self-update` Rule 4):
//!
//! - `--check-update` **never fails on a network error**. An unreachable host
//!   is an [`UpdateOutcome::Unreachable`] with exit code 0, so checking can
//!   never break a boot path, a container start or a cron job.
//! - `--self-update` is refused when `--no-self-update` or
//!   `CSAF_NO_SELF_UPDATE` is set, with a clear message and exit code 3.
//! - Both are terminal: they act and exit, never continuing into the program.
//! - An explicit CLI flag always beats the environment.

use crate::updater::{self, UpdateOutcome};

/// The update-related flags every binary in this workspace accepts.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct UpdateFlags {
    /// `--check-update` — report whether a newer release exists, then exit.
    pub check_update: bool,
    /// `--self-update` — download + install the latest release, then exit.
    pub self_update: bool,
    /// `--no-self-update` — policy opt-out (env: `CSAF_NO_SELF_UPDATE`).
    pub no_self_update: bool,
}

impl UpdateFlags {
    /// Does either flag ask us to act and exit instead of running normally?
    #[must_use]
    pub const fn is_terminal(self) -> bool {
        self.check_update || self.self_update
    }

    /// Is self-update refused? Either the flag or the environment is enough.
    ///
    /// Reads the environment. The pure half is [`Self::is_opted_out_with`],
    /// which is what the exhaustive tests drive — keeping the decision logic
    /// testable without mutating process state.
    #[must_use]
    pub fn is_opted_out(self) -> bool {
        self.is_opted_out_with(updater::env_opt_out())
    }

    /// The opt-out decision, with the environment answer already resolved.
    ///
    /// Pure and total, so every combination of (flag, environment) can be
    /// asserted deterministically. An explicit `--no-self-update` refuses
    /// regardless of the environment; the environment alone also refuses.
    #[must_use]
    pub const fn is_opted_out_with(self, env_says_opt_out: bool) -> bool {
        self.no_self_update || env_says_opt_out
    }
}

/// Pick the update flags out of an argument list, returning them together
/// with every argument that was **not** consumed.
///
/// Deliberately tolerant of `--flag=value` spellings and of unknown
/// arguments: each binary owns the rest of its command line and does its own
/// validation on the remainder.
#[must_use]
pub fn extract_update_flags<I, S>(args: I) -> (UpdateFlags, Vec<String>)
where
    I: IntoIterator<Item = S>,
    S: AsRef<str>,
{
    let mut flags = UpdateFlags::default();
    let mut rest = Vec::new();
    for arg in args {
        let arg = arg.as_ref();
        match arg {
            "--check-update" => flags.check_update = true,
            "--self-update" => flags.self_update = true,
            "--no-self-update" => flags.no_self_update = true,
            _ => rest.push(arg.to_owned()),
        }
    }
    (flags, rest)
}

/// Act on the update flags, if any were given.
///
/// Returns `None` when the caller should carry on and run the program
/// normally; `Some((message, exit_code))` when the binary must print the
/// message and exit with that code.
///
/// When both `--check-update` and `--self-update` are supplied, the check
/// runs first and the update follows — the check's "available" result is not
/// treated as terminal, because the user has explicitly asked for both.
#[must_use]
pub fn dispatch(
    flags: UpdateFlags,
    bin_name: &str,
    current_version: &str,
) -> Option<(String, i32)> {
    if !flags.is_terminal() {
        return None;
    }
    let triple = updater::target_triple();
    if flags.self_update {
        return Some(run_self_update(flags, bin_name, current_version, triple));
    }
    let outcome = updater::check(current_version);
    Some((outcome.message(bin_name, triple), outcome.exit_code()))
}

/// Policy gate, then install.
fn run_self_update(
    flags: UpdateFlags,
    bin_name: &str,
    current_version: &str,
    triple: &str,
) -> (String, i32) {
    if flags.is_opted_out() {
        let outcome = UpdateOutcome::DisabledByPolicy;
        return (outcome.message(bin_name, triple), outcome.exit_code());
    }
    match updater::perform(bin_name, current_version) {
        Ok(outcome) => (outcome.message(bin_name, triple), outcome.exit_code()),
        // A failed install IS an error (exit 1) — unlike a failed check.
        Err(e) => (format!("self-update failed: {e}"), 1),
    }
}

/// The block of `--help` text describing the update contract.
///
/// Listed unconditionally in every binary's usage output: the capability is
/// never absent, so the flags are never conditionally printed.
#[must_use]
pub const fn help_fragment() -> &'static str {
    "UPDATE OPTIONS:\n    \
         --check-update      Report whether a newer release exists, then exit\n    \
         --self-update       Download, verify and install the latest release, then exit\n    \
         --no-self-update    Refuse --self-update (env: CSAF_NO_SELF_UPDATE)\n\n\
     EXIT CODES:\n    \
         0    Up to date, update installed, update host unreachable, --help, --version\n    \
         1    --self-update failed (no asset for this triple, checksum mismatch, install error)\n    \
         2    Argument parse error\n    \
         3    --self-update refused by policy\n    \
         10   --check-update found a newer release\n\n\
     TRUST MODEL:\n    \
         Downloads are verified against the SHA256SUMS manifest published in the same\n    \
         release. That is integrity, not authenticity: anyone able to rewrite the release\n    \
         archive can rewrite the manifest too. The artifacts are not signed."
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn extracts_each_flag_and_preserves_everything_else() {
        let (flags, rest) = extract_update_flags([
            "--config",
            "a.toml",
            "--check-update",
            "--no-self-update",
            "positional",
        ]);
        assert!(flags.check_update);
        assert!(flags.no_self_update);
        assert!(!flags.self_update);
        assert_eq!(rest, ["--config", "a.toml", "positional"]);
    }

    /// Each flag must be recognised IN ISOLATION, and must not be mistaken
    /// for either of the others.
    ///
    /// Mutation testing found that deleting the `--self-update` match arm
    /// went undetected: every existing test happened to pass that flag
    /// alongside another one, or asserted only that it was absent. A dropped
    /// arm would send `--self-update` into the leftover-arguments list, so
    /// the binary would silently ignore it and start normally instead of
    /// updating — the worst kind of failure, because it looks like success.
    #[test]
    fn every_flag_is_recognised_on_its_own() {
        let cases = [
            (
                "--check-update",
                UpdateFlags {
                    check_update: true,
                    ..UpdateFlags::default()
                },
            ),
            (
                "--self-update",
                UpdateFlags {
                    self_update: true,
                    ..UpdateFlags::default()
                },
            ),
            (
                "--no-self-update",
                UpdateFlags {
                    no_self_update: true,
                    ..UpdateFlags::default()
                },
            ),
        ];
        for (arg, expected) in cases {
            let (flags, rest) = extract_update_flags([arg]);
            assert_eq!(flags, expected, "{arg} was not parsed correctly");
            assert!(rest.is_empty(), "{arg} leaked into the leftover arguments");
        }
    }

    /// All three together, so a shared-state bug between the arms shows up.
    #[test]
    fn all_three_flags_can_be_combined() {
        let (flags, rest) =
            extract_update_flags(["--check-update", "--self-update", "--no-self-update"]);
        assert_eq!(
            flags,
            UpdateFlags {
                check_update: true,
                self_update: true,
                no_self_update: true,
            },
        );
        assert!(rest.is_empty());
    }

    /// `--self-update` alone must be terminal and must reach the installer
    /// path, not the check path. This is what pins the dispatch precedence:
    /// when both are given, the update wins.
    #[test]
    fn self_update_alone_is_terminal() {
        let (flags, _) = extract_update_flags(["--self-update"]);
        assert!(flags.self_update, "--self-update did not set its flag");
        assert!(
            flags.is_terminal(),
            "--self-update must be a terminal action"
        );
    }

    #[test]
    fn no_update_flags_means_run_the_program() {
        let (flags, _) = extract_update_flags(["--config", "a.toml"]);
        assert!(!flags.is_terminal());
        assert!(dispatch(flags, "csaf-crud", "1.3.7").is_none());
    }

    /// The policy gate must refuse before any network call happens — this is
    /// what makes `--no-self-update` usable in a locked-down install.
    #[test]
    fn policy_opt_out_refuses_self_update_with_exit_3() {
        let flags = UpdateFlags {
            self_update: true,
            no_self_update: true,
            ..UpdateFlags::default()
        };
        let (msg, code) = dispatch(flags, "csaf-crud", "1.3.7").expect("terminal");
        assert_eq!(code, 3);
        assert!(msg.contains("disabled by policy"), "{msg}");
    }

    /// Exhaustive truth table for the opt-out decision. Driven through the
    /// pure half so no process state is mutated and the result cannot depend
    /// on the machine the suite runs on.
    ///
    /// The `(false, false)` row is the one that matters most: if
    /// `is_opted_out` ever returned `true` unconditionally, self-update would
    /// be permanently and silently disabled in every deployment. Mutation
    /// testing found exactly that gap here, which is why the row is asserted
    /// explicitly rather than left implied.
    #[test]
    fn opt_out_truth_table_is_exhaustive() {
        for (flag, env, expected) in [
            (false, false, false), // nothing set -> self-update is allowed
            (true, false, true),   // --no-self-update alone refuses
            (false, true, true),   // the environment alone refuses
            (true, true, true),    // both refuse
        ] {
            let flags = UpdateFlags {
                no_self_update: flag,
                ..UpdateFlags::default()
            };
            assert_eq!(
                flags.is_opted_out_with(env),
                expected,
                "flag={flag} env={env} should give {expected}",
            );
        }
    }

    /// The environment-reading wrapper must agree with the pure half.
    ///
    /// Asserted unconditionally: `CSAF_NO_SELF_UPDATE` is this project's own
    /// variable and is not set in a normal dev or CI environment. If it IS
    /// set, this failing is the correct signal — the suite is running under a
    /// configuration that disables self-update.
    #[test]
    fn env_wrapper_agrees_with_the_pure_decision() {
        let allowed = UpdateFlags::default();
        assert!(
            !allowed.is_opted_out(),
            "self-update reported as disabled with no flag set — is \
             {} set in this environment?",
            updater::NO_SELF_UPDATE_ENV,
        );
        let refused = UpdateFlags {
            no_self_update: true,
            ..UpdateFlags::default()
        };
        assert!(
            refused.is_opted_out(),
            "--no-self-update must always refuse"
        );
    }

    /// `--no-self-update` restricts only `--self-update`. Checking is a
    /// read-only operation and stays available under the policy opt-out.
    #[test]
    fn policy_opt_out_does_not_disable_checking() {
        let flags = UpdateFlags {
            check_update: true,
            no_self_update: true,
            ..UpdateFlags::default()
        };
        assert!(flags.is_terminal());
        assert!(flags.is_opted_out());
        // No assertion on the outcome: `check` reaches the network, and its
        // result (up-to-date / available / unreachable) is environment
        // dependent. The point is that it is not short-circuited to exit 3.
    }

    #[test]
    fn help_fragment_documents_every_flag_and_exit_code() {
        let help = help_fragment();
        for needle in [
            "--check-update",
            "--self-update",
            "--no-self-update",
            "CSAF_NO_SELF_UPDATE",
            "10",
            "integrity, not authenticity",
        ] {
            assert!(help.contains(needle), "help text is missing {needle:?}");
        }
    }
}