csaf-core 1.4.9

CSAF storage, validation, sidecar generation, import/export
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026 Pierre Gronau, ndaal in Cologne

//! Property suite for `csaf_core::update_cli::extract_update_flags`.
//!
//! The argument list is untrusted input — it comes from whoever invoked the
//! binary, including a service manager, a wrapper script, or a CI job that
//! interpolates a variable. `skills/rust-self-update`'s CLI-contract
//! reference is explicit that the parser belongs in the library precisely so
//! it can carry properties and a fuzz target that exercise **every** start-up
//! flag, rather than only the combinations a unit test happened to name.
//!
//! The invariants below are the ones whose violation would be silent:
//!
//! - A recognised flag leaking into the leftover list means the binary would
//!   treat `--self-update` as, say, a config path and start normally. That
//!   looks like success and is the worst failure mode in the contract.
//! - A leftover being dropped means the binary loses an argument it needs.
//! - Parsing must be order-independent and idempotent, or the behaviour of
//!   `--check-update --config x` and `--config x --check-update` diverges.

use csaf_core::update_cli::{UpdateFlags, extract_update_flags};
use proptest::prelude::*;

/// The three flags the contract defines.
const KNOWN: [&str; 3] = ["--check-update", "--self-update", "--no-self-update"];

proptest! {
    /// Total: no argument list, however strange, may panic the parser.
    #[test]
    fn prop_never_panics_on_arbitrary_arguments(args in prop::collection::vec(".*", 0..12)) {
        let _ = extract_update_flags(args);
    }

    /// A recognised flag is NEVER passed through to the leftovers, and an
    /// unrecognised argument is NEVER swallowed. Together these say the
    /// partition is exact — nothing is lost and nothing is duplicated.
    #[test]
    fn prop_partition_is_exact(args in prop::collection::vec("[-a-zA-Z0-9=._/]{0,24}", 0..12)) {
        let (_, rest) = extract_update_flags(args.clone());
        for r in &rest {
            prop_assert!(
                !KNOWN.contains(&r.as_str()),
                "recognised flag {r:?} leaked into the leftover arguments",
            );
        }
        let expected_rest: Vec<&String> =
            args.iter().filter(|a| !KNOWN.contains(&a.as_str())).collect();
        prop_assert_eq!(rest.len(), expected_rest.len(), "an argument was lost or duplicated");
        for (got, want) in rest.iter().zip(expected_rest) {
            prop_assert_eq!(got, want, "leftover order was not preserved");
        }
    }

    /// A flag is set if and only if it appears somewhere in the list —
    /// independent of position, repetition, or what surrounds it.
    #[test]
    fn prop_flag_set_iff_present(args in prop::collection::vec("[-a-zA-Z0-9=._/]{0,24}", 0..12)) {
        let (flags, _) = extract_update_flags(args.clone());
        let has = |f: &str| args.iter().any(|a| a == f);
        prop_assert_eq!(flags.check_update, has("--check-update"));
        prop_assert_eq!(flags.self_update, has("--self-update"));
        prop_assert_eq!(flags.no_self_update, has("--no-self-update"));
    }

    /// Order-independent: shuffling the arguments cannot change which flags
    /// are set. (The leftovers keep their relative order; the flags do not
    /// depend on it at all.)
    #[test]
    fn prop_flags_are_order_independent(
        args in prop::collection::vec("[-a-zA-Z0-9=._/]{0,24}", 0..10),
    ) {
        let (forward, _) = extract_update_flags(args.clone());
        let reversed: Vec<String> = args.into_iter().rev().collect();
        let (backward, _) = extract_update_flags(reversed);
        prop_assert_eq!(forward, backward);
    }

    /// Idempotent: feeding the leftovers back in yields the same leftovers
    /// and no additional flags. A second pass must be a no-op, or a wrapper
    /// that re-parses its own output would behave differently.
    #[test]
    fn prop_reparsing_leftovers_is_a_no_op(
        args in prop::collection::vec("[-a-zA-Z0-9=._/]{0,24}", 0..12),
    ) {
        let (_, rest) = extract_update_flags(args);
        let (second_flags, second_rest) = extract_update_flags(rest.clone());
        prop_assert_eq!(second_flags, UpdateFlags::default());
        prop_assert_eq!(second_rest, rest);
    }

    /// `is_terminal` is exactly "check or update was requested" — the policy
    /// opt-out alone must never make the binary exit instead of running.
    #[test]
    fn prop_is_terminal_ignores_the_policy_flag(
        check in any::<bool>(),
        update in any::<bool>(),
        no_update in any::<bool>(),
    ) {
        let flags = UpdateFlags {
            check_update: check,
            self_update: update,
            no_self_update: no_update,
        };
        prop_assert_eq!(flags.is_terminal(), check || update);
    }

    /// The opt-out decision is monotone: setting either input can only ever
    /// move the answer toward "refused", never back to "allowed".
    #[test]
    fn prop_opt_out_is_monotone(flag in any::<bool>(), env in any::<bool>()) {
        let base = UpdateFlags::default().is_opted_out_with(false);
        prop_assert!(!base, "with nothing set, self-update must be allowed");
        let flags = UpdateFlags { no_self_update: flag, ..UpdateFlags::default() };
        prop_assert_eq!(flags.is_opted_out_with(env), flag || env);
    }
}