use std::collections::HashSet;
use super::{
Cell, Finding, Health, Interrupted, Priority, ReadError, Report, Source, Status, Table, iso,
locale_order, source,
};
use crate::walk::total_of;
pub const DEFAULT_TITLE: &str = "Supply chain environment report";
pub const PAGE_SIZE: u32 = 10;
pub const DEFAULT_MAX_PAGES: u32 = 40;
const WINDOW_MS: i64 = 30 * 86_400_000;
pub const LIMITATIONS: [&str; 5] = [
"Read-only evidence, not a security certification or numerical health score. Browser APIs are undocumented and may change.",
"Scans use explicit start/end timestamps; statistics use the server rolling 30_DAYS window. These independently read sources are not an atomic snapshot and need not reconcile.",
"Scan rows are not unique skills. Parent/batch rows are excluded from outcome counts to avoid counting them alongside children; per-scan vulnerability counts are never summed.",
"Rule default states describe the catalog, not effective tenant policies. No scan creation, policy changes or skill uploads are performed.",
"Missing or incomplete evidence is unknown, not zero. Scan names, identifiers, URLs, device metadata, source code and finding text are excluded.",
];
pub const FAILED_NOTE: &str = "Request or validation failed; no upstream error body included.";
pub const INCOMPLETE_NOTE: &str = "Inventory incomplete: pagination changed, repeated, stopped early, failed or reached its budget.";
pub const EXCLUDED_NOTE: &str =
"Rows with missing or out-of-window creation timestamps were excluded.";
pub const UNKNOWN_BATCH_NOTE: &str =
"Rows with unknown batch membership were excluded from outcome counts.";
const RECOMMENDATION: &str = "Check service access or increase the page budget, then regenerate.";
const ARTIFACTS: [&str; 2] = ["SKILL", "AGENT"];
const OUTCOMES: [&str; 4] = ["BLOCKED", "ALLOWED", "PENDING", "ERROR"];
const STATES: [&str; 2] = ["BLOCKING", "ALLOWING"];
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ScanRow {
pub uuid: String,
pub created_at: Option<String>,
pub artifact_type: Option<String>,
pub status: Option<String>,
pub eval_outcome: Option<String>,
pub is_batch: Option<bool>,
pub children: Option<usize>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Membership {
Parent,
Leaf,
Unknown,
}
fn batch_membership(row: &ScanRow) -> Membership {
match (row.is_batch, row.children) {
(Some(true), _) | (_, Some(1..)) => Membership::Parent,
(Some(false), Some(0)) => Membership::Leaf,
_ => Membership::Unknown,
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RuleRow {
pub uuid: String,
pub default_state: Option<String>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Page<T> {
pub rows: Vec<T>,
pub total: f64,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Metric {
pub count: f64,
pub percent_change: Option<f64>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Stats {
pub unique_skills_scanned: Metric,
pub total_vulnerabilities_found: Metric,
}
trait Identified {
fn uuid(&self) -> &str;
}
impl Identified for ScanRow {
fn uuid(&self) -> &str {
&self.uuid
}
}
impl Identified for RuleRow {
fn uuid(&self) -> &str {
&self.uuid
}
}
pub(crate) trait Reader {
async fn scans(&self, skip: u64, start: &str, end: &str) -> Result<Page<ScanRow>, ReadError>;
async fn stats(&self) -> Result<Stats, ReadError>;
async fn rules(&self, skip: u64) -> Result<Page<RuleRow>, ReadError>;
}
pub struct Options<'a> {
pub title: &'a str,
pub max_pages: u32,
pub now: &'a dyn Fn() -> i64,
}
fn number(count: usize) -> f64 {
u32::try_from(count).map_or(f64::MAX, f64::from)
}
fn label(value: Option<&str>, allowed: &[&'static str]) -> &'static str {
match value {
None => "UNKNOWN",
Some(value) => allowed
.iter()
.find(|candidate| **candidate == value)
.copied()
.unwrap_or("OTHER"),
}
}
fn groups(labels: impl Iterator<Item = String>) -> Vec<Vec<Cell>> {
let mut counts: Vec<(String, u64)> = Vec::new();
for key in labels {
match counts.iter_mut().find(|(name, _)| *name == key) {
Some((_, count)) => *count += 1,
None => counts.push((key, 1)),
}
}
counts.sort_by(|(a, _), (b, _)| locale_order(a, b));
counts
.into_iter()
.map(|(name, count)| vec![Cell::Text(name), Cell::Count(count)])
.collect()
}
async fn pages<T, F, Fut>(
source: &mut Source,
max_pages: u32,
page_count: bool,
mut read: F,
) -> Result<Vec<T>, ReadError>
where
T: Identified,
F: FnMut(u64) -> Fut,
Fut: std::future::Future<Output = Result<Page<T>, ReadError>>,
{
let mut rows: Vec<T> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
let mut total: Option<f64> = None;
let outcome: Result<(), ReadError> = async {
for _ in 0..max_pages {
let skip = u64::try_from(rows.len()).unwrap_or(u64::MAX);
let page = read(skip).await?;
source.pages += 1;
if total_of(Some(page.total)).is_none() || page.rows.len() > PAGE_SIZE as usize {
return Err(ReadError::Failed);
}
let first = *total.get_or_insert(page.total);
if !page_count && (page.total != first || number(rows.len() + page.rows.len()) > first)
{
return Err(ReadError::Failed);
}
let received = page.rows.len();
for row in page.rows {
if row.uuid().is_empty() || !seen.insert(row.uuid().to_owned()) {
return Err(ReadError::Failed);
}
rows.push(row);
source.records = u64::try_from(rows.len()).unwrap_or(u64::MAX);
}
let done = if page_count {
received < PAGE_SIZE as usize
} else {
number(rows.len()) == first
};
if done {
return Ok(());
}
if received == 0 {
return Err(ReadError::Failed);
}
}
source
.notes
.push(format!("Reached the {max_pages}-page budget."));
Err(ReadError::Failed)
}
.await;
match outcome {
Ok(()) => Ok(rows),
Err(ReadError::Cancelled) => Err(ReadError::Cancelled),
Err(error) if rows.is_empty() => Err(error),
Err(_) => {
source.status = Status::Partial;
source.notes.push(INCOMPLETE_NOTE.to_owned());
Ok(rows)
}
}
}
struct Collector {
report: Report,
}
impl Collector {
fn finding(
&mut self,
priority: Priority,
title: &str,
evidence: String,
recommendation: &str,
source: &str,
) {
self.report.findings.push(Finding {
priority,
title: title.to_owned(),
evidence,
recommendation: recommendation.to_owned(),
source: source.to_owned(),
});
}
fn table(&mut self, title: &str, note: String, headers: &[&str], rows: Vec<Vec<Cell>>) {
self.report.tables.push(Table {
title: title.to_owned(),
note,
headers: headers.iter().map(|header| (*header).to_owned()).collect(),
rows,
});
}
fn finish(
&mut self,
mut source: Source,
result: Result<(), ReadError>,
) -> Result<(), Interrupted> {
match result {
Ok(()) => {
if source.status != Status::Partial {
source.status = Status::Complete;
}
}
Err(ReadError::Cancelled) => return Err(Interrupted),
Err(_) => {
source.status = if source.records > 0 {
Status::Partial
} else {
Status::Unavailable
};
source.notes.push(FAILED_NOTE.to_owned());
}
}
if source.status != Status::Complete {
self.report.findings.push(Finding {
priority: Priority::Review,
title: format!("{} incomplete", source.name),
evidence: source.notes.join(" "),
recommendation: RECOMMENDATION.to_owned(),
source: source.name.to_owned(),
});
}
self.report.sources.push(source);
Ok(())
}
fn scans(&mut self, source: &mut Source, rows: &[ScanRow], start_ms: i64, end_ms: i64) {
let inside: Vec<&ScanRow> = rows
.iter()
.filter(|row| {
row.created_at
.as_deref()
.and_then(crate::report::runtime::parse_date_text)
.is_some_and(|at| at >= start_ms && at <= end_ms)
})
.collect();
if inside.len() != rows.len() {
source.status = Status::Partial;
source.notes.push(EXCLUDED_NOTE.to_owned());
}
let mut parents = 0usize;
let mut unknown = 0usize;
let mut leaf: Vec<&ScanRow> = Vec::new();
for row in &inside {
match batch_membership(row) {
Membership::Parent => parents += 1,
Membership::Leaf => leaf.push(row),
Membership::Unknown => unknown += 1,
}
}
if unknown > 0 {
source.status = Status::Partial;
source.notes.push(UNKNOWN_BATCH_NOTE.to_owned());
}
let counted = groups(leaf.iter().map(|row| {
format!(
"{} / {}",
label(row.artifact_type.as_deref(), &ARTIFACTS),
label(row.eval_outcome.as_deref(), &OUTCOMES)
)
}));
let completeness = if source.status == Status::Partial {
"Partial inventory only."
} else {
"Complete inventory for requested window."
};
self.table(
"Scan outcomes",
format!(
"Collected {} rows; excluded {parents} parent/batch rows. {completeness}",
source.records,
),
&["Artifact / outcome", "Scan rows"],
counted,
);
let blocked = leaf
.iter()
.filter(|row| row.eval_outcome.as_deref() == Some("BLOCKED"))
.count();
if blocked > 0 {
self.finding(
Priority::Attention,
"Blocked scans require review",
format!("{blocked} collected non-parent scan rows have BLOCKED evaluations."),
"Inspect the affected scans and findings before approving the artifacts.",
"Scans",
);
}
let unsettled = leaf
.iter()
.filter(|row| {
row.status.as_deref() != Some("COMPLETED")
|| !matches!(row.eval_outcome.as_deref(), Some("ALLOWED" | "BLOCKED"))
})
.count();
if unsettled > 0 {
self.finding(
Priority::Review,
"Scans without a completed decision",
format!(
"{unsettled} collected non-parent rows are pending, failed or have an unfamiliar status."
),
"Review scan status; absence of a completed decision is not a passing evaluation.",
"Scans",
);
}
}
fn stats(&mut self, source: &mut Source, stats: &Stats) {
let metric = |name: &str, metric: &Metric| {
vec![
Cell::from(name),
Cell::Number(metric.count),
metric.percent_change.map_or(Cell::Null, Cell::Number),
]
};
self.table(
"Skill statistics",
"Server aggregates; percentage changes may be unavailable.".to_owned(),
&["Metric", "Count", "Change (%)"],
vec![
metric("Unique skills scanned", &stats.unique_skills_scanned),
metric("Vulnerabilities found", &stats.total_vulnerabilities_found),
],
);
let found = stats.total_vulnerabilities_found.count;
if found > 0.0 {
self.finding(
Priority::Attention,
"Skill vulnerabilities reported",
format!(
"{} findings reported in the server statistics window.",
crate::render::js_number(found)
),
"Review vulnerability details and remediate before use; findings are not a count of distinct vulnerable skills.",
"Statistics",
);
}
source.records = 1;
source.pages = 1;
}
fn rules(&mut self, rows: &[RuleRow]) {
let counted = groups(
rows.iter()
.map(|row| label(row.default_state.as_deref(), &STATES).to_owned()),
);
self.table(
"Rule catalog defaults",
"These are not effective tenant policy settings.".to_owned(),
&["Default state", "Rules"],
counted,
);
}
}
pub(crate) async fn collect(
reader: &impl Reader,
options: &Options<'_>,
) -> Result<Report, Interrupted> {
let end = (options.now)();
let start = end - WINDOW_MS;
let (start_iso, end_iso) = (iso(start), iso(end));
let mut c = Collector {
report: Report {
title: options.title.to_owned(),
collection_started_at: end_iso.clone(),
generated_at: String::new(),
window_start: start_iso.clone(),
window_end: end_iso.clone(),
health: Health::Unknown,
sources: Vec::new(),
findings: Vec::new(),
tables: Vec::new(),
},
};
let max_pages = options.max_pages.clamp(1, 100);
let mut s = source("Scans", "listScans()", &format!("{start_iso} to {end_iso}"));
let read = pages(&mut s, max_pages, false, |skip| {
reader.scans(skip, &start_iso, &end_iso)
})
.await;
let result = read.map(|rows| c.scans(&mut s, &rows, start, end));
c.finish(s, result)?;
let mut s = source("Statistics", "getScanStats()", "Server rolling 30_DAYS");
let result = reader.stats().await.map(|stats| c.stats(&mut s, &stats));
c.finish(s, result)?;
let mut s = source("Rules", "listRules()", "Current catalog");
let read = pages(&mut s, max_pages, true, |skip| reader.rules(skip)).await;
let result = read.map(|rows| c.rules(&rows));
c.finish(s, result)?;
let report = &mut c.report;
report.health = if report
.sources
.iter()
.all(|source| source.status == Status::Unavailable)
{
Health::Unknown
} else if report
.findings
.iter()
.any(|finding| finding.priority == Priority::Attention)
{
Health::Attention
} else if report.findings.is_empty() {
Health::NoFindings
} else {
Health::Review
};
report.generated_at = iso((options.now)());
Ok(c.report)
}
#[cfg(test)]
#[path = "supplychain_tests.rs"]
mod tests;