Skip to main content

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}