Skip to main content

jsdet_core/
coverage.rs

1/// Bytecode coverage tracking.
2///
3/// When enabled, the `QuickJS` interpreter records which bytecode offsets
4/// execute. This is essential for:
5///
6/// - **Fuzzing feedback** — Soleno's MCTS uses coverage to guide exploration.
7///   Inputs that cover new bytecodes score higher.
8/// - **Detonation completeness** — Sear verifies that the payload actually
9///   executed (not just parsed) by checking coverage against the script's
10///   bytecode.
11/// - **Evasion detection** — if a script has bytecodes that never execute
12///   regardless of input, those may be dead code or environment-gated payloads.
13///
14/// ## How it works
15///
16/// 1. Host allocates a coverage bitmap in WASM linear memory via `jsdet_alloc`
17/// 2. Host calls `jsdet_set_coverage(bitmap_ptr, size)` to register it
18/// 3. `QuickJS` interpreter sets bits as bytecodes execute
19/// 4. After execution, host reads the bitmap back
20/// 5. `jsdet_count_coverage()` returns the number of executed bytecodes
21/// 6. `jsdet_clear_coverage()` resets for the next run
22///
23/// The bitmap is bit-packed: byte N, bit M = bytecode offset (N*8 + M).
24///
25/// Coverage statistics from one execution.
26#[derive(Debug, Clone, Default, serde::Serialize)]
27pub struct CoverageReport {
28    /// Total bytecode offsets covered (bits set in the bitmap).
29    pub covered_offsets: u32,
30    /// Size of the coverage bitmap in bytes.
31    pub bitmap_size: u32,
32    /// Raw bitmap data (for differential analysis between runs).
33    pub bitmap: Vec<u8>,
34}
35
36impl CoverageReport {
37    /// Coverage ratio: covered / (`bitmap_size` * 8).
38    #[must_use]
39    pub fn coverage_ratio(&self) -> f64 {
40        if self.bitmap_size == 0 {
41            return 0.0;
42        }
43        f64::from(self.covered_offsets) / (f64::from(self.bitmap_size) * 8.0)
44    }
45
46    /// Count new offsets covered compared to a previous run.
47    /// This is the MCTS reward signal — new coverage = high reward.
48    #[must_use]
49    pub fn new_coverage_since(&self, previous: &CoverageReport) -> u32 {
50        if self.bitmap.len() != previous.bitmap.len() {
51            return self.covered_offsets;
52        }
53        let mut new_bits = 0u32;
54        for (current, prev) in self.bitmap.iter().zip(previous.bitmap.iter()) {
55            // Bits in current that are NOT in previous.
56            let new = current & !prev;
57            new_bits += new.count_ones();
58        }
59        new_bits
60    }
61
62    /// Merge coverage from another report (union of bitmaps).
63    pub fn merge(&mut self, other: &CoverageReport) {
64        if self.bitmap.len() != other.bitmap.len() {
65            return;
66        }
67        for (a, b) in self.bitmap.iter_mut().zip(other.bitmap.iter()) {
68            *a |= b;
69        }
70        self.covered_offsets = self.bitmap.iter().map(|b| b.count_ones()).sum();
71    }
72}
73
74/// Accumulates coverage across multiple runs.
75/// Used by Soleno to track total coverage during exploration.
76#[derive(Debug, Clone, Default)]
77pub struct CoverageAccumulator {
78    merged: Vec<u8>,
79    total_runs: u64,
80    total_covered: u32,
81}
82
83impl CoverageAccumulator {
84    #[must_use]
85    pub fn new() -> Self {
86        Self::default()
87    }
88
89    /// Add a new run's coverage. Returns the number of NEW offsets.
90    pub fn add_run(&mut self, report: &CoverageReport) -> u32 {
91        if self.merged.is_empty() {
92            self.merged.clone_from(&report.bitmap);
93            self.total_runs = 1;
94            self.total_covered = report.covered_offsets;
95            return report.covered_offsets;
96        }
97
98        if self.merged.len() != report.bitmap.len() {
99            return 0;
100        }
101
102        let mut new_bits = 0u32;
103        for (acc, run) in self.merged.iter_mut().zip(report.bitmap.iter()) {
104            let new = run & !*acc;
105            new_bits += new.count_ones();
106            *acc |= run;
107        }
108        self.total_runs += 1;
109        self.total_covered = self.merged.iter().map(|b| b.count_ones()).sum();
110        new_bits
111    }
112
113    /// Total unique offsets covered across all runs.
114    #[must_use]
115    pub fn total_covered(&self) -> u32 {
116        self.total_covered
117    }
118
119    /// Number of runs accumulated.
120    #[must_use]
121    pub fn total_runs(&self) -> u64 {
122        self.total_runs
123    }
124
125    /// Coverage ratio.
126    #[must_use]
127    #[expect(clippy::cast_precision_loss)]
128    pub fn coverage_ratio(&self) -> f64 {
129        if self.merged.is_empty() {
130            return 0.0;
131        }
132        f64::from(self.total_covered) / (self.merged.len() as f64 * 8.0)
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn new_coverage_detected() {
142        let prev = CoverageReport {
143            covered_offsets: 2,
144            bitmap_size: 1,
145            bitmap: vec![0b00000011],
146        };
147        let curr = CoverageReport {
148            covered_offsets: 3,
149            bitmap_size: 1,
150            bitmap: vec![0b00000111],
151        };
152        assert_eq!(curr.new_coverage_since(&prev), 1);
153    }
154
155    #[test]
156    fn accumulator_tracks_total() {
157        let mut acc = CoverageAccumulator::new();
158        let r1 = CoverageReport {
159            covered_offsets: 2,
160            bitmap_size: 1,
161            bitmap: vec![0b00000011],
162        };
163        let new1 = acc.add_run(&r1);
164        assert_eq!(new1, 2);
165
166        let r2 = CoverageReport {
167            covered_offsets: 2,
168            bitmap_size: 1,
169            bitmap: vec![0b00001100],
170        };
171        let new2 = acc.add_run(&r2);
172        assert_eq!(new2, 2);
173        assert_eq!(acc.total_covered(), 4);
174    }
175
176    #[test]
177    fn merge_unions_bitmaps() {
178        let mut a = CoverageReport {
179            covered_offsets: 2,
180            bitmap_size: 1,
181            bitmap: vec![0b00000011],
182        };
183        let b = CoverageReport {
184            covered_offsets: 2,
185            bitmap_size: 1,
186            bitmap: vec![0b00001100],
187        };
188        a.merge(&b);
189        assert_eq!(a.covered_offsets, 4);
190        assert_eq!(a.bitmap, vec![0b00001111]);
191    }
192}