1use 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#[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#[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 pub log_head_sha256: String,
76}
77
78#[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#[derive(Debug, Clone)]
100pub struct AppendOutcome {
101 pub risk_id: String,
102 pub event: RiskEvent,
103 pub log_head_sha256: String,
105}
106
107#[derive(Debug, Clone)]
109pub struct OpenOutcome {
110 pub risk_id: String,
111 pub event: RiskEvent,
112 pub log_head_sha256: String,
113}
114
115fn 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
133pub 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
198fn 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
218fn canonical_line(ev: &EventEnvelope) -> Result<String> {
224 Ok(serde_json::to_string(ev)?)
225}
226
227pub 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
256fn 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 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 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 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 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 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 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 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
356fn 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#[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
414fn 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
434fn 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
444pub 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 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
492fn 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
510fn 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
539pub 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
550pub 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 ®ister.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
570pub 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
592pub 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
621pub 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
631fn 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 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 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}