Skip to main content

secunit_core/risks/
store.rs

1//! On-disk risk register: the append protocol, id allocation, log loading,
2//! and the derived `index.json` build/rebuild.
3//!
4//! Writes mirror the evidence runner: every mutation takes the root lock
5//! ([`crate::evidence::lock::RootLock`]), reads the tail for `seq` +
6//! `prev_sha256`, validates the event, appends exactly one line with
7//! `O_APPEND` semantics, and refreshes that risk's index entry. Lines are
8//! never rewritten or deleted. SHA-256 reuses
9//! [`crate::evidence::hasher::sha256_bytes`].
10
11use std::collections::BTreeMap;
12use std::fs::{self, OpenOptions};
13use std::io::Write;
14use std::path::{Path, PathBuf};
15
16use anyhow::{anyhow, bail, Context, Result};
17use chrono::{DateTime, Utc};
18use serde::{Deserialize, Serialize};
19
20use crate::evidence::hasher::{atomic_write, sha256_bytes, sha256_file};
21use crate::evidence::lock::RootLock;
22use crate::risks::fold::{self, validate_transition};
23use crate::risks::model::{
24    Agent, EventData, EventEnvelope, ExternalLink, FindingRef, RiskEvent, RiskState, Severity,
25    Status,
26};
27use crate::schemas::Schema;
28use crate::SCHEMA_VERSION;
29
30const RISKS_DIR: &str = "risks";
31const EVENTS_FILE: &str = "events.jsonl";
32const INDEX_FILE: &str = "index.json";
33
34// ---------- index types -----------------------------------------------------
35
36/// `risks/index.json` — the derived register cache, same role as
37/// `state.json`. Regenerable from the logs with [`rebuild`].
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct RiskIndex {
40    pub schema_version: u32,
41    #[serde(default)]
42    pub risks: BTreeMap<String, RiskIndexEntry>,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub updated_at: Option<DateTime<Utc>>,
45}
46
47impl Default for RiskIndex {
48    fn default() -> Self {
49        Self {
50            schema_version: SCHEMA_VERSION,
51            risks: BTreeMap::new(),
52            updated_at: None,
53        }
54    }
55}
56
57/// One projected risk in the index — its fold flattened for fast
58/// list/dashboard reads.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct RiskIndexEntry {
61    pub title: String,
62    pub fingerprint: String,
63    pub severity: Severity,
64    pub status: Status,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub owner: Option<String>,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub due_at: Option<chrono::NaiveDate>,
69    pub source_control: String,
70    pub first_run_id: String,
71    #[serde(default, skip_serializing_if = "Vec::is_empty")]
72    pub external: Vec<IndexExternal>,
73    /// SHA-256 of the latest event line this entry was built from, so
74    /// readers can detect staleness without re-folding.
75    pub log_head_sha256: String,
76}
77
78/// External tracker mirror as projected into the index (`{system, id, url}`).
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct IndexExternal {
81    pub system: String,
82    pub id: String,
83    pub url: String,
84}
85
86impl From<&ExternalLink> for IndexExternal {
87    fn from(e: &ExternalLink) -> Self {
88        Self {
89            system: e.system.clone(),
90            id: e.external_id.clone(),
91            url: e.url.clone(),
92        }
93    }
94}
95
96// ---------- outcomes --------------------------------------------------------
97
98/// Result of [`append`]: the event written and the new chain head sha.
99#[derive(Debug, Clone)]
100pub struct AppendOutcome {
101    pub risk_id: String,
102    pub event: RiskEvent,
103    /// SHA-256 of the line just written — the new chain head.
104    pub log_head_sha256: String,
105}
106
107/// Result of [`open`]: the allocated id plus the `opened` event written.
108#[derive(Debug, Clone)]
109pub struct OpenOutcome {
110    pub risk_id: String,
111    pub event: RiskEvent,
112    pub log_head_sha256: String,
113}
114
115// ---------- paths -----------------------------------------------------------
116
117fn risks_root(root: &Path) -> PathBuf {
118    root.join(RISKS_DIR)
119}
120
121fn risk_dir(root: &Path, risk_id: &str) -> PathBuf {
122    risks_root(root).join(risk_id)
123}
124
125fn events_path(root: &Path, risk_id: &str) -> PathBuf {
126    risk_dir(root, risk_id).join(EVENTS_FILE)
127}
128
129fn index_path(root: &Path) -> PathBuf {
130    risks_root(root).join(INDEX_FILE)
131}
132
133// ---------- loading ---------------------------------------------------------
134
135/// Read and parse a risk's `events.jsonl` in `seq` order.
136///
137/// Validates structural integrity: monotonic 1-based `seq`, a leading
138/// `opened` event, and a `prev_sha256` chain where each line's `prev_sha256`
139/// equals the SHA-256 of the previous line's bytes. Returns the parsed
140/// events; fold them with [`crate::risks::fold::fold`].
141pub fn load_events(root: &Path, risk_id: &str) -> Result<Vec<RiskEvent>> {
142    let path = events_path(root, risk_id);
143    let text = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
144    parse_events(&text, &path)
145}
146
147fn parse_events(text: &str, path: &Path) -> Result<Vec<RiskEvent>> {
148    let mut events: Vec<RiskEvent> = Vec::new();
149    let mut prev_line_sha: Option<String> = None;
150    for (i, raw) in text.lines().enumerate() {
151        let line = raw.trim_end_matches(['\r', '\n']);
152        if line.trim().is_empty() {
153            continue;
154        }
155        let ev: RiskEvent = serde_json::from_str(line).with_context(|| {
156            format!(
157                "{}: line {} is not a valid risk event",
158                path.display(),
159                i + 1
160            )
161        })?;
162
163        let expected_seq = (events.len() as u64) + 1;
164        if ev.seq != expected_seq {
165            bail!(
166                "{}: line {} has seq {} but expected {}",
167                path.display(),
168                i + 1,
169                ev.seq,
170                expected_seq
171            );
172        }
173        if events.is_empty() && !matches!(ev.data, EventData::Opened { .. }) {
174            bail!(
175                "{}: first event is `{}`, must be `opened`",
176                path.display(),
177                ev.data.type_str()
178            );
179        }
180        if ev.prev_sha256 != prev_line_sha {
181            bail!(
182                "{}: line {} broken hash chain (prev_sha256={:?}, expected {:?})",
183                path.display(),
184                i + 1,
185                ev.prev_sha256,
186                prev_line_sha
187            );
188        }
189        prev_line_sha = Some(sha256_bytes(line.as_bytes()));
190        events.push(ev);
191    }
192    if events.is_empty() {
193        bail!("{}: empty risk log", path.display());
194    }
195    Ok(events)
196}
197
198/// Read the tail of an existing log without full validation: the next `seq`
199/// and the chain head sha (SHA-256 of the last line). Returns `None` if the
200/// log does not exist yet.
201fn read_tail(root: &Path, risk_id: &str) -> Result<Option<(u64, String, EventEnvelope)>> {
202    let path = events_path(root, risk_id);
203    if !path.exists() {
204        return Ok(None);
205    }
206    let text = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
207    let last = text
208        .lines()
209        .map(|l| l.trim_end_matches(['\r', '\n']))
210        .rfind(|l| !l.trim().is_empty())
211        .ok_or_else(|| anyhow!("{}: log exists but is empty", path.display()))?;
212    let ev: EventEnvelope = serde_json::from_str(last)
213        .with_context(|| format!("{}: tail line is not a valid risk event", path.display()))?;
214    let head_sha = sha256_bytes(last.as_bytes());
215    Ok(Some((ev.seq + 1, head_sha, ev)))
216}
217
218// ---------- canonical line --------------------------------------------------
219
220/// Serialise an envelope to its canonical, compact JSON line (no trailing
221/// newline). This is the exact byte string written to disk and the input to
222/// the next event's `prev_sha256`, so it must be deterministic.
223fn canonical_line(ev: &EventEnvelope) -> Result<String> {
224    Ok(serde_json::to_string(ev)?)
225}
226
227// ---------- append ----------------------------------------------------------
228
229/// Append exactly one event to `risk_id`'s log under the root lock, then
230/// refresh its index entry.
231///
232/// Protocol (mirrors the manifest chain + state rebuild):
233/// 1. take the root lock,
234/// 2. read the tail for `seq` and the chain head sha,
235/// 3. validate the status transition (for lifecycle events) against the
236///    status machine and the event against `risk-event.schema.json`,
237/// 4. set `prev_sha256` to the head sha and `seq` to tail+1,
238/// 5. append ONE line with `O_APPEND` — existing lines are never touched,
239/// 6. refresh this risk's `index.json` entry from the freshly-folded state.
240///
241/// `actor` is the operator handle; `agent` is `Some` when an agent (not a
242/// direct operator action) appended the event. `ts` lets tests pin the clock;
243/// production callers pass `None` for wall-clock.
244pub fn append(
245    root: &Path,
246    risk_id: &str,
247    data: EventData,
248    actor: &str,
249    agent: Option<Agent>,
250    ts: Option<DateTime<Utc>>,
251) -> Result<AppendOutcome> {
252    let _lock = RootLock::acquire(root).context("acquire root lock")?;
253    append_locked(root, risk_id, data, actor, agent, ts)
254}
255
256/// The append body, assuming the caller already holds the root lock. Used by
257/// [`open`], which must allocate the id and write under one lock hold.
258fn append_locked(
259    root: &Path,
260    risk_id: &str,
261    data: EventData,
262    actor: &str,
263    agent: Option<Agent>,
264    ts: Option<DateTime<Utc>>,
265) -> Result<AppendOutcome> {
266    let tail = read_tail(root, risk_id)?;
267    let (seq, prev_sha256, prior_events) = match &tail {
268        None => {
269            // First event of a brand-new log must be `opened`.
270            if !matches!(data, EventData::Opened { .. }) {
271                bail!(
272                    "cannot append `{}` to {risk_id}: log does not exist (first event must be `opened`)",
273                    data.type_str()
274                );
275            }
276            (1u64, None, Vec::new())
277        }
278        Some((next_seq, head_sha, _tail_ev)) => {
279            if matches!(data, EventData::Opened { .. }) {
280                bail!("cannot append a second `opened` event to {risk_id}");
281            }
282            // Load + validate the full prior chain so we fold against a sane
283            // state and can validate transitions from the real current
284            // status.
285            let prior = load_events(root, risk_id)?;
286            (*next_seq, Some(head_sha.clone()), prior)
287        }
288    };
289
290    let ev = EventEnvelope {
291        seq,
292        ts: ts.unwrap_or_else(Utc::now),
293        actor: actor.to_string(),
294        agent,
295        prev_sha256,
296        data,
297    };
298
299    // Validate the status transition for lifecycle events against the
300    // current folded status. Rejected transitions never reach the log.
301    if !prior_events.is_empty() {
302        let current = fold::fold(&prior_events).status;
303        if let Some((from, to)) = lifecycle_transition(&prior_events, &ev.data) {
304            // `from` declared on status-changed must match reality; for the
305            // shorthand events we derive `from` from the fold.
306            if let EventData::StatusChanged { from: declared, .. } = &ev.data {
307                if *declared != current {
308                    bail!(
309                        "status-changed `from` is {} but the risk is currently {}",
310                        declared.as_str(),
311                        current.as_str()
312                    );
313                }
314            }
315            validate_transition(from, to).map_err(|e| anyhow!(e))?;
316        }
317    }
318
319    // Schema-validate the on-disk shape before writing.
320    let value = serde_json::to_value(&ev)?;
321    let errs = Schema::RiskEvent.validate(&value);
322    if !errs.is_empty() {
323        bail!(
324            "risk event fails risk-event.schema.json: {}",
325            errs.join("; ")
326        );
327    }
328
329    let line = canonical_line(&ev)?;
330    let head_sha = sha256_bytes(line.as_bytes());
331
332    // Ensure the risk dir exists, then append one line with O_APPEND.
333    fs::create_dir_all(risk_dir(root, risk_id))?;
334    let path = events_path(root, risk_id);
335    let mut f = OpenOptions::new()
336        .create(true)
337        .append(true)
338        .open(&path)
339        .with_context(|| format!("open {} for append", path.display()))?;
340    f.write_all(line.as_bytes())?;
341    f.write_all(b"\n")?;
342    f.sync_all()?;
343
344    // Refresh this risk's index entry from the now-current fold.
345    let mut all = prior_events;
346    all.push(ev.clone());
347    refresh_index_entry(root, risk_id, &all, &head_sha)?;
348
349    Ok(AppendOutcome {
350        risk_id: risk_id.to_string(),
351        event: ev,
352        log_head_sha256: head_sha,
353    })
354}
355
356/// For an event about to be applied to a non-empty log, return the
357/// `(from, to)` lifecycle transition it represents, or `None` if it isn't a
358/// lifecycle-changing event.
359fn lifecycle_transition(prior: &[RiskEvent], data: &EventData) -> Option<(Status, Status)> {
360    let current = fold::fold(prior).status;
361    match data {
362        EventData::StatusChanged { from, to, .. } => Some((*from, *to)),
363        EventData::Remediated { .. } => Some((current, Status::Remediated)),
364        EventData::Reopened { .. } => Some((current, Status::Reopened)),
365        EventData::ExceptionDocumented { .. } => Some((current, Status::AcceptedException)),
366        _ => None,
367    }
368}
369
370// ---------- open ------------------------------------------------------------
371
372/// Allocate the next `R-NNNN` id under the root lock and write the `opened`
373/// event. The fingerprint is `<control_id>:<finding_id>` carried in
374/// `finding_ref`.
375///
376/// Callers that promote a sealed `draft_risk` from a run should first call
377/// [`verify_finding_ref`] so the risk cannot be bound to absent or fabricated
378/// evidence.
379#[allow(clippy::too_many_arguments)]
380pub fn open(
381    root: &Path,
382    finding_ref: FindingRef,
383    title: impl Into<String>,
384    severity: Severity,
385    impact: u8,
386    likelihood: u8,
387    affected_systems: Vec<String>,
388    sla_days: u32,
389    due_at: chrono::NaiveDate,
390    actor: &str,
391    agent: Option<Agent>,
392    ts: Option<DateTime<Utc>>,
393) -> Result<OpenOutcome> {
394    let _lock = RootLock::acquire(root).context("acquire root lock")?;
395    let risk_id = allocate_risk_id(root)?;
396    let data = EventData::Opened {
397        finding_ref,
398        title: title.into(),
399        severity,
400        impact,
401        likelihood,
402        affected_systems,
403        sla_days,
404        due_at,
405    };
406    let out = append_locked(root, &risk_id, data, actor, agent, ts)?;
407    Ok(OpenOutcome {
408        risk_id: out.risk_id,
409        event: out.event,
410        log_head_sha256: out.log_head_sha256,
411    })
412}
413
414/// Scan `risks/` for the highest `R-NNNN` and return the next id. Globally
415/// sequential, zero-padded to four digits, allocated under the lock.
416fn allocate_risk_id(root: &Path) -> Result<String> {
417    let dir = risks_root(root);
418    fs::create_dir_all(&dir)?;
419    let mut max = 0u32;
420    for entry in fs::read_dir(&dir)? {
421        let entry = entry?;
422        if !entry.file_type()?.is_dir() {
423            continue;
424        }
425        if let Some(name) = entry.file_name().to_str() {
426            if let Some(n) = parse_risk_id(name) {
427                max = max.max(n);
428            }
429        }
430    }
431    Ok(format!("R-{:04}", max + 1))
432}
433
434/// Parse the numeric component of an `R-NNNN` id.
435fn parse_risk_id(name: &str) -> Option<u32> {
436    let digits = name.strip_prefix("R-")?;
437    if digits.len() == 4 && digits.bytes().all(|b| b.is_ascii_digit()) {
438        digits.parse().ok()
439    } else {
440        None
441    }
442}
443
444// ---------- finding-ref verification ----------------------------------------
445
446/// Verify a `finding_ref` resolves to a real sealed manifest whose recomputed
447/// sha matches `manifest_sha256`. Used by `risks open --from <run-dir>` so a
448/// risk cannot be bound to absent or fabricated evidence.
449///
450/// `run_dir` is the sealed run directory holding `manifest.json`. Errors if
451/// the manifest is missing, its sha mismatches, or its control/run id differ
452/// from the finding ref.
453pub fn verify_finding_ref(run_dir: &Path, finding_ref: &FindingRef) -> Result<()> {
454    let manifest_path = run_dir.join("manifest.json");
455    if !manifest_path.exists() {
456        bail!(
457            "finding_ref points at {} but no manifest.json exists there",
458            run_dir.display()
459        );
460    }
461    let actual =
462        sha256_file(&manifest_path).with_context(|| format!("hash {}", manifest_path.display()))?;
463    if actual != finding_ref.manifest_sha256 {
464        bail!(
465            "manifest sha mismatch for {}: finding_ref says {}, recomputed {}",
466            run_dir.display(),
467            finding_ref.manifest_sha256,
468            actual
469        );
470    }
471    // Cross-check the manifest identifies the same control/run.
472    let bytes = fs::read(&manifest_path)?;
473    let manifest: crate::evidence::manifest::Manifest = serde_json::from_slice(&bytes)
474        .with_context(|| format!("parse {}", manifest_path.display()))?;
475    if manifest.control_id != finding_ref.control_id {
476        bail!(
477            "finding_ref control_id `{}` != manifest control_id `{}`",
478            finding_ref.control_id,
479            manifest.control_id
480        );
481    }
482    if manifest.run_id != finding_ref.run_id {
483        bail!(
484            "finding_ref run_id `{}` != manifest run_id `{}`",
485            finding_ref.run_id,
486            manifest.run_id
487        );
488    }
489    Ok(())
490}
491
492// ---------- index build / rebuild -------------------------------------------
493
494/// Project a folded state into an index entry pinned to `log_head_sha256`.
495fn entry_from_state(state: &RiskState, log_head_sha256: &str) -> RiskIndexEntry {
496    RiskIndexEntry {
497        title: state.title.clone(),
498        fingerprint: state.fingerprint().unwrap_or_default(),
499        severity: state.severity,
500        status: state.status,
501        owner: state.owner.clone(),
502        due_at: state.due_at,
503        source_control: state.source_control().unwrap_or_default().to_string(),
504        first_run_id: state.first_run_id().unwrap_or_default().to_string(),
505        external: state.external.iter().map(IndexExternal::from).collect(),
506        log_head_sha256: log_head_sha256.to_string(),
507    }
508}
509
510/// Refresh a single risk's index entry in place, leaving every other entry
511/// untouched. Assumes the caller holds the root lock.
512fn refresh_index_entry(
513    root: &Path,
514    risk_id: &str,
515    events: &[RiskEvent],
516    log_head_sha256: &str,
517) -> Result<()> {
518    let path = index_path(root);
519    let mut index: RiskIndex = if path.exists() {
520        let bytes = fs::read(&path)?;
521        serde_json::from_slice(&bytes).with_context(|| {
522            format!(
523                "{} is corrupt; refusing to overwrite — run `risks rebuild` to regenerate",
524                path.display()
525            )
526        })?
527    } else {
528        RiskIndex::default()
529    };
530    let state = fold::fold(events);
531    index.risks.insert(
532        risk_id.to_string(),
533        entry_from_state(&state, log_head_sha256),
534    );
535    index.updated_at = Some(Utc::now());
536    write_index(&path, &index)
537}
538
539/// Build the full index in memory by folding every risk log under
540/// `risks/`. Pure-ish: reads the logs but writes nothing. `rebuild` wraps
541/// this with the lock and an atomic write.
542pub fn build_index(root: &Path) -> Result<RiskIndex> {
543    let (index, errors) = build_index_lenient(root)?;
544    if let Some((name, error)) = errors.first() {
545        bail!("load events for {name}: {error}");
546    }
547    Ok(index)
548}
549
550/// [`build_index`], but a broken log degrades instead of failing: the
551/// index covers every readable risk and the broken ones come back as
552/// `(risk_id, error)` pairs the caller must surface. Read surfaces
553/// (`risks list`, the GUI, report assembly) use this so one corrupt log
554/// doesn't take every register view down; [`rebuild`] stays strict —
555/// regenerating the canonical cache from a broken register must fail.
556pub fn build_index_lenient(root: &Path) -> Result<(RiskIndex, Vec<(String, String)>)> {
557    let register = load_register(root)?;
558    let mut index = RiskIndex::default();
559    for (name, events) in &register.risks {
560        let head_sha = log_head_sha(root, name)?;
561        let state = fold::fold(events);
562        index
563            .risks
564            .insert(name.clone(), entry_from_state(&state, &head_sha));
565    }
566    index.updated_at = Some(Utc::now());
567    Ok((index, register.errors))
568}
569
570/// The register, leniently loaded: events for every member risk that
571/// parses, plus `(risk_id, error)` for every log that could not be read
572/// or failed chain validation. The single degradation rule for register
573/// consumers — a broken log never silently disappears and never takes
574/// the other risks with it.
575pub struct Register {
576    pub risks: Vec<(String, Vec<RiskEvent>)>,
577    pub errors: Vec<(String, String)>,
578}
579
580pub fn load_register(root: &Path) -> Result<Register> {
581    let mut risks = Vec::new();
582    let mut errors = Vec::new();
583    for id in risk_ids(root)? {
584        match load_events(root, &id) {
585            Ok(events) => risks.push((id, events)),
586            Err(e) => errors.push((id, format!("{e:#}"))),
587        }
588    }
589    Ok(Register { risks, errors })
590}
591
592/// Every risk id in the register: a `risks/<R-NNNN>/` directory whose name
593/// passes [`parse_risk_id`] and that has an `events.jsonl`. Sorted. This is
594/// the register's single membership rule — index builds and report
595/// assembly go through it so they can never disagree about what counts as
596/// a risk (stray dirs, backups, and scratch copies are ignored by both).
597pub fn risk_ids(root: &Path) -> Result<Vec<String>> {
598    let dir = risks_root(root);
599    let mut ids: Vec<String> = Vec::new();
600    if !dir.exists() {
601        return Ok(ids);
602    }
603    for entry in fs::read_dir(&dir)? {
604        let entry = entry?;
605        if !entry.file_type()?.is_dir() {
606            continue;
607        }
608        let name = match entry.file_name().to_str() {
609            Some(n) if parse_risk_id(n).is_some() => n.to_string(),
610            _ => continue,
611        };
612        if !events_path(root, &name).exists() {
613            continue;
614        }
615        ids.push(name);
616    }
617    ids.sort();
618    Ok(ids)
619}
620
621/// Regenerate `risks/index.json` from all logs and write it atomically under
622/// the root lock — the `state.json` rebuild analogue for the register.
623pub fn rebuild(root: &Path) -> Result<RiskIndex> {
624    let _lock = RootLock::acquire(root).context("acquire root lock")?;
625    let index = build_index(root)?;
626    fs::create_dir_all(risks_root(root))?;
627    write_index(&index_path(root), &index)?;
628    Ok(index)
629}
630
631/// SHA-256 of a risk log's last (head) line — the chain head.
632fn log_head_sha(root: &Path, risk_id: &str) -> Result<String> {
633    let path = events_path(root, risk_id);
634    let text = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
635    let last = text
636        .lines()
637        .map(|l| l.trim_end_matches(['\r', '\n']))
638        .rfind(|l| !l.trim().is_empty())
639        .ok_or_else(|| anyhow!("{}: empty log", path.display()))?;
640    Ok(sha256_bytes(last.as_bytes()))
641}
642
643fn write_index(path: &Path, index: &RiskIndex) -> Result<()> {
644    // Validate against risk-index.schema.json so a malformed projection
645    // never lands on disk.
646    let value = serde_json::to_value(index)?;
647    let errs = Schema::RiskIndex.validate(&value);
648    if !errs.is_empty() {
649        bail!(
650            "risk index fails risk-index.schema.json: {}",
651            errs.join("; ")
652        );
653    }
654    // Pretty-printed: the index is a derived cache (not chained), so human
655    // readability beats compactness, matching state.json.
656    let bytes = serde_json::to_vec_pretty(index)?;
657    atomic_write(path, &bytes).with_context(|| format!("write {}", path.display()))
658}
659
660#[cfg(test)]
661mod tests {
662    include!("tests.rs");
663}