1use serde::{Deserialize, Serialize};
19
20use crate::BrowserFamily;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
29pub enum Priority {
30 High,
31 Medium,
32 Info,
33}
34
35impl std::fmt::Display for Priority {
36 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37 match self {
38 Self::High => write!(f, "High"),
39 Self::Medium => write!(f, "Medium"),
40 Self::Info => write!(f, "Info"),
41 }
42 }
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
49pub enum Confidence {
50 High,
51 Medium,
52 Low,
53}
54
55impl std::fmt::Display for Confidence {
56 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57 match self {
58 Self::High => write!(f, "High"),
59 Self::Medium => write!(f, "Medium"),
60 Self::Low => write!(f, "Low"),
61 }
62 }
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
70pub enum EvidenceSource {
71 History,
72 Cache,
73 Cookie,
74 Download,
75 Carved,
76 Memory,
77 Extension,
79 Recovered,
84}
85
86impl std::fmt::Display for EvidenceSource {
87 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88 match self {
89 Self::History => write!(f, "history"),
90 Self::Cache => write!(f, "cache"),
91 Self::Cookie => write!(f, "cookie"),
92 Self::Download => write!(f, "download"),
93 Self::Carved => write!(f, "carved"),
94 Self::Memory => write!(f, "memory"),
95 Self::Extension => write!(f, "extension"),
96 Self::Recovered => write!(f, "recovered"),
97 }
98 }
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
104pub enum EvidenceState {
105 Live,
106 Deleted,
107 Carved,
108 Reconstructed,
109 Inferred,
110}
111
112impl std::fmt::Display for EvidenceState {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 match self {
115 Self::Live => write!(f, "live"),
116 Self::Deleted => write!(f, "deleted"),
117 Self::Carved => write!(f, "carved"),
118 Self::Reconstructed => write!(f, "reconstructed"),
119 Self::Inferred => write!(f, "inferred"),
120 }
121 }
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
126#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
127pub enum TimestampBasis {
128 Explicit,
130 Inferred,
132 SurroundingPage,
134 None,
136}
137
138impl std::fmt::Display for TimestampBasis {
139 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140 match self {
141 Self::Explicit => write!(f, "explicit"),
142 Self::Inferred => write!(f, "inferred"),
143 Self::SurroundingPage => write!(f, "surrounding-page"),
144 Self::None => write!(f, "none"),
145 }
146 }
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
153#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
154pub enum UserActionClaim {
155 Visited,
156 Downloaded,
157 Searched,
158 ObservedString,
159 Unknown,
160}
161
162impl std::fmt::Display for UserActionClaim {
163 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164 match self {
165 Self::Visited => write!(f, "visited"),
166 Self::Downloaded => write!(f, "downloaded"),
167 Self::Searched => write!(f, "searched"),
168 Self::ObservedString => write!(f, "observed-string"),
169 Self::Unknown => write!(f, "unknown"),
170 }
171 }
172}
173
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
179pub struct Provenance {
180 pub source: EvidenceSource,
182 pub state: EvidenceState,
184 pub timestamp_basis: TimestampBasis,
186 pub user_action_claim: UserActionClaim,
188}
189
190impl Provenance {
191 #[must_use]
193 pub fn new(
194 source: EvidenceSource,
195 state: EvidenceState,
196 timestamp_basis: TimestampBasis,
197 user_action_claim: UserActionClaim,
198 ) -> Self {
199 Self {
200 source,
201 state,
202 timestamp_basis,
203 user_action_claim,
204 }
205 }
206}
207
208#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
215pub struct Finding {
216 pub priority: Priority,
218 pub confidence: Confidence,
220 pub rule_id: String,
222 pub interpretation: String,
224 pub provenance: Provenance,
226 pub user: Option<String>,
228 pub profile: Option<String>,
230 pub browser: Option<BrowserFamily>,
232 pub evidence: String,
235 pub next: Option<String>,
237}
238
239impl Finding {
240 #[must_use]
247 pub fn new(
248 priority: Priority,
249 confidence: Confidence,
250 rule_id: impl Into<String>,
251 interpretation: impl Into<String>,
252 provenance: Provenance,
253 evidence: impl Into<String>,
254 ) -> Self {
255 Self {
256 priority,
257 confidence,
258 rule_id: rule_id.into(),
259 interpretation: interpretation.into(),
260 provenance,
261 user: None,
262 profile: None,
263 browser: None,
264 evidence: evidence.into(),
265 next: None,
266 }
267 }
268
269 #[must_use]
271 pub fn with_user(mut self, user: impl Into<String>) -> Self {
272 self.user = Some(user.into());
273 self
274 }
275
276 #[must_use]
278 pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
279 self.profile = Some(profile.into());
280 self
281 }
282
283 #[must_use]
285 pub fn with_browser(mut self, browser: BrowserFamily) -> Self {
286 self.browser = Some(browser);
287 self
288 }
289
290 #[must_use]
292 pub fn with_next(mut self, next: impl Into<String>) -> Self {
293 self.next = Some(next.into());
294 self
295 }
296
297 #[must_use]
305 pub fn render(&self) -> String {
306 use std::fmt::Write as _;
307 let mut out = String::new();
308 let _ = writeln!(
310 out,
311 "Priority: {} (look here first — a triage attention cue)",
312 self.priority
313 );
314 let _ = writeln!(
315 out,
316 "Confidence: {} (rule {})",
317 self.confidence, self.rule_id
318 );
319 let _ = writeln!(out, "Interpretation: {}", self.interpretation);
320 let _ = writeln!(out, "Rule: {}", self.rule_id);
321 let p = &self.provenance;
322 let _ = writeln!(
323 out,
324 "Provenance: {} · {} · time {} · {}",
325 p.source, p.state, p.timestamp_basis, p.user_action_claim
326 );
327 if let Some(origin) = self.origin_line() {
328 let _ = writeln!(out, "Origin: {origin}");
329 }
330 let _ = writeln!(out, "Evidence: {}", self.evidence);
331 if let Some(next) = &self.next {
332 let _ = writeln!(out, "Next: {next}");
333 }
334 out
335 }
336
337 fn origin_line(&self) -> Option<String> {
340 let mut parts: Vec<String> = Vec::new();
341 if let Some(browser) = &self.browser {
342 parts.push(browser.to_string());
343 }
344 if let Some(profile) = &self.profile {
345 parts.push(profile.clone());
346 }
347 if let Some(user) = &self.user {
348 parts.push(format!("user {user}"));
349 }
350 if parts.is_empty() {
351 None
352 } else {
353 Some(parts.join(" · "))
354 }
355 }
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361
362 fn sample_provenance() -> Provenance {
363 Provenance::new(
364 EvidenceSource::History,
365 EvidenceState::Live,
366 TimestampBasis::Explicit,
367 UserActionClaim::Visited,
368 )
369 }
370
371 fn sample_finding() -> Finding {
372 Finding::new(
373 Priority::High,
374 Confidence::Medium,
375 "integrity.history.rowid_gap.v1",
376 "consistent with manual deletion, DB maintenance, or profile sync",
377 sample_provenance(),
378 "Chrome History urls rowid gap 128 → 944",
379 )
380 }
381
382 #[test]
383 fn provenance_carries_four_axes() {
384 let p = sample_provenance();
385 assert_eq!(p.source, EvidenceSource::History);
386 assert_eq!(p.state, EvidenceState::Live);
387 assert_eq!(p.timestamp_basis, TimestampBasis::Explicit);
388 assert_eq!(p.user_action_claim, UserActionClaim::Visited);
389 }
390
391 #[test]
392 fn new_sets_three_distinct_axes() {
393 let f = sample_finding();
394 assert_eq!(f.priority, Priority::High);
397 assert_eq!(f.confidence, Confidence::Medium);
398 assert_eq!(f.rule_id, "integrity.history.rowid_gap.v1");
399 assert!(f.interpretation.starts_with("consistent with"));
400 }
401
402 #[test]
403 fn origin_builder_sets_user_profile_browser() {
404 let f = sample_finding()
405 .with_user("S-1-5-21-1004")
406 .with_profile("Chrome/Default")
407 .with_browser(BrowserFamily::Chromium)
408 .with_next("br4n6 artifact integrity --rule history.rowid_gap <PATH>");
409 assert_eq!(f.user.as_deref(), Some("S-1-5-21-1004"));
410 assert_eq!(f.profile.as_deref(), Some("Chrome/Default"));
411 assert_eq!(f.browser, Some(BrowserFamily::Chromium));
412 assert!(f.next.is_some());
413 }
414
415 #[test]
416 fn roundtrip_json_preserves_all_fields() {
417 let f = sample_finding()
418 .with_user("alice")
419 .with_profile("Chrome/Default")
420 .with_browser(BrowserFamily::Chromium)
421 .with_next("br4n6 artifact integrity <PATH>");
422 let json = serde_json::to_string(&f).expect("serialize");
423 let back: Finding = serde_json::from_str(&json).expect("deserialize");
424 assert_eq!(f, back, "JSONL round-trip must be faithful");
425 }
426
427 #[test]
428 fn three_axes_serialize_as_distinct_top_level_fields() {
429 let f = sample_finding();
430 let v = serde_json::to_value(&f).expect("to_value");
431 let obj = v.as_object().expect("finding serializes as an object");
432 assert_eq!(obj.get("priority").and_then(|x| x.as_str()), Some("High"));
435 assert_eq!(
436 obj.get("confidence").and_then(|x| x.as_str()),
437 Some("Medium")
438 );
439 assert!(
440 obj.get("interpretation").is_some(),
441 "interpretation is a distinct field"
442 );
443 assert!(obj.contains_key("rule_id"), "rule_id is a distinct field");
444 let prov = obj
446 .get("provenance")
447 .and_then(|x| x.as_object())
448 .expect("provenance object present");
449 for key in ["source", "state", "timestamp_basis", "user_action_claim"] {
450 assert!(prov.contains_key(key), "provenance carries `{key}`");
451 }
452 }
453
454 #[test]
455 fn render_shows_all_three_axes_with_labels() {
456 let f = sample_finding();
457 let r = f.render();
458 assert!(r.contains("Priority:"), "labels the priority axis: {r}");
459 assert!(r.contains("Confidence:"), "labels the confidence axis");
460 assert!(r.contains("Interpretation:"), "labels the interpretation");
461 assert!(
462 r.contains("integrity.history.rowid_gap.v1"),
463 "shows rule id"
464 );
465 assert!(
466 r.contains("Chrome History urls rowid gap"),
467 "shows evidence"
468 );
469 }
470
471 #[test]
472 fn render_labels_priority_as_attention_cue() {
473 let f = sample_finding();
474 let r = f.render();
475 assert!(
478 r.contains("attention cue"),
479 "priority is framed as a triage attention cue, not a finding of malice: {r}"
480 );
481 }
482
483 #[test]
484 fn render_priority_never_appears_without_interpretation_hedge() {
485 let f = sample_finding();
486 let r = f.render();
487 assert!(r.contains("Priority:"));
490 assert!(
491 r.contains(&f.interpretation),
492 "the interpretation hedge accompanies every rendered priority: {r}"
493 );
494 }
495}