///|
/// Canonical events and their evidence document as one case input.
pub(all) struct CaseEvidenceInput {
document : EvidenceDocument
events : Array[CanonicalEvent]
} derive(Debug, Eq)
///|
/// The result of assembling and analyzing one multi-source case.
pub(all) struct CaseAnalysis {
events : Array[CanonicalEvent]
graph : IncidentGraph
report : ForensicReport
} derive(Debug, Eq, ToJson)
///|
/// Case assembly stops before producing a partial report.
pub(all) suberror CaseAnalysisError {
InvalidCase(reason~ : String)
MissingEvidence(id~ : String)
UnexpectedEvidence(id~ : String)
EvidenceMismatch(id~ : String, reason~ : String)
InvalidProcessor(reason~ : String)
InvalidPack(rule_id~ : String, reason~ : String)
} derive(Debug, Eq)
///|
/// Maps one JSONL evidence document with a profile and binds its digest to
/// every resulting event provenance.
pub fn build_case_jsonl_evidence(
document : EvidenceDocument,
records : Array[JsonlRecord],
profile : JsonlMappingProfile,
) -> CaseEvidenceInput raise ProfileMapError {
let manifest = build_evidence_manifest([document])
let entry = manifest.entries[0]
let events = apply_jsonl_profile(records, profile, {
evidence_id: document.id,
path: document.path,
sha256: Some(entry.sha256),
})
{ document, events, }
}
///|
fn has_input_evidence(inputs : Array[CaseEvidenceInput], id : String) -> Bool {
for input in inputs {
if input.document.id == id {
return true
}
}
false
}
///|
fn has_duplicate_input_evidence(
inputs : Array[CaseEvidenceInput],
id : String,
) -> Bool {
let mut seen = false
for input in inputs {
if input.document.id == id {
if seen {
return true
}
seen = true
}
}
false
}
///|
fn case_evidence_item(case : Case, id : String) -> EvidenceItem? {
for item in case.evidence {
if item.id == id {
return Some(item)
}
}
None
}
///|
fn validate_case_inputs(
case : Case,
inputs : Array[CaseEvidenceInput],
) -> Unit raise CaseAnalysisError {
if case.case_id.trim() == "" {
raise CaseAnalysisError::InvalidCase(reason="case id must not be empty")
}
if case.title.trim() == "" {
raise CaseAnalysisError::InvalidCase(reason="case title must not be empty")
}
if case.timezone.trim() == "" {
raise CaseAnalysisError::InvalidCase(
reason="case timezone must not be empty",
)
}
if case.evidence.length() != inputs.length() {
raise CaseAnalysisError::InvalidCase(
reason="case evidence and supplied documents must have the same length",
)
}
for input in inputs {
let id = input.document.id
if id.trim() == "" {
raise CaseAnalysisError::UnexpectedEvidence(id~)
}
if has_duplicate_input_evidence(inputs, id) {
raise CaseAnalysisError::UnexpectedEvidence(id~)
}
match case_evidence_item(case, id) {
None => raise CaseAnalysisError::UnexpectedEvidence(id~)
Some(item) => {
if item.path != input.document.path {
raise CaseAnalysisError::EvidenceMismatch(
id~,
reason="document path does not match case metadata",
)
}
if item.source != input.document.source {
raise CaseAnalysisError::EvidenceMismatch(
id~,
reason="document source does not match case metadata",
)
}
for event in input.events {
if event.provenance.evidence_id != id ||
event.provenance.path != input.document.path {
raise CaseAnalysisError::EvidenceMismatch(
id~,
reason="event provenance does not match its evidence document",
)
}
}
}
}
}
for item in case.evidence {
if !has_input_evidence(inputs, item.id) {
raise CaseAnalysisError::MissingEvidence(id=item.id)
}
}
}
///|
fn process_case_events(
events : Array[CanonicalEvent],
pipeline : ProcessorPipeline?,
) -> Array[CanonicalEvent] raise CaseAnalysisError {
match pipeline {
None => events
Some(pipeline) =>
try process_events(events, pipeline) catch {
ProcessorError::InvalidWindow =>
raise CaseAnalysisError::InvalidProcessor(
reason="processor time window is invalid",
)
ProcessorError::InvalidStage(reason) =>
raise CaseAnalysisError::InvalidProcessor(reason~)
} noraise {
processed => processed
}
}
}
///|
fn analyze_case_graph(
events : Array[CanonicalEvent],
pack : AnalysisPack?,
) -> IncidentGraph raise CaseAnalysisError {
match pack {
None =>
try build_incident_graph(events, []) catch {
CorrelationRuleError::InvalidRule(rule_id~, reason~) =>
raise CaseAnalysisError::InvalidPack(rule_id~, reason~)
} noraise {
graph => graph
}
Some(pack) =>
try analyze_with_pack(events, pack) catch {
CorrelationRuleError::InvalidRule(rule_id~, reason~) =>
raise CaseAnalysisError::InvalidPack(rule_id~, reason~)
} noraise {
graph => graph
}
}
}
///|
fn case_events(inputs : Array[CaseEvidenceInput]) -> Array[CanonicalEvent] {
let events : Array[CanonicalEvent] = []
for input in inputs {
for event in input.events {
events.push(event)
}
}
events
}
///|
/// Assembles multiple in-memory evidence streams into one deterministic case.
///
/// The case metadata and document identities are checked before mapping results
/// enter the processor, graph, and report stages. No file-system access occurs.
pub fn analyze_case(
case : Case,
inputs : Array[CaseEvidenceInput],
pipeline : ProcessorPipeline?,
pack : AnalysisPack?,
summary : String,
limitations : Array[String],
) -> CaseAnalysis raise CaseAnalysisError {
validate_case_inputs(case, inputs)
let events = process_case_events(case_events(inputs), pipeline)
let graph = analyze_case_graph(events, pack)
let timeline_events : Array[NormalizedEvent] = []
for event in graph.nodes {
timeline_events.push(event.to_normalized_event())
}
let timeline = build_timeline(timeline_events)
let documents : Array[EvidenceDocument] = []
for input in inputs {
documents.push(input.document)
}
let report = ForensicReport::{
case,
summary,
timeline,
findings: DiagnosticReport::{ findings: [], },
evidence_index: build_evidence_manifest(documents),
limitations,
}
{ events, graph, report, }
}