mirror of
https://github.com/ruvnet/RuVector.git
synced 2026-08-21 06:33:45 +00:00
fix: make provenance resolution depth-safe (iterative traversal)
Security audit on #874 (MEDIUM): resolve_provenance/collect_provenance was recursive — cycle-safe via the visited_clusters guard but NOT depth-safe. A deep linear fuse chain (C1=fuse([obs]); C2=fuse([C1]); … CN) recursed ~N deep and overflowed the stack (reproduced N=200k → SIGABRT) on this load-bearing query. Convert to an iterative worklist (heap-allocated Vec) + the existing visited_clusters guard, so traversal depth is bounded by heap, not the call stack. Cycle-safety and error semantics are unchanged. Add a 200k-level deep-chain test asserting resolve_provenance returns the correct atomic source set without aborting (runs in <0.5s iteratively). Refs #865 #837 Co-Authored-By: claude-flow <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_012Jib2gQyJpqCoo2xYAbb4X
This commit is contained in:
parent
23cf4e1da1
commit
d43c6d97e3
2 changed files with 59 additions and 4 deletions
|
|
@ -260,16 +260,43 @@ impl CausalEpisodicGraph {
|
|||
/// Resolve `node` to the set of **atomic source observations** it derives
|
||||
/// from — the load-bearing provenance guarantee. For an observation this is
|
||||
/// the observation itself; for a cluster it is the transitive union of its
|
||||
/// members' atomic sources. The traversal is cycle-safe (the cluster layer
|
||||
/// is acyclic by construction, but a `visited` guard makes resolution total
|
||||
/// regardless).
|
||||
/// members' atomic sources.
|
||||
///
|
||||
/// Traversal is **iterative** (an explicit heap-allocated worklist, not the
|
||||
/// call stack), so a deeply nested fuse chain (`C1=fuse([obs])`,
|
||||
/// `C2=fuse([C1])`, …) is bounded by heap, not stack depth, and cannot
|
||||
/// overflow the stack / abort the process on this load-bearing query. It is
|
||||
/// also **cycle-safe**: the `visited_clusters` guard makes resolution total
|
||||
/// even though the cluster layer is acyclic by construction.
|
||||
pub fn resolve_provenance(
|
||||
&self,
|
||||
node: NodeRef,
|
||||
) -> Result<BTreeSet<ObservationId>, FusionError> {
|
||||
let mut atomic = BTreeSet::new();
|
||||
let mut visited_clusters = BTreeSet::new();
|
||||
self.collect_provenance(node, &mut atomic, &mut visited_clusters)?;
|
||||
let mut worklist: Vec<NodeRef> = vec![node];
|
||||
while let Some(current) = worklist.pop() {
|
||||
match current {
|
||||
NodeRef::Observation(id) => {
|
||||
if !self.observations.contains_key(&id) {
|
||||
return Err(FusionError::UnknownObservation(id));
|
||||
}
|
||||
atomic.insert(id);
|
||||
}
|
||||
NodeRef::Cluster(id) => {
|
||||
if !visited_clusters.insert(id) {
|
||||
continue; // already expanded; cycle-safe
|
||||
}
|
||||
let cluster = self
|
||||
.clusters
|
||||
.get(&id)
|
||||
.ok_or(FusionError::UnknownCluster(id))?;
|
||||
for member in &cluster.members {
|
||||
worklist.push(*member);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(atomic)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -249,6 +249,34 @@ fn governed_ingest_composes_with_wp4_ledger() {
|
|||
assert_eq!(prov, [parent_id, child_id].into_iter().collect());
|
||||
}
|
||||
|
||||
/// Depth-safety (security audit #874, MEDIUM): a deeply nested linear fuse
|
||||
/// chain (`C1=fuse([obs])`, `C2=fuse([C1])`, … `CN`) must resolve without
|
||||
/// overflowing the stack. The old recursive `collect_provenance` aborted the
|
||||
/// process (SIGABRT) at this depth; the iterative worklist bounds depth by heap.
|
||||
#[test]
|
||||
fn deep_fuse_chain_provenance_is_depth_safe() {
|
||||
let mut graph = CausalEpisodicGraph::new(Tenant::new("acme"));
|
||||
let obs = observe(SourceKind::RuViewRf, "rf", "acme", 0.5, vec![], b"root-evidence");
|
||||
let obs_id = graph.ingest(obs).unwrap();
|
||||
|
||||
// 200_000 levels deep — far past what the recursive version could survive,
|
||||
// but linear and fast iteratively.
|
||||
const DEPTH: usize = 200_000;
|
||||
let mut current = graph.fuse(&[NodeRef::Observation(obs_id)], "level-0").unwrap();
|
||||
for level in 1..DEPTH {
|
||||
current = graph
|
||||
.fuse(&[NodeRef::Cluster(current)], format!("level-{level}"))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// The load-bearing provenance query resolves to exactly the one atomic
|
||||
// source, without aborting.
|
||||
let provenance = graph.resolve_provenance(NodeRef::Cluster(current)).unwrap();
|
||||
assert_eq!(provenance, [obs_id].into_iter().collect());
|
||||
// Weakest-link confidence propagated unchanged through the whole chain.
|
||||
assert!((graph.cluster(current).unwrap().confidence - 0.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn governed_ingest_rejects_ungoverned_parent() {
|
||||
let mut graph = CausalEpisodicGraph::new(Tenant::new("acme"));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue