1use rusqlite::{Connection, OptionalExtension, params};
35use serde::{Deserialize, Deserializer, Serialize, Serializer};
36
37use crate::model::Span;
38use crate::store::StoreError;
39
40pub const SECURITY_LAYER_PREFIX: &str = "security";
42
43pub const FINDING_KEY_PREFIX: &str = "finding";
45
46pub const MAX_IDENTITY_PART: usize = 512;
50
51pub const MAX_ANALYZER_ID: usize = 64;
60
61#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
67pub enum FindingsError {
68 #[error("{}", analyzer_id_error(.0))]
73 InvalidAnalyzerId(String),
74 #[error("invalid worktree id: {0:?} (expected lowercase [a-z0-9-], 1..=64 chars)")]
76 InvalidWorktreeId(String),
77 #[error("finding identity is empty: a finding needs at least one identity component")]
80 EmptyIdentity,
81 #[error("invalid finding identity component {0:?}")]
84 InvalidIdentityPart(String),
85 #[error("malformed finding key: {0}")]
88 MalformedKey(String),
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(rename_all = "lowercase")]
97pub enum RunnerKind {
98 Ingested,
101 Subprocess,
103 Sandboxed,
106}
107
108impl RunnerKind {
109 #[must_use]
111 pub fn as_str(self) -> &'static str {
112 match self {
113 Self::Ingested => "ingested",
114 Self::Subprocess => "subprocess",
115 Self::Sandboxed => "sandboxed",
116 }
117 }
118
119 #[must_use]
122 pub fn from_token(s: &str) -> Option<Self> {
123 match s {
124 "ingested" => Some(Self::Ingested),
125 "subprocess" => Some(Self::Subprocess),
126 "sandboxed" => Some(Self::Sandboxed),
127 _ => None,
128 }
129 }
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(rename_all = "lowercase")]
136pub enum Isolation {
137 Ingested,
139 #[serde(rename = "microvm")]
141 MicroVm,
142 None,
144}
145
146impl Isolation {
147 #[must_use]
149 pub fn as_str(self) -> &'static str {
150 match self {
151 Self::Ingested => "ingested",
152 Self::MicroVm => "microvm",
153 Self::None => "none",
154 }
155 }
156
157 #[must_use]
160 pub fn from_token(s: &str) -> Option<Self> {
161 match s {
162 "ingested" => Some(Self::Ingested),
163 "microvm" => Some(Self::MicroVm),
164 "none" => Some(Self::None),
165 _ => None,
166 }
167 }
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
177#[serde(rename_all = "lowercase")]
178#[non_exhaustive]
179pub enum NetworkPolicy {
180 #[default]
182 Deny,
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
187#[serde(rename_all = "snake_case")]
188pub enum WorktreeAccess {
189 #[default]
192 ReadOnly,
193 ReadWrite,
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
199#[serde(rename_all = "snake_case")]
200pub enum EnvironmentPolicy {
201 #[default]
203 Scrubbed,
204 Inherited,
206}
207
208#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
211pub struct CommandPolicy {
212 pub network: NetworkPolicy,
214 pub worktree: WorktreeAccess,
216 pub environment: EnvironmentPolicy,
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
226pub struct AdvisoryDb {
227 pub digest: String,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub published_at: Option<String>,
232}
233
234#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
240pub struct SourceIdentity {
241 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub commit: Option<String>,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub tree: Option<String>,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub lockfile_blob: Option<String>,
250}
251
252#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
259pub enum Severity {
260 Critical,
262 High,
264 Medium,
266 Low,
268 Info,
270 Other(String),
272}
273
274impl Severity {
275 #[must_use]
277 pub fn as_str(&self) -> &str {
278 match self {
279 Self::Critical => "critical",
280 Self::High => "high",
281 Self::Medium => "medium",
282 Self::Low => "low",
283 Self::Info => "info",
284 Self::Other(s) => s,
285 }
286 }
287
288 #[must_use]
291 pub fn from_token(s: &str) -> Self {
292 match s {
293 "critical" => Self::Critical,
294 "high" => Self::High,
295 "medium" => Self::Medium,
296 "low" => Self::Low,
297 "info" => Self::Info,
298 other => Self::Other(other.to_owned()),
299 }
300 }
301}
302
303impl Serialize for Severity {
306 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
307 serializer.serialize_str(self.as_str())
308 }
309}
310
311impl<'de> Deserialize<'de> for Severity {
312 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
313 let s = String::deserialize(deserializer)?;
314 Ok(Self::from_token(&s))
315 }
316}
317
318#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
326pub struct WorktreeId(String);
327
328impl WorktreeId {
329 pub fn new(raw: &str) -> Result<Self, FindingsError> {
335 let ok = !raw.is_empty()
336 && raw.len() <= 64
337 && raw
338 .bytes()
339 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
340 if ok {
341 Ok(Self(raw.to_owned()))
342 } else {
343 Err(FindingsError::InvalidWorktreeId(raw.to_owned()))
344 }
345 }
346
347 #[must_use]
349 pub fn as_str(&self) -> &str {
350 &self.0
351 }
352}
353
354impl std::fmt::Display for WorktreeId {
355 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
356 f.write_str(&self.0)
357 }
358}
359
360fn is_analyzer_id_char(c: char) -> bool {
362 c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-')
363}
364
365#[must_use]
371pub fn is_valid_analyzer_id(id: &str) -> bool {
372 !id.is_empty() && id.len() <= MAX_ANALYZER_ID && id.chars().all(is_analyzer_id_char)
373}
374
375#[must_use]
393pub fn analyzer_id_error(id: &str) -> String {
394 format!(
395 "invalid analyzer id {id:?}: {} — an analyzer id is 1 to {MAX_ANALYZER_ID} \
396 characters of lowercase [a-z0-9._-]",
397 analyzer_id_rejection(id)
398 )
399}
400
401fn analyzer_id_rejection(id: &str) -> String {
405 if id.is_empty() {
406 return "it is empty".to_owned();
407 }
408 if let Some(bad) = id.chars().find(|c| !is_analyzer_id_char(*c)) {
409 return format!("it contains {bad:?}");
410 }
411 let length = id.chars().count();
412 if length > MAX_ANALYZER_ID {
413 return format!("it is {length} characters, over the {MAX_ANALYZER_ID}-character limit");
414 }
415 "it is well-formed".to_owned()
418}
419
420pub fn layer_key(analyzer: &str, worktree: &WorktreeId) -> Result<String, FindingsError> {
431 if !is_valid_analyzer_id(analyzer) {
432 return Err(FindingsError::InvalidAnalyzerId(analyzer.to_owned()));
433 }
434 Ok(format!(
435 "{SECURITY_LAYER_PREFIX}:{analyzer}:{}",
436 worktree.as_str()
437 ))
438}
439
440#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
457pub struct FindingKey {
458 analyzer: String,
459 parts: Vec<String>,
460}
461
462impl FindingKey {
463 pub fn new<S: AsRef<str>>(analyzer: &str, parts: &[S]) -> Result<Self, FindingsError> {
472 if !is_valid_analyzer_id(analyzer) {
473 return Err(FindingsError::InvalidAnalyzerId(analyzer.to_owned()));
474 }
475 if parts.is_empty() {
476 return Err(FindingsError::EmptyIdentity);
477 }
478 let mut owned = Vec::with_capacity(parts.len());
479 for part in parts {
480 let part = part.as_ref();
481 if part.is_empty()
482 || part.len() > MAX_IDENTITY_PART
483 || part.chars().any(char::is_control)
484 {
485 return Err(FindingsError::InvalidIdentityPart(part.to_owned()));
486 }
487 owned.push(part.to_owned());
488 }
489 Ok(Self {
490 analyzer: analyzer.to_owned(),
491 parts: owned,
492 })
493 }
494
495 #[must_use]
497 pub fn analyzer(&self) -> &str {
498 &self.analyzer
499 }
500
501 #[must_use]
503 pub fn parts(&self) -> &[String] {
504 &self.parts
505 }
506
507 #[must_use]
509 pub fn render(&self) -> String {
510 let mut out = String::from(FINDING_KEY_PREFIX);
511 out.push(':');
512 push_escaped(&mut out, &self.analyzer);
513 for part in &self.parts {
514 out.push(':');
515 push_escaped(&mut out, part);
516 }
517 out
518 }
519
520 pub fn parse(rendered: &str) -> Result<Self, FindingsError> {
528 let segments = split_escaped(rendered)?;
529 let mut it = segments.into_iter();
530 match it.next() {
531 Some(prefix) if prefix == FINDING_KEY_PREFIX => {}
532 _ => {
533 return Err(FindingsError::MalformedKey(format!(
534 "{rendered:?} does not start with `{FINDING_KEY_PREFIX}:`"
535 )));
536 }
537 }
538 let analyzer = it.next().ok_or_else(|| {
539 FindingsError::MalformedKey(format!("{rendered:?} names no analyzer"))
540 })?;
541 let parts: Vec<String> = it.collect();
542 if parts.is_empty() {
543 return Err(FindingsError::MalformedKey(format!(
544 "{rendered:?} carries no identity component"
545 )));
546 }
547 Self::new(&analyzer, &parts)
548 }
549}
550
551impl std::fmt::Display for FindingKey {
552 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
553 f.write_str(&self.render())
554 }
555}
556
557impl Serialize for FindingKey {
560 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
561 serializer.serialize_str(&self.render())
562 }
563}
564
565impl<'de> Deserialize<'de> for FindingKey {
566 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
567 let s = String::deserialize(deserializer)?;
568 Self::parse(&s).map_err(serde::de::Error::custom)
569 }
570}
571
572fn push_escaped(out: &mut String, raw: &str) {
575 for ch in raw.chars() {
576 if ch == '\\' || ch == ':' {
577 out.push('\\');
578 }
579 out.push(ch);
580 }
581}
582
583fn split_escaped(rendered: &str) -> Result<Vec<String>, FindingsError> {
585 let mut out = Vec::new();
586 let mut current = String::new();
587 let mut chars = rendered.chars();
588 while let Some(ch) = chars.next() {
589 match ch {
590 '\\' => match chars.next() {
591 Some(escaped) => current.push(escaped),
592 None => {
593 return Err(FindingsError::MalformedKey(format!(
594 "{rendered:?} ends in a dangling escape"
595 )));
596 }
597 },
598 ':' => out.push(std::mem::take(&mut current)),
599 other => current.push(other),
600 }
601 }
602 out.push(current);
603 Ok(out)
604}
605
606#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
612pub struct AnalysisRun {
613 pub layer: String,
616 pub analyzer: String,
618 pub analyzer_version: String,
620 pub runner: RunnerKind,
622 pub isolation: Isolation,
624 #[serde(default, skip_serializing_if = "Option::is_none")]
626 pub image_digest: Option<String>,
627 #[serde(default, skip_serializing_if = "Option::is_none")]
629 pub rules_digest: Option<String>,
630 #[serde(default, skip_serializing_if = "Option::is_none")]
632 pub advisory_db: Option<AdvisoryDb>,
633 pub command_policy: CommandPolicy,
635 pub source: SourceIdentity,
637 pub started_at: String,
639 pub ended_at: String,
641 pub exit_status: i32,
643 pub report_digest: String,
646}
647
648#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
650pub struct Finding {
651 pub key: FindingKey,
653 pub rule: String,
655 pub severity: Severity,
657 pub title: String,
659 pub message: String,
661 #[serde(default, skip_serializing_if = "Option::is_none")]
663 pub path: Option<String>,
664 #[serde(default, skip_serializing_if = "Option::is_none")]
666 pub span: Option<Span>,
667 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
669 pub meta: serde_json::Value,
670}
671
672#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
674pub struct FindingsLayer {
675 pub run: AnalysisRun,
677 pub findings: Vec<Finding>,
679}
680
681#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
684pub struct FindingsApplied {
685 pub layer: String,
687 pub findings: usize,
689 pub removed: usize,
692 pub replaced: bool,
694}
695
696const RUN_COLS: &str = "r.id, r.layer, r.analyzer, r.analyzer_version, r.runner, r.isolation, \
703 r.image_digest, r.rules_digest, r.advisory_db_digest, r.advisory_db_published_at, \
704 r.command_policy, r.source_commit, r.source_tree, r.source_lockfile_blob, \
705 r.started_at, r.ended_at, r.exit_status, r.report_digest";
706
707const FINDING_COLS: &str = "f.key, f.rule, f.severity, f.title, f.message, f.path, \
709 f.span_start, f.span_end, f.meta";
710
711pub(crate) fn replace_layer(
714 conn: &Connection,
715 run: &AnalysisRun,
716 findings: &[Finding],
717) -> Result<FindingsApplied, StoreError> {
718 let previous: Option<i64> = conn
719 .query_row(
720 "SELECT id FROM analysis_runs WHERE layer = ?1",
721 [&run.layer],
722 |r| r.get(0),
723 )
724 .optional()?;
725
726 let mut removed = 0usize;
733 if let Some(id) = previous {
734 removed = conn.execute("DELETE FROM findings WHERE run_id = ?1", [id])?;
735 conn.execute("DELETE FROM analysis_runs WHERE id = ?1", [id])?;
736 }
737
738 let policy = serde_json::to_string(&run.command_policy)?;
739 let (advisory_digest, advisory_published) = match &run.advisory_db {
740 Some(db) => (Some(db.digest.as_str()), db.published_at.as_deref()),
741 None => (None, None),
742 };
743 conn.execute(
744 "INSERT INTO analysis_runs (
745 layer, analyzer, analyzer_version, runner, isolation, image_digest,
746 rules_digest, advisory_db_digest, advisory_db_published_at, command_policy,
747 source_commit, source_tree, source_lockfile_blob, started_at, ended_at,
748 exit_status, report_digest
749 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
750 params![
751 run.layer,
752 run.analyzer,
753 run.analyzer_version,
754 run.runner.as_str(),
755 run.isolation.as_str(),
756 run.image_digest,
757 run.rules_digest,
758 advisory_digest,
759 advisory_published,
760 policy,
761 run.source.commit,
762 run.source.tree,
763 run.source.lockfile_blob,
764 run.started_at,
765 run.ended_at,
766 run.exit_status,
767 run.report_digest,
768 ],
769 )?;
770 let run_id = conn.last_insert_rowid();
771
772 for finding in findings {
773 let span = finding.span.map(|s| (s.start, s.end));
774 conn.execute(
775 "INSERT INTO findings (
776 run_id, key, rule, severity, title, message, path, span_start, span_end, meta
777 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
778 params![
779 run_id,
780 finding.key.render(),
781 finding.rule,
782 finding.severity.as_str(),
783 finding.title,
784 finding.message,
785 finding.path,
786 span.map(|(start, _)| start),
787 span.map(|(_, end)| end),
788 serde_json::to_string(&finding.meta)?,
789 ],
790 )?;
791 }
792
793 Ok(FindingsApplied {
794 layer: run.layer.clone(),
795 findings: findings.len(),
796 removed,
797 replaced: previous.is_some(),
798 })
799}
800
801pub(crate) fn delete_layer(conn: &Connection, layer: &str) -> Result<Option<usize>, StoreError> {
804 let Some(id): Option<i64> = conn
805 .query_row(
806 "SELECT id FROM analysis_runs WHERE layer = ?1",
807 [layer],
808 |r| r.get(0),
809 )
810 .optional()?
811 else {
812 return Ok(None);
813 };
814 let removed = conn.execute("DELETE FROM findings WHERE run_id = ?1", [id])?;
816 conn.execute("DELETE FROM analysis_runs WHERE id = ?1", [id])?;
817 Ok(Some(removed))
818}
819
820pub(crate) fn layers(
822 conn: &Connection,
823 analyzer: Option<&str>,
824) -> Result<Vec<FindingsLayer>, StoreError> {
825 let (sql, bound): (String, Vec<&str>) = match analyzer {
826 Some(a) => (
827 format!(
828 "SELECT {RUN_COLS} FROM analysis_runs r WHERE r.analyzer = ?1 ORDER BY r.layer"
829 ),
830 vec![a],
831 ),
832 None => (
833 format!("SELECT {RUN_COLS} FROM analysis_runs r ORDER BY r.layer"),
834 Vec::new(),
835 ),
836 };
837 let mut stmt = conn.prepare(&sql)?;
838 let mut rows = stmt.query(rusqlite::params_from_iter(bound))?;
839 let mut runs = Vec::new();
840 while let Some(row) = rows.next()? {
841 runs.push(run_from_row(row)?);
842 }
843 let mut out = Vec::with_capacity(runs.len());
844 for (id, run) in runs {
845 out.push(FindingsLayer {
846 findings: findings_for_run(conn, id)?,
847 run,
848 });
849 }
850 Ok(out)
851}
852
853fn findings_for_run(conn: &Connection, run_id: i64) -> Result<Vec<Finding>, StoreError> {
855 let sql = format!("SELECT {FINDING_COLS} FROM findings f WHERE f.run_id = ?1 ORDER BY f.key");
856 let mut stmt = conn.prepare(&sql)?;
857 let mut rows = stmt.query([run_id])?;
858 let mut out = Vec::new();
859 while let Some(row) = rows.next()? {
860 out.push(finding_from_row(row)?);
861 }
862 Ok(out)
863}
864
865pub(crate) fn count_findings(conn: &Connection) -> Result<u64, StoreError> {
867 let n: i64 = conn.query_row("SELECT COUNT(*) FROM findings", [], |r| r.get(0))?;
868 Ok(u64::try_from(n).unwrap_or(0))
869}
870
871pub(crate) fn count_runs(conn: &Connection) -> Result<u64, StoreError> {
873 let n: i64 = conn.query_row("SELECT COUNT(*) FROM analysis_runs", [], |r| r.get(0))?;
874 Ok(u64::try_from(n).unwrap_or(0))
875}
876
877pub(crate) fn count_orphan_findings(conn: &Connection) -> Result<u64, StoreError> {
881 let n: i64 = conn.query_row(
882 "SELECT COUNT(*) FROM findings f
883 WHERE NOT EXISTS (SELECT 1 FROM analysis_runs r WHERE r.id = f.run_id)",
884 [],
885 |r| r.get(0),
886 )?;
887 Ok(u64::try_from(n).unwrap_or(0))
888}
889
890fn run_from_row(row: &rusqlite::Row<'_>) -> Result<(i64, AnalysisRun), StoreError> {
892 let id: i64 = row.get(0)?;
893 let runner_token: String = row.get(4)?;
894 let runner = RunnerKind::from_token(&runner_token)
895 .ok_or_else(|| StoreError::Corrupt(format!("unknown runner kind: {runner_token}")))?;
896 let isolation_token: String = row.get(5)?;
897 let isolation = Isolation::from_token(&isolation_token)
898 .ok_or_else(|| StoreError::Corrupt(format!("unknown isolation: {isolation_token}")))?;
899 let advisory_digest: Option<String> = row.get(8)?;
900 let advisory_published: Option<String> = row.get(9)?;
901 let policy_json: String = row.get(10)?;
902 let run = AnalysisRun {
903 layer: row.get(1)?,
904 analyzer: row.get(2)?,
905 analyzer_version: row.get(3)?,
906 runner,
907 isolation,
908 image_digest: row.get(6)?,
909 rules_digest: row.get(7)?,
910 advisory_db: advisory_digest.map(|digest| AdvisoryDb {
911 digest,
912 published_at: advisory_published,
913 }),
914 command_policy: serde_json::from_str(&policy_json)?,
915 source: SourceIdentity {
916 commit: row.get(11)?,
917 tree: row.get(12)?,
918 lockfile_blob: row.get(13)?,
919 },
920 started_at: row.get(14)?,
921 ended_at: row.get(15)?,
922 exit_status: row.get(16)?,
923 report_digest: row.get(17)?,
924 };
925 Ok((id, run))
926}
927
928fn finding_from_row(row: &rusqlite::Row<'_>) -> Result<Finding, StoreError> {
930 let key_text: String = row.get(0)?;
931 let key = FindingKey::parse(&key_text)
932 .map_err(|e| StoreError::Corrupt(format!("stored finding key: {e}")))?;
933 let severity_token: String = row.get(2)?;
934 let span_start: Option<u32> = row.get(6)?;
935 let span_end: Option<u32> = row.get(7)?;
936 let meta_json: String = row.get(8)?;
937 Ok(Finding {
938 key,
939 rule: row.get(1)?,
940 severity: Severity::from_token(&severity_token),
941 title: row.get(3)?,
942 message: row.get(4)?,
943 path: row.get(5)?,
944 span: match (span_start, span_end) {
945 (Some(start), Some(end)) => Some(Span::new(start, end)),
946 _ => None,
950 },
951 meta: serde_json::from_str(&meta_json)?,
952 })
953}
954
955#[cfg(test)]
956mod tests {
957 use super::{
958 AdvisoryDb, CommandPolicy, EnvironmentPolicy, FindingKey, FindingsError, Isolation,
959 MAX_ANALYZER_ID, MAX_IDENTITY_PART, NetworkPolicy, RunnerKind, Severity, WorktreeAccess,
960 WorktreeId, analyzer_id_error, is_valid_analyzer_id, layer_key,
961 };
962
963 #[test]
964 fn renders_the_documented_analyzer_keys() {
965 let semgrep = FindingKey::new(
966 "semgrep",
967 &["rules.rust.unsafe", "src/lib.rs", "1024", "9f8e7d"],
968 )
969 .expect("key");
970 assert_eq!(
971 semgrep.render(),
972 "finding:semgrep:rules.rust.unsafe:src/lib.rs:1024:9f8e7d"
973 );
974
975 let audit = FindingKey::new(
976 "cargo-audit",
977 &["RUSTSEC-2024-0001", "openssl", "0.10.5", "abc123"],
978 )
979 .expect("key");
980 assert_eq!(
981 audit.render(),
982 "finding:cargo-audit:RUSTSEC-2024-0001:openssl:0.10.5:abc123"
983 );
984 }
985
986 #[test]
987 fn key_round_trips_including_components_containing_colons() {
988 let key = FindingKey::new("semgrep", &["a:b", "C:\\src\\x.rs", "7", "deadbeef"])
992 .expect("build key");
993 let rendered = key.render();
994 assert_eq!(FindingKey::parse(&rendered).expect("parse"), key);
995 assert_eq!(key.analyzer(), "semgrep");
996 assert_eq!(key.parts().len(), 4);
997
998 let a = FindingKey::new("semgrep", &["x:y", "z"]).expect("a");
1000 let b = FindingKey::new("semgrep", &["x", "y:z"]).expect("b");
1001 assert_ne!(a.render(), b.render());
1002 }
1003
1004 #[test]
1005 fn key_rejects_ill_formed_identities() {
1006 assert_eq!(
1007 FindingKey::new("Semgrep", &["x"]),
1008 Err(FindingsError::InvalidAnalyzerId("Semgrep".to_owned()))
1009 );
1010 let empty: [&str; 0] = [];
1011 assert_eq!(
1012 FindingKey::new("semgrep", &empty),
1013 Err(FindingsError::EmptyIdentity)
1014 );
1015 assert_eq!(
1016 FindingKey::new("semgrep", &[""]),
1017 Err(FindingsError::InvalidIdentityPart(String::new()))
1018 );
1019 let long = "x".repeat(MAX_IDENTITY_PART + 1);
1020 assert!(matches!(
1021 FindingKey::new("semgrep", &[long.as_str()]),
1022 Err(FindingsError::InvalidIdentityPart(_))
1023 ));
1024 assert!(matches!(
1025 FindingKey::new("semgrep", &["a\nb"]),
1026 Err(FindingsError::InvalidIdentityPart(_))
1027 ));
1028 }
1029
1030 #[test]
1031 fn key_parse_rejects_malformed_strings() {
1032 for bad in [
1033 "notafinding:semgrep:x",
1034 "finding:semgrep",
1035 "finding",
1036 "finding:semgrep:x\\",
1037 ] {
1038 assert!(
1039 matches!(FindingKey::parse(bad), Err(FindingsError::MalformedKey(_))),
1040 "{bad:?} should be rejected"
1041 );
1042 }
1043 }
1044
1045 #[test]
1046 fn key_serializes_as_its_rendered_string() {
1047 let key = FindingKey::new("semgrep", &["r", "p", "1", "h"]).expect("key");
1048 let json = serde_json::to_string(&key).expect("serialize");
1049 assert_eq!(json, "\"finding:semgrep:r:p:1:h\"");
1050 let back: FindingKey = serde_json::from_str(&json).expect("deserialize");
1051 assert_eq!(back, key);
1052 assert!(serde_json::from_str::<FindingKey>("\"nope\"").is_err());
1053 }
1054
1055 #[test]
1056 fn layer_keys_are_analyzer_and_worktree_scoped() {
1057 let wt = WorktreeId::new("ab12cd34").expect("worktree id");
1058 assert_eq!(
1059 layer_key("cargo-audit", &wt).expect("layer"),
1060 "security:cargo-audit:ab12cd34"
1061 );
1062 assert!(matches!(
1063 layer_key("Cargo Audit", &wt),
1064 Err(FindingsError::InvalidAnalyzerId(_))
1065 ));
1066 }
1067
1068 #[test]
1069 fn worktree_ids_are_validated() {
1070 assert_eq!(WorktreeId::new("a1-b2").expect("ok").as_str(), "a1-b2");
1071 for bad in ["", "Upper", "has space", &"x".repeat(65)] {
1072 assert!(
1073 matches!(
1074 WorktreeId::new(bad),
1075 Err(FindingsError::InvalidWorktreeId(_))
1076 ),
1077 "{bad:?} should be rejected"
1078 );
1079 }
1080 }
1081
1082 #[test]
1083 fn analyzer_ids_accept_the_real_tool_names_and_reject_separators() {
1084 assert!(is_valid_analyzer_id("cargo-audit"));
1085 assert!(is_valid_analyzer_id("semgrep"));
1086 assert!(is_valid_analyzer_id("trivy.fs"));
1087 assert!(!is_valid_analyzer_id(""));
1088 assert!(!is_valid_analyzer_id("a:b"));
1090 assert!(is_valid_analyzer_id(&"a".repeat(MAX_ANALYZER_ID)));
1093 assert!(!is_valid_analyzer_id(&"a".repeat(MAX_ANALYZER_ID + 1)));
1094 }
1095
1096 #[test]
1101 fn a_rejected_analyzer_id_says_which_rule_it_broke() {
1102 let contract = "an analyzer id is 1 to 64 characters of lowercase [a-z0-9._-]";
1103
1104 let empty = analyzer_id_error("");
1105 assert_eq!(
1106 empty,
1107 format!("invalid analyzer id \"\": it is empty — {contract}")
1108 );
1109
1110 let cased = analyzer_id_error("Semgrep");
1111 assert_eq!(
1112 cased,
1113 format!("invalid analyzer id \"Semgrep\": it contains 'S' — {contract}")
1114 );
1115
1116 let long = "a".repeat(MAX_ANALYZER_ID + 13);
1117 let over = analyzer_id_error(&long);
1118 assert!(
1119 over.contains("it is 77 characters, over the 64-character limit"),
1120 "a too-long id must be told about the length rule, got: {over}"
1121 );
1122 assert!(over.contains(contract), "and the whole contract: {over}");
1123
1124 let wide = analyzer_id_error(&"é".repeat(MAX_ANALYZER_ID + 1));
1127 assert!(wide.contains("it contains 'é'"), "got: {wide}");
1128 }
1129
1130 #[test]
1133 fn the_error_variant_renders_the_shared_message() {
1134 let err = FindingsError::InvalidAnalyzerId("Semgrep".to_owned());
1135 assert_eq!(err.to_string(), analyzer_id_error("Semgrep"));
1136
1137 let long = "a".repeat(MAX_ANALYZER_ID + 1);
1138 let err = layer_key(&long, &WorktreeId::new("ab12").expect("worktree"))
1139 .expect_err("a too-long analyzer id must be refused");
1140 assert_eq!(err.to_string(), analyzer_id_error(&long));
1141 assert!(
1142 err.to_string().contains("over the 64-character limit"),
1143 "the rejection must name the length rule: {err}"
1144 );
1145 }
1146
1147 #[test]
1148 fn stable_tokens_round_trip() {
1149 for r in [
1150 RunnerKind::Ingested,
1151 RunnerKind::Subprocess,
1152 RunnerKind::Sandboxed,
1153 ] {
1154 assert_eq!(RunnerKind::from_token(r.as_str()), Some(r));
1155 }
1156 assert_eq!(RunnerKind::from_token("nope"), None);
1157
1158 for i in [Isolation::Ingested, Isolation::MicroVm, Isolation::None] {
1159 assert_eq!(Isolation::from_token(i.as_str()), Some(i));
1160 }
1161 assert_eq!(Isolation::from_token("nope"), None);
1162
1163 for s in [
1164 Severity::Critical,
1165 Severity::High,
1166 Severity::Medium,
1167 Severity::Low,
1168 Severity::Info,
1169 ] {
1170 assert_eq!(Severity::from_token(s.as_str()), s);
1171 }
1172 assert_eq!(
1174 Severity::from_token("moderate"),
1175 Severity::Other("moderate".to_owned())
1176 );
1177 }
1178
1179 #[test]
1180 fn the_default_command_policy_is_the_locked_down_one() {
1181 let policy = CommandPolicy::default();
1182 assert_eq!(policy.network, NetworkPolicy::Deny);
1183 assert_eq!(policy.worktree, WorktreeAccess::ReadOnly);
1184 assert_eq!(policy.environment, EnvironmentPolicy::Scrubbed);
1185 let json = serde_json::to_string(&policy).expect("serialize");
1187 assert_eq!(
1188 serde_json::from_str::<CommandPolicy>(&json).expect("deserialize"),
1189 policy
1190 );
1191 }
1192
1193 #[test]
1194 fn advisory_db_publication_date_is_optional_but_preserved() {
1195 let db = AdvisoryDb {
1196 digest: "abc".to_owned(),
1197 published_at: Some("2026-08-01T00:00:00Z".to_owned()),
1198 };
1199 let json = serde_json::to_string(&db).expect("serialize");
1200 assert_eq!(
1201 serde_json::from_str::<AdvisoryDb>(&json).expect("deserialize"),
1202 db
1203 );
1204 let bare: AdvisoryDb = serde_json::from_str(r#"{"digest":"abc"}"#).expect("bare");
1205 assert_eq!(bare.published_at, None);
1206 }
1207}