use serde::{Deserialize, Serialize};
use crate::BrowserFamily;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum Priority {
High,
Medium,
Info,
}
impl std::fmt::Display for Priority {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::High => write!(f, "High"),
Self::Medium => write!(f, "Medium"),
Self::Info => write!(f, "Info"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum Confidence {
High,
Medium,
Low,
}
impl std::fmt::Display for Confidence {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::High => write!(f, "High"),
Self::Medium => write!(f, "Medium"),
Self::Low => write!(f, "Low"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum EvidenceSource {
History,
Cache,
Cookie,
Download,
Carved,
Memory,
Extension,
Recovered,
}
impl std::fmt::Display for EvidenceSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::History => write!(f, "history"),
Self::Cache => write!(f, "cache"),
Self::Cookie => write!(f, "cookie"),
Self::Download => write!(f, "download"),
Self::Carved => write!(f, "carved"),
Self::Memory => write!(f, "memory"),
Self::Extension => write!(f, "extension"),
Self::Recovered => write!(f, "recovered"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum EvidenceState {
Live,
Deleted,
Carved,
Reconstructed,
Inferred,
}
impl std::fmt::Display for EvidenceState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Live => write!(f, "live"),
Self::Deleted => write!(f, "deleted"),
Self::Carved => write!(f, "carved"),
Self::Reconstructed => write!(f, "reconstructed"),
Self::Inferred => write!(f, "inferred"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum TimestampBasis {
Explicit,
Inferred,
SurroundingPage,
None,
}
impl std::fmt::Display for TimestampBasis {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Explicit => write!(f, "explicit"),
Self::Inferred => write!(f, "inferred"),
Self::SurroundingPage => write!(f, "surrounding-page"),
Self::None => write!(f, "none"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum UserActionClaim {
Visited,
Downloaded,
Searched,
ObservedString,
Unknown,
}
impl std::fmt::Display for UserActionClaim {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Visited => write!(f, "visited"),
Self::Downloaded => write!(f, "downloaded"),
Self::Searched => write!(f, "searched"),
Self::ObservedString => write!(f, "observed-string"),
Self::Unknown => write!(f, "unknown"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct Provenance {
pub source: EvidenceSource,
pub state: EvidenceState,
pub timestamp_basis: TimestampBasis,
pub user_action_claim: UserActionClaim,
}
impl Provenance {
#[must_use]
pub fn new(
source: EvidenceSource,
state: EvidenceState,
timestamp_basis: TimestampBasis,
user_action_claim: UserActionClaim,
) -> Self {
Self {
source,
state,
timestamp_basis,
user_action_claim,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct Finding {
pub priority: Priority,
pub confidence: Confidence,
pub rule_id: String,
pub interpretation: String,
pub provenance: Provenance,
pub user: Option<String>,
pub profile: Option<String>,
pub browser: Option<BrowserFamily>,
pub evidence: String,
pub next: Option<String>,
}
impl Finding {
#[must_use]
pub fn new(
priority: Priority,
confidence: Confidence,
rule_id: impl Into<String>,
interpretation: impl Into<String>,
provenance: Provenance,
evidence: impl Into<String>,
) -> Self {
Self {
priority,
confidence,
rule_id: rule_id.into(),
interpretation: interpretation.into(),
provenance,
user: None,
profile: None,
browser: None,
evidence: evidence.into(),
next: None,
}
}
#[must_use]
pub fn with_user(mut self, user: impl Into<String>) -> Self {
self.user = Some(user.into());
self
}
#[must_use]
pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
self.profile = Some(profile.into());
self
}
#[must_use]
pub fn with_browser(mut self, browser: BrowserFamily) -> Self {
self.browser = Some(browser);
self
}
#[must_use]
pub fn with_next(mut self, next: impl Into<String>) -> Self {
self.next = Some(next.into());
self
}
#[must_use]
pub fn render(&self) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let _ = writeln!(
out,
"Priority: {} (look here first — a triage attention cue)",
self.priority
);
let _ = writeln!(
out,
"Confidence: {} (rule {})",
self.confidence, self.rule_id
);
let _ = writeln!(out, "Interpretation: {}", self.interpretation);
let _ = writeln!(out, "Rule: {}", self.rule_id);
let p = &self.provenance;
let _ = writeln!(
out,
"Provenance: {} · {} · time {} · {}",
p.source, p.state, p.timestamp_basis, p.user_action_claim
);
if let Some(origin) = self.origin_line() {
let _ = writeln!(out, "Origin: {origin}");
}
let _ = writeln!(out, "Evidence: {}", self.evidence);
if let Some(next) = &self.next {
let _ = writeln!(out, "Next: {next}");
}
out
}
fn origin_line(&self) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
if let Some(browser) = &self.browser {
parts.push(browser.to_string());
}
if let Some(profile) = &self.profile {
parts.push(profile.clone());
}
if let Some(user) = &self.user {
parts.push(format!("user {user}"));
}
if parts.is_empty() {
None
} else {
Some(parts.join(" · "))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_provenance() -> Provenance {
Provenance::new(
EvidenceSource::History,
EvidenceState::Live,
TimestampBasis::Explicit,
UserActionClaim::Visited,
)
}
fn sample_finding() -> Finding {
Finding::new(
Priority::High,
Confidence::Medium,
"integrity.history.rowid_gap.v1",
"consistent with manual deletion, DB maintenance, or profile sync",
sample_provenance(),
"Chrome History urls rowid gap 128 → 944",
)
}
#[test]
fn provenance_carries_four_axes() {
let p = sample_provenance();
assert_eq!(p.source, EvidenceSource::History);
assert_eq!(p.state, EvidenceState::Live);
assert_eq!(p.timestamp_basis, TimestampBasis::Explicit);
assert_eq!(p.user_action_claim, UserActionClaim::Visited);
}
#[test]
fn new_sets_three_distinct_axes() {
let f = sample_finding();
assert_eq!(f.priority, Priority::High);
assert_eq!(f.confidence, Confidence::Medium);
assert_eq!(f.rule_id, "integrity.history.rowid_gap.v1");
assert!(f.interpretation.starts_with("consistent with"));
}
#[test]
fn origin_builder_sets_user_profile_browser() {
let f = sample_finding()
.with_user("S-1-5-21-1004")
.with_profile("Chrome/Default")
.with_browser(BrowserFamily::Chromium)
.with_next("br4n6 artifact integrity --rule history.rowid_gap <PATH>");
assert_eq!(f.user.as_deref(), Some("S-1-5-21-1004"));
assert_eq!(f.profile.as_deref(), Some("Chrome/Default"));
assert_eq!(f.browser, Some(BrowserFamily::Chromium));
assert!(f.next.is_some());
}
#[test]
fn roundtrip_json_preserves_all_fields() {
let f = sample_finding()
.with_user("alice")
.with_profile("Chrome/Default")
.with_browser(BrowserFamily::Chromium)
.with_next("br4n6 artifact integrity <PATH>");
let json = serde_json::to_string(&f).expect("serialize");
let back: Finding = serde_json::from_str(&json).expect("deserialize");
assert_eq!(f, back, "JSONL round-trip must be faithful");
}
#[test]
fn three_axes_serialize_as_distinct_top_level_fields() {
let f = sample_finding();
let v = serde_json::to_value(&f).expect("to_value");
let obj = v.as_object().expect("finding serializes as an object");
assert_eq!(obj.get("priority").and_then(|x| x.as_str()), Some("High"));
assert_eq!(
obj.get("confidence").and_then(|x| x.as_str()),
Some("Medium")
);
assert!(
obj.get("interpretation").is_some(),
"interpretation is a distinct field"
);
assert!(obj.contains_key("rule_id"), "rule_id is a distinct field");
let prov = obj
.get("provenance")
.and_then(|x| x.as_object())
.expect("provenance object present");
for key in ["source", "state", "timestamp_basis", "user_action_claim"] {
assert!(prov.contains_key(key), "provenance carries `{key}`");
}
}
#[test]
fn render_shows_all_three_axes_with_labels() {
let f = sample_finding();
let r = f.render();
assert!(r.contains("Priority:"), "labels the priority axis: {r}");
assert!(r.contains("Confidence:"), "labels the confidence axis");
assert!(r.contains("Interpretation:"), "labels the interpretation");
assert!(
r.contains("integrity.history.rowid_gap.v1"),
"shows rule id"
);
assert!(
r.contains("Chrome History urls rowid gap"),
"shows evidence"
);
}
#[test]
fn render_labels_priority_as_attention_cue() {
let f = sample_finding();
let r = f.render();
assert!(
r.contains("attention cue"),
"priority is framed as a triage attention cue, not a finding of malice: {r}"
);
}
#[test]
fn render_priority_never_appears_without_interpretation_hedge() {
let f = sample_finding();
let r = f.render();
assert!(r.contains("Priority:"));
assert!(
r.contains(&f.interpretation),
"the interpretation hedge accompanies every rendered priority: {r}"
);
}
}