rto_graph/findings.rs
1//! Analyzer findings — a separate artifact model, never a graph fact.
2//!
3//! External analyzers (`cargo-audit`, `semgrep`, and successors) assert results
4//! at a point in time, against a rule set and an advisory database that both
5//! change independently of the source tree. That is a fourth production model,
6//! not one of the graph's three provenance classes, so the results live here — in
7//! their own tables, with their own retrieval surface — and never in
8//! `nodes`/`edges` (ADR-0012).
9//!
10//! Two consequences are load-bearing, and both are asserted by tests rather than
11//! assumed:
12//!
13//! - [`crate::Store::export_factset`] — and therefore the published
14//! [`crate::GraphArtifact`] — stays a pure function of the tree, because nothing
15//! in this module writes a node or an edge.
16//! - No finding acquires the `authored` relevance boost that [`crate::search`]
17//! applies, because `search` ranks `nodes` and a finding is not one.
18//!
19//! Nothing here adds a [`crate::Provenance`] variant, and nothing here is
20//! extraction output, so `EXTRACT_VERSION` is untouched.
21//!
22//! # The replaceable layer
23//!
24//! Findings form a layer keyed `security:<analyzer>:<worktree-id>` (see
25//! [`layer_key`]). Exactly one [`AnalysisRun`] is live per layer: a successful
26//! re-ingest replaces the previous one **wholesale, including the rows it owned**,
27//! so a finding that has been fixed disappears rather than lingering. The
28//! established import path (`Store::apply_import_layer`) deletes a layer's edges
29//! but *not* its obsolete owned nodes; that gap is deliberately not inherited —
30//! see [`crate::Store::replace_findings_layer`].
31//!
32//! @rto:0012
33
34use rusqlite::{Connection, OptionalExtension, params};
35use serde::{Deserialize, Deserializer, Serialize, Serializer};
36
37use crate::model::Span;
38use crate::store::StoreError;
39
40/// The layer-key prefix under which every findings layer is filed.
41pub const SECURITY_LAYER_PREFIX: &str = "security";
42
43/// The prefix of every [`FindingKey`].
44pub const FINDING_KEY_PREFIX: &str = "finding";
45
46/// Longest permitted identity component, in bytes. Identity parts are rule ids,
47/// paths, offsets and digests; anything longer is a malformed or hostile report,
48/// not a finding, and is refused before it can bloat the store.
49pub const MAX_IDENTITY_PART: usize = 512;
50
51/// Longest permitted analyzer id, in characters. Real analyzer ids are short
52/// (`cargo-audit`, `semgrep`, `trivy.fs`); the bound exists because an analyzer
53/// id is a component of both a layer key and every finding key, and those are
54/// stored, indexed and printed.
55///
56/// The constant is the single home of the number: [`is_valid_analyzer_id`]
57/// enforces it and [`analyzer_id_error`] quotes it, so the enforced rule and the
58/// reported rule cannot drift apart.
59pub const MAX_ANALYZER_ID: usize = 64;
60
61/// Errors raised when constructing the identity values this store is keyed by.
62///
63/// These are *validation* errors, raised by the constructors, so the store never
64/// has to accept an ill-formed key: by the time a value reaches
65/// [`crate::Store::replace_findings_layer`] it is already well-formed.
66#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
67pub enum FindingsError {
68 /// An analyzer id broke one of the rules [`is_valid_analyzer_id`] enforces:
69 /// non-empty, at most [`MAX_ANALYZER_ID`] characters, and lowercase
70 /// `[a-z0-9]` plus `.`, `_` and `-`. The message names the rule that was
71 /// actually broken — see [`analyzer_id_error`].
72 #[error("{}", analyzer_id_error(.0))]
73 InvalidAnalyzerId(String),
74 /// A worktree id was empty or contained characters outside `[a-z0-9-]`.
75 #[error("invalid worktree id: {0:?} (expected lowercase [a-z0-9-], 1..=64 chars)")]
76 InvalidWorktreeId(String),
77 /// A finding was offered with no identity components at all, so it would have
78 /// no stable identity across runs.
79 #[error("finding identity is empty: a finding needs at least one identity component")]
80 EmptyIdentity,
81 /// An identity component was empty, over-long, or contained a control
82 /// character.
83 #[error("invalid finding identity component {0:?}")]
84 InvalidIdentityPart(String),
85 /// A rendered finding key could not be parsed back (a corrupt row, or a key
86 /// produced by something other than [`FindingKey`]).
87 #[error("malformed finding key: {0}")]
88 MalformedKey(String),
89}
90
91/// Which backend produced an [`AnalysisRun`].
92///
93/// All three names from ADR-0014 exist from the start so the stored token set is
94/// stable; only [`RunnerKind::Ingested`] is produced by this crate today.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(rename_all = "lowercase")]
97pub enum RunnerKind {
98 /// A normalized report produced elsewhere (CI, a developer's own tooling)
99 /// and read in by `roteiro security ingest`.
100 Ingested,
101 /// The analyzer was executed as a child process on the host.
102 Subprocess,
103 /// The analyzer was executed inside a sandbox (a pinned OCI image in a
104 /// microVM).
105 Sandboxed,
106}
107
108impl RunnerKind {
109 /// Stable string token used in the `SQLite` store.
110 #[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 /// Parse a runner kind from its stable token, `None` for an unrecognised
120 /// value (a corrupt row).
121 #[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/// The isolation boundary a run actually had — recorded honestly, so a result
133/// produced with no boundary can never read as if it had one.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(rename_all = "lowercase")]
136pub enum Isolation {
137 /// No local execution happened at all: the report was produced elsewhere.
138 Ingested,
139 /// A microVM around a pinned OCI image.
140 #[serde(rename = "microvm")]
141 MicroVm,
142 /// None — the analyzer ran directly on the host.
143 None,
144}
145
146impl Isolation {
147 /// Stable string token used in the `SQLite` store.
148 #[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 /// Parse an isolation label from its stable token, `None` for an
158 /// unrecognised value (a corrupt row).
159 #[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/// What network access a run was permitted.
171///
172/// `Deny` is the only policy today and the only one any shipped runner requests.
173/// Marked `#[non_exhaustive]` because a later backend may need an explicit
174/// allow-list for an advisory-database refresh, and that must not be a breaking
175/// change; match with equality rather than exhaustively.
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
177#[serde(rename_all = "lowercase")]
178#[non_exhaustive]
179pub enum NetworkPolicy {
180 /// No egress. Analyzer inputs are pre-provisioned, never fetched mid-run.
181 #[default]
182 Deny,
183}
184
185/// How the analyzed worktree was exposed to the analyzer.
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
187#[serde(rename_all = "snake_case")]
188pub enum WorktreeAccess {
189 /// Read-only: analyzers parse source, manifests and lockfiles; none of them
190 /// needs to write to the tree.
191 #[default]
192 ReadOnly,
193 /// Writable — recorded for honesty if a future analyzer ever needs it.
194 ReadWrite,
195}
196
197/// How the analyzer's process environment was prepared.
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
199#[serde(rename_all = "snake_case")]
200pub enum EnvironmentPolicy {
201 /// Scrubbed: no ambient credentials are passed through.
202 #[default]
203 Scrubbed,
204 /// The ambient environment was inherited as-is.
205 Inherited,
206}
207
208/// The command policy a run was executed under. Part of the evidence chain: it
209/// records what the run was *allowed* to do, not merely what it did.
210#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
211pub struct CommandPolicy {
212 /// Egress policy.
213 pub network: NetworkPolicy,
214 /// How the worktree was mounted.
215 pub worktree: WorktreeAccess,
216 /// How the process environment was prepared.
217 pub environment: EnvironmentPolicy,
218}
219
220/// The pinned advisory database a run consulted, and when it was published.
221///
222/// `published_at` exists so a result can be labelled *possibly stale* rather than
223/// *current*: re-running the same analyzer at the same commit with a newer
224/// advisory database legitimately yields a different answer.
225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
226pub struct AdvisoryDb {
227 /// Digest of the advisory database as consulted.
228 pub digest: String,
229 /// Publication timestamp of that database, if the producer recorded one.
230 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub published_at: Option<String>,
232}
233
234/// The source identity a run was executed against.
235///
236/// All three components are optional because different analyzers pin different
237/// things: `semgrep` is meaningful against a commit/tree, `cargo-audit` against a
238/// lockfile blob.
239#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
240pub struct SourceIdentity {
241 /// Hex commit id the analyzer ran against.
242 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub commit: Option<String>,
244 /// Hex tree id the analyzer ran against.
245 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub tree: Option<String>,
247 /// Hex blob id of the lockfile the analyzer resolved dependencies from.
248 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub lockfile_blob: Option<String>,
250}
251
252/// The severity an analyzer assigned to a finding.
253///
254/// This is a **tool judgement**, deliberately kept away from the graph: it is not
255/// the confidence score `inferred` edges carry, and it must never be read as one.
256/// Known levels have stable tokens; anything else round-trips verbatim through
257/// [`Severity::Other`], so a new analyzer's vocabulary is not lost.
258#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
259pub enum Severity {
260 /// The analyzer's highest level.
261 Critical,
262 /// High.
263 High,
264 /// Medium / moderate.
265 Medium,
266 /// Low / minor.
267 Low,
268 /// Informational — not a defect claim.
269 Info,
270 /// Any level not covered above, kept verbatim.
271 Other(String),
272}
273
274impl Severity {
275 /// Stable string token used in the `SQLite` store.
276 #[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 /// Parse a severity from its token. Unknown tokens become
289 /// [`Severity::Other`], so this is infallible.
290 #[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
303// Severity (de)serializes as its bare token so reports, the database and `--json`
304// output all agree on one representation.
305impl 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/// An identifier for one checkout, used as the last component of a layer key.
319///
320/// It is an opaque token rather than a path on purpose: a layer key is stored and
321/// printed, and a local filesystem path is user-identifying data that has no
322/// business in a record. Producers derive it however they like (a digest of the
323/// canonical worktree path is the obvious choice); this type only guarantees the
324/// token is well-formed, so a layer key can never be ambiguous.
325#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
326pub struct WorktreeId(String);
327
328impl WorktreeId {
329 /// Validate and wrap a worktree id: 1..=64 characters of `[a-z0-9-]`.
330 ///
331 /// # Errors
332 /// Returns [`FindingsError::InvalidWorktreeId`] if `raw` is empty, too long,
333 /// or contains anything else.
334 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 /// The token.
348 #[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
360/// Whether a character may appear in an analyzer id.
361fn is_analyzer_id_char(c: char) -> bool {
362 c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-')
363}
364
365/// Whether `id` is a well-formed analyzer id: 1..=[`MAX_ANALYZER_ID`]
366/// characters, every one of them lowercase `[a-z0-9]` or one of `.`, `_`, `-`.
367///
368/// All three rules are reported by [`analyzer_id_error`], so a caller told its
369/// id was rejected is told which of them it broke.
370#[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/// The rejection message for an analyzer id that [`is_valid_analyzer_id`]
376/// refuses: which rule it broke, then the whole contract.
377///
378/// Every layer that rejects an analyzer id formats its error through this — this
379/// crate's [`FindingsError::InvalidAnalyzerId`] and `rto-exec`'s
380/// `ExecError::InvalidAnalyzerId` — so one rejection cannot read two different
381/// ways depending on how deep it was caught. It is a function rather than a
382/// format string so the enforced rule and the reported rule stay one thing.
383///
384/// ```
385/// # use rto_graph::analyzer_id_error;
386/// assert_eq!(
387/// analyzer_id_error("Semgrep"),
388/// "invalid analyzer id \"Semgrep\": it contains 'S' — an analyzer id is 1 to 64 \
389/// characters of lowercase [a-z0-9._-]"
390/// );
391/// ```
392#[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
401/// Which rule `id` broke, as a phrase. The character-set rule is checked before
402/// the length rule so a non-ASCII id is reported by the character it contains,
403/// never by a byte count that would not match what the caller sees.
404fn 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 // Unreachable for an id `is_valid_analyzer_id` rejected; a caller that
416 // formats a valid id gets an honest answer rather than a wrong one.
417 "it is well-formed".to_owned()
418}
419
420/// Render the layer key a findings layer is filed under:
421/// `security:<analyzer>:<worktree-id>`.
422///
423/// A successful re-ingest under the same key replaces the previous layer
424/// wholesale (see [`crate::Store::replace_findings_layer`]).
425///
426/// # Errors
427/// Returns [`FindingsError::InvalidAnalyzerId`] if `analyzer` is not 1..=
428/// [`MAX_ANALYZER_ID`] characters of lowercase `[a-z0-9._-]`; the error names
429/// which of those rules was broken.
430pub 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/// A finding's stable identity across runs.
441///
442/// The key is `finding:<analyzer>:<component>…` — the analyzer id followed by
443/// **that analyzer's own** ordered identity components. The schema deliberately
444/// does not know what those components mean, so a new analyzer is a new recipe in
445/// its adapter rather than a schema change:
446///
447/// ```text
448/// finding:semgrep:<rule>:<path>:<start-byte>:<snippet-hash>
449/// finding:cargo-audit:<advisory>:<pkg>:<version>:<lockfile-blob>
450/// ```
451///
452/// Components may themselves contain `:` (a rule id with a namespace, a Windows
453/// path), so on rendering `\` and `:` are escaped with a backslash. Parsing
454/// reverses that exactly, which makes the rendering injective: two different
455/// component lists can never collide on one key.
456#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
457pub struct FindingKey {
458 analyzer: String,
459 parts: Vec<String>,
460}
461
462impl FindingKey {
463 /// Build a key from an analyzer id and its ordered identity components.
464 ///
465 /// # Errors
466 /// Returns [`FindingsError::InvalidAnalyzerId`] if `analyzer` is not 1..=
467 /// [`MAX_ANALYZER_ID`] characters of lowercase `[a-z0-9._-]`,
468 /// [`FindingsError::EmptyIdentity`] if `parts` is empty, or
469 /// [`FindingsError::InvalidIdentityPart`] if a component is empty, longer
470 /// than [`MAX_IDENTITY_PART`], or contains a control character.
471 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 /// The analyzer that produced the finding.
496 #[must_use]
497 pub fn analyzer(&self) -> &str {
498 &self.analyzer
499 }
500
501 /// The analyzer-specific identity components, in order.
502 #[must_use]
503 pub fn parts(&self) -> &[String] {
504 &self.parts
505 }
506
507 /// Render the key to its stable string form.
508 #[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 /// Parse a rendered key back into its components — the exact inverse of
521 /// [`FindingKey::render`].
522 ///
523 /// # Errors
524 /// Returns [`FindingsError::MalformedKey`] if the prefix is wrong, an escape
525 /// is dangling, or there is no identity component; or the same validation
526 /// errors [`FindingKey::new`] raises.
527 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
557// A key (de)serializes as its rendered string, so a report, a database row and
558// `--json` output all show the same identity.
559impl 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
572/// Append `raw` to `out`, escaping the two characters that would otherwise make a
573/// rendered key ambiguous.
574fn 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
583/// Split on unescaped `:`, unescaping as it goes.
584fn 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/// One analyzer execution, plus everything needed to reproduce or distrust it.
607///
608/// This is the evidence chain graph provenance was never designed to hold: what
609/// ran, at what version, under what isolation and command policy, against which
610/// rules and advisory database, over which source identity, and with what result.
611#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
612pub struct AnalysisRun {
613 /// The replaceable layer this run owns: `security:<analyzer>:<worktree-id>`
614 /// (see [`layer_key`]). Unique — one live run per layer.
615 pub layer: String,
616 /// The analyzer id (`cargo-audit`, `semgrep`, …).
617 pub analyzer: String,
618 /// The analyzer's own version string, as reported by the producer.
619 pub analyzer_version: String,
620 /// Which backend produced this run.
621 pub runner: RunnerKind,
622 /// The isolation boundary the run actually had.
623 pub isolation: Isolation,
624 /// Digest of the container image, when one was used.
625 #[serde(default, skip_serializing_if = "Option::is_none")]
626 pub image_digest: Option<String>,
627 /// Digest of the rule set the analyzer was run with.
628 #[serde(default, skip_serializing_if = "Option::is_none")]
629 pub rules_digest: Option<String>,
630 /// The pinned advisory database consulted, and its publication date.
631 #[serde(default, skip_serializing_if = "Option::is_none")]
632 pub advisory_db: Option<AdvisoryDb>,
633 /// What the run was permitted to do.
634 pub command_policy: CommandPolicy,
635 /// The source identity the run was executed against.
636 pub source: SourceIdentity,
637 /// Producer-supplied start timestamp.
638 pub started_at: String,
639 /// Producer-supplied end timestamp.
640 pub ended_at: String,
641 /// The analyzer's process exit status.
642 pub exit_status: i32,
643 /// Digest of the raw report this run was derived from — the tie between the
644 /// stored findings and the exact bytes they came from.
645 pub report_digest: String,
646}
647
648/// One finding, belonging to an [`AnalysisRun`].
649#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
650pub struct Finding {
651 /// Stable identity across runs.
652 pub key: FindingKey,
653 /// The rule, advisory or check id the analyzer fired.
654 pub rule: String,
655 /// The severity the analyzer assigned — a tool judgement, not a confidence.
656 pub severity: Severity,
657 /// One-line summary.
658 pub title: String,
659 /// The analyzer's full message.
660 pub message: String,
661 /// Repository-relative path the finding is about, if any.
662 #[serde(default, skip_serializing_if = "Option::is_none")]
663 pub path: Option<String>,
664 /// Byte span within that path, if the analyzer located one.
665 #[serde(default, skip_serializing_if = "Option::is_none")]
666 pub span: Option<Span>,
667 /// Anything else the analyzer reported, kept verbatim.
668 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
669 pub meta: serde_json::Value,
670}
671
672/// A live layer: its run and the findings that run owns, ordered by key.
673#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
674pub struct FindingsLayer {
675 /// The run that owns this layer.
676 pub run: AnalysisRun,
677 /// Its findings, ordered by [`FindingKey`].
678 pub findings: Vec<Finding>,
679}
680
681/// A summary of replacing a findings layer (see
682/// [`crate::Store::replace_findings_layer`]).
683#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
684pub struct FindingsApplied {
685 /// The layer key written.
686 pub layer: String,
687 /// Findings written by this ingest.
688 pub findings: usize,
689 /// Owned finding rows deleted from the previous run of this layer. This is
690 /// the number that proves obsolete records are removed rather than orphaned.
691 pub removed: usize,
692 /// Whether a previous run of this layer existed and was replaced.
693 pub replaced: bool,
694}
695
696// --- Persistence. Free helpers over a `Connection` (a `Transaction` derefs to
697// one), mirroring how the node/edge store is written. Every one of these touches
698// `analysis_runs`/`findings` and nothing else: no statement in this module reads
699// or writes `nodes` or `edges`. ---
700
701/// Columns of `analysis_runs`, in the order [`run_from_row`] decodes them.
702const 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
707/// Columns of `findings`, in the order [`finding_from_row`] decodes them.
708const 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
711/// Replace this layer's live run and all the finding rows it owns, in one
712/// transaction. See [`crate::Store::replace_findings_layer`] for the contract.
713pub(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 // Owned-record cleanup, done explicitly. The `ON DELETE CASCADE` on
727 // `findings.run_id` would also remove these rows, but relying on it would
728 // repeat the mistake this store exists to avoid: the import path deletes a
729 // layer's edges and leaves its obsolete nodes behind. Deleting the owned rows
730 // by hand — and reporting how many — is what makes "a fixed finding
731 // disappears" a tested fact rather than a hope.
732 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
801/// Delete a layer — its run and every finding row it owns — returning how many
802/// findings went with it, or `None` if no such layer was live.
803pub(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 // Explicit owned-record cleanup, for the same reason as in `replace_layer`.
815 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
820/// Every live layer, ordered by layer key; optionally narrowed to one analyzer.
821pub(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
853/// The findings owned by one run, ordered by key so output is deterministic.
854fn 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
865/// Total number of stored findings, across every layer.
866pub(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
871/// Total number of live analysis runs (one per layer).
872pub(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
877/// Findings whose owning run no longer exists. Always zero in a healthy store —
878/// the layer-replacement tests assert exactly that, so an orphan can never pass
879/// for a clean replacement.
880pub(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
890/// Decode an `analysis_runs` row into `(row id, run)`.
891fn 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
928/// Decode a `findings` row.
929fn 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 // The migration's CHECK makes a half-span impossible; treat one as
947 // "no span" rather than failing a read on a database that cannot
948 // produce it.
949 _ => None,
950 },
951 meta: serde_json::from_str(&meta_json)?,
952 })
953}
954
955#[cfg(test)]
956mod tests {
957 use std::collections::HashMap;
958
959 use super::{
960 AdvisoryDb, CommandPolicy, EnvironmentPolicy, FindingKey, FindingsError, Isolation,
961 MAX_ANALYZER_ID, MAX_IDENTITY_PART, NetworkPolicy, RunnerKind, Severity, WorktreeAccess,
962 WorktreeId, analyzer_id_error, is_valid_analyzer_id, layer_key,
963 };
964
965 #[test]
966 fn renders_the_documented_analyzer_keys() {
967 let semgrep = FindingKey::new(
968 "semgrep",
969 &["rules.rust.unsafe", "src/lib.rs", "1024", "9f8e7d"],
970 )
971 .expect("key");
972 assert_eq!(
973 semgrep.render(),
974 "finding:semgrep:rules.rust.unsafe:src/lib.rs:1024:9f8e7d"
975 );
976
977 let audit = FindingKey::new(
978 "cargo-audit",
979 &["RUSTSEC-2024-0001", "openssl", "0.10.5", "abc123"],
980 )
981 .expect("key");
982 assert_eq!(
983 audit.render(),
984 "finding:cargo-audit:RUSTSEC-2024-0001:openssl:0.10.5:abc123"
985 );
986 }
987
988 #[test]
989 fn key_round_trips_including_components_containing_colons() {
990 // A namespaced rule id and a drive-letter path both contain `:`; the key
991 // must still parse back to exactly the components it was built from,
992 // otherwise two different findings could collide on one identity.
993 let key = FindingKey::new("semgrep", &["a:b", "C:\\src\\x.rs", "7", "deadbeef"])
994 .expect("build key");
995 let rendered = key.render();
996 assert_eq!(FindingKey::parse(&rendered).expect("parse"), key);
997 assert_eq!(key.analyzer(), "semgrep");
998 assert_eq!(key.parts().len(), 4);
999
1000 // Distinct component lists that would collide under naive joining do not.
1001 let a = FindingKey::new("semgrep", &["x:y", "z"]).expect("a");
1002 let b = FindingKey::new("semgrep", &["x", "y:z"]).expect("b");
1003 assert_ne!(a.render(), b.render());
1004 }
1005
1006 /// A deterministic 64-bit xorshift, so a generated counterexample is
1007 /// reproducible from the seed the assertion prints rather than from whatever
1008 /// the machine's entropy happened to be that run.
1009 ///
1010 /// Hand-rolled on purpose: the workspace carries no property-testing crate
1011 /// (no `proptest`, `quickcheck` or `arbitrary`, in any manifest or in
1012 /// `Cargo.lock`), and a key grammar with two escapable characters does not
1013 /// earn a new workspace dependency — see #787.
1014 struct Xorshift(u64);
1015
1016 impl Xorshift {
1017 /// The next value in the sequence. Never returns zero for a non-zero
1018 /// seed, which is the only state xorshift64 cannot leave.
1019 fn next_u64(&mut self) -> u64 {
1020 let mut x = self.0;
1021 x ^= x << 13;
1022 x ^= x >> 7;
1023 x ^= x << 17;
1024 self.0 = x;
1025 x
1026 }
1027
1028 /// A value in `0..bound`. `bound` is a small literal here, so the modulo
1029 /// bias is irrelevant and the conversions cannot fail.
1030 fn below(&mut self, bound: usize) -> usize {
1031 let bound = u64::try_from(bound).expect("bound fits in u64");
1032 usize::try_from(self.next_u64() % bound).expect("remainder fits in usize")
1033 }
1034 }
1035
1036 /// Analyzer ids the generator draws from. `is_valid_analyzer_id` already
1037 /// forbids `\` and `:` in this position, so the analyzer cannot carry an
1038 /// escape and only the identity components exercise the grammar.
1039 const GENERATED_ANALYZERS: [&str; 4] = ["semgrep", "cargo-audit", "trivy.fs", "a"];
1040
1041 /// The alphabet identity components are drawn from: the separator, a literal
1042 /// backslash, ordinary ASCII, and one multi-byte character (`MAX_IDENTITY_PART`
1043 /// is a *byte* bound while `push_escaped` iterates *chars*, so the two need a
1044 /// case where they disagree).
1045 const GENERATED_ALPHABET: [&str; 7] = ["a", "b", "1", "-", ":", "\\", "é"];
1046
1047 /// Longest generated component, in symbols drawn from [`GENERATED_ALPHABET`].
1048 const GENERATED_PART_LEN: usize = 6;
1049
1050 /// Most components in a generated key.
1051 const GENERATED_PART_COUNT: usize = 4;
1052
1053 /// How many random keys the property runs over, on top of [`ESCAPE_EDGE_CASES`].
1054 const GENERATED_CASES: usize = 400;
1055
1056 /// Hand-written components covering every shape of the escape grammar, so the
1057 /// corpus cannot lose a case to a later edit of the random generator: the
1058 /// separator alone, a literal backslash alone, a backslash before an ordinary
1059 /// character (the non-canonical form `split_escaped` is permissive about),
1060 /// adjacent runs and combinations of all three, and multi-byte characters
1061 /// both alone and adjacent to an escape.
1062 ///
1063 /// The multi-byte cases are deterministic rather than left to
1064 /// [`GENERATED_ALPHABET`] on purpose: `MAX_IDENTITY_PART` is a *byte* bound
1065 /// while `push_escaped` and `split_escaped` walk *chars*, and a seed or
1066 /// alphabet edit must not be able to drop the only case where those two
1067 /// disagree. [`SHAPE_NAMES`] tallies the shape as well, so it cannot go
1068 /// uncovered silently either.
1069 const ESCAPE_EDGE_CASES: [&[&str]; 20] = [
1070 &[":"],
1071 &["\\"],
1072 &["\\a"],
1073 &["a\\"],
1074 &["\\\\"],
1075 &["\\\\\\"],
1076 &["::"],
1077 &[":::"],
1078 &["\\:"],
1079 &[":\\"],
1080 &["a\\b"],
1081 &["a\\\\b"],
1082 &["C:\\src\\x.rs"],
1083 &["\\:\\:", "a"],
1084 &["a:b", "\\", "c\\d"],
1085 &[":", "\\", "\\a", "a\\\\:b"],
1086 &["é"],
1087 &["\\é"],
1088 &["é:\\é"],
1089 &["日本\\\\:é", "ß"],
1090 ];
1091
1092 /// How many *generated* keys must exhibit each shape of the grammar before
1093 /// the property is worth believing. Guards against a future edit to the
1094 /// alphabet quietly making the run vacuous.
1095 ///
1096 /// Applied to the generated half alone, never to the union with
1097 /// [`ESCAPE_EDGE_CASES`] — the fixed cases would otherwise carry a shape over
1098 /// this floor by themselves and hide the very edit the floor is for.
1099 const MIN_PER_SHAPE: usize = 10;
1100
1101 /// The shapes [`escape_shape_tally`] counts, in the order it returns them.
1102 const SHAPE_NAMES: [&str; 5] = [
1103 "separator",
1104 "backslash",
1105 "escaped-ordinary",
1106 "adjacent-run",
1107 "multi-byte",
1108 ];
1109
1110 /// How many of `keys` carry each shape named by [`SHAPE_NAMES`]: a separator,
1111 /// a literal backslash, a backslash before an ordinary character, an adjacent
1112 /// run of escapable characters, and a character outside ASCII.
1113 fn escape_shape_tally(keys: &[FindingKey]) -> [usize; SHAPE_NAMES.len()] {
1114 let mut tally = [0_usize; SHAPE_NAMES.len()];
1115 for key in keys {
1116 let mut shapes = [false; SHAPE_NAMES.len()];
1117 for part in key.parts() {
1118 let chars: Vec<char> = part.chars().collect();
1119 for (i, &ch) in chars.iter().enumerate() {
1120 shapes[0] |= ch == ':';
1121 shapes[1] |= ch == '\\';
1122 let next = chars.get(i + 1).copied();
1123 if ch == '\\' {
1124 // A backslash before anything other than `\` or `:` is
1125 // the non-canonical escape #787 is about.
1126 shapes[2] |= matches!(next, Some(n) if n != '\\' && n != ':');
1127 }
1128 shapes[3] |= matches!(ch, '\\' | ':')
1129 && matches!(next, Some(n) if matches!(n, '\\' | ':'));
1130 // One char, more than one byte: the seam between the byte
1131 // bound `FindingKey::new` enforces and the char-wise walk
1132 // `push_escaped`/`split_escaped` do.
1133 shapes[4] |= ch.len_utf8() > 1;
1134 }
1135 }
1136 for (slot, seen) in tally.iter_mut().zip(shapes) {
1137 *slot += usize::from(seen);
1138 }
1139 }
1140 tally
1141 }
1142
1143 /// One generated key: a valid analyzer id and 1..=[`GENERATED_PART_COUNT`]
1144 /// non-empty components drawn from [`GENERATED_ALPHABET`].
1145 fn generated_key(rng: &mut Xorshift) -> FindingKey {
1146 let analyzer = GENERATED_ANALYZERS[rng.below(GENERATED_ANALYZERS.len())];
1147 let count = 1 + rng.below(GENERATED_PART_COUNT);
1148 let mut parts = Vec::with_capacity(count);
1149 for _ in 0..count {
1150 let len = 1 + rng.below(GENERATED_PART_LEN);
1151 let mut part = String::new();
1152 for _ in 0..len {
1153 part.push_str(GENERATED_ALPHABET[rng.below(GENERATED_ALPHABET.len())]);
1154 }
1155 parts.push(part);
1156 }
1157 FindingKey::new(analyzer, &parts).expect("generated components are well formed")
1158 }
1159
1160 #[test]
1161 fn key_rendering_round_trips_and_is_injective_over_generated_keys() {
1162 // #787: `parse(render(x)) == x` over generated keys, including every
1163 // shape of the escape grammar. The round trip is what makes a rendered
1164 // key a *stable identity*: if it ever stopped holding, two findings
1165 // could share one key, or one finding could change key across runs,
1166 // with no test failing anywhere else.
1167 //
1168 // Deterministic seed, printed by every seed-dependent assertion below,
1169 // so a failure is reproducible from the message alone. The fixed-corpus
1170 // checks omit it deliberately: they do not depend on the seed, and a
1171 // seed in their message would imply they did.
1172 const SEED: u64 = 0x5150_7787_0BAD_5EED;
1173
1174 let mut rng = Xorshift(SEED);
1175 let fixed: Vec<FindingKey> = ESCAPE_EDGE_CASES
1176 .iter()
1177 .map(|parts| FindingKey::new("semgrep", parts).expect("edge-case components"))
1178 .collect();
1179 let generated: Vec<FindingKey> = (0..GENERATED_CASES)
1180 .map(|_| generated_key(&mut rng))
1181 .collect();
1182
1183 // The corpus has to contain what it claims to, or the property below is
1184 // true of nothing interesting. The two halves are tallied *separately*,
1185 // because they go vacuous in different ways and a combined tally hides
1186 // both: the fixed corpus must reach every shape on its own, so a seed
1187 // change cannot drop one, and the generated half must reach every shape
1188 // on its own, so an alphabet edit cannot.
1189 //
1190 // Tallying the union instead would be the bug this guard exists to
1191 // prevent, committed by the guard: eleven of the twenty fixed cases
1192 // contain a separator, so with `MIN_PER_SHAPE` at 10 the union clears the
1193 // floor on the fixed half alone. Deleting `:` from `GENERATED_ALPHABET`
1194 // then leaves the random half exercising no separator at all and this
1195 // assertion still green — measured, not supposed.
1196 for (shape, count) in SHAPE_NAMES.into_iter().zip(escape_shape_tally(&fixed)) {
1197 assert!(
1198 count > 0,
1199 "no ESCAPE_EDGE_CASES entry contains a {shape}; that shape would rest \
1200 entirely on the random generator"
1201 );
1202 }
1203 for (shape, count) in SHAPE_NAMES.into_iter().zip(escape_shape_tally(&generated)) {
1204 assert!(
1205 count >= MIN_PER_SHAPE,
1206 "seed {SEED:#x}: only {count} of {GENERATED_CASES} generated keys contain \
1207 a {shape}; the random half would be vacuous for that shape"
1208 );
1209 }
1210
1211 let mut keys = fixed;
1212 keys.extend(generated);
1213
1214 let mut rendered_to_key: HashMap<String, FindingKey> = HashMap::new();
1215 for key in &keys {
1216 let rendered = key.render();
1217 let parsed = FindingKey::parse(&rendered).unwrap_or_else(|err| {
1218 panic!(
1219 "seed {SEED:#x}: counterexample {key:?} rendered as {rendered:?}, \
1220 which does not parse: {err}"
1221 )
1222 });
1223 assert_eq!(
1224 parsed, *key,
1225 "seed {SEED:#x}: counterexample {key:?} rendered as {rendered:?} \
1226 and parsed back as {parsed:?}"
1227 );
1228 if let Some(earlier) = rendered_to_key.insert(rendered.clone(), key.clone()) {
1229 assert_eq!(
1230 earlier, *key,
1231 "seed {SEED:#x}: counterexample — {earlier:?} and {key:?} \
1232 both render to {rendered:?}"
1233 );
1234 }
1235 }
1236 }
1237
1238 /// Records what the parser does today. **Not** a guarantee that it is right.
1239 ///
1240 /// `split_escaped` strips a backslash before *any* character, so a string
1241 /// `render` would never emit parses to the same identity as the canonical
1242 /// one. `parse` is therefore not injective over *arbitrary input strings*.
1243 /// It **is** injective over the image of `render` — that is precisely what
1244 /// [`key_rendering_round_trips_and_is_injective_over_generated_keys`] holds,
1245 /// and `parse ∘ render` is the identity on keys. The gap is between those
1246 /// two domains, and none of the inputs below is in the image of `render`.
1247 /// That gap is the known `permissive-constraint` debt of review-corpus row
1248 /// `3789014471`, and this test does not resolve it. (That row was cited here
1249 /// by its `reviewed_sha` `4bed7d81` until #822, when the commit turned out to
1250 /// have been force-pushed away; the row is re-pinned to `fec606e` and is now
1251 /// named by its id, which is the field that cannot evaporate.)
1252 ///
1253 /// **The decision is deferred to a human — see #798 — and not settled here.**
1254 /// It is tracked as its own open decision, #798, carved out of #787 so that
1255 /// merging this test does not bury it: this PR settles the property, not the
1256 /// semantics. #798 carries the evidence table summarised below.
1257 ///
1258 /// # The blast radius, measured rather than assumed
1259 ///
1260 /// Tightening `parse` is still a compatibility decision, but the surface it
1261 /// touches was *traced*, not guessed, and it is narrow. It is **not** what
1262 /// an earlier draft of this comment claimed: neither `--json` output nor any
1263 /// row written by this code is exposed, because both go out through
1264 /// [`FindingKey::render`] — the [`Serialize`] impl is
1265 /// `serializer.serialize_str(&self.render())` (this file, `impl Serialize
1266 /// for FindingKey`), and the findings insert stores `finding.key.render()`.
1267 /// Both are canonical by construction. Every in-tree producer likewise
1268 /// builds keys from *components* via [`FindingKey::new`] and never by
1269 /// parsing a string — including the untrusted path, since a normalized
1270 /// report carries `identity: Vec<String>` that `rto_exec::ingest` converts
1271 /// with `new`.
1272 ///
1273 /// The re-verification is cheap and deliberately left to the next reader:
1274 /// [`FindingKey::parse`] has exactly **two non-test production call sites** —
1275 /// `finding_from_row` and the [`Deserialize`] impl.
1276 ///
1277 /// Every other call to it is in this module's own tests:
1278 /// `key_round_trips_including_components_containing_colons`,
1279 /// `key_rendering_round_trips_and_is_injective_over_generated_keys`,
1280 /// `parse_is_permissive_about_escapes_pending_a_wire_format_decision` and
1281 /// `key_parse_rejects_malformed_strings`.
1282 ///
1283 /// How that was checked, and what the check is worth. `AGENTS.md` requires a
1284 /// *"nothing else does X"* to go through `roteiro search` rather than `grep`;
1285 /// `roteiro search "FindingKey parse"`, `"parse rendered finding key"` and
1286 /// `"finding_from_row"` each return only nodes in this file, and the inbound
1287 /// `calls` edges of
1288 /// `sym:rust:crates/rto-graph/src/findings.rs#FindingKey::parse` are
1289 /// `#FindingKey::deserialize`, `#finding_from_row` and two `#tests::…`.
1290 ///
1291 /// That is **two short** of the four tests listed above, and the reason is
1292 /// worth carrying: the extractor records a `calls` edge for a
1293 /// `call_expression`, so an invocation written inside a macro — `assert_eq!`
1294 /// in the first test, `matches!` in the last — produces none. The graph
1295 /// therefore cannot, on its own, close the workspace-wide negative either: a
1296 /// macro-wrapped caller in another crate would be just as invisible to it.
1297 /// What actually closes it is a text search over `crates/` for
1298 /// `FindingKey::parse`, which finds no hit outside this file. Neither
1299 /// instrument is sufficient alone, and this comment has twice claimed more
1300 /// than one of them could support.
1301 ///
1302 /// Only the production count carries the argument below. It is two, and the
1303 /// graph, the searches and the text search agree on it.
1304 ///
1305 /// A stricter parser could therefore reject exactly two things in-tree:
1306 ///
1307 /// 1. **JSON authored outside this codebase**, and
1308 /// 2. **a stored row written by something other than this code.**
1309 ///
1310 /// That is the whole of the *in-tree* blast radius. A third category exists
1311 /// beyond it and is real: `parse` is `pub` and [`FindingKey`] is re-exported
1312 /// from the crate root (`lib.rs`), so an out-of-tree caller could depend on
1313 /// the permissive behaviour. Unlike the two above, that set cannot be
1314 /// enumerated from this repository.
1315 ///
1316 /// What it is **not** is a hard constraint, and it should not be argued as
1317 /// one. `AGENTS.md` carves the `rto-*` crates out on purpose: they publish
1318 /// only because `crates/roteiro/Cargo.toml` depends on them by version and
1319 /// crates.io rejects path-only dependencies, `roteiro` is their sole reverse
1320 /// dependency, and a technically-breaking change to their surface — a
1321 /// field's type, an enum variant, a signature — ships as a **minor** bump
1322 /// and does **not** take a `!`. This crate's own package description agrees:
1323 /// *"Implementation detail of the roteiro CLI; no API stability guarantee"*.
1324 /// So semver would not block tightening `parse`; the question is whether to
1325 /// break a promise that was never made, which is a judgement rather than a
1326 /// rule.
1327 ///
1328 /// What is left is a compatibility policy question about foreign input and
1329 /// out-of-tree callers — a human's call, not a test's, and #798 is where it
1330 /// gets made rather than here.
1331 ///
1332 /// Do not read a green run here as a decision that the permissiveness is
1333 /// intended: the assertions exist so that a change to it is visible rather
1334 /// than silent. What they do establish meanwhile is that the permissiveness
1335 /// *normalises* — whatever form came in, what goes back out is the canonical
1336 /// rendering, so this code never *writes* a non-canonical key.
1337 ///
1338 /// That normalisation is in-memory only, and the distinction matters to the
1339 /// decision: `finding_from_row` parses a row without rewriting it, so a row
1340 /// some other writer put in the table stays non-canonical on disk until a
1341 /// re-ingest replaces the whole layer. Reading it is what would start
1342 /// failing under a stricter parser — which is case 2 above, not an exception
1343 /// to it.
1344 #[test]
1345 fn parse_is_permissive_about_escapes_pending_a_wire_format_decision() {
1346 let canonical = FindingKey::new("semgrep", &["ab"]).expect("key");
1347 assert_eq!(canonical.render(), "finding:semgrep:ab");
1348 // The baseline: the canonical form parses to the canonical key. Split out
1349 // of the loop below so that loop holds only genuinely non-canonical input
1350 // and its name describes all of it.
1351 assert_eq!(
1352 FindingKey::parse("finding:semgrep:ab").expect("parse"),
1353 canonical
1354 );
1355
1356 for non_canonical in ["finding:semgrep:a\\b", "finding:semgrep:\\ab"] {
1357 let parsed = FindingKey::parse(non_canonical).expect("parse");
1358 assert_eq!(
1359 parsed, canonical,
1360 "{non_canonical:?} parses to the same identity as the canonical key"
1361 );
1362 assert_eq!(parsed.render(), canonical.render());
1363 }
1364 }
1365
1366 #[test]
1367 fn key_rejects_ill_formed_identities() {
1368 assert_eq!(
1369 FindingKey::new("Semgrep", &["x"]),
1370 Err(FindingsError::InvalidAnalyzerId("Semgrep".to_owned()))
1371 );
1372 let empty: [&str; 0] = [];
1373 assert_eq!(
1374 FindingKey::new("semgrep", &empty),
1375 Err(FindingsError::EmptyIdentity)
1376 );
1377 assert_eq!(
1378 FindingKey::new("semgrep", &[""]),
1379 Err(FindingsError::InvalidIdentityPart(String::new()))
1380 );
1381 let long = "x".repeat(MAX_IDENTITY_PART + 1);
1382 assert!(matches!(
1383 FindingKey::new("semgrep", &[long.as_str()]),
1384 Err(FindingsError::InvalidIdentityPart(_))
1385 ));
1386 assert!(matches!(
1387 FindingKey::new("semgrep", &["a\nb"]),
1388 Err(FindingsError::InvalidIdentityPart(_))
1389 ));
1390 }
1391
1392 #[test]
1393 fn key_parse_rejects_malformed_strings() {
1394 for bad in [
1395 "notafinding:semgrep:x",
1396 "finding:semgrep",
1397 "finding",
1398 "finding:semgrep:x\\",
1399 ] {
1400 assert!(
1401 matches!(FindingKey::parse(bad), Err(FindingsError::MalformedKey(_))),
1402 "{bad:?} should be rejected"
1403 );
1404 }
1405 }
1406
1407 #[test]
1408 fn key_serializes_as_its_rendered_string() {
1409 let key = FindingKey::new("semgrep", &["r", "p", "1", "h"]).expect("key");
1410 let json = serde_json::to_string(&key).expect("serialize");
1411 assert_eq!(json, "\"finding:semgrep:r:p:1:h\"");
1412 let back: FindingKey = serde_json::from_str(&json).expect("deserialize");
1413 assert_eq!(back, key);
1414 assert!(serde_json::from_str::<FindingKey>("\"nope\"").is_err());
1415 }
1416
1417 #[test]
1418 fn layer_keys_are_analyzer_and_worktree_scoped() {
1419 let wt = WorktreeId::new("ab12cd34").expect("worktree id");
1420 assert_eq!(
1421 layer_key("cargo-audit", &wt).expect("layer"),
1422 "security:cargo-audit:ab12cd34"
1423 );
1424 assert!(matches!(
1425 layer_key("Cargo Audit", &wt),
1426 Err(FindingsError::InvalidAnalyzerId(_))
1427 ));
1428 }
1429
1430 #[test]
1431 fn worktree_ids_are_validated() {
1432 assert_eq!(WorktreeId::new("a1-b2").expect("ok").as_str(), "a1-b2");
1433 for bad in ["", "Upper", "has space", &"x".repeat(65)] {
1434 assert!(
1435 matches!(
1436 WorktreeId::new(bad),
1437 Err(FindingsError::InvalidWorktreeId(_))
1438 ),
1439 "{bad:?} should be rejected"
1440 );
1441 }
1442 }
1443
1444 #[test]
1445 fn analyzer_ids_accept_the_real_tool_names_and_reject_separators() {
1446 assert!(is_valid_analyzer_id("cargo-audit"));
1447 assert!(is_valid_analyzer_id("semgrep"));
1448 assert!(is_valid_analyzer_id("trivy.fs"));
1449 assert!(!is_valid_analyzer_id(""));
1450 // A `:` in an analyzer id would make a layer key ambiguous.
1451 assert!(!is_valid_analyzer_id("a:b"));
1452 // The length rule, at the boundary: exactly the limit is fine, one over
1453 // is not.
1454 assert!(is_valid_analyzer_id(&"a".repeat(MAX_ANALYZER_ID)));
1455 assert!(!is_valid_analyzer_id(&"a".repeat(MAX_ANALYZER_ID + 1)));
1456 }
1457
1458 /// A caller must be able to fix its input from the message alone. Each rule
1459 /// the validator enforces has to be *nameable* by the error, and the whole
1460 /// contract has to be stated — the earlier message claimed only "non-empty",
1461 /// so an id rejected for length was told to satisfy a rule it already met.
1462 #[test]
1463 fn a_rejected_analyzer_id_says_which_rule_it_broke() {
1464 let contract = "an analyzer id is 1 to 64 characters of lowercase [a-z0-9._-]";
1465
1466 let empty = analyzer_id_error("");
1467 assert_eq!(
1468 empty,
1469 format!("invalid analyzer id \"\": it is empty — {contract}")
1470 );
1471
1472 let cased = analyzer_id_error("Semgrep");
1473 assert_eq!(
1474 cased,
1475 format!("invalid analyzer id \"Semgrep\": it contains 'S' — {contract}")
1476 );
1477
1478 let long = "a".repeat(MAX_ANALYZER_ID + 13);
1479 let over = analyzer_id_error(&long);
1480 assert!(
1481 over.contains("it is 77 characters, over the 64-character limit"),
1482 "a too-long id must be told about the length rule, got: {over}"
1483 );
1484 assert!(over.contains(contract), "and the whole contract: {over}");
1485
1486 // The character rule is reported before the length rule, so a non-ASCII
1487 // id is never described by a byte count the caller cannot see.
1488 let wide = analyzer_id_error(&"é".repeat(MAX_ANALYZER_ID + 1));
1489 assert!(wide.contains("it contains 'é'"), "got: {wide}");
1490 }
1491
1492 /// The rendered `FindingsError` is the message, not a paraphrase of it: the
1493 /// variant's `Display` and the shared formatter cannot drift apart.
1494 #[test]
1495 fn the_error_variant_renders_the_shared_message() {
1496 let err = FindingsError::InvalidAnalyzerId("Semgrep".to_owned());
1497 assert_eq!(err.to_string(), analyzer_id_error("Semgrep"));
1498
1499 let long = "a".repeat(MAX_ANALYZER_ID + 1);
1500 let err = layer_key(&long, &WorktreeId::new("ab12").expect("worktree"))
1501 .expect_err("a too-long analyzer id must be refused");
1502 assert_eq!(err.to_string(), analyzer_id_error(&long));
1503 assert!(
1504 err.to_string().contains("over the 64-character limit"),
1505 "the rejection must name the length rule: {err}"
1506 );
1507 }
1508
1509 #[test]
1510 fn stable_tokens_round_trip() {
1511 for r in [
1512 RunnerKind::Ingested,
1513 RunnerKind::Subprocess,
1514 RunnerKind::Sandboxed,
1515 ] {
1516 assert_eq!(RunnerKind::from_token(r.as_str()), Some(r));
1517 }
1518 assert_eq!(RunnerKind::from_token("nope"), None);
1519
1520 for i in [Isolation::Ingested, Isolation::MicroVm, Isolation::None] {
1521 assert_eq!(Isolation::from_token(i.as_str()), Some(i));
1522 }
1523 assert_eq!(Isolation::from_token("nope"), None);
1524
1525 for s in [
1526 Severity::Critical,
1527 Severity::High,
1528 Severity::Medium,
1529 Severity::Low,
1530 Severity::Info,
1531 ] {
1532 assert_eq!(Severity::from_token(s.as_str()), s);
1533 }
1534 // An unknown level is preserved, never coerced into a known one.
1535 assert_eq!(
1536 Severity::from_token("moderate"),
1537 Severity::Other("moderate".to_owned())
1538 );
1539 }
1540
1541 #[test]
1542 fn the_default_command_policy_is_the_locked_down_one() {
1543 let policy = CommandPolicy::default();
1544 assert_eq!(policy.network, NetworkPolicy::Deny);
1545 assert_eq!(policy.worktree, WorktreeAccess::ReadOnly);
1546 assert_eq!(policy.environment, EnvironmentPolicy::Scrubbed);
1547 // It survives the JSON round-trip it is stored as.
1548 let json = serde_json::to_string(&policy).expect("serialize");
1549 assert_eq!(
1550 serde_json::from_str::<CommandPolicy>(&json).expect("deserialize"),
1551 policy
1552 );
1553 }
1554
1555 #[test]
1556 fn advisory_db_publication_date_is_optional_but_preserved() {
1557 let db = AdvisoryDb {
1558 digest: "abc".to_owned(),
1559 published_at: Some("2026-08-01T00:00:00Z".to_owned()),
1560 };
1561 let json = serde_json::to_string(&db).expect("serialize");
1562 assert_eq!(
1563 serde_json::from_str::<AdvisoryDb>(&json).expect("deserialize"),
1564 db
1565 );
1566 let bare: AdvisoryDb = serde_json::from_str(r#"{"digest":"abc"}"#).expect("bare");
1567 assert_eq!(bare.published_at, None);
1568 }
1569}