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 |