./
Coverage:
low: ≥ 0%
medium: ≥ 75.0%
high: ≥ 90.0%
Lines:
344 / 376
91.5%
Branches:
205 / 285
71.9%
Functions:
23 / 23
100.0%

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
171 /// Returns the expected-match estimate by the formula in docs/guides/scanning/signature-health.md.
172 56 [[nodiscard]] double estimate_expected_matches(
173 double selectivity_bits,
174 const detail::PatternBuffer &buffer,
175 const HealthPolicy &policy
176 ) noexcept
177 {
178 56 double gap_multiplier = 1.0;
179
2/2
✓ Branch 6 → 3 taken 9 times.
✓ Branch 6 → 7 taken 56 times.
65 for (std::size_t index = 0; index < buffer.jump_count; ++index)
180 {
181 9 gap_multiplier *=
182 9 static_cast<double>(buffer.jumps[index].max_skip - buffer.jumps[index].min_skip + 1);
183 }
184 56 return static_cast<double>(policy.nominal_haystack_bytes) * std::exp2(-selectivity_bits) *
185 56 gap_multiplier;
186 }
187
188 /**
189 * @brief Appends the pattern findings to @p health, the most structural first.
190 * @details A pattern with no fixed byte cannot drive the memchr prefilter. NoFixedAnchor therefore replaces
191 * the ShortestAnchorRun finding, which needs at least one fixed byte.
192 */
193 56 void add_pattern_findings(PatternHealth &health, const HealthPolicy &policy)
194 {
195
2/2
✓ Branch 2 → 3 taken 2 times.
✓ Branch 2 → 4 taken 54 times.
56 if (health.fixed_bytes == 0)
196 {
197 2 add_finding(health.findings, FindingKind::NoFixedAnchor, Severity::Critical);
198 }
199
2/2
✓ Branch 4 → 5 taken 17 times.
✓ Branch 4 → 6 taken 37 times.
54 else if (health.longest_atom < policy.min_longest_atom)
200 {
201 17 add_finding(health.findings, FindingKind::ShortestAnchorRun, Severity::Warning);
202 }
203
2/2
✓ Branch 6 → 7 taken 18 times.
✓ Branch 6 → 8 taken 38 times.
56 if (health.length < policy.min_pattern_bytes)
204 {
205 18 add_finding(health.findings, FindingKind::ShortPattern, Severity::Warning);
206 }
207
2/2
✓ Branch 8 → 9 taken 4 times.
✓ Branch 8 → 10 taken 52 times.
56 if (health.common_bytes_only)
208 {
209 4 add_finding(health.findings, FindingKind::CommonBytesOnly, Severity::Warning);
210 }
211
2/2
✓ Branch 10 → 11 taken 4 times.
✓ Branch 10 → 12 taken 52 times.
56 if (health.wildcard_ratio > policy.max_wildcard_ratio)
212 {
213 4 add_finding(health.findings, FindingKind::HighWildcardRatio, Severity::Warning);
214 }
215 // Entropy is meaningful only with enough fixed bytes to distribute. A short 2-3 byte anchor has few
216 // samples, not low entropy, so the check requires a minimum sample size.
217 56 constexpr std::size_t min_entropy_sample = 4;
218
4/4
✓ Branch 12 → 13 taken 44 times.
✓ Branch 12 → 15 taken 12 times.
✓ Branch 13 → 14 taken 6 times.
✓ Branch 13 → 15 taken 38 times.
56 if (health.fixed_bytes >= min_entropy_sample && health.byte_entropy_bits < policy.min_byte_entropy_bits)
219 {
220 6 add_finding(health.findings, FindingKind::LowByteEntropy, Severity::Warning);
221 }
222
2/2
✓ Branch 15 → 16 taken 13 times.
✓ Branch 15 → 17 taken 43 times.
56 if (health.expected_matches > policy.fail_expected_matches)
223 {
224 13 add_finding(health.findings, FindingKind::WeakSelectivity, Severity::Critical);
225 }
226
2/2
✓ Branch 17 → 18 taken 4 times.
✓ Branch 17 → 19 taken 39 times.
43 else if (health.expected_matches > policy.warn_expected_matches)
227 {
228 4 add_finding(health.findings, FindingKind::WeakSelectivity, Severity::Warning);
229 }
230 56 }
231 } // namespace
232
233 56 PatternHealth analyze_pattern(const scan::Pattern &pattern, const HealthPolicy &policy)
234 {
235 56 PatternHealth health{};
236 56 health.length = pattern.size();
237
238 56 const std::span<const std::byte> bytes = pattern.bytes();
239 56 const std::span<const std::byte> mask = pattern.mask();
240
241 56 std::array<std::size_t, 256> value_counts{};
242 56 std::size_t current_run = 0;
243 56 bool any_rare_fixed = false;
244
245 // Pattern storage omits gap bytes, so each jump terminates the current atom.
246 56 const detail::PatternBuffer &buffer = detail::pattern_buffer(pattern);
247 56 std::size_t next_jump = 0;
248
249
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)
250 {
251
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)
252 {
253 9 ++next_jump;
254
2/2
✓ Branch 13 → 14 taken 7 times.
✓ Branch 13 → 17 taken 2 times.
9 if (current_run > 0)
255 {
256 7 ++health.atom_count;
257
2/2
✓ Branch 14 → 15 taken 6 times.
✓ Branch 14 → 16 taken 1 time.
7 if (current_run > health.longest_atom)
258 {
259 6 health.longest_atom = current_run;
260 }
261 7 current_run = 0;
262 }
263 }
264 353 const auto mask_byte = std::to_integer<std::uint8_t>(mask[index]);
265
2/2
✓ Branch 20 → 21 taken 309 times.
✓ Branch 20 → 29 taken 44 times.
353 if (mask_byte == 0xFF)
266 {
267 // Fully-known byte: it contributes to selectivity, to the entropy sample, to the current atom run,
268 // and to the rarity check.
269 309 const auto value = std::to_integer<std::uint8_t>(bytes[index]);
270 309 ++health.fixed_bytes;
271 309 ++value_counts[value];
272 309 ++current_run;
273 309 health.selectivity_bits += fixed_byte_bits(value);
274
2/2
✓ Branch 27 → 28 taken 250 times.
✓ Branch 27 → 36 taken 59 times.
309 if (detail::byte_frequency_class(value) == 0)
275 {
276 250 any_rare_fixed = true;
277 }
278 }
279 else
280 {
281 // Any non-full mask ends the current atom; a half-known nibble still narrows a position by 4 bits
282 // (one hex digit in sixteen), a full wildcard by nothing.
283
2/2
✓ Branch 29 → 30 taken 11 times.
✓ Branch 29 → 33 taken 33 times.
44 if (current_run > 0)
284 {
285 11 ++health.atom_count;
286
1/2
✓ Branch 30 → 31 taken 11 times.
✗ Branch 30 → 32 not taken.
11 if (current_run > health.longest_atom)
287 {
288 11 health.longest_atom = current_run;
289 }
290 11 current_run = 0;
291 }
292
2/2
✓ Branch 33 → 34 taken 41 times.
✓ Branch 33 → 35 taken 3 times.
44 if (mask_byte == 0x00)
293 {
294 41 ++health.wildcard_bytes;
295 }
296 else
297 {
298 3 ++health.nibble_bytes;
299 3 constexpr double nibble_bits = 4.0;
300 3 health.selectivity_bits += nibble_bits;
301 }
302 }
303 }
304 // Close the final atom if the pattern ended inside a fixed run.
305
2/2
✓ Branch 38 → 39 taken 49 times.
✓ Branch 38 → 41 taken 7 times.
56 if (current_run > 0)
306 {
307 49 ++health.atom_count;
308
2/2
✓ Branch 39 → 40 taken 42 times.
✓ Branch 39 → 41 taken 7 times.
49 if (current_run > health.longest_atom)
309 {
310 42 health.longest_atom = current_run;
311 }
312 }
313
314
1/2
✓ Branch 41 → 42 taken 56 times.
✗ Branch 41 → 43 not taken.
56 if (health.length > 0)
315 {
316 56 health.wildcard_ratio = static_cast<double>(health.wildcard_bytes) / static_cast<double>(health.length);
317 }
318 56 health.byte_entropy_bits = shannon_entropy_bits(value_counts, health.fixed_bytes);
319
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;
320
321 56 health.expected_matches = estimate_expected_matches(health.selectivity_bits, buffer, policy);
322
1/2
✓ Branch 49 → 50 taken 56 times.
✗ Branch 49 → 53 not taken.
56 add_pattern_findings(health, policy);
323
324 56 health.grade = grade_from(health.findings);
325 56 return health;
326 ✗ }
327
328 33 CandidateHealth analyze_candidate(const manifest::CandidateSpec &spec, const HealthPolicy &policy)
329 {
330 33 CandidateHealth health{};
331 33 health.mode = spec.mode;
332
333
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)
334 {
335 28 case scan::Mode::Direct:
336 case scan::Mode::RipRelative:
337 {
338 // The file carries the AOB as text; compile it the same way the resolver will, so the analysis sees the
339 // exact byte/mask the engine would. A malformed rung is a hard defect (it can never resolve), reported
340 // as a finding rather than thrown, so a whole-manifest lint never aborts on one bad rung.
341 28 const Result<scan::Pattern> compiled = scan::Pattern::compile(spec.pattern);
342
2/2
✓ Branch 6 → 7 taken 1 time.
✓ Branch 6 → 9 taken 27 times.
28 if (!compiled)
343 {
344 1 health.compiled = false;
345
1/2
✓ Branch 7 → 8 taken 1 time.
✗ Branch 7 → 52 not taken.
1 add_finding(health.findings, FindingKind::UncompilablePattern, Severity::Critical);
346 1 break;
347 }
348
1/2
✓ Branch 10 → 11 taken 27 times.
✗ Branch 10 → 50 not taken.
27 health.pattern = analyze_pattern(*compiled, policy);
349
1/2
✓ Branch 13 → 14 taken 27 times.
✗ Branch 13 → 52 not taken.
27 health.findings = health.pattern.findings;
350
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)
351 {
352 // Pattern offsets after the first jump depend on gap width, so only segment 0 maps directly.
353 9 const std::span<const std::byte> pattern_mask = compiled->mask();
354 9 const detail::PatternBuffer &buffer = detail::pattern_buffer(*compiled);
355 const std::size_t segment0_end =
356
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();
357 9 const std::size_t instruction_begin = compiled->offset();
358 9 const auto displacement_offset = static_cast<std::size_t>(spec.displacement_at);
359
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)
360 {
361 8 const std::size_t window_begin = instruction_begin + displacement_offset;
362 8 const std::size_t window_end = std::min(window_begin + 4, segment0_end);
363
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)
364 {
365
2/2
✓ Branch 32 → 33 taken 4 times.
✓ Branch 32 → 35 taken 16 times.
20 if (pattern_mask[index] != std::byte{0x00})
366 {
367
1/2
✓ Branch 33 → 34 taken 4 times.
✗ Branch 33 → 51 not taken.
4 add_finding(health.findings, FindingKind::VolatileDisplacementBytes, Severity::Warning);
368 4 break;
369 }
370 }
371 }
372 }
373 27 break;
374 }
375 2 case scan::Mode::RttiVtable:
376 {
377 // A mangled type name resolves unique-only through the reverse-RTTI walk, so its only failure mode as
378 // an anchor is being empty; a short but valid name is still unique.
379 2 health.anchor_text_bytes = spec.mangled.size();
380
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);
381 2 break;
382 }
383 3 case scan::Mode::StringXref:
384 {
385 // A string literal can genuinely collide when short (the linker pools identical literals), so the
386 // length floor applies here.
387 3 health.anchor_text_bytes = spec.string_text.size();
388
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);
389 3 break;
390 }
391 }
392
393 33 health.grade = grade_from(health.findings);
394 33 return health;
395 ✗ }
396
397 namespace
398 {
399 /// Grades every ladder rung for diagnostics and seeds the grade of @p health from the first declared rung.
400 void
401 11 grade_byte_ladder(RecordHealth &health, const manifest::SignatureRecord &record, const HealthPolicy &policy)
402 {
403 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 35 → 6 taken 15 times.
✓ Branch 35 → 36 taken 11 times.
37 for (const manifest::CandidateSpec &rung : record.ladder)
407 {
408
1/2
✓ Branch 8 → 9 taken 15 times.
✗ Branch 8 → 47 not taken.
15 CandidateHealth rung_health = analyze_candidate(rung, policy);
409
2/2
✓ Branch 9 → 10 taken 9 times.
✓ Branch 9 → 11 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 11 → 12 taken 15 times.
✗ Branch 11 → 22 not taken.
15 if (rung_health.compiled &&
421
3/4
✓ Branch 12 → 13 taken 2 times.
✓ Branch 12 → 14 taken 13 times.
✓ Branch 13 → 14 taken 2 times.
✗ Branch 13 → 22 not taken.
15 (rung.mode == scan::Mode::Direct || rung.mode == scan::Mode::RipRelative))
422 {
423
2/2
✓ Branch 14 → 15 taken 12 times.
✓ Branch 14 → 17 taken 3 times.
27 const bool stronger = rung_health.pattern.expected_matches < health.best_expected_matches ||
424
1/2
✗ Branch 15 → 16 not taken.
✓ Branch 15 → 18 taken 12 times.
12 (rung_health.pattern.expected_matches == health.best_expected_matches &&
425
0/2
✗ Branch 16 → 17 not taken.
✗ Branch 16 → 18 not taken.
✗ rung_health.pattern.selectivity_bits > health.best_selectivity_bits);
426
4/4
✓ Branch 19 → 20 taken 5 times.
✓ Branch 19 → 21 taken 10 times.
✓ Branch 20 → 21 taken 3 times.
✓ Branch 20 → 22 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 24 → 25 taken 15 times.
✗ Branch 24 → 45 not taken.
30 health.ladder.push_back(std::move(rung_health));
434 15 }
435
436
2/2
✓ Branch 37 → 38 taken 1 time.
✓ Branch 37 → 39 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 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 40 → 41 taken 3 times.
✓ Branch 40 → 42 taken 7 times.
10 if (health.robust_rungs == 0)
449 {
450 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 }
455
456 /**
457 * @brief Sets the grade of @p health to Unusable when Signature::compile rejects @p record.
458 * @details The resolver sees only a record that Signature::compile accepts. Signature::compile enforces
459 * constraints that the rung analysis cannot model: a RIP-relative rung's (displacement_at,
460 * instruction_length) layout, a RipGlobal's page class, and the non-serializable composite kinds.
461 * It rejects the WHOLE record when any one rung is malformed, so a ladder whose graded rung reads
462 * Robust can still be uncompilable. When the grade is Unusable before the check, the report names
463 * a specific reason, so the ceiling adds no UncompilableRecord finding.
464 */
465 27 void apply_compilability_ceiling(RecordHealth &health, const manifest::SignatureRecord &record)
466 {
467
4/6
✓ Branch 2 → 3 taken 27 times.
✗ Branch 2 → 15 not taken.
✓ Branch 3 → 4 taken 27 times.
✗ Branch 3 → 13 not taken.
✓ Branch 6 → 7 taken 6 times.
✓ Branch 6 → 11 taken 21 times.
27 if (const Result<manifest::Signature> compiled = manifest::Signature::compile(record); !compiled)
468 {
469
2/2
✓ Branch 7 → 8 taken 2 times.
✓ Branch 7 → 9 taken 4 times.
6 if (health.grade != Grade::Unusable)
470 {
471
1/2
✓ Branch 8 → 9 taken 2 times.
✗ Branch 8 → 16 not taken.
2 add_finding(health.findings, FindingKind::UncompilableRecord, Severity::Critical);
472 }
473 6 health.grade = worse_grade(health.grade, Grade::Unusable);
474 27 }
475 27 }
476 } // namespace
477
478 27 RecordHealth analyze_record(const manifest::SignatureRecord &record, const HealthPolicy &policy)
479 {
480
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 27 times.
27 RecordHealth health{};
481
1/2
✓ Branch 5 → 6 taken 27 times.
✗ Branch 5 → 30 not taken.
27 health.label = record.label;
482 27 health.kind = record.kind;
483
484
6/7
✓ Branch 6 → 7 taken 11 times.
✓ Branch 6 → 9 taken 6 times.
✓ Branch 6 → 13 taken 1 time.
✓ Branch 6 → 17 taken 4 times.
✓ Branch 6 → 21 taken 3 times.
✓ Branch 6 → 24 taken 2 times.
✗ Branch 6 → 27 not taken.
27 switch (record.kind)
485 {
486 11 case anchor::AnchorKind::RipGlobal:
487 case anchor::AnchorKind::CodeOperand:
488 {
489
1/2
✓ Branch 7 → 8 taken 11 times.
✗ Branch 7 → 30 not taken.
11 grade_byte_ladder(health, record, policy);
490 11 break;
491 }
492 6 case anchor::AnchorKind::StringXref:
493 {
494 6 health.anchor_text_bytes = record.xref_text.size();
495
1/2
✓ Branch 10 → 11 taken 6 times.
✗ Branch 10 → 30 not taken.
6 grade_text_anchor(health.findings, health.anchor_text_bytes, /*apply_length_floor=*/true, policy);
496 6 health.grade = grade_from(health.findings);
497 6 break;
498 }
499 1 case anchor::AnchorKind::VtableIdentity:
500 {
501 1 health.anchor_text_bytes = record.mangled.size();
502
1/2
✓ Branch 14 → 15 taken 1 time.
✗ Branch 14 → 30 not taken.
1 grade_text_anchor(health.findings, health.anchor_text_bytes, /*apply_length_floor=*/false, policy);
503 1 health.grade = grade_from(health.findings);
504 1 break;
505 }
506 4 case anchor::AnchorKind::ExportName:
507 {
508 // Grade by the export name, but WITHOUT the short-text length floor StringXref applies. An EAT lookup
509 // compares an exact name within one module rather than searching image bytes for a statistically
510 // selective literal, so a short name ("malloc") is not weaker than a long one; only an empty name is a
511 // real defect.
512 4 health.anchor_text_bytes = record.export_name.size();
513
1/2
✓ Branch 18 → 19 taken 4 times.
✗ Branch 18 → 30 not taken.
4 grade_text_anchor(health.findings, health.anchor_text_bytes, /*apply_length_floor=*/false, policy);
514 4 health.grade = grade_from(health.findings);
515 4 break;
516 }
517 3 case anchor::AnchorKind::Manual:
518 {
519 // A pinned literal has no backend and cannot self-heal across a patch; it is usable today but will go
520 // stale silently, so it is Fragile by design rather than a defect.
521
1/2
✓ Branch 21 → 22 taken 3 times.
✗ Branch 21 → 30 not taken.
3 add_finding(health.findings, FindingKind::UnhealableManual, Severity::Warning);
522 3 health.grade = grade_from(health.findings);
523 3 break;
524 }
525 2 case anchor::AnchorKind::CallArgHome:
526 case anchor::AnchorKind::Quorum:
527 case anchor::AnchorKind::Unset:
528 {
529 // A Quorum composes its M voting sub-anchors by pointer and CallArgHome has no resolver, so neither can
530 // be expressed as a flat file record; Unset is a record whose kind was never set. The compiler rejects
531 // all three, and the linter names the same reason (a record that can never resolve as a file
532 // signature).
533
1/2
✓ Branch 24 → 25 taken 2 times.
✗ Branch 24 → 30 not taken.
2 add_finding(health.findings, FindingKind::NonSerializableKind, Severity::Critical);
534 2 health.grade = grade_from(health.findings);
535 2 break;
536 }
537 }
538
539
1/2
✓ Branch 27 → 28 taken 27 times.
✗ Branch 27 → 30 not taken.
27 apply_compilability_ceiling(health, record);
540
541 27 return health;
542 ✗ }
543
544 3 ManifestHealth analyze_manifest(const manifest::Manifest &manifest, const HealthPolicy &policy)
545 {
546 3 ManifestHealth health{};
547
1/2
✓ Branch 3 → 4 taken 3 times.
✗ Branch 3 → 34 not taken.
3 health.records.reserve(manifest.records.size());
548
2/2
✓ Branch 27 → 6 taken 6 times.
✓ Branch 27 → 28 taken 3 times.
12 for (const manifest::SignatureRecord &record : manifest.records)
549 {
550
1/2
✓ Branch 8 → 9 taken 6 times.
✗ Branch 8 → 32 not taken.
6 RecordHealth record_health = analyze_record(record, policy);
551
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)
552 {
553 3 case Grade::Robust:
554 3 ++health.robust;
555 3 break;
556 1 case Grade::Fragile:
557 1 ++health.fragile;
558 1 break;
559 2 case Grade::Unusable:
560 2 ++health.unusable;
561 2 break;
562 }
563 // A manifest is only as trustworthy as its weakest signature, since each gates its own feature.
564 6 health.grade = worse_grade(health.grade, record_health.grade);
565
1/2
✓ Branch 16 → 17 taken 6 times.
✗ Branch 16 → 30 not taken.
12 health.records.push_back(std::move(record_health));
566 6 }
567 3 return health;
568 ✗ }
569
570 17 std::string_view to_string(Severity severity) noexcept
571 {
572
2/3
✓ Branch 2 → 3 taken 10 times.
✓ Branch 2 → 4 taken 7 times.
✗ Branch 2 → 5 not taken.
17 switch (severity)
573 {
574 10 case Severity::Warning:
575 10 return "warning";
576 7 case Severity::Critical:
577 7 return "critical";
578 }
579 ✗ return "unknown";
580 }
581
582 30 std::string_view to_string(FindingKind kind) noexcept
583 {
584
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)
585 {
586 2 case FindingKind::NoFixedAnchor:
587 2 return "no fully-known byte to anchor on (masked compare at every position)";
588 1 case FindingKind::UncompilablePattern:
589 1 return "the AOB pattern failed to compile";
590 3 case FindingKind::ShortPattern:
591 3 return "pattern shorter than the recommended byte floor";
592 2 case FindingKind::ShortestAnchorRun:
593 2 return "longest fully-known byte run is short (weak prefilter atom)";
594 3 case FindingKind::CommonBytesOnly:
595 3 return "every fully-known byte is a common opcode or padding (low atom rarity)";
596 2 case FindingKind::HighWildcardRatio:
597 2 return "wildcards dominate the pattern";
598 4 case FindingKind::LowByteEntropy:
599 4 return "fully-known bytes are repetitive (low entropy)";
600 5 case FindingKind::WeakSelectivity:
601 5 return "high estimated false-match count (weak selectivity)";
602 1 case FindingKind::EmptyAnchorText:
603 1 return "the anchor string or mangled name is empty";
604 1 case FindingKind::ShortAnchorText:
605 1 return "the anchor string is short and may not be unique";
606 1 case FindingKind::UnhealableManual:
607 1 return "a pinned Manual literal cannot self-heal across a patch";
608 1 case FindingKind::NonSerializableKind:
609 1 return "the record kind is not file-serializable (Quorum / CallArgHome / Unset)";
610 2 case FindingKind::NoRobustRung:
611 2 return "no candidate rung graded Robust";
612 1 case FindingKind::UncompilableRecord:
613 1 return "the record does not compile as a signature (bad rung layout, page class, or kind)";
614 1 case FindingKind::VolatileDisplacementBytes:
615 1 return "fixed pattern bytes cover the declared disp32 (link-volatile; wildcard them)";
616 }
617 ✗ return "unknown finding";
618 }
619
620 14 std::string_view to_string(Grade grade) noexcept
621 {
622
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)
623 {
624 3 case Grade::Robust:
625 3 return "Robust";
626 3 case Grade::Fragile:
627 3 return "Fragile";
628 8 case Grade::Unusable:
629 8 return "Unusable";
630 }
631 ✗ return "Unknown";
632 }
633
634 namespace
635 {
636 // Appends " [severity] description\n" for each finding into an existing report body.
637 7 void append_findings(std::string &out, const std::vector<Finding> &findings)
638 {
639
2/2
✓ Branch 20 → 4 taken 14 times.
✓ Branch 20 → 21 taken 7 times.
28 for (const Finding &finding : findings)
640 {
641
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));
642 }
643 7 }
644 } // namespace
645
646 3 std::string format_report(const PatternHealth &health, std::string_view label)
647 {
648 3 std::string out;
649
1/2
✓ Branch 4 → 5 taken 3 times.
✗ Branch 4 → 9 not taken.
3 if (!label.empty())
650 {
651
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);
652 }
653
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));
654 3 out += std::format(
655 " bytes={} fixed={} nibble={} wildcard={} (wildcard {:.0f}%)\n",
656 3 health.length,
657 3 health.fixed_bytes,
658 3 health.nibble_bytes,
659 3 health.wildcard_bytes,
660
1/2
✓ Branch 13 → 14 taken 3 times.
✗ Branch 13 → 36 not taken.
3 health.wildcard_ratio * 100.0
661
1/2
✓ Branch 14 → 15 taken 3 times.
✗ Branch 14 → 34 not taken.
3 );
662 3 out += std::format(
663 " atoms={} longest_atom={} entropy={:.1f} bits selectivity={:.1f} bits\n",
664 3 health.atom_count,
665 3 health.longest_atom,
666 3 health.byte_entropy_bits,
667
1/2
✓ Branch 16 → 17 taken 3 times.
✗ Branch 16 → 41 not taken.
3 health.selectivity_bits
668
1/2
✓ Branch 17 → 18 taken 3 times.
✗ Branch 17 → 39 not taken.
3 );
669
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);
670
1/2
✓ Branch 22 → 23 taken 3 times.
✗ Branch 22 → 47 not taken.
3 append_findings(out, health.findings);
671 3 return out;
672 ✗ }
673
674 3 std::string format_report(const RecordHealth &health)
675 {
676 std::string out = std::format(
677 "[{}] kind={} grade={}\n",
678 3 health.label,
679 3 anchor_kind_name(health.kind),
680 3 to_string(health.grade)
681
1/2
✓ Branch 4 → 5 taken 3 times.
✗ Branch 4 → 31 not taken.
3 );
682
2/2
✓ Branch 6 → 7 taken 1 time.
✓ Branch 6 → 23 taken 2 times.
3 if (!health.ladder.empty())
683 {
684 1 out += std::format(
685 " ladder: {} rungs, {} robust; strongest byte rung selectivity={:.1f} bits, "
686 "expected_matches~={:.3g}\n",
687
1/2
✓ Branch 8 → 9 taken 1 time.
✗ Branch 8 → 36 not taken.
1 health.ladder.size(),
688 1 health.robust_rungs,
689 1 health.best_selectivity_bits,
690 1 health.best_expected_matches
691
1/2
✓ Branch 9 → 10 taken 1 time.
✗ Branch 9 → 34 not taken.
1 );
692
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)
693 {
694 1 const CandidateHealth &rung = health.ladder[index];
695
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));
696
1/2
✓ Branch 18 → 19 taken 1 time.
✗ Branch 18 → 45 not taken.
1 append_findings(out, rung.findings);
697 }
698 }
699
1/2
✓ Branch 23 → 24 taken 2 times.
✗ Branch 23 → 28 not taken.
2 else if (health.anchor_text_bytes > 0)
700 {
701
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);
702 }
703
1/2
✓ Branch 28 → 29 taken 3 times.
✗ Branch 28 → 50 not taken.
3 append_findings(out, health.findings);
704 3 return out;
705 ✗ }
706
707 1 std::string format_report(const ManifestHealth &health)
708 {
709 std::string out = std::format(
710 "manifest health: {} ({} robust, {} fragile, {} unusable of {})\n",
711 ✗ to_string(health.grade),
712 1 health.robust,
713 1 health.fragile,
714 1 health.unusable,
715 1 health.records.size()
716
1/2
✓ Branch 4 → 5 taken 1 time.
✗ Branch 4 → 24 not taken.
1 );
717
2/2
✓ Branch 21 → 7 taken 1 time.
✓ Branch 21 → 22 taken 1 time.
3 for (const RecordHealth &record : health.records)
718 {
719
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);
720 }
721 1 return out;
722 ✗ }
723 } // namespace sighealth
724 } // namespace DetourModKit
725