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 super::{
958        AdvisoryDb, CommandPolicy, EnvironmentPolicy, FindingKey, FindingsError, Isolation,
959        MAX_ANALYZER_ID, MAX_IDENTITY_PART, NetworkPolicy, RunnerKind, Severity, WorktreeAccess,
960        WorktreeId, analyzer_id_error, is_valid_analyzer_id, layer_key,
961    };
962
963    #[test]
964    fn renders_the_documented_analyzer_keys() {
965        let semgrep = FindingKey::new(
966            "semgrep",
967            &["rules.rust.unsafe", "src/lib.rs", "1024", "9f8e7d"],
968        )
969        .expect("key");
970        assert_eq!(
971            semgrep.render(),
972            "finding:semgrep:rules.rust.unsafe:src/lib.rs:1024:9f8e7d"
973        );
974
975        let audit = FindingKey::new(
976            "cargo-audit",
977            &["RUSTSEC-2024-0001", "openssl", "0.10.5", "abc123"],
978        )
979        .expect("key");
980        assert_eq!(
981            audit.render(),
982            "finding:cargo-audit:RUSTSEC-2024-0001:openssl:0.10.5:abc123"
983        );
984    }
985
986    #[test]
987    fn key_round_trips_including_components_containing_colons() {
988        // A namespaced rule id and a drive-letter path both contain `:`; the key
989        // must still parse back to exactly the components it was built from,
990        // otherwise two different findings could collide on one identity.
991        let key = FindingKey::new("semgrep", &["a:b", "C:\\src\\x.rs", "7", "deadbeef"])
992            .expect("build key");
993        let rendered = key.render();
994        assert_eq!(FindingKey::parse(&rendered).expect("parse"), key);
995        assert_eq!(key.analyzer(), "semgrep");
996        assert_eq!(key.parts().len(), 4);
997
998        // Distinct component lists that would collide under naive joining do not.
999        let a = FindingKey::new("semgrep", &["x:y", "z"]).expect("a");
1000        let b = FindingKey::new("semgrep", &["x", "y:z"]).expect("b");
1001        assert_ne!(a.render(), b.render());
1002    }
1003
1004    #[test]
1005    fn key_rejects_ill_formed_identities() {
1006        assert_eq!(
1007            FindingKey::new("Semgrep", &["x"]),
1008            Err(FindingsError::InvalidAnalyzerId("Semgrep".to_owned()))
1009        );
1010        let empty: [&str; 0] = [];
1011        assert_eq!(
1012            FindingKey::new("semgrep", &empty),
1013            Err(FindingsError::EmptyIdentity)
1014        );
1015        assert_eq!(
1016            FindingKey::new("semgrep", &[""]),
1017            Err(FindingsError::InvalidIdentityPart(String::new()))
1018        );
1019        let long = "x".repeat(MAX_IDENTITY_PART + 1);
1020        assert!(matches!(
1021            FindingKey::new("semgrep", &[long.as_str()]),
1022            Err(FindingsError::InvalidIdentityPart(_))
1023        ));
1024        assert!(matches!(
1025            FindingKey::new("semgrep", &["a\nb"]),
1026            Err(FindingsError::InvalidIdentityPart(_))
1027        ));
1028    }
1029
1030    #[test]
1031    fn key_parse_rejects_malformed_strings() {
1032        for bad in [
1033            "notafinding:semgrep:x",
1034            "finding:semgrep",
1035            "finding",
1036            "finding:semgrep:x\\",
1037        ] {
1038            assert!(
1039                matches!(FindingKey::parse(bad), Err(FindingsError::MalformedKey(_))),
1040                "{bad:?} should be rejected"
1041            );
1042        }
1043    }
1044
1045    #[test]
1046    fn key_serializes_as_its_rendered_string() {
1047        let key = FindingKey::new("semgrep", &["r", "p", "1", "h"]).expect("key");
1048        let json = serde_json::to_string(&key).expect("serialize");
1049        assert_eq!(json, "\"finding:semgrep:r:p:1:h\"");
1050        let back: FindingKey = serde_json::from_str(&json).expect("deserialize");
1051        assert_eq!(back, key);
1052        assert!(serde_json::from_str::<FindingKey>("\"nope\"").is_err());
1053    }
1054
1055    #[test]
1056    fn layer_keys_are_analyzer_and_worktree_scoped() {
1057        let wt = WorktreeId::new("ab12cd34").expect("worktree id");
1058        assert_eq!(
1059            layer_key("cargo-audit", &wt).expect("layer"),
1060            "security:cargo-audit:ab12cd34"
1061        );
1062        assert!(matches!(
1063            layer_key("Cargo Audit", &wt),
1064            Err(FindingsError::InvalidAnalyzerId(_))
1065        ));
1066    }
1067
1068    #[test]
1069    fn worktree_ids_are_validated() {
1070        assert_eq!(WorktreeId::new("a1-b2").expect("ok").as_str(), "a1-b2");
1071        for bad in ["", "Upper", "has space", &"x".repeat(65)] {
1072            assert!(
1073                matches!(
1074                    WorktreeId::new(bad),
1075                    Err(FindingsError::InvalidWorktreeId(_))
1076                ),
1077                "{bad:?} should be rejected"
1078            );
1079        }
1080    }
1081
1082    #[test]
1083    fn analyzer_ids_accept_the_real_tool_names_and_reject_separators() {
1084        assert!(is_valid_analyzer_id("cargo-audit"));
1085        assert!(is_valid_analyzer_id("semgrep"));
1086        assert!(is_valid_analyzer_id("trivy.fs"));
1087        assert!(!is_valid_analyzer_id(""));
1088        // A `:` in an analyzer id would make a layer key ambiguous.
1089        assert!(!is_valid_analyzer_id("a:b"));
1090        // The length rule, at the boundary: exactly the limit is fine, one over
1091        // is not.
1092        assert!(is_valid_analyzer_id(&"a".repeat(MAX_ANALYZER_ID)));
1093        assert!(!is_valid_analyzer_id(&"a".repeat(MAX_ANALYZER_ID + 1)));
1094    }
1095
1096    /// A caller must be able to fix its input from the message alone. Each rule
1097    /// the validator enforces has to be *nameable* by the error, and the whole
1098    /// contract has to be stated — the earlier message claimed only "non-empty",
1099    /// so an id rejected for length was told to satisfy a rule it already met.
1100    #[test]
1101    fn a_rejected_analyzer_id_says_which_rule_it_broke() {
1102        let contract = "an analyzer id is 1 to 64 characters of lowercase [a-z0-9._-]";
1103
1104        let empty = analyzer_id_error("");
1105        assert_eq!(
1106            empty,
1107            format!("invalid analyzer id \"\": it is empty — {contract}")
1108        );
1109
1110        let cased = analyzer_id_error("Semgrep");
1111        assert_eq!(
1112            cased,
1113            format!("invalid analyzer id \"Semgrep\": it contains 'S' — {contract}")
1114        );
1115
1116        let long = "a".repeat(MAX_ANALYZER_ID + 13);
1117        let over = analyzer_id_error(&long);
1118        assert!(
1119            over.contains("it is 77 characters, over the 64-character limit"),
1120            "a too-long id must be told about the length rule, got: {over}"
1121        );
1122        assert!(over.contains(contract), "and the whole contract: {over}");
1123
1124        // The character rule is reported before the length rule, so a non-ASCII
1125        // id is never described by a byte count the caller cannot see.
1126        let wide = analyzer_id_error(&"é".repeat(MAX_ANALYZER_ID + 1));
1127        assert!(wide.contains("it contains 'é'"), "got: {wide}");
1128    }
1129
1130    /// The rendered `FindingsError` is the message, not a paraphrase of it: the
1131    /// variant's `Display` and the shared formatter cannot drift apart.
1132    #[test]
1133    fn the_error_variant_renders_the_shared_message() {
1134        let err = FindingsError::InvalidAnalyzerId("Semgrep".to_owned());
1135        assert_eq!(err.to_string(), analyzer_id_error("Semgrep"));
1136
1137        let long = "a".repeat(MAX_ANALYZER_ID + 1);
1138        let err = layer_key(&long, &WorktreeId::new("ab12").expect("worktree"))
1139            .expect_err("a too-long analyzer id must be refused");
1140        assert_eq!(err.to_string(), analyzer_id_error(&long));
1141        assert!(
1142            err.to_string().contains("over the 64-character limit"),
1143            "the rejection must name the length rule: {err}"
1144        );
1145    }
1146
1147    #[test]
1148    fn stable_tokens_round_trip() {
1149        for r in [
1150            RunnerKind::Ingested,
1151            RunnerKind::Subprocess,
1152            RunnerKind::Sandboxed,
1153        ] {
1154            assert_eq!(RunnerKind::from_token(r.as_str()), Some(r));
1155        }
1156        assert_eq!(RunnerKind::from_token("nope"), None);
1157
1158        for i in [Isolation::Ingested, Isolation::MicroVm, Isolation::None] {
1159            assert_eq!(Isolation::from_token(i.as_str()), Some(i));
1160        }
1161        assert_eq!(Isolation::from_token("nope"), None);
1162
1163        for s in [
1164            Severity::Critical,
1165            Severity::High,
1166            Severity::Medium,
1167            Severity::Low,
1168            Severity::Info,
1169        ] {
1170            assert_eq!(Severity::from_token(s.as_str()), s);
1171        }
1172        // An unknown level is preserved, never coerced into a known one.
1173        assert_eq!(
1174            Severity::from_token("moderate"),
1175            Severity::Other("moderate".to_owned())
1176        );
1177    }
1178
1179    #[test]
1180    fn the_default_command_policy_is_the_locked_down_one() {
1181        let policy = CommandPolicy::default();
1182        assert_eq!(policy.network, NetworkPolicy::Deny);
1183        assert_eq!(policy.worktree, WorktreeAccess::ReadOnly);
1184        assert_eq!(policy.environment, EnvironmentPolicy::Scrubbed);
1185        // It survives the JSON round-trip it is stored as.
1186        let json = serde_json::to_string(&policy).expect("serialize");
1187        assert_eq!(
1188            serde_json::from_str::<CommandPolicy>(&json).expect("deserialize"),
1189            policy
1190        );
1191    }
1192
1193    #[test]
1194    fn advisory_db_publication_date_is_optional_but_preserved() {
1195        let db = AdvisoryDb {
1196            digest: "abc".to_owned(),
1197            published_at: Some("2026-08-01T00:00:00Z".to_owned()),
1198        };
1199        let json = serde_json::to_string(&db).expect("serialize");
1200        assert_eq!(
1201            serde_json::from_str::<AdvisoryDb>(&json).expect("deserialize"),
1202            db
1203        );
1204        let bare: AdvisoryDb = serde_json::from_str(r#"{"digest":"abc"}"#).expect("bare");
1205        assert_eq!(bare.published_at, None);
1206    }
1207}