use rusqlite::{Connection, OptionalExtension, params};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::model::Span;
use crate::store::StoreError;
pub const SECURITY_LAYER_PREFIX: &str = "security";
pub const FINDING_KEY_PREFIX: &str = "finding";
pub const MAX_IDENTITY_PART: usize = 512;
pub const MAX_ANALYZER_ID: usize = 64;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum FindingsError {
#[error("{}", analyzer_id_error(.0))]
InvalidAnalyzerId(String),
#[error("invalid worktree id: {0:?} (expected lowercase [a-z0-9-], 1..=64 chars)")]
InvalidWorktreeId(String),
#[error("finding identity is empty: a finding needs at least one identity component")]
EmptyIdentity,
#[error("invalid finding identity component {0:?}")]
InvalidIdentityPart(String),
#[error("malformed finding key: {0}")]
MalformedKey(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RunnerKind {
Ingested,
Subprocess,
Sandboxed,
}
impl RunnerKind {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Ingested => "ingested",
Self::Subprocess => "subprocess",
Self::Sandboxed => "sandboxed",
}
}
#[must_use]
pub fn from_token(s: &str) -> Option<Self> {
match s {
"ingested" => Some(Self::Ingested),
"subprocess" => Some(Self::Subprocess),
"sandboxed" => Some(Self::Sandboxed),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Isolation {
Ingested,
#[serde(rename = "microvm")]
MicroVm,
None,
}
impl Isolation {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Ingested => "ingested",
Self::MicroVm => "microvm",
Self::None => "none",
}
}
#[must_use]
pub fn from_token(s: &str) -> Option<Self> {
match s {
"ingested" => Some(Self::Ingested),
"microvm" => Some(Self::MicroVm),
"none" => Some(Self::None),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum NetworkPolicy {
#[default]
Deny,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorktreeAccess {
#[default]
ReadOnly,
ReadWrite,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EnvironmentPolicy {
#[default]
Scrubbed,
Inherited,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct CommandPolicy {
pub network: NetworkPolicy,
pub worktree: WorktreeAccess,
pub environment: EnvironmentPolicy,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdvisoryDb {
pub digest: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub published_at: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceIdentity {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub commit: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tree: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lockfile_blob: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
Critical,
High,
Medium,
Low,
Info,
Other(String),
}
impl Severity {
#[must_use]
pub fn as_str(&self) -> &str {
match self {
Self::Critical => "critical",
Self::High => "high",
Self::Medium => "medium",
Self::Low => "low",
Self::Info => "info",
Self::Other(s) => s,
}
}
#[must_use]
pub fn from_token(s: &str) -> Self {
match s {
"critical" => Self::Critical,
"high" => Self::High,
"medium" => Self::Medium,
"low" => Self::Low,
"info" => Self::Info,
other => Self::Other(other.to_owned()),
}
}
}
impl Serialize for Severity {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for Severity {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Ok(Self::from_token(&s))
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct WorktreeId(String);
impl WorktreeId {
pub fn new(raw: &str) -> Result<Self, FindingsError> {
let ok = !raw.is_empty()
&& raw.len() <= 64
&& raw
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
if ok {
Ok(Self(raw.to_owned()))
} else {
Err(FindingsError::InvalidWorktreeId(raw.to_owned()))
}
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for WorktreeId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
fn is_analyzer_id_char(c: char) -> bool {
c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-')
}
#[must_use]
pub fn is_valid_analyzer_id(id: &str) -> bool {
!id.is_empty() && id.len() <= MAX_ANALYZER_ID && id.chars().all(is_analyzer_id_char)
}
#[must_use]
pub fn analyzer_id_error(id: &str) -> String {
format!(
"invalid analyzer id {id:?}: {} — an analyzer id is 1 to {MAX_ANALYZER_ID} \
characters of lowercase [a-z0-9._-]",
analyzer_id_rejection(id)
)
}
fn analyzer_id_rejection(id: &str) -> String {
if id.is_empty() {
return "it is empty".to_owned();
}
if let Some(bad) = id.chars().find(|c| !is_analyzer_id_char(*c)) {
return format!("it contains {bad:?}");
}
let length = id.chars().count();
if length > MAX_ANALYZER_ID {
return format!("it is {length} characters, over the {MAX_ANALYZER_ID}-character limit");
}
"it is well-formed".to_owned()
}
pub fn layer_key(analyzer: &str, worktree: &WorktreeId) -> Result<String, FindingsError> {
if !is_valid_analyzer_id(analyzer) {
return Err(FindingsError::InvalidAnalyzerId(analyzer.to_owned()));
}
Ok(format!(
"{SECURITY_LAYER_PREFIX}:{analyzer}:{}",
worktree.as_str()
))
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct FindingKey {
analyzer: String,
parts: Vec<String>,
}
impl FindingKey {
pub fn new<S: AsRef<str>>(analyzer: &str, parts: &[S]) -> Result<Self, FindingsError> {
if !is_valid_analyzer_id(analyzer) {
return Err(FindingsError::InvalidAnalyzerId(analyzer.to_owned()));
}
if parts.is_empty() {
return Err(FindingsError::EmptyIdentity);
}
let mut owned = Vec::with_capacity(parts.len());
for part in parts {
let part = part.as_ref();
if part.is_empty()
|| part.len() > MAX_IDENTITY_PART
|| part.chars().any(char::is_control)
{
return Err(FindingsError::InvalidIdentityPart(part.to_owned()));
}
owned.push(part.to_owned());
}
Ok(Self {
analyzer: analyzer.to_owned(),
parts: owned,
})
}
#[must_use]
pub fn analyzer(&self) -> &str {
&self.analyzer
}
#[must_use]
pub fn parts(&self) -> &[String] {
&self.parts
}
#[must_use]
pub fn render(&self) -> String {
let mut out = String::from(FINDING_KEY_PREFIX);
out.push(':');
push_escaped(&mut out, &self.analyzer);
for part in &self.parts {
out.push(':');
push_escaped(&mut out, part);
}
out
}
pub fn parse(rendered: &str) -> Result<Self, FindingsError> {
let segments = split_escaped(rendered)?;
let mut it = segments.into_iter();
match it.next() {
Some(prefix) if prefix == FINDING_KEY_PREFIX => {}
_ => {
return Err(FindingsError::MalformedKey(format!(
"{rendered:?} does not start with `{FINDING_KEY_PREFIX}:`"
)));
}
}
let analyzer = it.next().ok_or_else(|| {
FindingsError::MalformedKey(format!("{rendered:?} names no analyzer"))
})?;
let parts: Vec<String> = it.collect();
if parts.is_empty() {
return Err(FindingsError::MalformedKey(format!(
"{rendered:?} carries no identity component"
)));
}
Self::new(&analyzer, &parts)
}
}
impl std::fmt::Display for FindingKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.render())
}
}
impl Serialize for FindingKey {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.render())
}
}
impl<'de> Deserialize<'de> for FindingKey {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Self::parse(&s).map_err(serde::de::Error::custom)
}
}
fn push_escaped(out: &mut String, raw: &str) {
for ch in raw.chars() {
if ch == '\\' || ch == ':' {
out.push('\\');
}
out.push(ch);
}
}
fn split_escaped(rendered: &str) -> Result<Vec<String>, FindingsError> {
let mut out = Vec::new();
let mut current = String::new();
let mut chars = rendered.chars();
while let Some(ch) = chars.next() {
match ch {
'\\' => match chars.next() {
Some(escaped) => current.push(escaped),
None => {
return Err(FindingsError::MalformedKey(format!(
"{rendered:?} ends in a dangling escape"
)));
}
},
':' => out.push(std::mem::take(&mut current)),
other => current.push(other),
}
}
out.push(current);
Ok(out)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AnalysisRun {
pub layer: String,
pub analyzer: String,
pub analyzer_version: String,
pub runner: RunnerKind,
pub isolation: Isolation,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_digest: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rules_digest: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub advisory_db: Option<AdvisoryDb>,
pub command_policy: CommandPolicy,
pub source: SourceIdentity,
pub started_at: String,
pub ended_at: String,
pub exit_status: i32,
pub report_digest: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Finding {
pub key: FindingKey,
pub rule: String,
pub severity: Severity,
pub title: String,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub span: Option<Span>,
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
pub meta: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FindingsLayer {
pub run: AnalysisRun,
pub findings: Vec<Finding>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct FindingsApplied {
pub layer: String,
pub findings: usize,
pub removed: usize,
pub replaced: bool,
}
const RUN_COLS: &str = "r.id, r.layer, r.analyzer, r.analyzer_version, r.runner, r.isolation, \
r.image_digest, r.rules_digest, r.advisory_db_digest, r.advisory_db_published_at, \
r.command_policy, r.source_commit, r.source_tree, r.source_lockfile_blob, \
r.started_at, r.ended_at, r.exit_status, r.report_digest";
const FINDING_COLS: &str = "f.key, f.rule, f.severity, f.title, f.message, f.path, \
f.span_start, f.span_end, f.meta";
pub(crate) fn replace_layer(
conn: &Connection,
run: &AnalysisRun,
findings: &[Finding],
) -> Result<FindingsApplied, StoreError> {
let previous: Option<i64> = conn
.query_row(
"SELECT id FROM analysis_runs WHERE layer = ?1",
[&run.layer],
|r| r.get(0),
)
.optional()?;
let mut removed = 0usize;
if let Some(id) = previous {
removed = conn.execute("DELETE FROM findings WHERE run_id = ?1", [id])?;
conn.execute("DELETE FROM analysis_runs WHERE id = ?1", [id])?;
}
let policy = serde_json::to_string(&run.command_policy)?;
let (advisory_digest, advisory_published) = match &run.advisory_db {
Some(db) => (Some(db.digest.as_str()), db.published_at.as_deref()),
None => (None, None),
};
conn.execute(
"INSERT INTO analysis_runs (
layer, analyzer, analyzer_version, runner, isolation, image_digest,
rules_digest, advisory_db_digest, advisory_db_published_at, command_policy,
source_commit, source_tree, source_lockfile_blob, started_at, ended_at,
exit_status, report_digest
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
params![
run.layer,
run.analyzer,
run.analyzer_version,
run.runner.as_str(),
run.isolation.as_str(),
run.image_digest,
run.rules_digest,
advisory_digest,
advisory_published,
policy,
run.source.commit,
run.source.tree,
run.source.lockfile_blob,
run.started_at,
run.ended_at,
run.exit_status,
run.report_digest,
],
)?;
let run_id = conn.last_insert_rowid();
for finding in findings {
let span = finding.span.map(|s| (s.start, s.end));
conn.execute(
"INSERT INTO findings (
run_id, key, rule, severity, title, message, path, span_start, span_end, meta
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
params![
run_id,
finding.key.render(),
finding.rule,
finding.severity.as_str(),
finding.title,
finding.message,
finding.path,
span.map(|(start, _)| start),
span.map(|(_, end)| end),
serde_json::to_string(&finding.meta)?,
],
)?;
}
Ok(FindingsApplied {
layer: run.layer.clone(),
findings: findings.len(),
removed,
replaced: previous.is_some(),
})
}
pub(crate) fn delete_layer(conn: &Connection, layer: &str) -> Result<Option<usize>, StoreError> {
let Some(id): Option<i64> = conn
.query_row(
"SELECT id FROM analysis_runs WHERE layer = ?1",
[layer],
|r| r.get(0),
)
.optional()?
else {
return Ok(None);
};
let removed = conn.execute("DELETE FROM findings WHERE run_id = ?1", [id])?;
conn.execute("DELETE FROM analysis_runs WHERE id = ?1", [id])?;
Ok(Some(removed))
}
pub(crate) fn layers(
conn: &Connection,
analyzer: Option<&str>,
) -> Result<Vec<FindingsLayer>, StoreError> {
let (sql, bound): (String, Vec<&str>) = match analyzer {
Some(a) => (
format!(
"SELECT {RUN_COLS} FROM analysis_runs r WHERE r.analyzer = ?1 ORDER BY r.layer"
),
vec![a],
),
None => (
format!("SELECT {RUN_COLS} FROM analysis_runs r ORDER BY r.layer"),
Vec::new(),
),
};
let mut stmt = conn.prepare(&sql)?;
let mut rows = stmt.query(rusqlite::params_from_iter(bound))?;
let mut runs = Vec::new();
while let Some(row) = rows.next()? {
runs.push(run_from_row(row)?);
}
let mut out = Vec::with_capacity(runs.len());
for (id, run) in runs {
out.push(FindingsLayer {
findings: findings_for_run(conn, id)?,
run,
});
}
Ok(out)
}
fn findings_for_run(conn: &Connection, run_id: i64) -> Result<Vec<Finding>, StoreError> {
let sql = format!("SELECT {FINDING_COLS} FROM findings f WHERE f.run_id = ?1 ORDER BY f.key");
let mut stmt = conn.prepare(&sql)?;
let mut rows = stmt.query([run_id])?;
let mut out = Vec::new();
while let Some(row) = rows.next()? {
out.push(finding_from_row(row)?);
}
Ok(out)
}
pub(crate) fn count_findings(conn: &Connection) -> Result<u64, StoreError> {
let n: i64 = conn.query_row("SELECT COUNT(*) FROM findings", [], |r| r.get(0))?;
Ok(u64::try_from(n).unwrap_or(0))
}
pub(crate) fn count_runs(conn: &Connection) -> Result<u64, StoreError> {
let n: i64 = conn.query_row("SELECT COUNT(*) FROM analysis_runs", [], |r| r.get(0))?;
Ok(u64::try_from(n).unwrap_or(0))
}
pub(crate) fn count_orphan_findings(conn: &Connection) -> Result<u64, StoreError> {
let n: i64 = conn.query_row(
"SELECT COUNT(*) FROM findings f
WHERE NOT EXISTS (SELECT 1 FROM analysis_runs r WHERE r.id = f.run_id)",
[],
|r| r.get(0),
)?;
Ok(u64::try_from(n).unwrap_or(0))
}
fn run_from_row(row: &rusqlite::Row<'_>) -> Result<(i64, AnalysisRun), StoreError> {
let id: i64 = row.get(0)?;
let runner_token: String = row.get(4)?;
let runner = RunnerKind::from_token(&runner_token)
.ok_or_else(|| StoreError::Corrupt(format!("unknown runner kind: {runner_token}")))?;
let isolation_token: String = row.get(5)?;
let isolation = Isolation::from_token(&isolation_token)
.ok_or_else(|| StoreError::Corrupt(format!("unknown isolation: {isolation_token}")))?;
let advisory_digest: Option<String> = row.get(8)?;
let advisory_published: Option<String> = row.get(9)?;
let policy_json: String = row.get(10)?;
let run = AnalysisRun {
layer: row.get(1)?,
analyzer: row.get(2)?,
analyzer_version: row.get(3)?,
runner,
isolation,
image_digest: row.get(6)?,
rules_digest: row.get(7)?,
advisory_db: advisory_digest.map(|digest| AdvisoryDb {
digest,
published_at: advisory_published,
}),
command_policy: serde_json::from_str(&policy_json)?,
source: SourceIdentity {
commit: row.get(11)?,
tree: row.get(12)?,
lockfile_blob: row.get(13)?,
},
started_at: row.get(14)?,
ended_at: row.get(15)?,
exit_status: row.get(16)?,
report_digest: row.get(17)?,
};
Ok((id, run))
}
fn finding_from_row(row: &rusqlite::Row<'_>) -> Result<Finding, StoreError> {
let key_text: String = row.get(0)?;
let key = FindingKey::parse(&key_text)
.map_err(|e| StoreError::Corrupt(format!("stored finding key: {e}")))?;
let severity_token: String = row.get(2)?;
let span_start: Option<u32> = row.get(6)?;
let span_end: Option<u32> = row.get(7)?;
let meta_json: String = row.get(8)?;
Ok(Finding {
key,
rule: row.get(1)?,
severity: Severity::from_token(&severity_token),
title: row.get(3)?,
message: row.get(4)?,
path: row.get(5)?,
span: match (span_start, span_end) {
(Some(start), Some(end)) => Some(Span::new(start, end)),
_ => None,
},
meta: serde_json::from_str(&meta_json)?,
})
}
#[cfg(test)]
mod tests {
use super::{
AdvisoryDb, CommandPolicy, EnvironmentPolicy, FindingKey, FindingsError, Isolation,
MAX_ANALYZER_ID, MAX_IDENTITY_PART, NetworkPolicy, RunnerKind, Severity, WorktreeAccess,
WorktreeId, analyzer_id_error, is_valid_analyzer_id, layer_key,
};
#[test]
fn renders_the_documented_analyzer_keys() {
let semgrep = FindingKey::new(
"semgrep",
&["rules.rust.unsafe", "src/lib.rs", "1024", "9f8e7d"],
)
.expect("key");
assert_eq!(
semgrep.render(),
"finding:semgrep:rules.rust.unsafe:src/lib.rs:1024:9f8e7d"
);
let audit = FindingKey::new(
"cargo-audit",
&["RUSTSEC-2024-0001", "openssl", "0.10.5", "abc123"],
)
.expect("key");
assert_eq!(
audit.render(),
"finding:cargo-audit:RUSTSEC-2024-0001:openssl:0.10.5:abc123"
);
}
#[test]
fn key_round_trips_including_components_containing_colons() {
let key = FindingKey::new("semgrep", &["a:b", "C:\\src\\x.rs", "7", "deadbeef"])
.expect("build key");
let rendered = key.render();
assert_eq!(FindingKey::parse(&rendered).expect("parse"), key);
assert_eq!(key.analyzer(), "semgrep");
assert_eq!(key.parts().len(), 4);
let a = FindingKey::new("semgrep", &["x:y", "z"]).expect("a");
let b = FindingKey::new("semgrep", &["x", "y:z"]).expect("b");
assert_ne!(a.render(), b.render());
}
#[test]
fn key_rejects_ill_formed_identities() {
assert_eq!(
FindingKey::new("Semgrep", &["x"]),
Err(FindingsError::InvalidAnalyzerId("Semgrep".to_owned()))
);
let empty: [&str; 0] = [];
assert_eq!(
FindingKey::new("semgrep", &empty),
Err(FindingsError::EmptyIdentity)
);
assert_eq!(
FindingKey::new("semgrep", &[""]),
Err(FindingsError::InvalidIdentityPart(String::new()))
);
let long = "x".repeat(MAX_IDENTITY_PART + 1);
assert!(matches!(
FindingKey::new("semgrep", &[long.as_str()]),
Err(FindingsError::InvalidIdentityPart(_))
));
assert!(matches!(
FindingKey::new("semgrep", &["a\nb"]),
Err(FindingsError::InvalidIdentityPart(_))
));
}
#[test]
fn key_parse_rejects_malformed_strings() {
for bad in [
"notafinding:semgrep:x",
"finding:semgrep",
"finding",
"finding:semgrep:x\\",
] {
assert!(
matches!(FindingKey::parse(bad), Err(FindingsError::MalformedKey(_))),
"{bad:?} should be rejected"
);
}
}
#[test]
fn key_serializes_as_its_rendered_string() {
let key = FindingKey::new("semgrep", &["r", "p", "1", "h"]).expect("key");
let json = serde_json::to_string(&key).expect("serialize");
assert_eq!(json, "\"finding:semgrep:r:p:1:h\"");
let back: FindingKey = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, key);
assert!(serde_json::from_str::<FindingKey>("\"nope\"").is_err());
}
#[test]
fn layer_keys_are_analyzer_and_worktree_scoped() {
let wt = WorktreeId::new("ab12cd34").expect("worktree id");
assert_eq!(
layer_key("cargo-audit", &wt).expect("layer"),
"security:cargo-audit:ab12cd34"
);
assert!(matches!(
layer_key("Cargo Audit", &wt),
Err(FindingsError::InvalidAnalyzerId(_))
));
}
#[test]
fn worktree_ids_are_validated() {
assert_eq!(WorktreeId::new("a1-b2").expect("ok").as_str(), "a1-b2");
for bad in ["", "Upper", "has space", &"x".repeat(65)] {
assert!(
matches!(
WorktreeId::new(bad),
Err(FindingsError::InvalidWorktreeId(_))
),
"{bad:?} should be rejected"
);
}
}
#[test]
fn analyzer_ids_accept_the_real_tool_names_and_reject_separators() {
assert!(is_valid_analyzer_id("cargo-audit"));
assert!(is_valid_analyzer_id("semgrep"));
assert!(is_valid_analyzer_id("trivy.fs"));
assert!(!is_valid_analyzer_id(""));
assert!(!is_valid_analyzer_id("a:b"));
assert!(is_valid_analyzer_id(&"a".repeat(MAX_ANALYZER_ID)));
assert!(!is_valid_analyzer_id(&"a".repeat(MAX_ANALYZER_ID + 1)));
}
#[test]
fn a_rejected_analyzer_id_says_which_rule_it_broke() {
let contract = "an analyzer id is 1 to 64 characters of lowercase [a-z0-9._-]";
let empty = analyzer_id_error("");
assert_eq!(
empty,
format!("invalid analyzer id \"\": it is empty — {contract}")
);
let cased = analyzer_id_error("Semgrep");
assert_eq!(
cased,
format!("invalid analyzer id \"Semgrep\": it contains 'S' — {contract}")
);
let long = "a".repeat(MAX_ANALYZER_ID + 13);
let over = analyzer_id_error(&long);
assert!(
over.contains("it is 77 characters, over the 64-character limit"),
"a too-long id must be told about the length rule, got: {over}"
);
assert!(over.contains(contract), "and the whole contract: {over}");
let wide = analyzer_id_error(&"é".repeat(MAX_ANALYZER_ID + 1));
assert!(wide.contains("it contains 'é'"), "got: {wide}");
}
#[test]
fn the_error_variant_renders_the_shared_message() {
let err = FindingsError::InvalidAnalyzerId("Semgrep".to_owned());
assert_eq!(err.to_string(), analyzer_id_error("Semgrep"));
let long = "a".repeat(MAX_ANALYZER_ID + 1);
let err = layer_key(&long, &WorktreeId::new("ab12").expect("worktree"))
.expect_err("a too-long analyzer id must be refused");
assert_eq!(err.to_string(), analyzer_id_error(&long));
assert!(
err.to_string().contains("over the 64-character limit"),
"the rejection must name the length rule: {err}"
);
}
#[test]
fn stable_tokens_round_trip() {
for r in [
RunnerKind::Ingested,
RunnerKind::Subprocess,
RunnerKind::Sandboxed,
] {
assert_eq!(RunnerKind::from_token(r.as_str()), Some(r));
}
assert_eq!(RunnerKind::from_token("nope"), None);
for i in [Isolation::Ingested, Isolation::MicroVm, Isolation::None] {
assert_eq!(Isolation::from_token(i.as_str()), Some(i));
}
assert_eq!(Isolation::from_token("nope"), None);
for s in [
Severity::Critical,
Severity::High,
Severity::Medium,
Severity::Low,
Severity::Info,
] {
assert_eq!(Severity::from_token(s.as_str()), s);
}
assert_eq!(
Severity::from_token("moderate"),
Severity::Other("moderate".to_owned())
);
}
#[test]
fn the_default_command_policy_is_the_locked_down_one() {
let policy = CommandPolicy::default();
assert_eq!(policy.network, NetworkPolicy::Deny);
assert_eq!(policy.worktree, WorktreeAccess::ReadOnly);
assert_eq!(policy.environment, EnvironmentPolicy::Scrubbed);
let json = serde_json::to_string(&policy).expect("serialize");
assert_eq!(
serde_json::from_str::<CommandPolicy>(&json).expect("deserialize"),
policy
);
}
#[test]
fn advisory_db_publication_date_is_optional_but_preserved() {
let db = AdvisoryDb {
digest: "abc".to_owned(),
published_at: Some("2026-08-01T00:00:00Z".to_owned()),
};
let json = serde_json::to_string(&db).expect("serialize");
assert_eq!(
serde_json::from_str::<AdvisoryDb>(&json).expect("deserialize"),
db
);
let bare: AdvisoryDb = serde_json::from_str(r#"{"digest":"abc"}"#).expect("bare");
assert_eq!(bare.published_at, None);
}
}