pub mod scans;
pub mod views;
use crate::error::{CliError, InterruptContext};
use crate::json::OValue;
use crate::output::{Output, terminal_safe};
use crate::pagination::MAX_SAFE_INTEGER;
use crate::render::style::Style;
use crate::walk::{self, Page, Spec};
use scm::airs::agentguard::models::{FiniteNumber, Optional};
use std::collections::HashSet;
use std::future::Future;
use std::time::Duration;
pub const DEFAULT_LIMIT: u32 = 10;
pub const TIME_PERIOD: &str = "30_DAYS";
const QUOTED_CHARS: usize = 200;
pub(crate) fn member(key: &str, value: OValue) -> (String, OValue) {
(key.to_owned(), value)
}
pub(crate) fn count(value: u64) -> Optional<FiniteNumber> {
Optional::Value(FiniteNumber::from(value))
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Paging {
pub limit: u32,
pub offset: u64,
pub all: bool,
pub max: u64,
}
pub(crate) fn digits(value: &str) -> Option<u64> {
if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
let trimmed = value.trim_start_matches('0');
if trimmed.is_empty() {
return Some(0);
}
trimmed.parse().ok()
}
pub fn paging(
limit: Option<&str>,
offset: Option<&str>,
all: bool,
max: Option<&str>,
) -> Result<Paging, CliError> {
let limit = match limit {
None => DEFAULT_LIMIT,
Some(value) => digits(value)
.filter(|n| *n >= 1)
.and_then(|n| u32::try_from(n).ok())
.ok_or_else(|| CliError::usage("--limit must be a positive integer"))?,
};
let offset = match offset {
None => 0,
Some(value) => digits(value)
.filter(|n| *n <= MAX_SAFE_INTEGER)
.ok_or_else(|| CliError::usage("--offset must be a non-negative integer"))?,
};
let max = match max {
None => crate::pagination::DEFAULT_MAX,
Some(value) => digits(value)
.filter(|n| *n <= MAX_SAFE_INTEGER)
.ok_or_else(|| CliError::usage("--max must be a safe nonnegative integer"))?,
};
Ok(Paging {
limit,
offset,
all,
max,
})
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ScanWindow {
pub start: Option<String>,
pub end: Option<String>,
}
fn timestamp_error(flag: &str) -> CliError {
CliError::usage(format!(
"{flag} must be an ISO-8601 timestamp with a timezone (for example 2026-10-01T00:00:00Z)"
))
}
pub fn window(start: Option<&str>, end: Option<&str>) -> Result<ScanWindow, CliError> {
let start_at = start
.map(|value| crate::modelsec::datetime(value).ok_or_else(|| timestamp_error("--start")))
.transpose()?;
let end_at = end
.map(|value| crate::modelsec::datetime(value).ok_or_else(|| timestamp_error("--end")))
.transpose()?;
if let (Some(start_at), Some(end_at)) = (start_at, end_at) {
match (start_at, end_at) {
(Some(a), Some(b)) if a <= b => {}
_ => return Err(CliError::usage("--start must not be after --end")),
}
}
Ok(ScanWindow {
start: start.map(str::to_owned),
end: end.map(str::to_owned),
})
}
fn quoted(value: &str) -> String {
let safe = terminal_safe(value);
let mut chars = safe.chars();
let head: String = chars.by_ref().take(QUOTED_CHARS).collect();
if chars.next().is_some() {
format!("{head}…")
} else {
head
}
}
pub fn scan_uuid(value: &str) -> Result<String, CliError> {
if crate::pagination::is_uuid(value) {
Ok(value.to_owned())
} else {
Err(CliError::usage(format!(
"Invalid scanUuid: {} (expected a UUID)",
quoted(value)
)))
}
}
pub fn time_period(value: Option<&str>) -> Result<(), CliError> {
match value {
None | Some(TIME_PERIOD) => Ok(()),
Some(_) => Err(CliError::usage(
"--time-period must be 30_DAYS (the only period the service supports)",
)),
}
}
pub fn options(budget: Duration) -> Result<scm::RequestOptions, CliError> {
sdk_options(budget).map_err(|_| {
CliError::operational("AISEC_SDK_ERROR:The request options could not be configured")
})
}
pub fn sdk_options(budget: Duration) -> scm::Result<scm::RequestOptions> {
scm::RequestOptions::with_timeout(budget)
.and_then(|options| options.with_retries(0))
.map(scm::RequestOptions::without_response_defaults)
}
const FAILED_TAIL: &str =
"Check credentials, query options and service availability. No upstream body is included.";
pub fn request_failure_text(status: Option<u16>) -> String {
let status = status
.filter(|status| (400..=599).contains(status))
.map_or_else(String::new, |status| format!(" (HTTP {status})"));
format!("✗ AgentGuard request failed{status}. {FAILED_TAIL}")
}
pub const SIZE_FAILURE: &str = "✗ AgentGuard response was larger than 32 MiB; nothing was printed. No upstream body is included.";
pub fn incomplete_findings_text(returned: usize, total: u64, scan: &str) -> String {
format!(
"✗ The service returned {returned} of {total} findings for scan {}; the list is incomplete (the API offers no paging)",
terminal_safe(scan)
)
}
pub fn is_oversized(error: &scm::Error) -> bool {
error.kind() == scm::ErrorKind::Limit
&& error.failure_kind() == Some(scm::FailureKind::Http)
&& !error
.status()
.is_some_and(|status| (400..=599).contains(&status))
}
pub const CONTRACT_FAILURE: &str = "✗ The AgentGuard answer does not match the API contract; nothing was printed. No upstream body is included.";
#[derive(Debug)]
pub enum Failure {
Sdk(scm::Error),
Contract,
}
impl From<scm::Error> for Failure {
fn from(error: scm::Error) -> Self {
Self::Sdk(error)
}
}
pub fn read_failure(output: &mut Output, failed: &Failure) -> CliError {
match failed {
Failure::Sdk(error) => failure(output, error),
Failure::Contract => {
fail_line(output, CONTRACT_FAILURE);
CliError::Reported(crate::error::EXIT_OPERATIONAL)
}
}
}
pub fn failure(output: &mut Output, error: &scm::Error) -> CliError {
if error.kind() == scm::ErrorKind::Cancelled {
return CliError::Interrupted(InterruptContext::NoDataWritten);
}
let text = if is_oversized(error) {
SIZE_FAILURE.to_owned()
} else {
request_failure_text(error.status())
};
fail_line(output, &text);
CliError::Reported(crate::error::EXIT_OPERATIONAL)
}
pub fn fail_line(output: &mut Output, text: &str) {
let line = output.paint_err(Style::Red, text);
output.write_stderr(&format!(" {line}\n"));
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
Scans,
Rules,
}
pub struct ListPage {
pub rows: Vec<OValue>,
pub total: Option<u64>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Pagination {
pub total_items: Option<u64>,
pub returned: usize,
pub skip: u64,
pub next_offset: Option<u64>,
pub truncated: bool,
}
impl Pagination {
fn new(
kind: Kind,
total: Option<u64>,
paging: &Paging,
returned: usize,
complete: bool,
) -> Self {
let rows = u64::try_from(returned).unwrap_or(u64::MAX);
let total_items = match kind {
Kind::Scans => total,
Kind::Rules => (complete && paging.offset == 0).then_some(rows),
};
Self {
total_items,
returned,
skip: paging.offset,
next_offset: (!complete).then(|| paging.offset.saturating_add(rows)),
truncated: !complete,
}
}
pub fn value(&self) -> OValue {
let count = |n: u64| OValue::Number(n.into());
let optional = |n: Option<u64>| n.map_or(OValue::Null, count);
OValue::Object(vec![
("total_items".to_owned(), optional(self.total_items)),
(
"returned".to_owned(),
count(u64::try_from(self.returned).unwrap_or(u64::MAX)),
),
("skip".to_owned(), count(self.skip)),
("next_offset".to_owned(), optional(self.next_offset)),
("truncated".to_owned(), OValue::Bool(self.truncated)),
])
}
}
pub struct Listed {
pub rows: Vec<OValue>,
pub pagination: Pagination,
}
fn unstable() -> scm::Error {
scm::Error::new(scm::ErrorKind::Pagination)
}
struct Checks {
kind: Kind,
offset: u64,
first_total: Option<u64>,
seen: HashSet<String>,
}
impl Checks {
fn new(kind: Kind, offset: u64) -> Self {
Self {
kind,
offset,
first_total: None,
seen: HashSet::new(),
}
}
fn check(
&mut self,
rows: &[OValue],
total: Option<u64>,
skip: u64,
asked: u32,
) -> Result<(), scm::Error> {
let received = u64::try_from(rows.len()).map_err(|_| unstable())?;
if received > u64::from(asked) {
return Err(unstable());
}
if self.kind == Kind::Scans {
let total = total.ok_or_else(unstable)?;
match self.first_total {
None => self.first_total = Some(total),
Some(first) if first != total => return Err(unstable()),
Some(_) => {}
}
}
for row in rows {
let uuid = row.get("uuid").and_then(OValue::as_str).unwrap_or_default();
if uuid.is_empty() || !self.seen.insert(uuid.to_owned()) {
return Err(unstable());
}
}
if let (Kind::Scans, Some(total)) = (self.kind, self.first_total) {
let collected = skip.saturating_sub(self.offset).saturating_add(received);
if collected > 0 && skip.saturating_add(received) > total {
return Err(unstable());
}
}
Ok(())
}
}
fn row_size(row: &OValue) -> usize {
crate::json::to_compact(row).map_or(0, |text| text.len())
}
pub async fn inventory<F, Fut>(kind: Kind, paging: &Paging, mut read: F) -> Result<Listed, Failure>
where
F: FnMut(u64, u32) -> Fut,
Fut: Future<Output = Result<ListPage, Failure>>,
{
let mut checks = Checks::new(kind, paging.offset);
if !paging.all {
let page = read(paging.offset, paging.limit).await?;
checks.check(&page.rows, page.total, paging.offset, paging.limit)?;
let rows = u64::try_from(page.rows.len()).unwrap_or(u64::MAX);
let complete = match kind {
Kind::Scans => page
.total
.is_some_and(|total| paging.offset.saturating_add(rows) >= total),
Kind::Rules => rows < u64::from(paging.limit),
};
let pagination = Pagination::new(kind, page.total, paging, page.rows.len(), complete);
return Ok(Listed {
rows: page.rows,
pagination,
});
}
let refused = std::cell::Cell::new(false);
let spec = Spec {
start: paging.offset,
shrink_last_page: true,
..Spec::default()
};
let walked = walk::walk_with(
spec,
paging.limit,
paging.max,
|skip, asked| {
let page = read(skip, asked);
let refused = &refused;
async move {
let page = match page.await {
Ok(page) => page,
Err(Failure::Sdk(error)) => return Err(error),
Err(Failure::Contract) => {
refused.set(true);
return Err(scm::Error::new(scm::ErrorKind::Decode));
}
};
let total = match kind {
Kind::Scans => page.total,
Kind::Rules => None,
};
Ok(Page::new(page.rows, total))
}
},
row_size,
|_| None,
|skip, asked, page| checks.check(&page.rows, page.total, skip, asked),
)
.await
.map_err(|error| {
if refused.get() {
Failure::Contract
} else {
Failure::Sdk(error)
}
})?;
let complete = !walked.stopped_at_max;
let pagination = Pagination::new(
kind,
checks.first_total,
paging,
walked.rows.len(),
complete,
);
Ok(Listed {
rows: walked.rows,
pagination,
})
}
#[cfg(test)]
mod tests;