use std::path::Path;
use rto_graph::{AdvisoryDb, AnalysisRun, Finding, FindingsLayer, Isolation, RunnerKind, Severity};
use serde::Serialize;
use crate::adapter::ADAPTERS;
use crate::assets::{AssetStatus, resolve, status};
use crate::clock::age_in_days;
use crate::crossref::{Correspondence, across_analyzers};
pub const TOOL_SECURITY_LIST_SCHEMA: &str = "roteiro.security.list/v1";
pub const TOOL_SECURITY_STATUS_SCHEMA: &str = "roteiro.security.status/v1";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Coverage {
Analyzed,
NoAnalyzerOnRecord,
}
impl Coverage {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Analyzed => "analyzed",
Self::NoAnalyzerOnRecord => "no-analyzer-on-record",
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ToolSecurityList {
pub schema: &'static str,
pub coverage: Coverage,
#[serde(skip_serializing_if = "Option::is_none")]
pub report: Option<SecurityListReport>,
#[serde(skip_serializing_if = "Option::is_none")]
pub no_result_reason: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SecurityListReport {
pub layers: Vec<ToolFindingsLayer>,
pub findings: usize,
pub returned: usize,
pub truncated: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub cross_reference: Vec<CrossReference>,
pub cross_reference_total: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct ToolFindingsLayer {
pub run: AnalysisRun,
pub findings: usize,
pub page: Vec<Finding>,
pub truncated: bool,
pub omitted: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct CrossReference {
pub advisory: String,
pub aliases: Vec<String>,
pub package: String,
pub version: String,
pub confirmed_by: usize,
pub reports: Vec<CrossReferenceReport>,
}
#[derive(Debug, Clone, Serialize)]
pub struct CrossReferenceReport {
pub analyzer: String,
pub key: String,
pub rule: String,
pub severity: Severity,
}
impl From<Correspondence> for CrossReference {
fn from(c: Correspondence) -> Self {
let confirmed_by = c.confirmed_by();
Self {
advisory: c.advisory,
aliases: c.aliases,
package: c.package,
version: c.version,
confirmed_by,
reports: c
.reports
.into_iter()
.map(|r| CrossReferenceReport {
analyzer: r.analyzer,
key: r.key,
rule: r.rule,
severity: r.severity,
})
.collect(),
}
}
}
#[must_use]
pub fn security_list(layers: Vec<FindingsLayer>, limit: usize) -> ToolSecurityList {
if layers.is_empty() {
return ToolSecurityList {
schema: TOOL_SECURITY_LIST_SCHEMA,
coverage: Coverage::NoAnalyzerOnRecord,
report: None,
no_result_reason: Some(NO_RESULT_REASON.to_owned()),
};
}
let correspondences = across_analyzers(&layers);
let cross_reference_total = correspondences.len();
let mut cross_reference = corroborated_first(correspondences);
cross_reference.truncate(limit);
let findings: usize = layers.iter().map(|l| l.findings.len()).sum();
let layers: Vec<ToolFindingsLayer> = layers.into_iter().map(|l| page(l, limit)).collect();
let returned: usize = layers.iter().map(|l| l.page.len()).sum();
ToolSecurityList {
schema: TOOL_SECURITY_LIST_SCHEMA,
coverage: Coverage::Analyzed,
report: Some(SecurityListReport {
layers,
findings,
returned,
truncated: returned < findings,
cross_reference,
cross_reference_total,
}),
no_result_reason: None,
}
}
const NO_RESULT_REASON: &str = "No analyzer has filed a findings layer here, so nothing has been \
analyzed. This is NOT a clean result and must not be reported as \
one: a clean run leaves a layer whose findings are empty, which \
would appear above with coverage `analyzed`. Run `roteiro \
security ingest <report.json>` (or `roteiro security run \
--analyzer <name>`) to produce a result.";
fn corroborated_first(correspondences: Vec<Correspondence>) -> Vec<CrossReference> {
let mut views: Vec<CrossReference> = correspondences.into_iter().map(Into::into).collect();
views.sort_by_key(|c| std::cmp::Reverse(c.confirmed_by));
views
}
fn page(layer: FindingsLayer, limit: usize) -> ToolFindingsLayer {
let FindingsLayer { run, mut findings } = layer;
let total = findings.len();
findings.sort_by(|a, b| a.severity.cmp(&b.severity));
findings.truncate(limit);
ToolFindingsLayer {
run,
findings: total,
omitted: total - findings.len(),
truncated: findings.len() < total,
page: findings,
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ToolSecurityStatus {
pub schema: &'static str,
pub machine: MachineScope,
pub repository: RepositoryScope,
}
#[derive(Debug, Clone, Serialize)]
pub struct MachineScope {
pub scope: &'static str,
pub asset_root: String,
pub analyzers: Vec<AnalyzerCoverage>,
pub assets: Vec<AssetStatus>,
}
#[derive(Debug, Clone, Serialize)]
pub struct RepositoryScope {
pub scope: &'static str,
pub project: String,
pub coverage: Coverage,
#[serde(skip_serializing_if = "Option::is_none")]
pub layers: Option<Vec<LayerStaleness>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub no_result_reason: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Readiness {
Ready,
AssetsNotProvisioned,
BinaryNotFound,
}
impl Readiness {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Ready => "ready",
Self::AssetsNotProvisioned => "assets not provisioned",
Self::BinaryNotFound => "binary not found",
}
}
}
#[must_use]
fn readiness(assets_provisioned: bool, missing_programs: &[&str]) -> Readiness {
if !assets_provisioned {
Readiness::AssetsNotProvisioned
} else if missing_programs.is_empty() {
Readiness::Ready
} else {
Readiness::BinaryNotFound
}
}
#[must_use]
fn program_in(dirs: &[std::path::PathBuf], program: &str) -> bool {
if std::path::Path::new(program).components().count() > 1 {
return is_executable_file(std::path::Path::new(program));
}
dirs.iter()
.any(|dir| is_executable_file(&dir.join(program)))
}
#[must_use]
fn on_path(program: &str) -> bool {
let Some(var) = std::env::var_os("PATH") else {
return false;
};
let dirs: Vec<std::path::PathBuf> = std::env::split_paths(&var).collect();
program_in(&dirs, program)
}
#[cfg(unix)]
#[must_use]
fn is_executable_file(path: &std::path::Path) -> bool {
use std::os::unix::fs::PermissionsExt as _;
std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
}
#[cfg(not(unix))]
#[must_use]
fn is_executable_file(path: &std::path::Path) -> bool {
if path.is_file() {
return true;
}
match path.file_name().and_then(|n| n.to_str()) {
Some(name) => path.with_file_name(format!("{name}.exe")).is_file(),
None => false,
}
}
#[derive(Debug, Clone, Serialize)]
pub struct AnalyzerCoverage {
pub analyzer: &'static str,
pub summary: &'static str,
pub languages: &'static [&'static str],
pub host_readiness: Readiness,
pub assets_provisioned: bool,
pub host_programs: &'static [&'static str],
pub missing_programs: Vec<&'static str>,
}
#[derive(Debug, Clone, Serialize)]
pub struct LayerStaleness {
pub layer: String,
pub analyzer: String,
pub findings: usize,
pub runner: RunnerKind,
pub isolation: Isolation,
#[serde(skip_serializing_if = "Option::is_none")]
pub advisory_db: Option<AdvisoryDb>,
#[serde(skip_serializing_if = "Option::is_none")]
pub advisory_db_age_days: Option<i64>,
pub possibly_stale: bool,
}
#[must_use]
pub fn coverage_matrix(root: &Path, analyzer: Option<&str>) -> Vec<AnalyzerCoverage> {
coverage_matrix_with(root, analyzer, on_path)
}
#[must_use]
pub fn coverage_matrix_with(
root: &Path,
analyzer: Option<&str>,
on_path: impl Fn(&str) -> bool,
) -> Vec<AnalyzerCoverage> {
ADAPTERS
.iter()
.filter(|a| analyzer.is_none_or(|name| a.analyzer() == name))
.map(|adapter| {
let host_programs = adapter.host_programs();
let missing_programs: Vec<&'static str> = host_programs
.iter()
.copied()
.filter(|program| !on_path(program))
.collect();
let assets_provisioned = resolve(root, adapter.analyzer()).is_ok();
AnalyzerCoverage {
analyzer: adapter.analyzer(),
summary: adapter.summary(),
languages: adapter.languages(),
host_readiness: readiness(assets_provisioned, &missing_programs),
assets_provisioned,
host_programs,
missing_programs,
}
})
.collect()
}
#[must_use]
pub fn layer_staleness(layers: &[FindingsLayer], now: &str) -> Vec<LayerStaleness> {
layers
.iter()
.map(|layer| {
let age = layer
.run
.advisory_db
.as_ref()
.and_then(|db| db.published_at.as_deref())
.and_then(|published| age_in_days(published, now));
LayerStaleness {
layer: layer.run.layer.clone(),
analyzer: layer.run.analyzer.clone(),
findings: layer.findings.len(),
runner: layer.run.runner,
isolation: layer.run.isolation,
advisory_db: layer.run.advisory_db.clone(),
advisory_db_age_days: age,
possibly_stale: layer.run.advisory_db.is_some(),
}
})
.collect()
}
#[must_use]
pub fn security_status(
root: &Path,
analyzer: Option<&str>,
project: &str,
layers: &[FindingsLayer],
now: &str,
) -> ToolSecurityStatus {
let staleness = layer_staleness(layers, now);
let (coverage, layers, reason) = if staleness.is_empty() {
(
Coverage::NoAnalyzerOnRecord,
None,
Some(NO_RESULT_REASON.to_owned()),
)
} else {
(Coverage::Analyzed, Some(staleness), None)
};
ToolSecurityStatus {
schema: TOOL_SECURITY_STATUS_SCHEMA,
machine: MachineScope {
scope: "machine",
asset_root: root.display().to_string(),
analyzers: coverage_matrix(root, analyzer),
assets: status(root, analyzer),
},
repository: RepositoryScope {
scope: "repository",
project: project.to_owned(),
coverage,
layers,
no_result_reason: reason,
},
}
}
#[cfg(test)]
mod tests {
use super::{
Coverage, Readiness, TOOL_SECURITY_LIST_SCHEMA, TOOL_SECURITY_STATUS_SCHEMA,
coverage_matrix_with, layer_staleness, program_in, readiness, security_list,
security_status,
};
use rto_graph::{
AdvisoryDb, AnalysisRun, CommandPolicy, Finding, FindingKey, FindingsLayer, Isolation,
RunnerKind, Severity, SourceIdentity,
};
fn run(analyzer: &str, advisory_db: Option<AdvisoryDb>) -> AnalysisRun {
AnalysisRun {
layer: format!("security:{analyzer}:wt"),
analyzer: analyzer.to_owned(),
analyzer_version: "1.0.0".to_owned(),
runner: RunnerKind::Ingested,
isolation: Isolation::Ingested,
image_digest: None,
rules_digest: None,
advisory_db,
command_policy: CommandPolicy::default(),
source: SourceIdentity::default(),
started_at: "2026-08-01T00:00:00Z".to_owned(),
ended_at: "2026-08-01T00:00:01Z".to_owned(),
exit_status: 0,
report_digest: "deadbeef".to_owned(),
}
}
fn dependency_finding(analyzer: &str, rule: &str, package: &str) -> Finding {
Finding {
meta: serde_json::json!({ "package": package, "version": "1.0.0" }),
..finding(analyzer, rule, Severity::High)
}
}
fn finding(analyzer: &str, rule: &str, severity: Severity) -> Finding {
Finding {
key: FindingKey::new(analyzer, &[rule, crate::NO_SNIPPET]).expect("key"),
rule: rule.to_owned(),
severity,
title: format!("{rule} title"),
message: format!("{rule} message"),
path: None,
span: None,
meta: serde_json::Value::Null,
}
}
#[test]
fn nothing_analyzed_carries_no_findings_field_at_all() {
let doc = security_list(Vec::new(), 20);
assert_eq!(doc.coverage, Coverage::NoAnalyzerOnRecord);
let json = serde_json::to_value(&doc).expect("serialise");
assert_eq!(json["schema"], TOOL_SECURITY_LIST_SCHEMA);
assert_eq!(json["coverage"], "no-analyzer-on-record");
assert!(
json.get("report").is_none(),
"a listing with nothing to list must carry no report: {json}"
);
assert!(json.get("findings").is_none(), "{json}");
assert!(json.get("layers").is_none(), "{json}");
let reason = json["no_result_reason"].as_str().expect("reason");
assert!(reason.contains("NOT a clean result"), "{reason}");
}
#[test]
fn a_clean_run_is_analyzed_with_zero_findings() {
let layers = vec![FindingsLayer {
run: run("semgrep", None),
findings: Vec::new(),
}];
let doc = security_list(layers, 20);
assert_eq!(doc.coverage, Coverage::Analyzed);
let json = serde_json::to_value(&doc).expect("serialise");
assert_eq!(json["coverage"], "analyzed");
assert_eq!(json["report"]["findings"], 0);
assert_eq!(json["report"]["layers"][0]["findings"], 0);
assert!(json.get("no_result_reason").is_none(), "{json}");
}
#[test]
fn the_page_bound_is_per_layer_and_never_hides_a_layer() {
let layers = vec![
FindingsLayer {
run: run("cargo-audit", None),
findings: (0..5)
.map(|i| finding("cargo-audit", &format!("RUSTSEC-{i}"), Severity::High))
.collect(),
},
FindingsLayer {
run: run("semgrep", None),
findings: (0..5)
.map(|i| finding("semgrep", &format!("rule-{i}"), Severity::Medium))
.collect(),
},
];
let doc = security_list(layers, 2);
let report = doc.report.expect("analyzed");
assert_eq!(report.findings, 10, "the true total survives the bound");
assert_eq!(report.returned, 4, "two per layer, both layers reached");
assert!(report.truncated);
for layer in &report.layers {
assert_eq!(layer.findings, 5, "true count per layer");
assert_eq!(layer.page.len(), 2);
assert_eq!(layer.omitted, 3);
assert!(layer.truncated);
}
}
#[test]
fn a_truncated_page_keeps_the_most_severe() {
let layers = vec![FindingsLayer {
run: run("semgrep", None),
findings: vec![
finding("semgrep", "aaa-info", Severity::Info),
finding("semgrep", "bbb-low", Severity::Low),
finding("semgrep", "zzz-critical", Severity::Critical),
],
}];
let doc = security_list(layers, 1);
let report = doc.report.expect("analyzed");
assert_eq!(report.layers[0].page.len(), 1);
assert_eq!(report.layers[0].page[0].rule, "zzz-critical");
assert_eq!(report.layers[0].omitted, 2);
}
#[test]
fn an_untruncated_listing_says_so() {
let layers = vec![FindingsLayer {
run: run("semgrep", None),
findings: vec![finding("semgrep", "rule-1", Severity::High)],
}];
let report = security_list(layers, 20).report.expect("analyzed");
assert_eq!(report.findings, 1);
assert_eq!(report.returned, 1);
assert!(!report.truncated);
assert!(!report.layers[0].truncated);
assert_eq!(report.layers[0].omitted, 0);
}
#[test]
fn a_single_dependency_analyzer_yields_no_cross_reference() {
let layers = vec![FindingsLayer {
run: run("cargo-audit", None),
findings: vec![
dependency_finding("cargo-audit", "RUSTSEC-2024-0001", "openssl"),
dependency_finding("cargo-audit", "RUSTSEC-2024-0002", "time"),
],
}];
let report = security_list(layers, 20).report.expect("analyzed");
assert!(
report.cross_reference.is_empty(),
"a table in which every row reads `confirmed_by: 1` is noise dressed as \
information: {:?}",
report.cross_reference
);
assert_eq!(report.cross_reference_total, 0);
assert_eq!(report.findings, 2);
}
#[test]
fn two_dependency_analyzers_are_cross_referenced_and_counted() {
let layers = vec![
FindingsLayer {
run: run("cargo-audit", None),
findings: vec![dependency_finding(
"cargo-audit",
"RUSTSEC-2024-0001",
"openssl",
)],
},
FindingsLayer {
run: run("osv-scanner", None),
findings: vec![dependency_finding(
"osv-scanner",
"RUSTSEC-2024-0001",
"openssl",
)],
},
];
let report = security_list(layers, 20).report.expect("analyzed");
assert_eq!(report.cross_reference.len(), 1, "one advisory, two reports");
assert_eq!(report.cross_reference_total, 1);
assert_eq!(report.cross_reference[0].confirmed_by, 2);
assert_eq!(
report.findings, 2,
"the count is unchanged by the view (ADR-0018 v1.1)"
);
}
#[test]
fn status_labels_its_two_scopes_in_the_document() {
let root = std::path::Path::new("/nonexistent-asset-root");
let doc = security_status(root, None, "spoke", &[], "2026-08-19T00:00:00Z");
let json = serde_json::to_value(&doc).expect("serialise");
assert_eq!(json["schema"], TOOL_SECURITY_STATUS_SCHEMA);
assert_eq!(json["machine"]["scope"], "machine");
assert_eq!(json["repository"]["scope"], "repository");
assert!(json["machine"]["asset_root"].is_string(), "{json}");
assert_eq!(json["repository"]["project"], "spoke");
assert!(json["machine"].get("project").is_none(), "{json}");
assert!(json["repository"].get("asset_root").is_none(), "{json}");
}
#[test]
fn status_repository_half_distinguishes_unanalyzed_from_clean() {
let root = std::path::Path::new("/nonexistent-asset-root");
let empty = security_status(root, None, "p", &[], "2026-08-19T00:00:00Z");
let json = serde_json::to_value(&empty).expect("serialise");
assert_eq!(json["repository"]["coverage"], "no-analyzer-on-record");
assert!(json["repository"].get("layers").is_none(), "{json}");
assert!(
json["repository"]["no_result_reason"]
.as_str()
.expect("reason")
.contains("NOT a clean result")
);
let layers = vec![FindingsLayer {
run: run("semgrep", None),
findings: Vec::new(),
}];
let clean = security_status(root, None, "p", &layers, "2026-08-19T00:00:00Z");
let json = serde_json::to_value(&clean).expect("serialise");
assert_eq!(json["repository"]["coverage"], "analyzed");
assert_eq!(json["repository"]["layers"][0]["findings"], 0);
}
#[test]
fn readiness_names_the_remedy_that_applies() {
assert_eq!(readiness(true, &[]), Readiness::Ready);
assert_eq!(
readiness(false, &[]),
Readiness::AssetsNotProvisioned,
"assets missing, binary present"
);
assert_eq!(
readiness(true, &["semgrep"]),
Readiness::BinaryNotFound,
"the state the old `ready: bool` could not express"
);
assert_eq!(
readiness(false, &["semgrep"]),
Readiness::AssetsNotProvisioned,
"both missing must not read as a binary-only problem"
);
}
#[test]
fn a_provisioned_analyzer_with_no_binary_is_not_ready() {
let root = std::path::Path::new("/nonexistent-asset-root");
let all_present = coverage_matrix_with(root, Some("semgrep"), |_| true);
assert_eq!(
all_present[0].host_readiness,
Readiness::AssetsNotProvisioned
);
assert!(all_present[0].missing_programs.is_empty());
let none_present = coverage_matrix_with(root, Some("semgrep"), |_| false);
assert_eq!(
none_present[0].host_readiness,
Readiness::AssetsNotProvisioned
);
assert_eq!(none_present[0].missing_programs, vec!["semgrep"]);
}
#[test]
fn all_three_states_are_reachable_on_a_provisioned_cache() {
use crate::assets::{assets_for, provision};
let root = std::env::temp_dir().join(format!(
"rto-exec-readiness-{}-{}",
std::process::id(),
line!()
));
std::fs::remove_dir_all(&root).ok();
for spec in assets_for("semgrep") {
provision(&root, spec).expect("vendored asset provisions with no fetcher");
}
let ready = coverage_matrix_with(&root, Some("semgrep"), |_| true);
assert_eq!(ready[0].host_readiness, Readiness::Ready);
assert!(ready[0].assets_provisioned);
assert!(ready[0].missing_programs.is_empty());
let no_binary = coverage_matrix_with(&root, Some("semgrep"), |_| false);
assert_eq!(
no_binary[0].host_readiness,
Readiness::BinaryNotFound,
"provisioned assets alone must not earn the word `ready`"
);
assert!(
no_binary[0].assets_provisioned,
"the asset half is still true, and still reported"
);
assert_eq!(no_binary[0].missing_programs, vec!["semgrep"]);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn coverage_matrix_reports_both_facts_not_just_the_verdict() {
let root = std::path::Path::new("/nonexistent-asset-root");
let rows = coverage_matrix_with(root, None, |_| false);
assert_eq!(rows.len(), 3, "one row per shipped analyzer");
for row in &rows {
let json = serde_json::to_value(row).expect("serialise");
assert_eq!(json["assets_provisioned"], false, "{json}");
assert!(json["host_programs"].is_array(), "{json}");
assert!(json["missing_programs"].is_array(), "{json}");
assert_eq!(json["host_readiness"], "assets-not-provisioned", "{json}");
assert!(json.get("ready").is_none(), "{json}");
}
}
#[test]
fn cargo_audit_is_not_ready_on_cargo_alone() {
let root = std::path::Path::new("/nonexistent-asset-root");
let rows = coverage_matrix_with(root, Some("cargo-audit"), |program| program == "cargo");
assert_eq!(rows[0].host_programs, &["cargo", "cargo-audit"]);
assert_eq!(
rows[0].missing_programs,
vec!["cargo-audit"],
"`cargo` being present must not stand in for the subcommand binary"
);
}
#[test]
fn the_path_probe_requires_an_executable_file() {
let dir = std::env::temp_dir().join(format!(
"rto-exec-path-probe-{}-{}",
std::process::id(),
line!()
));
std::fs::create_dir_all(&dir).expect("temp dir");
let exec = dir.join("runnable");
std::fs::write(&exec, b"#!/bin/sh\ntrue\n").expect("write");
let plain = dir.join("not-runnable");
std::fs::write(&plain, b"data").expect("write");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&exec, std::fs::Permissions::from_mode(0o755)).expect("chmod");
std::fs::set_permissions(&plain, std::fs::Permissions::from_mode(0o644))
.expect("chmod");
}
let dirs = vec![dir.clone()];
assert!(program_in(&dirs, "runnable"), "an executable file resolves");
assert!(!program_in(&dirs, "absent"), "a name with no file does not");
#[cfg(unix)]
assert!(
!program_in(&dirs, "not-runnable"),
"a file with no execute bit is not something this host runs"
);
assert!(program_in(&[], exec.to_str().expect("utf-8")));
assert!(!program_in(&[], "/nonexistent/runnable"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn possibly_stale_tracks_the_presence_of_an_advisory_database() {
let with_db = FindingsLayer {
run: run(
"cargo-audit",
Some(AdvisoryDb {
digest: "abc".to_owned(),
published_at: Some("2026-08-09T00:00:00Z".to_owned()),
}),
),
findings: Vec::new(),
};
let without = FindingsLayer {
run: run("semgrep", None),
findings: Vec::new(),
};
let rows = layer_staleness(&[with_db, without], "2026-08-19T00:00:00Z");
assert!(rows[0].possibly_stale);
assert_eq!(rows[0].advisory_db_age_days, Some(10));
assert!(!rows[1].possibly_stale);
assert!(rows[1].advisory_db_age_days.is_none());
}
}