GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 91.2% 332 / 0 / 364
Functions: 100.0% 19 / 0 / 19
Branches: 70.8% 213 / 0 / 301

src/sighealth.cpp
Line Branch Exec Source
1 #include "DetourModKit/sighealth.hpp"
2
3 #include "DetourModKit/anchor.hpp"
4 #include "DetourModKit/manifest.hpp"
5 #include "DetourModKit/scan.hpp"
6
7 #include <algorithm>
8 #include <array>
9 #include <cmath>
10 #include <cstddef>
11 #include <cstdint>
12 #include <format>
13 #include <span>
14 #include <string>
15 #include <string_view>
16 #include <utility>
17 #include <vector>
18
19 namespace DetourModKit
20 {
21 namespace sighealth
22 {
23 namespace
24 {
25 // Grade is ordered Robust (0) < Fragile (1) < Unusable (2). "Worse" folds two verdicts toward the more
26 // severe one. A byte record starts from its first declared rung because static lint cannot know whether a
27 // weak but compilable pattern will resolve uniquely in the live scope; record-level findings and the
28 // whole-record compilability ceiling then only worsen that starting verdict, never raise it.
29
30 23 [[nodiscard]] Grade worse_grade(Grade lhs, Grade rhs) noexcept
31 {
32
2/2
✓ Branch 2 → 3 taken 17 times.
✓ Branch 2 → 4 taken 6 times.
23 return (static_cast<std::uint8_t>(lhs) >= static_cast<std::uint8_t>(rhs)) ? lhs : rhs;
33 }
34
35 // A single Critical finding forces Unusable; any Warning forces Fragile; a report with no findings is
36 // Robust. There is no informational tier, so the mere presence of any finding lowers the grade.
37 116 [[nodiscard]] Grade grade_from(const std::vector<Finding> &findings) noexcept
38 {
39 116 Grade grade = Grade::Robust;
40
2/2
✓ Branch 19 → 4 taken 103 times.
✓ Branch 19 → 20 taken 91 times.
310 for (const Finding &finding : findings)
41 {
42
2/2
✓ Branch 6 → 7 taken 25 times.
✓ Branch 6 → 8 taken 78 times.
103 if (finding.severity == Severity::Critical)
43 {
44 25 return Grade::Unusable;
45 }
46
1/2
✓ Branch 8 → 9 taken 78 times.
✗ Branch 8 → 10 not taken.
78 if (finding.severity == Severity::Warning)
47 {
48 78 grade = Grade::Fragile;
49 }
50 }
51 91 return grade;
52 }
53
54 89 void add_finding(std::vector<Finding> &findings, FindingKind kind, Severity severity)
55 {
56
1/2
✓ Branch 2 → 3 taken 89 times.
✗ Branch 2 → 4 not taken.
89 findings.push_back(Finding{kind, severity});
57 89 }
58
59 // Byte-selectivity model
60
61 // Estimated selectivity of one fully-known byte, in bits. A byte drawn uniformly at random contributes 8
62 // bits (one position in 256 matches). Real x64 .text is far from uniform: padding (0x00), INT3 fill (0xCC),
63 // REX prefixes (0x48) and common opcode leads recur so often that pinning one of them barely narrows the
64 // search. We reuse the scan engine's own frequency-class table (detail::byte_frequency_class, 0 = rare ..
65 // 10 = ubiquitous) so this offline estimate anchors on the same rarity model the engine's prefilter uses,
66 // then discount each class step by a fixed amount and floor the result so even the most common byte still
67 // counts as some evidence. This discount is exactly what atom-rarity analysis is for: it stops a long run
68 // of padding from scoring like a long run of rare bytes.
69 309 [[nodiscard]] double fixed_byte_bits(std::uint8_t value) noexcept
70 {
71 309 constexpr double uniform_bits = 8.0;
72 309 constexpr double bits_per_class = 0.7;
73 309 constexpr double bits_floor = 1.0;
74 309 const auto frequency_class = static_cast<double>(detail::byte_frequency_class(value));
75 309 const double bits = uniform_bits - bits_per_class * frequency_class;
76
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 309 times.
309 return (bits < bits_floor) ? bits_floor : bits;
77 }
78
79 // Shannon entropy in bits over the distribution of fully-known byte values. A run of identical bytes has
80 // near-zero entropy; a varied set approaches log2(distinct values). Computed over the fixed bytes only,
81 // because nibble and wildcard positions carry no known value to distribute.
82 [[nodiscard]] double
83 56 shannon_entropy_bits(const std::array<std::size_t, 256> &counts, std::size_t total) noexcept
84 {
85
2/2
✓ Branch 2 → 3 taken 2 times.
✓ Branch 2 → 4 taken 54 times.
56 if (total == 0)
86 {
87 2 return 0.0;
88 }
89 54 double entropy = 0.0;
90 54 const double denominator = static_cast<double>(total);
91
2/2
✓ Branch 10 → 6 taken 13824 times.
✓ Branch 10 → 11 taken 54 times.
13878 for (const std::size_t count : counts)
92 {
93
2/2
✓ Branch 6 → 7 taken 13544 times.
✓ Branch 6 → 8 taken 280 times.
13824 if (count == 0)
94 {
95 13544 continue;
96 }
97 280 const double probability = static_cast<double>(count) / denominator;
98 280 entropy -= probability * std::log2(probability);
99 }
100 54 return entropy;
101 }
102
103 // Enum naming (local: no public stringifier exists for these)
104
105 3 [[nodiscard]] std::string_view anchor_kind_name(anchor::AnchorKind kind) noexcept
106 {
107
3/10
✗ Branch 2 → 3 not taken.
✓ Branch 2 → 4 taken 1 time.
✗ Branch 2 → 5 not taken.
✓ Branch 2 → 6 taken 1 time.
✓ Branch 2 → 7 taken 1 time.
✗ Branch 2 → 8 not taken.
✗ Branch 2 → 9 not taken.
✗ Branch 2 → 10 not taken.
✗ Branch 2 → 11 not taken.
✗ Branch 2 → 12 not taken.
3 switch (kind)
108 {
109 case anchor::AnchorKind::VtableIdentity:
110 return "VtableIdentity";
111 1 case anchor::AnchorKind::RipGlobal:
112 1 return "RipGlobal";
113 case anchor::AnchorKind::CodeOperand:
114 return "CodeOperand";
115 1 case anchor::AnchorKind::StringXref:
116 1 return "StringXref";
117 1 case anchor::AnchorKind::ExportName:
118 1 return "ExportName";
119 case anchor::AnchorKind::Manual:
120 return "Manual";
121 case anchor::AnchorKind::CallArgHome:
122 return "CallArgHome";
123 case anchor::AnchorKind::Quorum:
124 return "Quorum";
125 case anchor::AnchorKind::Unset:
126 return "Unset";
127 }
128 return "Unknown";
129 }
130
131 1 [[nodiscard]] std::string_view mode_name(scan::Mode mode) noexcept
132 {
133
1/5
✓ Branch 2 → 3 taken 1 time.
✗ Branch 2 → 4 not taken.
✗ Branch 2 → 5 not taken.
✗ Branch 2 → 6 not taken.
✗ Branch 2 → 7 not taken.
1 switch (mode)
134 {
135 1 case scan::Mode::Direct:
136 1 return "Direct";
137 case scan::Mode::RipRelative:
138 return "RipRelative";
139 case scan::Mode::RttiVtable:
140 return "RttiVtable";
141 case scan::Mode::StringXref:
142 return "StringXref";
143 }
144 return "Unknown";
145 }
146
147 // Text-anchor grading
148 // Shared by the StringXref rung/record path. A mangled RTTI name is unique by construction, so an empty
149 // name is the only defect worth flagging there; a string literal, by contrast, can genuinely collide when
150 // it is short (the linker pools identical literals), so a length floor applies to strings but not to type
151 // names.
152
153 16 void grade_text_anchor(
154 std::vector<Finding> &findings,
155 std::size_t text_length,
156 bool apply_length_floor,
157 const HealthPolicy &policy
158 )
159 {
160
2/2
✓ Branch 2 → 3 taken 3 times.
✓ Branch 2 → 5 taken 13 times.
16 if (text_length == 0)
161 {
162 3 add_finding(findings, FindingKind::EmptyAnchorText, Severity::Critical);
163 3 return;
164 }
165
4/4
✓ Branch 5 → 6 taken 8 times.
✓ Branch 5 → 8 taken 5 times.
✓ Branch 6 → 7 taken 2 times.
✓ Branch 6 → 8 taken 6 times.
13 if (apply_length_floor && text_length < policy.min_anchor_text_bytes)
166 {
167 2 add_finding(findings, FindingKind::ShortAnchorText, Severity::Warning);
168 }
169 }
170 } // namespace
171
172 56 PatternHealth analyze_pattern(const scan::Pattern &pattern, const HealthPolicy &policy)
173 {
174 56 PatternHealth health{};
175 56 health.length = pattern.size();
176
177 56 const std::span<const std::byte> bytes = pattern.bytes();
178 56 const std::span<const std::byte> mask = pattern.mask();
179
180 56 std::array<std::size_t, 256> value_counts{};
181 56 std::size_t current_run = 0;
182 56 bool any_rare_fixed = false;
183
184 // Pattern storage omits gap bytes, so each jump terminates the current atom.
185 56 const detail::PatternBuffer &buffer = detail::pattern_buffer(pattern);
186 56 std::size_t next_jump = 0;
187
188
2/2
✓ Branch 37 → 7 taken 353 times.
✓ Branch 37 → 38 taken 56 times.
409 for (std::size_t index = 0; index < health.length; ++index)
189 {
190
6/6
✓ Branch 7 → 8 taken 44 times.
✓ Branch 7 → 11 taken 309 times.
✓ Branch 9 → 10 taken 9 times.
✓ Branch 9 → 11 taken 35 times.
✓ Branch 12 → 13 taken 9 times.
✓ Branch 12 → 17 taken 344 times.
353 if (next_jump < buffer.jump_count && buffer.jumps[next_jump].position == index)
191 {
192 9 ++next_jump;
193
2/2
✓ Branch 13 → 14 taken 7 times.
✓ Branch 13 → 17 taken 2 times.
9 if (current_run > 0)
194 {
195 7 ++health.atom_count;
196
2/2
✓ Branch 14 → 15 taken 6 times.
✓ Branch 14 → 16 taken 1 time.
7 if (current_run > health.longest_atom)
197 {
198 6 health.longest_atom = current_run;
199 }
200 7 current_run = 0;
201 }
202 }
203 353 const auto mask_byte = std::to_integer<std::uint8_t>(mask[index]);
204
2/2
✓ Branch 20 → 21 taken 309 times.
✓ Branch 20 → 29 taken 44 times.
353 if (mask_byte == 0xFF)
205 {
206 // Fully-known byte: it contributes to selectivity, to the entropy sample, to the current atom run,
207 // and to the rarity check.
208 309 const auto value = std::to_integer<std::uint8_t>(bytes[index]);
209 309 ++health.fixed_bytes;
210 309 ++value_counts[value];
211 309 ++current_run;
212 309 health.selectivity_bits += fixed_byte_bits(value);
213
2/2
✓ Branch 27 → 28 taken 250 times.
✓ Branch 27 → 36 taken 59 times.
309 if (detail::byte_frequency_class(value) == 0)
214 {
215 250 any_rare_fixed = true;
216 }
217 }
218 else
219 {
220 // Any non-full mask ends the current atom; a half-known nibble still narrows a position by 4 bits
221 // (one hex digit in sixteen), a full wildcard by nothing.
222
2/2
✓ Branch 29 → 30 taken 11 times.
✓ Branch 29 → 33 taken 33 times.
44 if (current_run > 0)
223 {
224 11 ++health.atom_count;
225
1/2
✓ Branch 30 → 31 taken 11 times.
✗ Branch 30 → 32 not taken.
11 if (current_run > health.longest_atom)
226 {
227 11 health.longest_atom = current_run;
228 }
229 11 current_run = 0;
230 }
231
2/2
✓ Branch 33 → 34 taken 41 times.
✓ Branch 33 → 35 taken 3 times.
44 if (mask_byte == 0x00)
232 {
233 41 ++health.wildcard_bytes;
234 }
235 else
236 {
237 3 ++health.nibble_bytes;
238 3 constexpr double nibble_bits = 4.0;
239 3 health.selectivity_bits += nibble_bits;
240 }
241 }
242 }
243 // Close the final atom if the pattern ended inside a fixed run.
244
2/2
✓ Branch 38 → 39 taken 49 times.
✓ Branch 38 → 41 taken 7 times.
56 if (current_run > 0)
245 {
246 49 ++health.atom_count;
247
2/2
✓ Branch 39 → 40 taken 42 times.
✓ Branch 39 → 41 taken 7 times.
49 if (current_run > health.longest_atom)
248 {
249 42 health.longest_atom = current_run;
250 }
251 }
252
253
1/2
✓ Branch 41 → 42 taken 56 times.
✗ Branch 41 → 43 not taken.
56 if (health.length > 0)
254 {
255 56 health.wildcard_ratio = static_cast<double>(health.wildcard_bytes) / static_cast<double>(health.length);
256 }
257 56 health.byte_entropy_bits = shannon_entropy_bits(value_counts, health.fixed_bytes);
258
4/4
✓ Branch 44 → 45 taken 54 times.
✓ Branch 44 → 47 taken 2 times.
✓ Branch 45 → 46 taken 4 times.
✓ Branch 45 → 47 taken 50 times.
56 health.common_bytes_only = (health.fixed_bytes > 0) && !any_rare_fixed;
259
260 // expected_matches models the pattern against an independent-byte haystack: each position multiplies the
261 // per-position match probability, and selectivity_bits is the sum of the per-position -log2 probabilities,
262 // so N * 2^(-selectivity_bits) is the expected count of matching windows. It is an order-of-magnitude
263 // heuristic, not a promise (the runtime resolver still verifies uniqueness), but it cleanly separates a
264 // few-rare-byte anchor (effectively unique) from a short or common one (thousands of hits).
265 // A bounded jump multiplies the match opportunities: each of its (max_skip - min_skip + 1) widths is a
266 // distinct place the following segment can sit, so a variable-gap signature is less unique than its fixed
267 // bytes alone imply. Fold that widening in so health does not over-rate a gapped pattern as if its segments
268 // were adjacent. A jump-free pattern keeps a multiplier of 1.
269 56 double gap_multiplier = 1.0;
270
2/2
✓ Branch 52 → 49 taken 9 times.
✓ Branch 52 → 53 taken 56 times.
65 for (std::size_t index = 0; index < buffer.jump_count; ++index)
271 {
272 9 gap_multiplier *= static_cast<double>(buffer.jumps[index].max_skip - buffer.jumps[index].min_skip + 1);
273 }
274 56 health.expected_matches = static_cast<double>(policy.nominal_haystack_bytes) *
275 56 std::exp2(-health.selectivity_bits) * gap_multiplier;
276
277 // Findings, most structural first. A pattern with no fully-known byte cannot drive the memchr prefilter at
278 // all; every other check assumes at least one fixed byte exists.
279
2/2
✓ Branch 53 → 54 taken 2 times.
✓ Branch 53 → 55 taken 54 times.
56 if (health.fixed_bytes == 0)
280 {
281
1/2
✓ Branch 54 → 57 taken 2 times.
✗ Branch 54 → 73 not taken.
2 add_finding(health.findings, FindingKind::NoFixedAnchor, Severity::Critical);
282 }
283
2/2
✓ Branch 55 → 56 taken 17 times.
✓ Branch 55 → 57 taken 37 times.
54 else if (health.longest_atom < policy.min_longest_atom)
284 {
285
1/2
✓ Branch 56 → 57 taken 17 times.
✗ Branch 56 → 73 not taken.
17 add_finding(health.findings, FindingKind::ShortestAnchorRun, Severity::Warning);
286 }
287
2/2
✓ Branch 57 → 58 taken 18 times.
✓ Branch 57 → 59 taken 38 times.
56 if (health.length < policy.min_pattern_bytes)
288 {
289
1/2
✓ Branch 58 → 59 taken 18 times.
✗ Branch 58 → 73 not taken.
18 add_finding(health.findings, FindingKind::ShortPattern, Severity::Warning);
290 }
291
2/2
✓ Branch 59 → 60 taken 4 times.
✓ Branch 59 → 61 taken 52 times.
56 if (health.common_bytes_only)
292 {
293
1/2
✓ Branch 60 → 61 taken 4 times.
✗ Branch 60 → 73 not taken.
4 add_finding(health.findings, FindingKind::CommonBytesOnly, Severity::Warning);
294 }
295
2/2
✓ Branch 61 → 62 taken 4 times.
✓ Branch 61 → 63 taken 52 times.
56 if (health.wildcard_ratio > policy.max_wildcard_ratio)
296 {
297
1/2
✓ Branch 62 → 63 taken 4 times.
✗ Branch 62 → 73 not taken.
4 add_finding(health.findings, FindingKind::HighWildcardRatio, Severity::Warning);
298 }
299 // Entropy is only meaningful with enough fixed bytes to distribute; a legitimately short 2-3 byte anchor is
300 // not "low entropy", it simply has few samples, so gate the check on a minimum sample size.
301 56 constexpr std::size_t min_entropy_sample = 4;
302
4/4
✓ Branch 63 → 64 taken 44 times.
✓ Branch 63 → 66 taken 12 times.
✓ Branch 64 → 65 taken 6 times.
✓ Branch 64 → 66 taken 38 times.
56 if (health.fixed_bytes >= min_entropy_sample && health.byte_entropy_bits < policy.min_byte_entropy_bits)
303 {
304
1/2
✓ Branch 65 → 66 taken 6 times.
✗ Branch 65 → 73 not taken.
6 add_finding(health.findings, FindingKind::LowByteEntropy, Severity::Warning);
305 }
306
2/2
✓ Branch 66 → 67 taken 13 times.
✓ Branch 66 → 68 taken 43 times.
56 if (health.expected_matches > policy.fail_expected_matches)
307 {
308
1/2
✓ Branch 67 → 70 taken 13 times.
✗ Branch 67 → 73 not taken.
13 add_finding(health.findings, FindingKind::WeakSelectivity, Severity::Critical);
309 }
310
2/2
✓ Branch 68 → 69 taken 4 times.
✓ Branch 68 → 70 taken 39 times.
43 else if (health.expected_matches > policy.warn_expected_matches)
311 {
312
1/2
✓ Branch 69 → 70 taken 4 times.
✗ Branch 69 → 73 not taken.
4 add_finding(health.findings, FindingKind::WeakSelectivity, Severity::Warning);
313 }
314
315 56 health.grade = grade_from(health.findings);
316 56 return health;
317 }
318
319 33 CandidateHealth analyze_candidate(const manifest::CandidateSpec &spec, const HealthPolicy &policy)
320 {
321 33 CandidateHealth health{};
322 33 health.mode = spec.mode;
323
324
3/4
✓ Branch 2 → 3 taken 28 times.
✓ Branch 2 → 41 taken 2 times.
✓ Branch 2 → 44 taken 3 times.
✗ Branch 2 → 47 not taken.
33 switch (spec.mode)
325 {
326 28 case scan::Mode::Direct:
327 case scan::Mode::RipRelative:
328 {
329 // The file carries the AOB as text; compile it the same way the resolver will, so the analysis sees the
330 // exact byte/mask the engine would. A malformed rung is a hard defect (it can never resolve), reported
331 // as a finding rather than thrown, so a whole-manifest lint never aborts on one bad rung.
332 28 const Result<scan::Pattern> compiled = scan::Pattern::compile(spec.pattern);
333
2/2
✓ Branch 6 → 7 taken 1 time.
✓ Branch 6 → 9 taken 27 times.
28 if (!compiled)
334 {
335 1 health.compiled = false;
336
1/2
✓ Branch 7 → 8 taken 1 time.
✗ Branch 7 → 52 not taken.
1 add_finding(health.findings, FindingKind::UncompilablePattern, Severity::Critical);
337 1 break;
338 }
339
1/2
✓ Branch 10 → 11 taken 27 times.
✗ Branch 10 → 50 not taken.
27 health.pattern = analyze_pattern(*compiled, policy);
340
1/2
✓ Branch 13 → 14 taken 27 times.
✗ Branch 13 → 52 not taken.
27 health.findings = health.pattern.findings;
341
3/4
✓ Branch 14 → 15 taken 9 times.
✓ Branch 14 → 39 taken 18 times.
✓ Branch 15 → 16 taken 9 times.
✗ Branch 15 → 39 not taken.
27 if (spec.mode == scan::Mode::RipRelative && spec.displacement_at >= 0)
342 {
343 // Pattern offsets after the first jump depend on gap width, so only segment 0 maps directly.
344 9 const std::span<const std::byte> pattern_mask = compiled->mask();
345 9 const detail::PatternBuffer &buffer = detail::pattern_buffer(*compiled);
346 const std::size_t segment0_end =
347
2/2
✓ Branch 20 → 21 taken 1 time.
✓ Branch 20 → 23 taken 8 times.
9 (buffer.jump_count > 0) ? buffer.jumps[0].position : compiled->size();
348 9 const std::size_t instruction_begin = compiled->offset();
349 9 const auto displacement_offset = static_cast<std::size_t>(spec.displacement_at);
350
3/4
✓ Branch 27 → 28 taken 9 times.
✗ Branch 27 → 38 not taken.
✓ Branch 28 → 29 taken 8 times.
✓ Branch 28 → 38 taken 1 time.
9 if (instruction_begin < segment0_end && displacement_offset < segment0_end - instruction_begin)
351 {
352 8 const std::size_t window_begin = instruction_begin + displacement_offset;
353 8 const std::size_t window_end = std::min(window_begin + 4, segment0_end);
354
2/2
✓ Branch 36 → 31 taken 20 times.
✓ Branch 36 → 37 taken 4 times.
24 for (std::size_t index = window_begin; index < window_end; ++index)
355 {
356
2/2
✓ Branch 32 → 33 taken 4 times.
✓ Branch 32 → 35 taken 16 times.
20 if (pattern_mask[index] != std::byte{0x00})
357 {
358
1/2
✓ Branch 33 → 34 taken 4 times.
✗ Branch 33 → 51 not taken.
4 add_finding(health.findings, FindingKind::VolatileDisplacementBytes, Severity::Warning);
359 4 break;
360 }
361 }
362 }
363 }
364 27 break;
365 }
366 2 case scan::Mode::RttiVtable:
367 {
368 // A mangled type name resolves unique-only through the reverse-RTTI walk, so its only failure mode as
369 // an anchor is being empty; a short but valid name is still unique.
370 2 health.anchor_text_bytes = spec.mangled.size();
371
1/2
✓ Branch 42 → 43 taken 2 times.
✗ Branch 42 → 53 not taken.
2 grade_text_anchor(health.findings, health.anchor_text_bytes, /*apply_length_floor=*/false, policy);
372 2 break;
373 }
374 3 case scan::Mode::StringXref:
375 {
376 // A string literal can genuinely collide when short (the linker pools identical literals), so the
377 // length floor applies here.
378 3 health.anchor_text_bytes = spec.string_text.size();
379
1/2
✓ Branch 45 → 46 taken 3 times.
✗ Branch 45 → 53 not taken.
3 grade_text_anchor(health.findings, health.anchor_text_bytes, /*apply_length_floor=*/true, policy);
380 3 break;
381 }
382 }
383
384 33 health.grade = grade_from(health.findings);
385 33 return health;
386 }
387
388 27 RecordHealth analyze_record(const manifest::SignatureRecord &record, const HealthPolicy &policy)
389 {
390
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 27 times.
27 RecordHealth health{};
391
1/2
✓ Branch 5 → 6 taken 27 times.
✗ Branch 5 → 90 not taken.
27 health.label = record.label;
392 27 health.kind = record.kind;
393
394
6/7
✓ Branch 6 → 7 taken 11 times.
✓ Branch 6 → 50 taken 6 times.
✓ Branch 6 → 54 taken 1 time.
✓ Branch 6 → 58 taken 4 times.
✓ Branch 6 → 62 taken 3 times.
✓ Branch 6 → 65 taken 2 times.
✗ Branch 6 → 68 not taken.
27 switch (record.kind)
395 {
396 11 case anchor::AnchorKind::RipGlobal:
397 case anchor::AnchorKind::CodeOperand:
398 {
399 // Grade every rung for diagnostics, but seed the record verdict from the first declared rung. Static
400 // lint cannot prove that a weak compilable rung will miss in the live scope. This is only the starting
401 // verdict: the record-level findings folded in below and the compilability ceiling at function end can
402 // still worsen it (down to Unusable), never raise it.
403
1/2
✓ Branch 8 → 9 taken 11 times.
✗ Branch 8 → 90 not taken.
11 health.ladder.reserve(record.ladder.size());
404 11 Grade effective_grade = Grade::Robust;
405 11 bool have_byte_estimate = false;
406
2/2
✓ Branch 40 → 11 taken 15 times.
✓ Branch 40 → 41 taken 11 times.
37 for (const manifest::CandidateSpec &rung : record.ladder)
407 {
408
1/2
✓ Branch 13 → 14 taken 15 times.
✗ Branch 13 → 82 not taken.
15 CandidateHealth rung_health = analyze_candidate(rung, policy);
409
2/2
✓ Branch 14 → 15 taken 9 times.
✓ Branch 14 → 16 taken 6 times.
15 if (rung_health.grade == Grade::Robust)
410 {
411 9 ++health.robust_rungs;
412 }
413 // The strongest BYTE rung supplies the record's numeric selectivity summary; a text-tier rung has
414 // no byte estimate (its uniqueness is guaranteed by the backend, not by byte selectivity). Rank by
415 // expected_matches, which folds each rung's bounded-jump gap widening into the estimate, so the
416 // rung reported as strongest is the one that resolves most uniquely rather than the one with the
417 // fixed bits: a wide-gap rung can carry more selectivity_bits yet expect more matches than a
418 // gap-free rung with fewer fixed bytes. selectivity_bits breaks a tie on equal expected_matches,
419 // and the first rung wins when both are equal.
420
1/2
✓ Branch 16 → 17 taken 15 times.
✗ Branch 16 → 27 not taken.
15 if (rung_health.compiled &&
421
3/4
✓ Branch 17 → 18 taken 2 times.
✓ Branch 17 → 19 taken 13 times.
✓ Branch 18 → 19 taken 2 times.
✗ Branch 18 → 27 not taken.
15 (rung.mode == scan::Mode::Direct || rung.mode == scan::Mode::RipRelative))
422 {
423
2/2
✓ Branch 19 → 20 taken 12 times.
✓ Branch 19 → 22 taken 3 times.
27 const bool stronger = rung_health.pattern.expected_matches < health.best_expected_matches ||
424
1/2
✗ Branch 20 → 21 not taken.
✓ Branch 20 → 23 taken 12 times.
12 (rung_health.pattern.expected_matches == health.best_expected_matches &&
425 rung_health.pattern.selectivity_bits > health.best_selectivity_bits);
426
4/4
✓ Branch 24 → 25 taken 5 times.
✓ Branch 24 → 26 taken 10 times.
✓ Branch 25 → 26 taken 3 times.
✓ Branch 25 → 27 taken 2 times.
15 if (!have_byte_estimate || stronger)
427 {
428 13 health.best_selectivity_bits = rung_health.pattern.selectivity_bits;
429 13 health.best_expected_matches = rung_health.pattern.expected_matches;
430 13 have_byte_estimate = true;
431 }
432 }
433
1/2
✓ Branch 29 → 30 taken 15 times.
✗ Branch 29 → 80 not taken.
30 health.ladder.push_back(std::move(rung_health));
434 15 }
435
436
2/2
✓ Branch 42 → 43 taken 1 time.
✓ Branch 42 → 44 taken 10 times.
11 if (health.ladder.empty())
437 {
438 // A byte backend with no rungs cannot resolve at all; the compiler would reject it
439 // (EmptyCandidates), and the linter flags the same defect on the raw record.
440
1/2
✓ Branch 43 → 47 taken 1 time.
✗ Branch 43 → 90 not taken.
1 add_finding(health.findings, FindingKind::NoRobustRung, Severity::Critical);
441 }
442 else
443 {
444 // A static Unusable verdict describes reliability, not a guaranteed runtime miss. The resolver can
445 // still commit a weak but compilable first pattern when it is unique in the supplied scope, so a
446 // stronger fallback cannot raise the record grade before a live resolution proves the first failed.
447 10 effective_grade = health.ladder.front().grade;
448
2/2
✓ Branch 45 → 46 taken 3 times.
✓ Branch 45 → 47 taken 7 times.
10 if (health.robust_rungs == 0)
449 {
450
1/2
✓ Branch 46 → 47 taken 3 times.
✗ Branch 46 → 90 not taken.
3 add_finding(health.findings, FindingKind::NoRobustRung, Severity::Warning);
451 }
452 }
453 11 health.grade = worse_grade(effective_grade, grade_from(health.findings));
454 11 break;
455 }
456 6 case anchor::AnchorKind::StringXref:
457 {
458 6 health.anchor_text_bytes = record.xref_text.size();
459
1/2
✓ Branch 51 → 52 taken 6 times.
✗ Branch 51 → 90 not taken.
6 grade_text_anchor(health.findings, health.anchor_text_bytes, /*apply_length_floor=*/true, policy);
460 6 health.grade = grade_from(health.findings);
461 6 break;
462 }
463 1 case anchor::AnchorKind::VtableIdentity:
464 {
465 1 health.anchor_text_bytes = record.mangled.size();
466
1/2
✓ Branch 55 → 56 taken 1 time.
✗ Branch 55 → 90 not taken.
1 grade_text_anchor(health.findings, health.anchor_text_bytes, /*apply_length_floor=*/false, policy);
467 1 health.grade = grade_from(health.findings);
468 1 break;
469 }
470 4 case anchor::AnchorKind::ExportName:
471 {
472 // Grade by the export name, but WITHOUT the short-text length floor StringXref applies. An EAT lookup
473 // compares an exact name within one module rather than searching image bytes for a statistically
474 // selective literal, so a short name ("malloc") is not weaker than a long one; only an empty name is a
475 // real defect.
476 4 health.anchor_text_bytes = record.export_name.size();
477
1/2
✓ Branch 59 → 60 taken 4 times.
✗ Branch 59 → 90 not taken.
4 grade_text_anchor(health.findings, health.anchor_text_bytes, /*apply_length_floor=*/false, policy);
478 4 health.grade = grade_from(health.findings);
479 4 break;
480 }
481 3 case anchor::AnchorKind::Manual:
482 {
483 // A pinned literal has no backend and cannot self-heal across a patch; it is usable today but will go
484 // stale silently, so it is Fragile by design rather than a defect.
485
1/2
✓ Branch 62 → 63 taken 3 times.
✗ Branch 62 → 90 not taken.
3 add_finding(health.findings, FindingKind::UnhealableManual, Severity::Warning);
486 3 health.grade = grade_from(health.findings);
487 3 break;
488 }
489 2 case anchor::AnchorKind::CallArgHome:
490 case anchor::AnchorKind::Quorum:
491 case anchor::AnchorKind::Unset:
492 {
493 // A Quorum composes its M voting sub-anchors by pointer and CallArgHome has no resolver, so neither can
494 // be expressed as a flat file record; Unset is a record whose kind was never set. The compiler rejects
495 // all three, and the linter names the same reason (a record that can never resolve as a file
496 // signature).
497
1/2
✓ Branch 65 → 66 taken 2 times.
✗ Branch 65 → 90 not taken.
2 add_finding(health.findings, FindingKind::NonSerializableKind, Severity::Critical);
498 2 health.grade = grade_from(health.findings);
499 2 break;
500 }
501 }
502
503 // Compilability ceiling. The per-rung analysis grades a byte record by its first declared rung, but the
504 // resolver only ever sees a record Signature::compile accepts, and compile enforces constraints the rung
505 // analysis cannot model: a RIP-relative rung's (displacement_at, instruction_length) layout, a RipGlobal's
506 // page class, the non-serializable composite kinds. Because compile rejects the WHOLE record when any one
507 // rung is malformed, a ladder whose graded rung reads Robust can still be uncompilable, so grading Robust
508 // would certify a signature the trust gate could never build. Re-check compilability here so the grade
509 // cannot EXCEED it: a record compile would reject is floored to Unusable however strong a rung looks in
510 // isolation. compile() only ever fails a superset of what the analysis flags Unusable (empty text or
511 // ladder, an uncompilable pattern), so folding it in can only worsen a grade, never inflate one. When the
512 // grade is already Unusable the specific reason is reported, so the generic finding is suppressed to
513 // avoid noise while the Unusable floor still holds.
514
4/6
✓ Branch 68 → 69 taken 27 times.
✗ Branch 68 → 86 not taken.
✓ Branch 69 → 70 taken 27 times.
✗ Branch 69 → 84 not taken.
✓ Branch 72 → 73 taken 6 times.
✓ Branch 72 → 77 taken 21 times.
27 if (const Result<manifest::Signature> compiled = manifest::Signature::compile(record); !compiled)
515 {
516
2/2
✓ Branch 73 → 74 taken 2 times.
✓ Branch 73 → 75 taken 4 times.
6 if (health.grade != Grade::Unusable)
517 {
518
1/2
✓ Branch 74 → 75 taken 2 times.
✗ Branch 74 → 87 not taken.
2 add_finding(health.findings, FindingKind::UncompilableRecord, Severity::Critical);
519 }
520 6 health.grade = worse_grade(health.grade, Grade::Unusable);
521 27 }
522
523 27 return health;
524 }
525
526 3 ManifestHealth analyze_manifest(const manifest::Manifest &manifest, const HealthPolicy &policy)
527 {
528 3 ManifestHealth health{};
529
1/2
✓ Branch 3 → 4 taken 3 times.
✗ Branch 3 → 34 not taken.
3 health.records.reserve(manifest.records.size());
530
2/2
✓ Branch 27 → 6 taken 6 times.
✓ Branch 27 → 28 taken 3 times.
12 for (const manifest::SignatureRecord &record : manifest.records)
531 {
532
1/2
✓ Branch 8 → 9 taken 6 times.
✗ Branch 8 → 32 not taken.
6 RecordHealth record_health = analyze_record(record, policy);
533
3/4
✓ Branch 9 → 10 taken 3 times.
✓ Branch 9 → 11 taken 1 time.
✓ Branch 9 → 12 taken 2 times.
✗ Branch 9 → 13 not taken.
6 switch (record_health.grade)
534 {
535 3 case Grade::Robust:
536 3 ++health.robust;
537 3 break;
538 1 case Grade::Fragile:
539 1 ++health.fragile;
540 1 break;
541 2 case Grade::Unusable:
542 2 ++health.unusable;
543 2 break;
544 }
545 // A manifest is only as trustworthy as its weakest signature, since each gates its own feature.
546 6 health.grade = worse_grade(health.grade, record_health.grade);
547
1/2
✓ Branch 16 → 17 taken 6 times.
✗ Branch 16 → 30 not taken.
12 health.records.push_back(std::move(record_health));
548 6 }
549 3 return health;
550 }
551
552 17 std::string_view to_string(Severity severity) noexcept
553 {
554
2/3
✓ Branch 2 → 3 taken 10 times.
✓ Branch 2 → 4 taken 7 times.
✗ Branch 2 → 5 not taken.
17 switch (severity)
555 {
556 10 case Severity::Warning:
557 10 return "warning";
558 7 case Severity::Critical:
559 7 return "critical";
560 }
561 return "unknown";
562 }
563
564 30 std::string_view to_string(FindingKind kind) noexcept
565 {
566
15/16
✓ Branch 2 → 3 taken 2 times.
✓ Branch 2 → 4 taken 1 time.
✓ Branch 2 → 5 taken 3 times.
✓ Branch 2 → 6 taken 2 times.
✓ Branch 2 → 7 taken 3 times.
✓ Branch 2 → 8 taken 2 times.
✓ Branch 2 → 9 taken 4 times.
✓ Branch 2 → 10 taken 5 times.
✓ Branch 2 → 11 taken 1 time.
✓ Branch 2 → 12 taken 1 time.
✓ Branch 2 → 13 taken 1 time.
✓ Branch 2 → 14 taken 1 time.
✓ Branch 2 → 15 taken 2 times.
✓ Branch 2 → 16 taken 1 time.
✓ Branch 2 → 17 taken 1 time.
✗ Branch 2 → 18 not taken.
30 switch (kind)
567 {
568 2 case FindingKind::NoFixedAnchor:
569 2 return "no fully-known byte to anchor on (masked compare at every position)";
570 1 case FindingKind::UncompilablePattern:
571 1 return "the AOB pattern failed to compile";
572 3 case FindingKind::ShortPattern:
573 3 return "pattern shorter than the recommended byte floor";
574 2 case FindingKind::ShortestAnchorRun:
575 2 return "longest fully-known byte run is short (weak prefilter atom)";
576 3 case FindingKind::CommonBytesOnly:
577 3 return "every fully-known byte is a common opcode or padding (low atom rarity)";
578 2 case FindingKind::HighWildcardRatio:
579 2 return "wildcards dominate the pattern";
580 4 case FindingKind::LowByteEntropy:
581 4 return "fully-known bytes are repetitive (low entropy)";
582 5 case FindingKind::WeakSelectivity:
583 5 return "high estimated false-match count (weak selectivity)";
584 1 case FindingKind::EmptyAnchorText:
585 1 return "the anchor string or mangled name is empty";
586 1 case FindingKind::ShortAnchorText:
587 1 return "the anchor string is short and may not be unique";
588 1 case FindingKind::UnhealableManual:
589 1 return "a pinned Manual literal cannot self-heal across a patch";
590 1 case FindingKind::NonSerializableKind:
591 1 return "the record kind is not file-serializable (Quorum / CallArgHome / Unset)";
592 2 case FindingKind::NoRobustRung:
593 2 return "no candidate rung graded Robust";
594 1 case FindingKind::UncompilableRecord:
595 1 return "the record does not compile as a signature (bad rung layout, page class, or kind)";
596 1 case FindingKind::VolatileDisplacementBytes:
597 1 return "fixed pattern bytes cover the declared disp32 (link-volatile; wildcard them)";
598 }
599 return "unknown finding";
600 }
601
602 14 std::string_view to_string(Grade grade) noexcept
603 {
604
3/4
✓ Branch 2 → 3 taken 3 times.
✓ Branch 2 → 4 taken 3 times.
✓ Branch 2 → 5 taken 8 times.
✗ Branch 2 → 6 not taken.
14 switch (grade)
605 {
606 3 case Grade::Robust:
607 3 return "Robust";
608 3 case Grade::Fragile:
609 3 return "Fragile";
610 8 case Grade::Unusable:
611 8 return "Unusable";
612 }
613 return "Unknown";
614 }
615
616 namespace
617 {
618 // Appends " [severity] description\n" for each finding into an existing report body.
619 7 void append_findings(std::string &out, const std::vector<Finding> &findings)
620 {
621
2/2
✓ Branch 20 → 4 taken 14 times.
✓ Branch 20 → 21 taken 7 times.
28 for (const Finding &finding : findings)
622 {
623
2/4
✓ Branch 8 → 9 taken 14 times.
✗ Branch 8 → 24 not taken.
✓ Branch 9 → 10 taken 14 times.
✗ Branch 9 → 22 not taken.
14 out += std::format(" [{}] {}\n", to_string(finding.severity), to_string(finding.kind));
624 }
625 7 }
626 } // namespace
627
628 3 std::string format_report(const PatternHealth &health, std::string_view label)
629 {
630 3 std::string out;
631
1/2
✓ Branch 4 → 5 taken 3 times.
✗ Branch 4 → 9 not taken.
3 if (!label.empty())
632 {
633
2/4
✓ Branch 5 → 6 taken 3 times.
✗ Branch 5 → 27 not taken.
✓ Branch 6 → 7 taken 3 times.
✗ Branch 6 → 25 not taken.
3 out += std::format("{}: ", label);
634 }
635
2/4
✓ Branch 10 → 11 taken 3 times.
✗ Branch 10 → 31 not taken.
✓ Branch 11 → 12 taken 3 times.
✗ Branch 11 → 29 not taken.
3 out += std::format("{}\n", to_string(health.grade));
636 3 out += std::format(
637 " bytes={} fixed={} nibble={} wildcard={} (wildcard {:.0f}%)\n",
638 3 health.length,
639 3 health.fixed_bytes,
640 3 health.nibble_bytes,
641 3 health.wildcard_bytes,
642
1/2
✓ Branch 13 → 14 taken 3 times.
✗ Branch 13 → 36 not taken.
3 health.wildcard_ratio * 100.0
643
1/2
✓ Branch 14 → 15 taken 3 times.
✗ Branch 14 → 34 not taken.
3 );
644 3 out += std::format(
645 " atoms={} longest_atom={} entropy={:.1f} bits selectivity={:.1f} bits\n",
646 3 health.atom_count,
647 3 health.longest_atom,
648 3 health.byte_entropy_bits,
649
1/2
✓ Branch 16 → 17 taken 3 times.
✗ Branch 16 → 41 not taken.
3 health.selectivity_bits
650
1/2
✓ Branch 17 → 18 taken 3 times.
✗ Branch 17 → 39 not taken.
3 );
651
2/4
✓ Branch 19 → 20 taken 3 times.
✗ Branch 19 → 45 not taken.
✓ Branch 20 → 21 taken 3 times.
✗ Branch 20 → 43 not taken.
3 out += std::format(" expected_matches~={:.3g}\n", health.expected_matches);
652
1/2
✓ Branch 22 → 23 taken 3 times.
✗ Branch 22 → 47 not taken.
3 append_findings(out, health.findings);
653 3 return out;
654 }
655
656 3 std::string format_report(const RecordHealth &health)
657 {
658 std::string out = std::format(
659 "[{}] kind={} grade={}\n",
660 3 health.label,
661 3 anchor_kind_name(health.kind),
662 3 to_string(health.grade)
663
1/2
✓ Branch 4 → 5 taken 3 times.
✗ Branch 4 → 31 not taken.
3 );
664
2/2
✓ Branch 6 → 7 taken 1 time.
✓ Branch 6 → 23 taken 2 times.
3 if (!health.ladder.empty())
665 {
666 1 out += std::format(
667 " ladder: {} rungs, {} robust; strongest byte rung selectivity={:.1f} bits, "
668 "expected_matches~={:.3g}\n",
669
1/2
✓ Branch 8 → 9 taken 1 time.
✗ Branch 8 → 36 not taken.
1 health.ladder.size(),
670 1 health.robust_rungs,
671 1 health.best_selectivity_bits,
672 1 health.best_expected_matches
673
1/2
✓ Branch 9 → 10 taken 1 time.
✗ Branch 9 → 34 not taken.
1 );
674
2/2
✓ Branch 21 → 12 taken 1 time.
✓ Branch 21 → 22 taken 1 time.
2 for (std::size_t index = 0; index < health.ladder.size(); ++index)
675 {
676 1 const CandidateHealth &rung = health.ladder[index];
677
2/4
✓ Branch 15 → 16 taken 1 time.
✗ Branch 15 → 41 not taken.
✓ Branch 16 → 17 taken 1 time.
✗ Branch 16 → 39 not taken.
1 out += std::format(" rung {} ({}): {}\n", index, mode_name(rung.mode), to_string(rung.grade));
678
1/2
✓ Branch 18 → 19 taken 1 time.
✗ Branch 18 → 45 not taken.
1 append_findings(out, rung.findings);
679 }
680 }
681
1/2
✓ Branch 23 → 24 taken 2 times.
✗ Branch 23 → 28 not taken.
2 else if (health.anchor_text_bytes > 0)
682 {
683
2/4
✓ Branch 24 → 25 taken 2 times.
✗ Branch 24 → 48 not taken.
✓ Branch 25 → 26 taken 2 times.
✗ Branch 25 → 46 not taken.
2 out += std::format(" anchor text: {} bytes\n", health.anchor_text_bytes);
684 }
685
1/2
✓ Branch 28 → 29 taken 3 times.
✗ Branch 28 → 50 not taken.
3 append_findings(out, health.findings);
686 3 return out;
687 }
688
689 1 std::string format_report(const ManifestHealth &health)
690 {
691 std::string out = std::format(
692 "manifest health: {} ({} robust, {} fragile, {} unusable of {})\n",
693 to_string(health.grade),
694 1 health.robust,
695 1 health.fragile,
696 1 health.unusable,
697 1 health.records.size()
698
1/2
✓ Branch 4 → 5 taken 1 time.
✗ Branch 4 → 24 not taken.
1 );
699
2/2
✓ Branch 21 → 7 taken 1 time.
✓ Branch 21 → 22 taken 1 time.
3 for (const RecordHealth &record : health.records)
700 {
701
2/4
✓ Branch 9 → 10 taken 1 time.
✗ Branch 9 → 29 not taken.
✓ Branch 10 → 11 taken 1 time.
✗ Branch 10 → 27 not taken.
1 out += format_report(record);
702 }
703 1 return out;
704 }
705 } // namespace sighealth
706 } // namespace DetourModKit
707