Skip to main content

sqlite_forensic/
case_uco.rs

1//! CASE/UCO JSON-LD export of recovered BLOBs (roadmap §4.5).
2//!
3//! Emits a CASE/UCO bundle so recovered media is addressable in case-management
4//! tools. Each recovered `BLOB` becomes a `uco-observable:ObservableObject` with a
5//! `uco-observable:ContentDataFacet` carrying its media type (when a magic
6//! signature is recognized), byte size, and a `uco-types:Hash` (method `SHA256`,
7//! `xsd:hexBinary` value). The structure follows the CASE/UCO ontology and its
8//! published examples (caseontology.org); the inner hash literals carry `@type` +
9//! `@value` only (no `@id`), per the ontology's typed-literal rule.
10//!
11//! Scope: this maps recovered *media blobs* to UCO content observables — the
12//! part of the recovery that has a clean UCO representation. It does not attempt
13//! to model arbitrary deleted rows (UCO has no observable type for a carved
14//! table row). Validate the output with the CASE validator before relying on it.
15
16use std::fmt::Write as _;
17
18use sqlite_core::Value;
19
20use crate::blob::{identify_media_type, sha256_hex};
21use crate::CarvedRecord;
22
23/// The CASE/UCO `@context` prefix map (ontology 1.x IRIs).
24const CONTEXT: &str = "\"@context\":{\
25     \"kb\":\"http://example.org/kb/\",\
26     \"uco-core\":\"https://ontology.unifiedcyberontology.org/uco/core/\",\
27     \"uco-observable\":\"https://ontology.unifiedcyberontology.org/uco/observable/\",\
28     \"uco-types\":\"https://ontology.unifiedcyberontology.org/uco/types/\",\
29     \"uco-vocabulary\":\"https://ontology.unifiedcyberontology.org/uco/vocabulary/\",\
30     \"xsd\":\"http://www.w3.org/2001/XMLSchema#\"}";
31
32/// Build a CASE/UCO JSON-LD bundle of every recovered `BLOB` across `records`.
33///
34/// Deterministic `@id`s (blob index within the run) so the output is reproducible.
35/// A record with no blobs contributes no observable; a run with no blobs yields a
36/// valid bundle with an empty `@graph`.
37#[must_use]
38pub fn bundle_for_records(records: &[CarvedRecord]) -> String {
39    let blobs: Vec<&[u8]> = records
40        .iter()
41        .flat_map(|r| r.values.iter())
42        .filter_map(|v| match v {
43            Value::Blob(b) => Some(b.as_slice()),
44            _ => None,
45        })
46        .collect();
47
48    let mut graph = String::new();
49    for (i, bytes) in blobs.iter().enumerate() {
50        if i > 0 {
51            graph.push(',');
52        }
53        let hash = sha256_hex(bytes);
54        // mimeType field is present ONLY when a signature is recognized.
55        let mime = identify_media_type(bytes)
56            .map(|m| format!("\"uco-observable:mimeType\":\"{m}\","))
57            .unwrap_or_default();
58        let _ = write!(
59            graph,
60            "{{\"@id\":\"kb:observable-blob-{i}\",\
61             \"@type\":\"uco-observable:ObservableObject\",\
62             \"uco-core:hasFacet\":[{{\"@id\":\"kb:content-data-facet-{i}\",\
63             \"@type\":\"uco-observable:ContentDataFacet\",\
64             {mime}\"uco-observable:sizeInBytes\":{size},\
65             \"uco-observable:hash\":[{{\"@type\":\"uco-types:Hash\",\
66             \"uco-types:hashMethod\":{{\"@type\":\"uco-vocabulary:HashNameVocab\",\"@value\":\"SHA256\"}},\
67             \"uco-types:hashValue\":{{\"@type\":\"xsd:hexBinary\",\"@value\":\"{hash}\"}}}}]}}]}}",
68            size = bytes.len()
69        );
70    }
71
72    format!("{{{CONTEXT},\"@graph\":[{graph}]}}")
73}