use rto_graph::SourceIdentity;
use crate::ingest::NormalizedReport;
use crate::runner::ExecError;
use crate::snippet::SnippetSource;
pub mod cargo_audit;
pub mod osv_scanner;
pub mod semgrep;
#[derive(Clone)]
pub struct NativeContext<'a> {
pub started_at: String,
pub ended_at: String,
pub analyzer_version: Option<String>,
pub exit_status: i32,
pub source: &'a SourceIdentity,
pub rules_digest: Option<String>,
pub advisory_db: Option<rto_graph::AdvisoryDb>,
pub worktree: Option<&'a std::path::Path>,
pub snippets: &'a dyn SnippetSource,
}
impl std::fmt::Debug for NativeContext<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NativeContext")
.field("started_at", &self.started_at)
.field("ended_at", &self.ended_at)
.field("analyzer_version", &self.analyzer_version)
.field("exit_status", &self.exit_status)
.field("source", self.source)
.field("rules_digest", &self.rules_digest)
.field("advisory_db", &self.advisory_db)
.field("worktree", &self.worktree)
.finish_non_exhaustive()
}
}
impl NativeContext<'_> {
#[must_use]
pub fn version_or(&self, from_report: Option<&str>) -> String {
self.analyzer_version
.as_deref()
.or(from_report)
.map(str::trim)
.filter(|v| !v.is_empty())
.unwrap_or(UNKNOWN_VERSION)
.to_owned()
}
}
pub const UNKNOWN_VERSION: &str = "unknown";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Invocation {
pub program: String,
pub args: Vec<String>,
pub success_statuses: Vec<i32>,
}
pub trait Adapter: Sync + std::fmt::Debug {
fn analyzer(&self) -> &'static str;
fn summary(&self) -> &'static str;
fn languages(&self) -> &'static [&'static str];
fn asset_ids(&self) -> &'static [&'static str];
fn command(&self, assets: &AssetPaths<'_>) -> Invocation;
fn normalize(
&self,
native: &[u8],
ctx: &NativeContext<'_>,
) -> Result<NormalizedReport, ExecError>;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct AssetPaths<'a> {
entries: &'a [(&'a str, std::path::PathBuf)],
}
impl<'a> AssetPaths<'a> {
#[must_use]
pub fn new(entries: &'a [(&'a str, std::path::PathBuf)]) -> Self {
Self { entries }
}
#[must_use]
pub fn get(&self, id: &str) -> Option<&std::path::Path> {
self.entries
.iter()
.find(|(key, _)| *key == id)
.map(|(_, path)| path.as_path())
}
#[must_use]
pub fn arg(&self, id: &str) -> String {
self.get(id)
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default()
}
}
pub static ADAPTERS: &[&dyn Adapter] = &[
&semgrep::Semgrep,
&cargo_audit::CargoAudit,
&osv_scanner::OsvScanner,
];
#[must_use]
pub fn adapter_for(analyzer: &str) -> Option<&'static dyn Adapter> {
ADAPTERS.iter().copied().find(|a| a.analyzer() == analyzer)
}
#[must_use]
pub fn known_analyzers() -> Vec<&'static str> {
let mut ids: Vec<&'static str> = ADAPTERS.iter().map(|a| a.analyzer()).collect();
ids.sort_unstable();
ids
}
pub const NO_SNIPPET: &str = "no-snippet";
#[must_use]
pub fn snippet_hash(snippet: &str) -> String {
crate::sha256_hex(snippet.trim().as_bytes())[..16].to_owned()
}
#[must_use]
pub fn snippet_hash_at(snippets: &dyn SnippetSource, path: &str, start: u32, end: u32) -> String {
snippets
.snippet(path, start, end)
.map_or_else(|| NO_SNIPPET.to_owned(), |text| snippet_hash(&text))
}
#[cfg(test)]
mod tests {
use super::{
AssetPaths, NO_SNIPPET, NativeContext, UNKNOWN_VERSION, adapter_for, known_analyzers,
snippet_hash, snippet_hash_at,
};
use rto_graph::SourceIdentity;
fn ctx(version: Option<&str>) -> NativeContext<'static> {
static SOURCE: std::sync::LazyLock<SourceIdentity> =
std::sync::LazyLock::new(SourceIdentity::default);
NativeContext {
started_at: "2026-08-15T09:00:00Z".to_owned(),
ended_at: "2026-08-15T09:00:04Z".to_owned(),
analyzer_version: version.map(str::to_owned),
exit_status: 0,
source: &SOURCE,
rules_digest: None,
advisory_db: None,
worktree: None,
snippets: &crate::snippet::NoSnippets,
}
}
#[test]
fn the_registry_answers_for_every_analyzer_it_lists() {
for id in known_analyzers() {
assert_eq!(adapter_for(id).expect("registered").analyzer(), id);
}
assert!(adapter_for("no-such-analyzer").is_none());
}
#[test]
fn every_adapter_states_its_coverage() {
for id in known_analyzers() {
let adapter = adapter_for(id).expect("registered");
assert!(!adapter.languages().is_empty(), "{id} claims no language");
assert!(!adapter.summary().is_empty(), "{id} has no summary");
}
}
#[test]
fn a_version_is_taken_from_the_caller_then_the_report_then_unknown() {
assert_eq!(ctx(Some("1.2.3")).version_or(Some("0.0.1")), "1.2.3");
assert_eq!(ctx(None).version_or(Some("0.0.1")), "0.0.1");
assert_eq!(ctx(None).version_or(None), UNKNOWN_VERSION);
assert_eq!(ctx(Some(" ")).version_or(None), UNKNOWN_VERSION);
}
#[test]
fn snippet_hashes_are_short_stable_and_whitespace_insensitive() {
let hash = snippet_hash("eval(user_input)");
assert_eq!(hash.len(), 16);
assert_eq!(hash, snippet_hash(" eval(user_input)\n"));
assert_ne!(hash, snippet_hash("eval(other_input)"));
}
#[test]
fn an_unavailable_snippet_is_named_not_hashed_as_empty() {
let hash = snippet_hash_at(&crate::snippet::NoSnippets, "a.py", 0, 4);
assert_eq!(hash, NO_SNIPPET);
assert_ne!(hash, snippet_hash(""));
}
#[test]
fn asset_paths_resolve_only_what_was_provisioned() {
let entries = [("semgrep-rules", std::path::PathBuf::from("/cache/r.yaml"))];
let paths = AssetPaths::new(&entries);
assert_eq!(paths.arg("semgrep-rules"), "/cache/r.yaml");
assert!(paths.get("advisory-db").is_none());
assert!(paths.arg("advisory-db").is_empty());
}
}