Skip to main content

weir/reachability_witness/
path.rs

1use super::bitmask::try_bit_is_set;
2use super::graph::{try_prepare_witness_graph, PreparedWitnessGraph, ReverseEdges};
3use super::{
4    ExtractedPath, ExtractedStatement, NodeAttr, PathError, PathSeed, PrepareWitnessGraphError,
5};
6use vyre_primitives::predicate::edge_kind;
7
8const IFDS_PATH_EDGE_MASK: u32 = edge_kind::ASSIGNMENT
9    | edge_kind::CALL_ARG
10    | edge_kind::RETURN
11    | edge_kind::PHI
12    | edge_kind::ALIAS
13    | edge_kind::MEM_STORE
14    | edge_kind::MEM_LOAD
15    | edge_kind::MUT_REF;
16
17pub(super) const MAX_PATH_DEPTH: usize = 1024;
18
19/// One prepared witness extraction request.
20///
21/// `source_reach` is intentionally per request because witness soundness
22/// requires a per-source reachability mask. Aggregating masks across sources
23/// can produce a path that starts at the wrong source.
24#[derive(Clone, Copy, Debug)]
25pub struct PathExtractionRequest<'a> {
26    /// Source/sink pair to explain.
27    pub seed: &'a PathSeed,
28    /// Per-source reachability mask for `seed.source_node`.
29    pub source_reach: &'a [u32],
30    /// Per-rule sanitizer mask. Empty means the rule has no sanitizers.
31    pub sanitizer_mask: &'a [u32],
32}
33
34/// Batch witness extraction counters for one prepared graph.
35#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
36pub struct PreparedWitnessBatchStats {
37    /// Request rows consumed by the batch call.
38    pub requests: u64,
39    /// Paths reconstructed successfully.
40    pub successes: u64,
41    /// Request rows that returned a structured witness error.
42    pub errors: u64,
43    /// Prepared reverse-CSR graph reuses. This equals `requests` for the
44    /// prepared batch API and is exposed so release evidence can prove callers
45    /// did not rebuild the reverse graph per finding.
46    pub prepared_graph_reuses: u64,
47    /// Statement nodes in the prepared witness graph.
48    pub prepared_node_count: u64,
49    /// Reverse CSR edge entries retained by the prepared witness graph.
50    pub prepared_reverse_edges: u64,
51}
52
53fn path_node_index(node: u32, node_count: u32) -> Result<usize, PathError> {
54    if node >= node_count {
55        return Err(PathError::NodeOutOfBounds { node, node_count });
56    }
57    usize::try_from(node).map_err(|error| PathError::MalformedGraph {
58        reason: PrepareWitnessGraphError::TargetOutOfBounds {
59            edge_index: 0,
60            target: node,
61            node_count,
62        },
63        fix: format!(
64            "Fix: witness path node id {node} cannot fit usize: {error}; shard the ProgramGraph before witness extraction."
65        ),
66    })
67}
68
69fn reverse_offset_to_usize(
70    value: u32,
71    node: usize,
72    label: &'static str,
73) -> Result<usize, PathError> {
74    usize::try_from(value).map_err(|error| PathError::MalformedGraph {
75        reason: PrepareWitnessGraphError::ReverseEdgeCountOverflow { node },
76        fix: format!(
77            "Fix: witness path {label} cannot fit usize: {error}; shard the ProgramGraph before witness extraction."
78        ),
79    })
80}
81
82fn witness_bit_is_set(bitmask: &[u32], node: u32, field: &'static str) -> Result<bool, PathError> {
83    try_bit_is_set(bitmask, node).map_err(|error| PathError::MalformedGraph {
84        reason: PrepareWitnessGraphError::TargetOutOfBounds {
85            edge_index: 0,
86            target: node,
87            node_count: u32::MAX,
88        },
89        fix: format!("Fix: witness {field} bitmask cannot address node {node}: {error}"),
90    })
91}
92
93/// Reconstruct a source→sink path by walking the CSR backward from
94/// `seed.sink_node`, greedily choosing a *source-reached*,
95/// *non-sanitized* predecessor at each step until `seed.source_node`
96/// is hit.
97///
98/// `source_reach` MUST be a per-source reachability mask: bit `n` is
99/// set iff the *specific source named in the `PathSeed`* reaches
100/// node `n` along an IFDS-eligible path. Aggregate-over-all-sources
101/// masks must NOT be passed here  -  they admit witnesses that
102/// originate from a different source than the seed (audit 2026-04-27
103/// finding 3).
104///
105/// `sanitizer_mask` is a per-node bitmask whose bit `n` is set iff
106/// node `n` is a sanitizer for this rule. The walker rejects any
107/// sanitizer-tagged predecessor (audit finding 2). Pass an empty
108/// slice (`&[]`) iff the rule has no sanitizers.
109///
110/// `pg_node_attrs` is a slice of `(byte_start, byte_end, file_idx)`
111/// tuples  -  one per node  -  that a consumer pipeline emits alongside
112/// the CSR. `file_table` maps `file_idx` to a repository-relative
113/// path. `adapter` is the language-adapter id (e.g. `"c-c11"`).
114/// `descriptions` is per-node short text.
115///
116/// Returns:
117/// - `Ok(path)` when source is reached from sink along an
118///   IFDS-eligible chain that does not cross any sanitizer.
119/// - `Err(PathError::NoPath)` when no such path exists.
120/// - `Err(PathError::DepthExceeded { partial_chain })` when the
121///   chain exceeded `MAX_PATH_DEPTH`.
122/// - `Err(PathError::NodeOutOfBounds { .. })` when the seed names a
123///   node id that is not present in `pg_node_attrs`.
124#[allow(clippy::too_many_arguments)]
125pub fn extract_path(
126    seed: &PathSeed,
127    source_reach: &[u32],
128    sanitizer_mask: &[u32],
129    edge_offsets: &[u32],
130    edge_targets: &[u32],
131    edge_kind_mask: &[u32],
132    pg_node_attrs: &[NodeAttr],
133    file_table: &[String],
134    descriptions: &[String],
135    adapter: &str,
136) -> Result<ExtractedPath, PathError> {
137    let graph = try_prepare_witness_graph(
138        edge_offsets,
139        edge_targets,
140        edge_kind_mask,
141        pg_node_attrs,
142        file_table,
143        descriptions,
144        adapter,
145    )?;
146    extract_path_prepared(seed, source_reach, sanitizer_mask, &graph)
147}
148
149/// Reconstruct a source→sink path using a reusable prepared graph.
150pub fn extract_path_prepared(
151    seed: &PathSeed,
152    source_reach: &[u32],
153    sanitizer_mask: &[u32],
154    graph: &PreparedWitnessGraph<'_>,
155) -> Result<ExtractedPath, PathError> {
156    let node_count = u32::try_from(graph.pg_node_attrs.len()).map_err(|error| {
157        PathError::MalformedGraph {
158            reason: PrepareWitnessGraphError::TargetOutOfBounds {
159                edge_index: 0,
160                target: u32::MAX,
161                node_count: u32::MAX,
162            },
163            fix: format!(
164                "Fix: prepared witness graph has {} node attribute rows, which does not fit u32: {error}. Shard the ProgramGraph before witness extraction.",
165                graph.pg_node_attrs.len()
166            ),
167        }
168    })?;
169    // Bound-check the seed before indexing into visited/attrs.
170    if seed.source_node >= node_count {
171        return Err(PathError::NodeOutOfBounds {
172            node: seed.source_node,
173            node_count,
174        });
175    }
176    if seed.sink_node >= node_count {
177        return Err(PathError::NodeOutOfBounds {
178            node: seed.sink_node,
179            node_count,
180        });
181    }
182    let _source_index = path_node_index(seed.source_node, node_count)?;
183    let sink_index = path_node_index(seed.sink_node, node_count)?;
184
185    if seed.source_node == seed.sink_node {
186        // Trivial path of length 1. Sanitizer check still applies:
187        // if the source itself is tagged a sanitizer the witness is
188        // unsound.
189        if witness_bit_is_set(sanitizer_mask, seed.source_node, "sanitizer")? {
190            return Err(PathError::NoPath);
191        }
192        let stmt = build_statement(
193            seed.source_node,
194            node_count,
195            graph.pg_node_attrs,
196            graph.file_table,
197            graph.descriptions,
198            graph.adapter,
199        )?;
200        return Ok(ExtractedPath {
201            statements: vec![stmt],
202        });
203    }
204
205    if !witness_bit_is_set(source_reach, seed.sink_node, "source reachability")?
206        || !witness_bit_is_set(source_reach, seed.source_node, "source reachability")?
207    {
208        // The seeded source did not reach the seeded sink.
209        return Err(PathError::NoPath);
210    }
211    // The endpoints themselves cannot be sanitizers  -  that would
212    // mean the proof crosses a sanitizer at iteration 0.
213    if witness_bit_is_set(sanitizer_mask, seed.sink_node, "sanitizer")?
214        || witness_bit_is_set(sanitizer_mask, seed.source_node, "sanitizer")?
215    {
216        return Err(PathError::NoPath);
217    }
218
219    let mut visited = vec![false; graph.pg_node_attrs.len()];
220    let mut chain: Vec<u32> = Vec::new();
221    let mut current = seed.sink_node;
222    chain.push(current);
223    visited[sink_index] = true;
224
225    for _ in 0..MAX_PATH_DEPTH {
226        if current == seed.source_node {
227            break;
228        }
229        let Some(pred) = find_reached_predecessor(
230            current,
231            node_count,
232            source_reach,
233            sanitizer_mask,
234            &graph.reverse_edges,
235            &visited,
236        )?
237        else {
238            return Err(PathError::NoPath);
239        };
240        chain.push(pred);
241        let pred_index = path_node_index(pred, node_count)?;
242        visited[pred_index] = true;
243        current = pred;
244    }
245
246    if current != seed.source_node {
247        // Hit the depth limit before reaching the source. Hand the
248        // partial chain back as evidence for an analysis-capacity
249        // failure. Callers must increase the witness budget rather
250        // than downgrade an incomplete proof.
251        return Err(PathError::DepthExceeded {
252            partial_chain: chain,
253        });
254    }
255
256    // chain is sink-first; flip to source-first.
257    chain.reverse();
258    let mut statements =
259        crate::staging_reserve::reserved_vec(chain.len(), "reachability witness statements")
260            .map_err(|error| PathError::ResourceExhausted {
261                field: "reachability witness statements".to_string(),
262                fix: format!(
263                    "Fix: {error}; reduce the witness batch size or shard the ProgramGraph before extracting paths."
264                ),
265            })?;
266    for node in chain {
267        statements.push(build_statement(
268            node,
269            node_count,
270            graph.pg_node_attrs,
271            graph.file_table,
272            graph.descriptions,
273            graph.adapter,
274        )?);
275    }
276    Ok(ExtractedPath { statements })
277}
278
279/// Reconstruct many source→sink paths over one prepared witness graph.
280///
281/// Each request produces its own `Result`, so an out-of-bounds seed, sanitizer
282/// block, or depth limit on one finding does not discard paths for unrelated
283/// findings in the same program graph.
284pub fn extract_paths_prepared(
285    requests: &[PathExtractionRequest<'_>],
286    graph: &PreparedWitnessGraph<'_>,
287) -> Result<(Vec<Result<ExtractedPath, PathError>>, PreparedWitnessBatchStats), PathError> {
288    let mut results = crate::staging_reserve::reserved_vec(
289        requests.len(),
290        "reachability prepared witness batch results",
291    )
292    .map_err(|error| PathError::ResourceExhausted {
293        field: "reachability prepared witness batch results".to_string(),
294        fix: format!(
295            "Fix: {error}; reduce the witness batch size or shard the ProgramGraph before extracting paths."
296        ),
297    })?;
298    let stats = extract_paths_prepared_into(requests, graph, &mut results)?;
299    Ok((results, stats))
300}
301
302/// Reconstruct many source→sink paths into caller-owned result storage over
303/// one prepared witness graph.
304pub fn extract_paths_prepared_into(
305    requests: &[PathExtractionRequest<'_>],
306    graph: &PreparedWitnessGraph<'_>,
307    results: &mut Vec<Result<ExtractedPath, PathError>>,
308) -> Result<PreparedWitnessBatchStats, PathError> {
309    results.clear();
310    if results.capacity() < requests.len() {
311        results
312            .try_reserve(requests.len() - results.capacity())
313            .map_err(|error| PathError::ResourceExhausted {
314                field: "reachability prepared witness batch results".to_string(),
315                fix: format!(
316                    "Fix: could not reserve {} prepared witness result slot(s): {error}; reduce the witness batch size or shard the ProgramGraph before extracting paths.",
317                    requests.len()
318                ),
319            })?;
320    }
321
322    let mut successes = 0u64;
323    let mut errors = 0u64;
324    for request in requests {
325        let result = extract_path_prepared(
326            request.seed,
327            request.source_reach,
328            request.sanitizer_mask,
329            graph,
330        );
331        if result.is_ok() {
332            successes = successes.checked_add(1).ok_or_else(|| {
333                PathError::ResourceExhausted {
334                    field: "reachability prepared witness batch success counter".to_string(),
335                    fix: "Fix: prepared witness success counter overflowed u64; shard the witness request batch.".to_string(),
336                }
337            })?;
338        } else {
339            errors = errors.checked_add(1).ok_or_else(|| {
340                PathError::ResourceExhausted {
341                    field: "reachability prepared witness batch error counter".to_string(),
342                    fix: "Fix: prepared witness error counter overflowed u64; shard the witness request batch.".to_string(),
343                }
344            })?;
345        }
346        results.push(result);
347    }
348
349    let requests_u64 = u64::try_from(requests.len()).map_err(|error| {
350        PathError::ResourceExhausted {
351            field: "reachability prepared witness batch request counter".to_string(),
352            fix: format!(
353                "Fix: witness request count does not fit u64: {error}; shard the witness request batch."
354            ),
355        }
356    })?;
357    let prepared_node_count = u64::try_from(graph.node_count()).map_err(|error| {
358        PathError::ResourceExhausted {
359            field: "reachability prepared witness graph node counter".to_string(),
360            fix: format!(
361                "Fix: prepared witness node count does not fit u64: {error}; shard the ProgramGraph before extracting paths."
362            ),
363        }
364    })?;
365    let prepared_reverse_edges = u64::try_from(graph.reverse_edge_count()).map_err(|error| {
366        PathError::ResourceExhausted {
367            field: "reachability prepared witness reverse edge counter".to_string(),
368            fix: format!(
369                "Fix: prepared witness reverse edge count does not fit u64: {error}; shard the ProgramGraph before extracting paths."
370            ),
371        }
372    })?;
373    Ok(PreparedWitnessBatchStats {
374        requests: requests_u64,
375        successes,
376        errors,
377        prepared_graph_reuses: requests_u64,
378        prepared_node_count,
379        prepared_reverse_edges,
380    })
381}
382
383/// Find any unvisited node `pred` such that there's an incoming
384/// IFDS-eligible edge `pred -> current` AND `pred` is reached by the
385/// seeded source's reachability mask AND `pred` is NOT a sanitizer.
386/// Returns `None` when no such pred exists.
387fn find_reached_predecessor(
388    current: u32,
389    node_count: u32,
390    source_reach: &[u32],
391    sanitizer_mask: &[u32],
392    reverse_edges: &ReverseEdges,
393    visited: &[bool],
394) -> Result<Option<u32>, PathError> {
395    let current_index = path_node_index(current, node_count)?;
396    let next_index = current_index.checked_add(1).ok_or_else(|| PathError::MalformedGraph {
397        reason: PrepareWitnessGraphError::InvalidOffsetCount {
398            offsets: reverse_edges.offsets.len(),
399            expected: usize::MAX,
400        },
401        fix: format!(
402            "Fix: witness reverse-edge offset index overflowed after node {current}; rebuild the prepared witness graph."
403        ),
404    })?;
405    let start_value =
406        *reverse_edges
407            .offsets
408            .get(current_index)
409            .ok_or_else(|| PathError::MalformedGraph {
410                reason: PrepareWitnessGraphError::InvalidOffsetCount {
411                    offsets: reverse_edges.offsets.len(),
412                    expected: next_index,
413                },
414                fix: format!(
415                    "Fix: witness reverse-edge offsets are missing row start for node {current}."
416                ),
417            })?;
418    let end_value =
419        *reverse_edges
420            .offsets
421            .get(next_index)
422            .ok_or_else(|| PathError::MalformedGraph {
423                reason: PrepareWitnessGraphError::InvalidOffsetCount {
424                    offsets: reverse_edges.offsets.len(),
425                    expected: match next_index.checked_add(1) {
426                        Some(expected) => expected,
427                        None => usize::MAX,
428                    },
429                },
430                fix: format!(
431                    "Fix: witness reverse-edge offsets are missing row end for node {current}."
432                ),
433            })?;
434    let start = reverse_offset_to_usize(start_value, current_index, "reverse row start")?;
435    let end = reverse_offset_to_usize(end_value, current_index, "reverse row end")?;
436    for edge_index in start..end {
437        let pred =
438            *reverse_edges
439                .preds
440                .get(edge_index)
441                .ok_or_else(|| PathError::MalformedGraph {
442                    reason: PrepareWitnessGraphError::TerminalOffsetMismatch {
443                        terminal: end,
444                        edges: reverse_edges.preds.len(),
445                    },
446                    fix: format!(
447                    "Fix: witness reverse predecessor array is shorter than reverse offset {end}."
448                ),
449                })?;
450        let mask =
451            *reverse_edges
452                .kinds
453                .get(edge_index)
454                .ok_or_else(|| PathError::MalformedGraph {
455                    reason: PrepareWitnessGraphError::TerminalOffsetMismatch {
456                        terminal: end,
457                        edges: reverse_edges.kinds.len(),
458                    },
459                    fix: format!(
460                    "Fix: witness reverse edge-kind array is shorter than reverse offset {end}."
461                ),
462                })?;
463        let pred_index = path_node_index(pred, node_count)?;
464        if visited[pred_index] {
465            continue;
466        }
467        if !witness_bit_is_set(source_reach, pred, "source reachability")? {
468            continue;
469        }
470        if witness_bit_is_set(sanitizer_mask, pred, "sanitizer")? {
471            // A sanitizer-tagged predecessor would make the witness
472            // cross a sanitizer; reject (audit finding 2).
473            continue;
474        }
475        if mask & IFDS_PATH_EDGE_MASK != 0 {
476            return Ok(Some(pred));
477        }
478    }
479    Ok(None)
480}
481
482fn build_statement(
483    node_id: u32,
484    node_count: u32,
485    pg_node_attrs: &[NodeAttr],
486    file_table: &[String],
487    descriptions: &[String],
488    adapter: &str,
489) -> Result<ExtractedStatement, PathError> {
490    let node_index = path_node_index(node_id, node_count)?;
491    let attr = pg_node_attrs
492        .get(node_index)
493        .ok_or(PathError::NodeOutOfBounds {
494            node: node_id,
495            node_count,
496        })?;
497    let file = file_table
498        .get(usize::try_from(attr.file_idx).ok().unwrap_or(usize::MAX))
499        .cloned()
500        .unwrap_or_else(|| String::from("<unknown>"));
501    let description = descriptions
502        .get(node_index)
503        .cloned()
504        .unwrap_or_else(|| format!("node {node_id}"));
505    Ok(ExtractedStatement {
506        adapter: adapter.to_string(),
507        description,
508        file,
509        node_id,
510        byte_start: attr.byte_start,
511        byte_end: attr.byte_end,
512    })
513}