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;