airs-cli 0.9.0

Native Rust CLI for Prisma AIRS (executable airs-cli-rs until 1.0), built on the scm-sdk crate
Documentation
//! Shared red team rules (DESIGN §15.3): the argument checks with the TypeScript texts,
//! the number and filter rules, and the list flags. Every check here is pure and runs
//! during planning, before the tenant is read (D141).

pub mod args;
pub mod backup;
pub mod secrets;
pub mod views;

pub use crate::walk::ListParams;

use crate::error::CliError;
use crate::pagination;

/// `2^53 - 1`, the largest `--offset` (a JavaScript safe integer).
const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
/// The SDK filter bound (bytes).
const FILTER_LIMIT: usize = 4096;
/// The SDK bound on repeated `status` values of `channels list`.
pub const MAX_STATUS_VALUES: usize = 252;

/// `AISEC_USER_REQUEST_PAYLOAD_ERROR:Invalid <field>: <value>` (the TypeScript SDK's
/// `assertUuid` text) unless `value` has the UUID shape.
pub fn check_uuid(field: &str, value: &str) -> Result<(), CliError> {
    if pagination::is_uuid(value) {
        Ok(())
    } else {
        Err(CliError::usage(format!(
            "AISEC_USER_REQUEST_PAYLOAD_ERROR:Invalid {field}: {value}"
        )))
    }
}

/// A filter value (`--status`, `--type`, `--target`, `--search`, …): at most 4,096 bytes
/// and no control character (the SDK `filter` rule), else
/// `<flag> must be at most 4096 bytes and contain no control characters`.
pub fn check_filter(flag: &str, value: &str) -> Result<(), CliError> {
    if value.len() <= FILTER_LIMIT && !value.chars().any(char::is_control) {
        Ok(())
    } else {
        Err(CliError::usage(format!(
            "{flag} must be at most {FILTER_LIMIT} bytes and contain no control characters"
        )))
    }
}

/// `--serial-numbers` names at least one serial number (D158): an empty value, or one
/// made only of commas and whitespace, is refused rather than sent as an empty list. A
/// list that names one is sent as given (TS).
pub fn check_serial_numbers(value: &str) -> Result<(), CliError> {
    if value.split(',').any(|serial| !serial.trim().is_empty()) {
        Ok(())
    } else {
        Err(CliError::usage(
            "--serial-numbers needs at least one serial number",
        ))
    }
}

/// An optional filter value, checked.
pub fn filter(flag: &str, value: Option<&str>) -> Result<Option<String>, CliError> {
    match value {
        Some(value) => {
            check_filter(flag, value)?;
            Ok(Some(value.to_owned()))
        }
        None => Ok(None),
    }
}

/// A query filter value, checked: an empty value sends no parameter (TypeScript skips a
/// falsy filter, so `--target ""` lists every scan rather than none).
pub fn query_filter(flag: &str, value: Option<&str>) -> Result<Option<String>, CliError> {
    Ok(filter(flag, value)?.filter(|value| !value.is_empty()))
}

/// Decimal digits only, as a `u64`.
fn decimal(value: &str) -> Option<u64> {
    if value.is_empty() || !value.bytes().all(|b| b.is_ascii_digit()) {
        return None;
    }
    value.parse::<u64>().ok()
}

/// How a command words a bad `--limit`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LimitText {
    /// `--limit must be a positive integer` (the §7.1 text).
    Contract,
    /// `<flag>: expected a positive integer, got "<value>"` (TypeScript
    /// `parsePositiveInt`).
    Typescript,
}

/// A positive integer from 1 to 4,294,967,295.
pub fn positive(flag: &str, value: &str, text: LimitText) -> Result<u32, CliError> {
    decimal(value)
        .filter(|n| *n >= 1)
        .and_then(|n| u32::try_from(n).ok())
        .ok_or_else(|| match text {
            LimitText::Contract => CliError::usage(format!("{flag} must be a positive integer")),
            LimitText::Typescript => CliError::usage(format!(
                "{flag}: expected a positive integer, got \"{value}\""
            )),
        })
}

/// `--offset`: 0 to 2^53 − 1.
pub fn offset(value: &str) -> Result<u64, CliError> {
    decimal(value)
        .filter(|n| *n <= MAX_SAFE_INTEGER)
        .ok_or_else(|| CliError::usage("--offset must be a non-negative integer"))
}

/// `--max` (§7.1): a non-negative safe integer; `0` means no item cap.
pub fn max(value: &str) -> Result<u64, CliError> {
    decimal(value)
        .filter(|n| *n <= MAX_SAFE_INTEGER)
        .ok_or_else(|| CliError::usage("--max must be a non-negative integer"))
}

/// Validate `--limit`, `--offset`, `--max` and `--all` with `--offset`, in that order.
pub fn list_params(
    limit: Option<&str>,
    offset_value: Option<&str>,
    all: bool,
    max_value: Option<&str>,
    text: LimitText,
) -> Result<ListParams, CliError> {
    let limit = limit
        .map(|value| positive("--limit", value, text))
        .transpose()?;
    let offset = offset_value.map(offset).transpose()?;
    let max = max_value
        .map(max)
        .transpose()?
        .unwrap_or(pagination::DEFAULT_MAX);
    if all && offset_value.is_some() {
        return Err(CliError::usage("--all cannot be combined with --offset"));
    }
    Ok(ListParams {
        limit,
        offset,
        all,
        max,
    })
}

/// `--status` of `channels list`: one or more non-empty filter values, at most 252.
pub fn statuses(values: &[String]) -> Result<Vec<String>, CliError> {
    if values.len() > MAX_STATUS_VALUES {
        return Err(CliError::usage(format!(
            "--status accepts at most {MAX_STATUS_VALUES} values"
        )));
    }
    for value in values {
        if value.is_empty() {
            return Err(CliError::usage("--status values must not be empty"));
        }
        check_filter("--status", value)?;
    }
    Ok(values.to_vec())
}

/// The red team banner (`renderRedteamHeader`).
pub const BANNER_TITLE: &str = "Prisma AIRS — AI Red Team";
pub const BANNER_SUBTITLE: &str = "Adversarial scan operations";

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

    fn usage(result: Result<impl std::fmt::Debug, CliError>) -> String {
        match result {
            Err(CliError::Usage(message)) => message,
            other => panic!("expected a usage error, got {other:?}"),
        }
    }

    #[test]
    fn uuid_checks_use_the_typescript_text() {
        assert!(check_uuid("job id", "00000000-0000-4000-8000-000000000101").is_ok());
        assert_eq!(
            usage(check_uuid("job id", "not-a-uuid")),
            "AISEC_USER_REQUEST_PAYLOAD_ERROR:Invalid job id: not-a-uuid"
        );
        assert_eq!(
            usage(check_uuid("target uuid", "")),
            "AISEC_USER_REQUEST_PAYLOAD_ERROR:Invalid target uuid: "
        );
    }

    #[test]
    fn serial_number_lists_name_at_least_one() {
        for good in ["SN1", "SN1,SN2", " SN1 ", ",SN1,", "SN1,,SN2"] {
            assert!(check_serial_numbers(good).is_ok(), "{good:?}");
        }
        for empty in ["", " ", ",", " , ,", ",,,", "\u{a0}"] {
            assert_eq!(
                usage(check_serial_numbers(empty)),
                "--serial-numbers needs at least one serial number",
                "{empty:?}"
            );
        }
    }

    #[test]
    fn filters_numbers_and_list_flags() {
        assert!(check_filter("--status", "RUNNING").is_ok());
        assert_eq!(
            usage(check_filter("--search", "a\nb")),
            "--search must be at most 4096 bytes and contain no control characters"
        );
        assert!(check_filter("--search", &"x".repeat(4096)).is_ok());
        assert!(check_filter("--search", &"x".repeat(4097)).is_err());
        for bad in ["0", "-1", "abc", "1.5", "2.5x", "", "4294967296"] {
            assert_eq!(
                usage(positive("--limit", bad, LimitText::Contract)),
                "--limit must be a positive integer",
                "{bad}"
            );
            assert_eq!(
                usage(positive("--depth", bad, LimitText::Typescript)),
                format!("--depth: expected a positive integer, got \"{bad}\""),
            );
        }
        assert_eq!(
            positive("--limit", "4294967295", LimitText::Contract).unwrap(),
            u32::MAX
        );
        assert_eq!(offset("9007199254740991").unwrap(), MAX_SAFE_INTEGER);
        assert!(offset("9007199254740992").is_err());
        assert_eq!(
            usage(list_params(
                None,
                Some("0"),
                true,
                None,
                LimitText::Contract
            )),
            "--all cannot be combined with --offset"
        );
        assert_eq!(
            usage(list_params(
                Some("x"),
                Some("y"),
                true,
                Some("z"),
                LimitText::Contract
            )),
            "--limit must be a positive integer"
        );
        assert_eq!(
            usage(list_params(
                None,
                Some("y"),
                true,
                Some("z"),
                LimitText::Contract
            )),
            "--offset must be a non-negative integer"
        );
        assert_eq!(
            usage(list_params(
                None,
                None,
                false,
                Some("z"),
                LimitText::Contract
            )),
            "--max must be a non-negative integer"
        );
        let params = list_params(Some("5"), None, true, Some("0"), LimitText::Contract).unwrap();
        assert_eq!(
            params,
            ListParams {
                limit: Some(5),
                offset: None,
                all: true,
                max: 0
            }
        );
        assert_eq!(
            usage(statuses(&["ONLINE".into(), String::new()])),
            "--status values must not be empty"
        );
        assert_eq!(
            usage(statuses(&vec!["A".to_owned(); 253])),
            "--status accepts at most 252 values"
        );
        assert_eq!(statuses(&vec!["A".to_owned(); 252]).unwrap().len(), 252);
    }
}