Skip to main content

weir/
reachability_witness.rs

1//! Reachability witness  -  bridge between the Tier-3 ProgramGraph
2//! forward-reach primitive (`vyre_primitives::ifds_reach_step` driven
3//! to fixpoint by the caller) and consumer proof-bundle assembly.
4//!
5//! ## What this module produces
6//!
7//! Given:
8//! - `source_reach`: a per-node reachability bitmask whose bit `n` is
9//!   set iff the *specific source named in the `PathSeed`* reaches
10//!   node `n` along some IFDS-eligible path. Per-source masks are
11//!   required so that a multi-source taint analysis cannot produce a
12//!   witness that mixes paths originating from a different source.
13//! - `sanitizer_mask`: a per-node bitmask whose bit `n` is set iff
14//!   node `n` is a sanitizer for this rule. The walker rejects any
15//!   sanitizer-tagged predecessor so a witness that crosses a
16//!   sanitizer is never emitted (audit 2026-04-27 finding 2).
17//! - the program-graph CSR (edge_offsets / edge_targets /
18//!   edge_kind_mask).
19//! - a [`PathSeed`] naming the source and sink stmt-ids.
20//!
21//! [`extract_path`] walks the CFG **backward** from the sink,
22//! greedily choosing a reached, non-sanitized predecessor at each
23//! step until the source is hit. The resulting ordered statement
24//! list is the path proven by the upstream forward-reach analysis.
25//!
26//! This is the current host witness explainer for already-computed
27//! reachability results. It is retained as oracle/projection logic
28//! while the production direction remains GPU-resident reachability
29//! and witness materialization; new product reachability work should
30//! extend the dispatch path instead of growing host-only analysis here.
31//!
32//! ## Soundness
33//!
34//! The walk is greedy: it selects ANY reached, non-sanitized
35//! predecessor at each step. That gives one valid path, not
36//! necessarily the shortest. For consumer proof-bundling needs
37//! (showing the user the chain of statements taint flows through),
38//! any valid path is sufficient  -  the existence of A path is what
39//! the proof asserts.
40//!
41//! ## Scope of the upstream analysis
42//!
43//! The caller's bitmasks can come from the Tier-3 forward-reach
44//! primitive (`vyre_primitives::graph::ifds_reach_step`) directly, or
45//! from the full exploded-supergraph IFDS solver in `weir::ifds_gpu`
46//! after converting `(proc, block, fact)` triples with
47//! [`exploded_reachability_to_statement_mask`]. Do not feed raw
48//! exploded-supergraph node ids into [`extract_path`]; statement ids
49//! and exploded node ids are different namespaces.
50//!
51//! For a `MayUnder` mode that returns the shortest path or all
52//! paths, see `vyre_primitives::graph::shortest_path` /
53//! `path_reconstruct`  -  those compose on top of `extract_path`'s
54//! output if a rule needs more than one witness.
55
56mod bitmask;
57mod conversion;
58mod graph;
59mod path;
60
61pub use conversion::exploded_reachability_to_statement_mask;
62pub use graph::{try_prepare_witness_graph, PreparedWitnessGraph};
63#[cfg(any(test, feature = "legacy-infallible"))]
64pub use graph::prepare_witness_graph;
65pub use path::{
66    extract_path, extract_path_prepared, extract_paths_prepared, extract_paths_prepared_into,
67    PathExtractionRequest, PreparedWitnessBatchStats,
68};
69
70#[cfg(test)]
71use vyre_primitives::predicate::edge_kind;
72
73#[cfg(test)]
74const MAX_PATH_DEPTH: usize = path::MAX_PATH_DEPTH;
75
76#[cfg(test)]
77fn bit_is_set(bitmask: &[u32], i: u32) -> bool {
78    bitmask::try_bit_is_set(bitmask, i).expect("test witness bitmask query should be addressable")
79}
80
81/// Source-and-sink seed for path extraction. Caller supplies
82/// the file + byte offsets it identified from rule firing, and we
83/// look them up against the CSR.
84#[derive(Debug, Clone, PartialEq, Eq)]
85#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
86pub struct PathSeed {
87    /// Repository-relative file containing the source.
88    pub source_file: String,
89    /// Source stmt-id (index into pg_nodes).
90    pub source_node: u32,
91    /// Repository-relative file containing the sink.
92    pub sink_file: String,
93    /// Sink stmt-id.
94    pub sink_node: u32,
95}
96
97/// One statement on an extracted path. Mirrors what consumer
98/// `proof::PathStatement` carries; this type lives in weir so the
99/// witness backends can depend on weir without depending on the downstream consumer.
100#[derive(Debug, Clone, PartialEq, Eq)]
101#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
102pub struct ExtractedStatement {
103    /// Source-language adapter id (e.g. `"c-c11"`).
104    pub adapter: String,
105    /// Brief human-readable description (e.g.
106    /// `"call to recv at offset 1142"`).
107    pub description: String,
108    /// Repository-relative file.
109    pub file: String,
110    /// Statement node id (index into pg_nodes).
111    pub node_id: u32,
112    /// Byte range start (inclusive).
113    pub byte_start: u32,
114    /// Byte range end (exclusive).
115    pub byte_end: u32,
116}
117
118/// The output of `extract_path`  -  the ordered statement list a path
119/// traversed, source → sink.
120#[derive(Debug, Clone, PartialEq, Eq, Default)]
121#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
122pub struct ExtractedPath {
123    /// Path statements in source-to-sink order.
124    pub statements: Vec<ExtractedStatement>,
125}
126
127/// Structured validation failures for witness-graph CSR preparation.
128#[derive(Debug, Clone, PartialEq, Eq)]
129#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
130pub enum PrepareWitnessGraphError {
131    /// `edge_offsets` was not exactly `node_count + 1` entries.
132    InvalidOffsetCount {
133        /// Actual offset entries.
134        offsets: usize,
135        /// Required offset entries.
136        expected: usize,
137    },
138    /// `node_count + 1` overflowed the host index type.
139    OffsetCountOverflow {
140        /// Number of graph nodes.
141        nodes: usize,
142    },
143    /// `edge_targets` and `edge_kind_mask` did not both match final offset.
144    EdgeArrayLengthMismatch {
145        /// Number of target entries.
146        targets: usize,
147        /// Number of edge-kind entries.
148        kinds: usize,
149    },
150    /// A CSR edge count or edge offset could not be represented by the host
151    /// index type while preparing the reverse witness graph.
152    EdgeCountOverflow {
153        /// Edge count or edge offset that overflowed host indexing.
154        edges: usize,
155    },
156    /// CSR offsets did not start at zero.
157    NonZeroInitialOffset {
158        /// Supplied first offset.
159        first: u32,
160    },
161    /// CSR offsets decreased between adjacent rows.
162    NonMonotonicOffsets {
163        /// Source row whose start exceeded its end.
164        source: usize,
165        /// Row start offset.
166        start: usize,
167        /// Row end offset.
168        end: usize,
169    },
170    /// A row end exceeded the final declared edge count.
171    OffsetExceedsEdgeCount {
172        /// Offset index that exceeded the edge count.
173        offset_index: usize,
174        /// Offset value.
175        offset: usize,
176        /// Declared edge count.
177        edges: usize,
178    },
179    /// Reverse CSR edge count overflowed while preparing predecessor rows.
180    ReverseEdgeCountOverflow {
181        /// Destination node whose incoming edge count overflowed.
182        node: usize,
183    },
184    /// Final CSR offset did not match the supplied edge arrays.
185    TerminalOffsetMismatch {
186        /// Final offset value.
187        terminal: usize,
188        /// Supplied edge count.
189        edges: usize,
190    },
191    /// An edge target named a node outside the graph.
192    TargetOutOfBounds {
193        /// Edge index.
194        edge_index: usize,
195        /// Target node id.
196        target: u32,
197        /// Valid node count.
198        node_count: u32,
199    },
200}
201
202/// Distinguishable failure modes for [`extract_path`]. The launch's
203/// proof-assembly code maps each variant to a different finding-class
204/// outcome; collapsing every error into `None` (the prior shape) hid
205/// the depth-overrun case from the caller.
206#[derive(Debug, Clone, PartialEq, Eq)]
207#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
208pub enum PathError {
209    /// The IFDS analysis did not reach one of the endpoints; no path
210    /// exists. Caller should keep the finding at Class 3 (heuristic).
211    NoPath,
212    /// A reached, non-sanitized predecessor existed at every step but
213    /// the chain exceeded `MAX_PATH_DEPTH` before reaching the
214    /// source. The partial chain (sink-first, length =
215    /// `MAX_PATH_DEPTH`) is returned for diagnostics. Callers must
216    /// treat this as an analysis-capacity failure and rerun with a
217    /// larger witness budget; returning a downgraded finding would
218    /// hide an incomplete proof.
219    DepthExceeded {
220        /// Sink-first list of statement ids walked before bailing.
221        partial_chain: Vec<u32>,
222    },
223    /// `seed.source_node` or `seed.sink_node` is out of bounds for
224    /// the supplied `pg_node_attrs` slice. Returned instead of
225    /// panicking on `visited[current]` (audit finding 5).
226    NodeOutOfBounds {
227        /// The offending node id.
228        node: u32,
229        /// The valid range upper bound.
230        node_count: u32,
231    },
232    /// The supplied program-graph CSR is malformed. Witness extraction must
233    /// fail instead of silently dropping edges, edge kinds, or out-of-range
234    /// targets because that can manufacture a proof path that the GPU analysis
235    /// did not actually establish.
236    MalformedGraph {
237        /// Structured reason for the malformed CSR.
238        reason: PrepareWitnessGraphError,
239        /// Actionable diagnostic describing the malformed CSR field.
240        fix: String,
241    },
242    /// Host staging memory for witness extraction could not be reserved.
243    /// Callers must treat this as an analysis-capacity failure, not as proof
244    /// that no vulnerable path exists.
245    ResourceExhausted {
246        /// Staging field whose allocation failed.
247        field: String,
248        /// Actionable diagnostic describing how to reduce or shard the load.
249        fix: String,
250    },
251}
252
253impl PartialEq<PrepareWitnessGraphError> for PathError {
254    fn eq(&self, other: &PrepareWitnessGraphError) -> bool {
255        matches!(self, Self::MalformedGraph { reason, .. } if reason == other)
256    }
257}
258
259/// Distinguishable failures when converting exploded IFDS reachability
260/// into the statement-id bitmask consumed by [`extract_path`].
261#[derive(Debug, Clone, PartialEq, Eq)]
262#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
263pub enum ExplodedDecodeError {
264    /// `blocks_per_proc` was zero, so `(proc, block)` cannot be flattened.
265    InvalidShape,
266    /// The decoded `(proc, block)` slot was not present in `block_to_statement`.
267    BlockOutOfBounds {
268        /// Decoded procedure id.
269        proc_id: u32,
270        /// Decoded block id.
271        block_id: u32,
272        /// Flattened block slot requested.
273        slot: u32,
274        /// Number of block slots supplied.
275        slot_count: u32,
276    },
277    /// The block map named a statement outside the caller's statement table.
278    StatementOutOfBounds {
279        /// Statement id read from `block_to_statement`.
280        statement: u32,
281        /// Valid statement count.
282        statement_count: u32,
283    },
284}
285
286/// Per-node byte-range and file-index attributes that a consumer
287/// pipeline emits alongside the CSR. Lives in this module because
288/// path reconstruction is the consumer of these attributes.
289#[derive(Debug, Clone, Copy, PartialEq, Eq)]
290#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
291pub struct NodeAttr {
292    /// Byte range start (inclusive).
293    pub byte_start: u32,
294    /// Byte range end (exclusive).
295    pub byte_end: u32,
296    /// Index into the file table.
297    pub file_idx: u32,
298}
299
300#[cfg(test)]
301#[path = "reachability_witness_graph_validation_tests.rs"]
302mod reachability_witness_graph_validation_tests;
303
304#[cfg(test)]
305#[path = "reachability_witness_tests.rs"]
306mod reachability_witness_tests;