Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
817c5eee41 |
@@ -329,6 +329,47 @@ impl KnowledgeCache {
|
||||
(id, true)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Adjacency index (built fresh per traversal call — see doc comment)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Build an O(V+R) adjacency index for one traversal call: an entity-id →
|
||||
/// vec-index map for O(1) entity lookups, and an entity-id →
|
||||
/// `(neighbour_id, relation_weight)` map (covering both outgoing and
|
||||
/// incoming edges) for O(1) neighbour expansion. The weight is carried
|
||||
/// alongside each neighbour so callers like `spreading_activation` that
|
||||
/// need per-edge weight don't have to re-scan `relations`.
|
||||
///
|
||||
/// This is rebuilt at the start of every `bfs_neighbors`/
|
||||
/// `spreading_activation` call rather than cached on the struct: `entities`
|
||||
/// and `relations` are public fields, and `schema.rs`'s deserialization
|
||||
/// path pushes into them directly (bypassing `add_entity`/`add_relation`),
|
||||
/// so a struct-cached index could go stale. Building it once per call
|
||||
/// still turns an O(V·R) (or O(steps·V·R)) traversal into O(V+R) (or
|
||||
/// O(steps·(V+E))), since the old code repeated the O(R) relation scan
|
||||
/// once per visited node instead of once per call.
|
||||
fn build_adjacency(&self) -> (HashMap<u64, usize>, HashMap<u64, Vec<(u64, f32)>>) {
|
||||
let mut entity_index: HashMap<u64, usize> = HashMap::with_capacity(self.entities.len());
|
||||
for (i, e) in self.entities.iter().enumerate() {
|
||||
entity_index.insert(e.id, i);
|
||||
}
|
||||
|
||||
// Note: a self-loop relation (src == tgt) contributes a single
|
||||
// neighbour entry, not two, matching the if/else-if (not two
|
||||
// independent ifs) structure this replaces — otherwise a self-loop
|
||||
// would be double-counted by `spreading_activation`.
|
||||
let mut adjacency: HashMap<u64, Vec<(u64, f32)>> =
|
||||
HashMap::with_capacity(self.relations.len());
|
||||
for r in &self.relations {
|
||||
adjacency.entry(r.src).or_default().push((r.tgt, r.weight));
|
||||
if r.tgt != r.src {
|
||||
adjacency.entry(r.tgt).or_default().push((r.src, r.weight));
|
||||
}
|
||||
}
|
||||
|
||||
(entity_index, adjacency)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Graph traversal: BFS neighbors
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -337,6 +378,8 @@ impl KnowledgeCache {
|
||||
/// together with their discovered depth. The seed entity itself is NOT
|
||||
/// included. Traversal follows both outgoing and incoming relation edges.
|
||||
pub fn bfs_neighbors(&self, entity_id: u64, max_depth: usize) -> Vec<(Entity, usize)> {
|
||||
let (entity_index, adjacency) = self.build_adjacency();
|
||||
|
||||
let mut visited: HashSet<u64> = HashSet::new();
|
||||
let mut queue: VecDeque<(u64, usize)> = VecDeque::new();
|
||||
let mut results: Vec<(Entity, usize)> = Vec::new();
|
||||
@@ -349,25 +392,15 @@ impl KnowledgeCache {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect neighbour IDs from outgoing and incoming edges.
|
||||
let neighbours: Vec<u64> = self
|
||||
.relations
|
||||
.iter()
|
||||
.filter_map(|r| {
|
||||
if r.src == current_id {
|
||||
Some(r.tgt)
|
||||
} else if r.tgt == current_id {
|
||||
Some(r.src)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let Some(neighbours) = adjacency.get(¤t_id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for neighbour_id in neighbours {
|
||||
for &(neighbour_id, _weight) in neighbours {
|
||||
if visited.insert(neighbour_id)
|
||||
&& let Some(entity) = self.get_entity(neighbour_id)
|
||||
&& let Some(&idx) = entity_index.get(&neighbour_id)
|
||||
{
|
||||
let entity = &self.entities[idx];
|
||||
results.push((entity.clone(), depth + 1));
|
||||
queue.push_back((neighbour_id, depth + 1));
|
||||
}
|
||||
@@ -439,6 +472,8 @@ impl KnowledgeCache {
|
||||
min_activation: f32,
|
||||
max_steps: usize,
|
||||
) -> Vec<(u64, f32)> {
|
||||
let (_entity_index, adjacency) = self.build_adjacency();
|
||||
|
||||
let mut activation: HashMap<u64, f32> = HashMap::new();
|
||||
|
||||
// Initialise seeds with activation 1.0.
|
||||
@@ -462,16 +497,11 @@ impl KnowledgeCache {
|
||||
|
||||
for (source_id, source_score) in current {
|
||||
// Spread to all neighbours via outgoing and incoming edges.
|
||||
for rel in &self.relations {
|
||||
let neighbour_id = if rel.src == source_id {
|
||||
rel.tgt
|
||||
} else if rel.tgt == source_id {
|
||||
rel.src
|
||||
} else {
|
||||
let Some(neighbours) = adjacency.get(&source_id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let delta = source_score * rel.weight * decay_factor;
|
||||
for &(neighbour_id, weight) in neighbours {
|
||||
let delta = source_score * weight * decay_factor;
|
||||
if delta >= min_activation {
|
||||
*activation.entry(neighbour_id).or_insert(0.0) += delta;
|
||||
any_spread = true;
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
//! Memory provenance tracking and integrity verification.
|
||||
//!
|
||||
//! Records the origin, authorship, and a content hash of every memory chunk
|
||||
//! so the system can detect *accidental* corruption and trace data lineage.
|
||||
//! The hash is unkeyed (see [`fnv1a_64`]) — this is not a tamper-evidence or
|
||||
//! authenticity guarantee.
|
||||
//! so the system can detect content corruption and trace data lineage. The
|
||||
//! hash is a SHA-256 digest (see [`hash_content`]), computed via
|
||||
//! [`clawhdf5_format::provenance::sha256_hex`]. It is still **unkeyed** — an
|
||||
//! actor able to overwrite the stored chunk can also recompute and overwrite
|
||||
//! the stored hash alongside it, so this is not an authenticity guarantee
|
||||
//! against that threat. What SHA-256 does provide over a fast non-cryptographic
|
||||
//! hash (the previous FNV-1a implementation) is collision resistance: an
|
||||
//! adversary cannot cheaply craft *different* poisoned content that matches
|
||||
//! an already-recorded legitimate hash.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub use crate::consolidation::MemorySource;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hash helper (std-only FNV-1a 64-bit)
|
||||
// Hash helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Unkeyed, non-cryptographic FNV-1a hash for detecting accidental content
|
||||
/// corruption. It is trivially forgeable by anyone able to modify the stored
|
||||
/// data, since they can recompute and overwrite the stored hash alongside
|
||||
/// it — do not rely on this as a tamper-evidence or authenticity control.
|
||||
fn fnv1a_64(text: &str) -> u64 {
|
||||
const OFFSET: u64 = 14_695_981_039_346_656_037;
|
||||
const PRIME: u64 = 1_099_511_628_211;
|
||||
let mut hash = OFFSET;
|
||||
for byte in text.bytes() {
|
||||
hash ^= byte as u64;
|
||||
hash = hash.wrapping_mul(PRIME);
|
||||
}
|
||||
hash
|
||||
/// SHA-256 hex digest of `text`, used to detect content corruption/tampering.
|
||||
///
|
||||
/// Unkeyed: an actor able to modify the stored chunk can also recompute and
|
||||
/// overwrite the stored hash, so a match is not proof of authenticity — only
|
||||
/// that the stored chunk and stored hash are mutually consistent.
|
||||
fn hash_content(text: &str) -> String {
|
||||
clawhdf5_format::provenance::sha256_hex(text.as_bytes())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -57,8 +57,8 @@ pub struct MemoryProvenance {
|
||||
pub created_by: String,
|
||||
/// Unix timestamp (seconds) of creation.
|
||||
pub created_at: f64,
|
||||
/// FNV-1a 64-bit hash of the chunk text for integrity checking.
|
||||
pub content_hash: u64,
|
||||
/// SHA-256 hex digest of the chunk text for integrity checking.
|
||||
pub content_hash: String,
|
||||
pub session_id: String,
|
||||
pub verified: bool,
|
||||
}
|
||||
@@ -78,7 +78,7 @@ impl MemoryProvenance {
|
||||
source,
|
||||
created_by: created_by.into(),
|
||||
created_at,
|
||||
content_hash: fnv1a_64(chunk),
|
||||
content_hash: hash_content(chunk),
|
||||
session_id: session_id.into(),
|
||||
verified: false,
|
||||
}
|
||||
@@ -121,13 +121,16 @@ impl ProvenanceStore {
|
||||
/// Re-hash `current_chunk` and compare against the stored hash.
|
||||
/// Returns `true` if the content matches (integrity intact).
|
||||
///
|
||||
/// This only detects accidental corruption: the hash is unkeyed, so an
|
||||
/// actor able to modify the stored chunk can also recompute and
|
||||
/// overwrite the stored hash. Do not treat a `true` result as proof the
|
||||
/// data hasn't been tampered with.
|
||||
/// The hash is unkeyed, so an actor able to modify the stored chunk can
|
||||
/// also recompute and overwrite the stored hash. Do not treat a `true`
|
||||
/// result as proof of authenticity against that threat — but unlike a
|
||||
/// non-cryptographic hash, a `false` result reliably indicates that the
|
||||
/// content does not match what was recorded, since SHA-256 makes it
|
||||
/// computationally infeasible to craft different content that collides
|
||||
/// with a specific existing digest.
|
||||
pub fn verify_integrity(&self, record_id: u64, current_chunk: &str) -> bool {
|
||||
match self.records.get(&record_id) {
|
||||
Some(p) => p.content_hash == fnv1a_64(current_chunk),
|
||||
Some(p) => p.content_hash == hash_content(current_chunk),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
@@ -241,22 +244,32 @@ mod tests {
|
||||
1_700_000_000.0
|
||||
}
|
||||
|
||||
// --- fnv1a_64 ---
|
||||
// --- hash_content ---
|
||||
|
||||
#[test]
|
||||
fn hash_deterministic() {
|
||||
assert_eq!(fnv1a_64("hello"), fnv1a_64("hello"));
|
||||
assert_eq!(hash_content("hello"), hash_content("hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_different_inputs() {
|
||||
assert_ne!(fnv1a_64("hello"), fnv1a_64("world"));
|
||||
assert_ne!(hash_content("hello"), hash_content("world"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_empty() {
|
||||
// Should not panic
|
||||
let _ = fnv1a_64("");
|
||||
// Should not panic, and should match the well-known SHA-256 of the empty string.
|
||||
assert_eq!(
|
||||
hash_content(""),
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_is_sha256_hex() {
|
||||
let h = hash_content("clawhdf5");
|
||||
assert_eq!(h.len(), 64);
|
||||
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
// --- MemorySource Display ---
|
||||
@@ -275,7 +288,7 @@ mod tests {
|
||||
#[test]
|
||||
fn provenance_new_hashes_chunk() {
|
||||
let p = MemoryProvenance::new(1, MemorySource::User, "agent-1", ts(), "hello", "s1");
|
||||
assert_eq!(p.content_hash, fnv1a_64("hello"));
|
||||
assert_eq!(p.content_hash, hash_content("hello"));
|
||||
assert!(!p.verified);
|
||||
}
|
||||
|
||||
|
||||
@@ -143,10 +143,6 @@ pub fn parse_vds_mappings(
|
||||
let source_selection = read_selection(heap_data, &mut pos)?;
|
||||
let virtual_selection = read_selection(heap_data, &mut pos)?;
|
||||
|
||||
// Validate external file name to prevent directory traversal attacks
|
||||
// (Dataset paths within files can use absolute HDF5 paths like "/data")
|
||||
validate_vds_file_name(&source_file)?;
|
||||
|
||||
mappings.push(VdsMapping {
|
||||
source_file,
|
||||
source_dataset,
|
||||
@@ -158,37 +154,6 @@ pub fn parse_vds_mappings(
|
||||
Ok(mappings)
|
||||
}
|
||||
|
||||
/// Validate external file names to prevent directory traversal.
|
||||
/// Dataset paths within files can use absolute HDF5 paths (starting with /),
|
||||
/// but external file names must not escape the file tree via .. or absolute paths.
|
||||
fn validate_vds_file_name(filename: &str) -> Result<(), FormatError> {
|
||||
if filename.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// "." means same file - always OK
|
||||
if filename == "." {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Filesystem paths cannot start with / (absolute filesystem path)
|
||||
if filename.starts_with('/') {
|
||||
return Err(FormatError::FilterError(
|
||||
"VDS file name cannot be an absolute filesystem path".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Reject directory traversal (..)
|
||||
if filename.contains("..") {
|
||||
return Err(FormatError::FilterError(
|
||||
"VDS file name contains illegal traversal sequence (..)".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Relative filesystem paths are OK
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a null-terminated UTF-8 string from data starting at `pos`.
|
||||
fn read_null_terminated_string(data: &[u8], pos: &mut usize) -> Result<String, FormatError> {
|
||||
let start = *pos;
|
||||
@@ -897,68 +862,4 @@ mod tests {
|
||||
let blob = [0x01u8, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
assert!(parse_vds_mappings(&blob, 8).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vds_mappings_rejects_path_traversal() {
|
||||
// INT-06: Verify that VDS file names containing ".." are rejected
|
||||
let blob = [
|
||||
0x00u8, // version 0 (with explicit file name)
|
||||
0x01, 0, 0, 0, 0, 0, 0, 0, // nused = 1
|
||||
0x2e, 0x2e, 0x2f, 0x65, 0x74, 0x63, 0x2f, 0x70, 0x61, 0x73, 0x73, 0x77, 0x64, 0x00, // "../etc/passwd | ||||