panicgraph 0.1.9

Reports which functions can panic, why, and through what call path.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
//! The suppression-aware reachability solver.
//!
//! Suppressing a category does not hide findings, it assumes the panic cannot
//! happen. That assumption has to be applied before propagation, because a
//! caller that only panics through a suppressed callee is genuinely clean, and
//! it has to reach into control flow, because a cleanup block that is only
//! reachable while a suppressed panic unwinds is unreachable too.

use std::collections::VecDeque;

use anyhow::{Result, ensure};

use crate::{
    category::{ALL, Category, CategorySet, Termination},
    graph::{FuncId, Graph},
    model::{Body, CallSite, Guard, UnwindOrigin},
};

/// Which optional edges the solver follows.
#[derive(Debug, Clone, Copy)]
pub struct Edges {
    /// Whether to follow edges that are candidates rather than exact,
    /// namely vtable and function pointer calls.
    pub follow_inexact: bool,
    /// Whether to follow the expanded candidate targets of those calls.
    ///
    /// Candidates sharpen the answer, they do not close it: the unresolved
    /// edge stays alongside them, so the assumed category remains either
    /// way.
    pub candidates: bool,
}

impl Default for Edges {
    fn default() -> Self {
        Self {
            follow_inexact: true,
            candidates: false,
        }
    }
}

/// What the user wants assumed impossible.
#[derive(Debug, Clone, Copy)]
pub struct Policy {
    /// Categories treated as though they cannot occur.
    pub suppressed: CategorySet,
    /// Which optional edges to follow.
    pub edges: Edges,
}

impl Default for Policy {
    fn default() -> Self {
        Self {
            suppressed: CategorySet::oom(),
            edges: Edges::default(),
        }
    }
}

impl Policy {
    /// Whether the policy admits this edge.
    #[must_use]
    pub const fn follows(&self, call: &CallSite) -> bool {
        if call.candidate && !self.edges.candidates {
            return false;
        }
        self.edges.follow_inexact || call.kind.is_exact()
    }
}

/// The solved state of one function.
///
/// The two planes exist for the unwind barrier: a catch contains what
/// unwinds and nothing else, so which way a panic terminates has to travel
/// with its category.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct NodeState {
    /// Categories this function can raise by unwinding, after suppression.
    pub unwind: CategorySet,
    /// Categories this function can raise by aborting, after suppression.
    pub abort: CategorySet,
}

impl NodeState {
    /// Every category the function can raise.
    #[must_use]
    pub const fn enabled(self) -> CategorySet {
        self.unwind.union(self.abort)
    }

    /// Whether the function can unwind into its caller's cleanup blocks.
    #[must_use]
    pub const fn unwinds(self) -> bool {
        !self.unwind.is_empty()
    }
}

/// Which sites and calls of one body are reachable under a policy.
#[derive(Debug, Clone, Default)]
pub struct Activity {
    /// Reachability of each entry in [`Body::sites`].
    pub sites: Vec<bool>,
    /// Reachability of each entry in [`Body::calls`].
    pub calls: Vec<bool>,
}

impl Activity {
    /// Whether the site at `index` is reachable. An index the body does not
    /// have is not.
    #[must_use]
    pub fn site(&self, index: usize) -> bool {
        self.sites.get(index).copied().unwrap_or(false)
    }

    /// Whether the call at `index` is reachable. An index the body does not
    /// have is not.
    #[must_use]
    pub fn call(&self, index: usize) -> bool {
        self.calls.get(index).copied().unwrap_or(false)
    }
}

/// Shared evaluation logic, used both while solving and while explaining.
struct Eval<'a> {
    graph: &'a Graph,
    policy: Policy,
    states: &'a [NodeState],
}

impl Eval<'_> {
    /// The state a target the analysis cannot read contributes.
    ///
    /// Unknown code may unwind or abort, so the category sits in both
    /// planes: a barrier contains the first possibility but not the second.
    const fn unreadable(&self, category: Category) -> NodeState {
        let live =
            CategorySet::single(category).difference(self.policy.suppressed);
        NodeState {
            unwind: live,
            abort: live,
        }
    }

    /// Computes one function's state from the current state of its callees.
    fn evaluate(&self, id: FuncId) -> NodeState {
        let body = self.graph.body(id);
        if body.opaque {
            // An opaque body is unknown, not proven clean.
            return self.unreadable(body.unreadable());
        }

        let activity = self.activity(body);
        let mut state = NodeState::default();

        for (i, site) in body.sites.iter().enumerate() {
            if !activity.sites[i]
                || self.policy.suppressed.contains(site.category)
            {
                continue;
            }
            match site.termination {
                Termination::Unwind => state.unwind.insert(site.category),
                Termination::Abort => state.abort.insert(site.category),
            }
        }

        for (i, call) in body.calls.iter().enumerate() {
            if !activity.calls[i] || !self.policy.follows(call) {
                continue;
            }
            let callee = self.callee_state(call);
            // A barrier contains what unwinds out of the callee. An abort
            // cannot be caught by anything, so that plane always crosses.
            if !call.barrier {
                state.unwind = state.unwind.union(callee.unwind);
            }
            state.abort = state.abort.union(callee.abort);
        }

        state
    }

    /// Determines which sites and calls of a body are reachable.
    ///
    /// Ordinary control flow is reachable unconditionally. Cleanup paths are
    /// reachable only while the panic that unwinds into them is enabled, so
    /// this runs to a local fixpoint.
    fn activity(&self, body: &Body) -> Activity {
        let mut act = Activity {
            sites: vec![false; body.sites.len()],
            calls: vec![false; body.calls.len()],
        };
        // Each productive round sets at least one flag, and flags never
        // clear, so the number of flags bounds the number of rounds.
        let rounds = body.sites.len() + body.calls.len();
        for _ in 0..=rounds {
            let mut changed = false;
            for i in 0..body.sites.len() {
                if !act.sites[i]
                    && self.guard_live(&body.sites[i].guard, body, &act)
                {
                    act.sites[i] = true;
                    changed = true;
                }
            }
            for i in 0..body.calls.len() {
                if !act.calls[i]
                    && self.guard_live(&body.calls[i].guard, body, &act)
                {
                    act.calls[i] = true;
                    changed = true;
                }
            }
            if !changed {
                break;
            }
        }
        act
    }

    /// Evaluates a reachability guard against the current local activity.
    fn guard_live(&self, guard: &Guard, body: &Body, act: &Activity) -> bool {
        if guard.normal {
            return true;
        }
        guard.origins.iter().any(|origin| match *origin {
            UnwindOrigin::Site(i) => {
                let i = i as usize;
                body.sites.get(i).is_some_and(|site| {
                    act.sites[i]
                        && site.termination == Termination::Unwind
                        && !self.policy.suppressed.contains(site.category)
                })
            }
            UnwindOrigin::Call(i) => {
                let i = i as usize;
                body.calls.get(i).is_some_and(|call| {
                    act.calls[i]
                        && self.policy.follows(call)
                        && !call.barrier
                        && self.callee_state(call).unwinds()
                })
            }
        })
    }

    /// The current state of a call's target.
    fn callee_state(&self, call: &CallSite) -> NodeState {
        let Some(key) = &call.callee else {
            // An unresolved target is assumed panicking, under the name of
            // whatever stopped the resolution, and it may unwind.
            return self.unreadable(call.kind.assumed_category());
        };
        self.graph
            .id_of(key)
            .map_or_else(NodeState::default, |id| self.states[id.index()])
    }
}

/// The result of solving a graph under one policy.
#[derive(Debug)]
pub struct Solution {
    states: Vec<NodeState>,
    policy: Policy,
}

impl Solution {
    /// The categories a function can raise under the solved policy.
    #[must_use]
    pub fn enabled(&self, id: FuncId) -> CategorySet {
        self.states[id.index()].enabled()
    }

    /// Whether a function can unwind under the solved policy.
    #[must_use]
    pub fn unwinds(&self, id: FuncId) -> bool {
        self.states[id.index()].unwinds()
    }

    /// The categories of a callee that are visible through a call.
    ///
    /// A barrier call contains the callee's unwinding panics, so only the
    /// aborting ones are seen through it.
    #[must_use]
    pub fn through(&self, call: &CallSite, callee: FuncId) -> CategorySet {
        let state = self.states[callee.index()];
        if call.barrier {
            state.abort
        } else {
            state.enabled()
        }
    }

    /// Whether a function raises nothing the user asked to see.
    #[must_use]
    pub fn is_clean(&self, id: FuncId) -> bool {
        self.states[id.index()].enabled().is_empty()
    }

    /// The policy this solution was produced under.
    #[must_use]
    pub const fn policy(&self) -> Policy {
        self.policy
    }

    /// Which sites and calls of a function are reachable under the policy.
    #[must_use]
    pub fn activity(&self, graph: &Graph, id: FuncId) -> Activity {
        let eval = Eval {
            graph,
            policy: self.policy,
            states: &self.states,
        };
        eval.activity(graph.body(id))
    }

    /// Whether the policy admits an edge.
    #[must_use]
    pub const fn follows(&self, call: &CallSite) -> bool {
        self.policy.follows(call)
    }

    /// How many local functions are clean only because of the policy.
    ///
    /// A function that raises nothing whatever is assumed is not one the
    /// suppression cleared, so the answer is the difference between two
    /// solutions rather than a count of the clean ones. That costs a second
    /// fixpoint over the graph, which is why callers ask for it only when
    /// they are about to say something about it.
    ///
    /// # Errors
    ///
    /// Returns an error if the second fixpoint does not converge.
    pub fn cleared_by_suppression(&self, graph: &Graph) -> Result<usize> {
        let bare = Solver::new(
            graph,
            Policy {
                suppressed: CategorySet::EMPTY,
                edges: self.policy.edges,
            },
        )
        .solve()?;
        Ok(graph
            .locals()
            .filter(|(id, _)| self.is_clean(*id) && !bare.is_clean(*id))
            .count())
    }
}

/// Solves a graph under one suppression policy.
pub struct Solver<'g> {
    graph: &'g Graph,
    policy: Policy,
    states: Vec<NodeState>,
}

impl<'g> Solver<'g> {
    /// Prepares a solver over `graph`.
    #[must_use]
    pub fn new(graph: &'g Graph, policy: Policy) -> Self {
        Self {
            graph,
            policy,
            states: vec![NodeState::default(); graph.len()],
        }
    }

    /// Runs the fixpoint to convergence.
    ///
    /// # Errors
    ///
    /// Returns an error if the iteration bound is exceeded, which would mean
    /// the transfer function stopped being monotone.
    pub fn solve(mut self) -> Result<Solution> {
        let n = self.graph.len();
        let mut queued = vec![true; n];
        let mut queue: VecDeque<FuncId> =
            (0..n).map(FuncId::from_index).collect();

        // Termination: a node's state only ever grows, since a category is
        // added to a plane and never taken out of it. Each accepted update
        // therefore sets at least one bit that was clear, and a node holds
        // one bit per category in each of the two termination planes, so
        // that is how many the graph has to give.
        let bound = n.saturating_mul(2 * ALL.len()).saturating_add(1);
        let mut updates = 0usize;

        while let Some(id) = queue.pop_front() {
            queued[id.index()] = false;
            let next = {
                let eval = Eval {
                    graph: self.graph,
                    policy: self.policy,
                    states: &self.states,
                };
                eval.evaluate(id)
            };
            if next == self.states[id.index()] {
                continue;
            }
            self.states[id.index()] = next;
            updates += 1;
            ensure!(
                updates <= bound,
                "panic propagation failed to converge after {updates} \
                 updates over {n} functions"
            );
            for &caller in self.graph.callers(id) {
                if !queued[caller.index()] {
                    queued[caller.index()] = true;
                    queue.push_back(caller);
                }
            }
        }

        Ok(Solution {
            states: self.states,
            policy: self.policy,
        })
    }
}