pub mod body;
pub mod inference;
pub mod inventory;
pub mod provider;
pub mod provision;
pub mod resolve;
pub mod secrets;
pub mod views;
pub mod window;
pub mod withhold;
pub mod withhold_rules;
pub mod writes;
use crate::error::CliError;
use crate::output::terminal_safe;
use std::time::Duration;
pub const BANNER_TITLE: &str = "Prisma AIRS — AI Gateway";
pub const BANNER_SUBTITLE: &str = "Gateway workspace operations";
pub const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
pub const TEXT_LIMIT: usize = 4096;
const SHOWN_LIMIT: usize = 200;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GwPlane {
Data,
Admin,
Iam,
}
impl GwPlane {
pub fn sdk(self) -> scm::airs::gateway::AIGatewayPlane {
match self {
Self::Admin => scm::airs::gateway::AIGatewayPlane::Admin,
Self::Data | Self::Iam => scm::airs::gateway::AIGatewayPlane::Data,
}
}
pub fn name(self) -> &'static str {
match self {
Self::Data => "data",
Self::Admin => "admin",
Self::Iam => "IAM",
}
}
}
pub fn options(budget: Duration) -> Result<scm::RequestOptions, CliError> {
scm::RequestOptions::with_timeout(budget)
.map(scm::RequestOptions::without_response_defaults)
.map_err(|_| {
CliError::operational("AISEC_SDK_ERROR:The request options could not be configured")
})
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Incomplete {
Of(u64),
MoreThanRows,
}
pub fn incomplete_total(
rows: usize,
total: Option<&crate::json::OValue>,
has_more: bool,
) -> Option<Incomplete> {
let total = total.and_then(|value| match value {
crate::json::OValue::Number(number) => number.as_f64(),
_ => None,
});
match crate::walk::total_of(total) {
Some(total) if usize::try_from(total).ok() != Some(rows) => Some(Incomplete::Of(total)),
_ if has_more => Some(Incomplete::MoreThanRows),
_ => None,
}
}
pub fn sdk_options(budget: Duration) -> scm::Result<scm::RequestOptions> {
options(budget).map_err(|_| scm::Error::new(scm::ErrorKind::Configuration))
}
pub fn shown(value: &str) -> String {
let safe = terminal_safe(value);
let mut chars = safe.chars();
let head: String = chars.by_ref().take(SHOWN_LIMIT).collect();
if chars.next().is_some() {
format!("{head}…")
} else {
head
}
}
fn payload_error(text: String) -> CliError {
CliError::usage(format!("AISEC_USER_REQUEST_PAYLOAD_ERROR:{text}"))
}
pub fn check_workspace_ref(value: &str) -> Result<(), CliError> {
let slug = value.len() <= 512
&& value
.as_bytes()
.first()
.is_some_and(u8::is_ascii_alphanumeric)
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'));
if slug || crate::pagination::is_uuid(value) {
Ok(())
} else {
Err(payload_error(format!(
"Invalid workspaceRef: {} (expected a workspace UUID or slug)",
shown(value)
)))
}
}
pub fn check_resolved_ref(flag: &str, value: &str) -> Result<(), CliError> {
if crate::tenant::config::js_trim(value).is_empty() {
return Err(CliError::usage(format!("{flag} cannot be empty")));
}
check_text(flag, value)
}
pub fn check_text(flag: &str, value: &str) -> Result<(), CliError> {
if value.len() <= TEXT_LIMIT && !value.chars().any(char::is_control) {
Ok(())
} else {
Err(CliError::usage(format!(
"{flag} must be at most {TEXT_LIMIT} bytes and contain no control characters"
)))
}
}
pub fn check_scope_name(value: &str) -> Result<(), CliError> {
scm::shared::iam::validate_scope_name(value).map_err(|_| {
payload_error(format!(
"Invalid name: {} (expected an IAM scope name such as ws_production_bx7qw0)",
shown(value)
))
})
}
pub fn check_uuid(field: &str, value: &str) -> Result<(), CliError> {
if crate::pagination::is_uuid(value) {
Ok(())
} else {
Err(payload_error(format!("Invalid {field}: {}", shown(value))))
}
}
pub fn check_tsg(field: &str, value: &str) -> Result<(), CliError> {
if (1..=32).contains(&value.len()) && value.bytes().all(|byte| byte.is_ascii_digit()) {
Ok(())
} else {
Err(payload_error(format!("Invalid {field}: {}", shown(value))))
}
}
pub fn foreign_tsg(flag: &str, value: &str) -> CliError {
CliError::operational(format!(
"{flag} {value} is not the selected tenant's TSG; airs reads and changes only the selected tenant's organisation"
))
}
pub fn paging_integer(flag: &str, value: &str, min: i64, max: i64) -> Result<u64, CliError> {
match integer(value) {
Ok(number) if (min..=max).contains(&number) => Ok(number.unsigned_abs()),
Ok(_) => Err(CliError::usage(format!(
"{flag} must be an integer from {min} to {max}"
))),
Err(reason) => Err(CliError::usage(reason)),
}
}
pub fn integer(value: &str) -> Result<i64, &'static str> {
let digits = value.strip_prefix('-').unwrap_or(value);
let shaped = !digits.is_empty()
&& digits.bytes().all(|byte| byte.is_ascii_digit())
&& (digits == "0" || !digits.starts_with('0'));
if !shaped {
return Err("Expected an integer");
}
if digits.len() > 16 {
return Err("Expected a safe integer");
}
value
.parse::<i64>()
.ok()
.filter(|number| number.abs() <= MAX_SAFE_INTEGER)
.ok_or("Expected a safe integer")
}
pub fn positive(flag: &str, value: &str) -> Result<u64, CliError> {
let reason = match integer(value) {
Ok(number) if number > 0 => return Ok(number.unsigned_abs()),
Ok(_) => "Expected a positive integer",
Err(reason) => reason,
};
Err(CliError::usage(format!("Invalid {flag}: {reason}")))
}
pub fn grant_hint(error: &scm::Error, plane: GwPlane) -> Option<String> {
if error.kind() != scm::ErrorKind::Http || error.status() != Some(403) {
return None;
}
let code = error.service_code();
let grant = match (plane, code) {
(GwPlane::Iam, _) => return None,
(_, Some("AB03")) | (GwPlane::Data, None) => {
"the service account is missing a workspace-scope grant (data plane, /ai_gw/v2)"
}
(GwPlane::Admin, _) => {
"the service account is missing a tenant-root admin grant (admin plane, /ai_gw/admin/v2)"
}
(GwPlane::Data, Some(_)) => return None,
};
Some(format!(
"⚠ 403: {grant}. SCM Access Management edits the existing role row by default — use \"Add Role\" so the account ends up with both role rows, not one row moved."
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn incomplete_total_is_the_one_g39_rule() {
let number = |text: &str| crate::json::parse(text).unwrap();
assert_eq!(incomplete_total(2, Some(&number("2")), false), None);
assert_eq!(incomplete_total(2, None, false), None);
assert_eq!(
incomplete_total(2, Some(&number("5")), false),
Some(Incomplete::Of(5))
);
assert_eq!(
incomplete_total(2, Some(&number("5")), true),
Some(Incomplete::Of(5)),
"a differing total is named even with has_more"
);
assert_eq!(
incomplete_total(2, Some(&number("2")), true),
Some(Incomplete::MoreThanRows)
);
assert_eq!(
incomplete_total(0, None, true),
Some(Incomplete::MoreThanRows)
);
for ignored in ["2.5", "-1", "9007199254740992", "\"5\""] {
assert_eq!(
incomplete_total(2, Some(&number(ignored)), false),
None,
"{ignored}"
);
}
for (answer, incomplete) in [
(r#"{"data":[{}],"total":3}"#, true),
(r#"{"data":[{}],"has_more":true}"#, true),
(r#"{"data":[{}],"total":1,"has_more":false}"#, false),
(r#"{"data":[],"total":"many"}"#, false),
] {
let answer = number(answer);
assert_eq!(resolve::incomplete(&answer), incomplete, "{answer:?}");
let rows = answer
.get("data")
.and_then(crate::json::OValue::as_array)
.unwrap();
let has_more = matches!(
answer.get("has_more"),
Some(crate::json::OValue::Bool(true))
);
assert_eq!(
crate::commands::gateway::incomplete_warning(
rows.len(),
answer.get("total"),
has_more,
"rows"
)
.is_some(),
incomplete
);
}
}
#[test]
fn shown_values_are_terminal_safe_and_bounded() {
assert_eq!(shown("a\u{1b}b"), "a\u{fffd}b");
let long = "x".repeat(250);
assert_eq!(shown(&long), format!("{}…", "x".repeat(200)));
assert_eq!(shown(&"y".repeat(200)), "y".repeat(200));
}
#[test]
fn workspace_refs_scope_names_and_texts() {
for good in ["ws-main", "A_b-1", "11111111-1111-4111-8111-000000000001"] {
assert!(check_workspace_ref(good).is_ok(), "{good}");
}
assert_eq!(
check_workspace_ref("Main A").unwrap_err(),
CliError::usage(
"AISEC_USER_REQUEST_PAYLOAD_ERROR:Invalid workspaceRef: Main A (expected a workspace UUID or slug)"
)
);
assert!(check_workspace_ref("-x").is_err());
assert!(check_workspace_ref(&"a".repeat(513)).is_err());
assert!(check_scope_name("ws_production_bx7qw0").is_ok());
assert!(check_scope_name("_x.y-z").is_ok());
assert_eq!(
check_scope_name("bad/name").unwrap_err(),
CliError::usage(
"AISEC_USER_REQUEST_PAYLOAD_ERROR:Invalid name: bad/name (expected an IAM scope name such as ws_production_bx7qw0)"
)
);
assert_eq!(
check_resolved_ref("--workspace", " \u{a0}").unwrap_err(),
CliError::usage("--workspace cannot be empty")
);
assert_eq!(
check_resolved_ref("--workspace", "a\u{7}").unwrap_err(),
CliError::usage(
"--workspace must be at most 4096 bytes and contain no control characters"
)
);
assert!(check_resolved_ref("--workspace", "Main A").is_ok());
}
#[test]
fn ids_tsgs_and_paging() {
assert!(check_uuid("configId", "11111111-1111-4111-8111-000000000301").is_ok());
assert_eq!(
check_uuid("configId", "config-1").unwrap_err(),
CliError::usage("AISEC_USER_REQUEST_PAYLOAD_ERROR:Invalid configId: config-1")
);
assert!(check_tsg("tsgId", "1234567890").is_ok());
for bad in ["", "12a", "-1", &"1".repeat(33)] {
assert!(check_tsg("tsgId", bad).is_err(), "{bad}");
}
assert_eq!(
check_tsg("tsgId", "x\u{1b}").unwrap_err(),
CliError::usage("AISEC_USER_REQUEST_PAYLOAD_ERROR:Invalid tsgId: x\u{fffd}")
);
assert_eq!(paging_integer("--page-size", "1000", 1, 1000), Ok(1000));
assert_eq!(
paging_integer("--page-size", "0", 1, 1000).unwrap_err(),
CliError::usage("--page-size must be an integer from 1 to 1000")
);
assert_eq!(
paging_integer("--page-size", "x", 1, 1000).unwrap_err(),
CliError::usage("Expected an integer")
);
assert_eq!(
paging_integer("--current-page", "-1", 0, MAX_SAFE_INTEGER).unwrap_err(),
CliError::usage("--current-page must be an integer from 0 to 9007199254740991")
);
assert_eq!(
paging_integer("--current-page", "9007199254740992", 0, MAX_SAFE_INTEGER).unwrap_err(),
CliError::usage("Expected a safe integer")
);
}
#[test]
fn integers_follow_the_typescript_parsers() {
assert_eq!(integer("0"), Ok(0));
assert_eq!(integer("-12"), Ok(-12));
assert_eq!(integer("9007199254740991"), Ok(MAX_SAFE_INTEGER));
for bad in ["", "-", "01", "1.0", "1e2", "+1", " 1", "7junk", "0x10"] {
assert_eq!(integer(bad), Err("Expected an integer"), "{bad}");
}
for unsafe_value in ["9007199254740992", "99999999999999999999"] {
assert_eq!(integer(unsafe_value), Err("Expected a safe integer"));
}
assert_eq!(positive("--days", "7"), Ok(7));
assert_eq!(
positive("--days", "0").unwrap_err(),
CliError::usage("Invalid --days: Expected a positive integer")
);
assert_eq!(
positive("--days", "-3").unwrap_err(),
CliError::usage("Invalid --days: Expected a positive integer")
);
assert_eq!(
positive("--page-size", "abc").unwrap_err(),
CliError::usage("Invalid --page-size: Expected an integer")
);
}
}