src/anchors.cpp
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | /** | ||
| 2 | * @file anchors.cpp | ||
| 3 | * @brief Declarative self-healing anchor registry. | ||
| 4 | * | ||
| 5 | * Dispatches each anchor to the backend its kind names (reverse-RTTI vtable resolve, AOB/RIP cascade, in-code constant | ||
| 6 | * decode, a string-literal xref resolve, or a pinned literal) and reports a uniform value + status, so a consumer | ||
| 7 | * declares every magic constant once and resolves the whole table in a single pass. A quorum kind layers corroboration | ||
| 8 | * on top, accepting a target only when two independent sub-anchors resolve and agree, and any resolved value may be | ||
| 9 | * screened by an optional caller-supplied validator. Every backend already fails closed; this layer only maps their | ||
| 10 | * typed failures onto a common status. | ||
| 11 | */ | ||
| 12 | |||
| 13 | #include "DetourModKit/anchors.hpp" | ||
| 14 | |||
| 15 | #include "DetourModKit/memory.hpp" | ||
| 16 | #include "DetourModKit/profile.hpp" | ||
| 17 | #include "DetourModKit/rtti.hpp" | ||
| 18 | #include "DetourModKit/scanner.hpp" | ||
| 19 | |||
| 20 | #include "fork_join.hpp" | ||
| 21 | |||
| 22 | #include <cstddef> | ||
| 23 | #include <cstdint> | ||
| 24 | #include <span> | ||
| 25 | #include <type_traits> | ||
| 26 | #include <vector> | ||
| 27 | |||
| 28 | namespace DetourModKit | ||
| 29 | { | ||
| 30 | namespace | ||
| 31 | { | ||
| 32 | // Applies the profile's candidate-order preference by building a local reordered span. The caller's candidate | ||
| 33 | // table is typically static storage and must not be mutated; the vector owns the temporary copy for the | ||
| 34 | // duration of the backend call. AsDeclared returns the original span without allocation. | ||
| 35 | [[nodiscard]] std::span<const Scanner::AddrCandidate> | ||
| 36 | 37 | profiled_candidates(const ScanProfile &profile, std::span<const Scanner::AddrCandidate> site, | |
| 37 | std::vector<Scanner::AddrCandidate> &ordered) | ||
| 38 | { | ||
| 39 |
5/6✓ Branch 2 → 3 taken 3 times.
✓ Branch 2 → 5 taken 34 times.
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 3 times.
✓ Branch 7 → 8 taken 34 times.
✓ Branch 7 → 9 taken 3 times.
|
37 | if (profile.candidate_order == CandidateOrder::AsDeclared || site.size() < 2) |
| 40 | { | ||
| 41 | 34 | return site; | |
| 42 | } | ||
| 43 | |||
| 44 |
1/2✓ Branch 12 → 13 taken 3 times.
✗ Branch 12 → 27 not taken.
|
3 | std::vector<std::size_t> indices(site.size()); |
| 45 | 3 | const std::size_t count = order_candidates(profile, site, indices); | |
| 46 |
1/2✓ Branch 16 → 17 taken 3 times.
✗ Branch 16 → 30 not taken.
|
3 | ordered.reserve(count); |
| 47 |
2/2✓ Branch 22 → 18 taken 6 times.
✓ Branch 22 → 23 taken 3 times.
|
9 | for (std::size_t i = 0; i < count; ++i) |
| 48 | { | ||
| 49 |
1/2✓ Branch 20 → 21 taken 6 times.
✗ Branch 20 → 30 not taken.
|
6 | ordered.push_back(site[indices[i]]); |
| 50 | } | ||
| 51 | 3 | return ordered; | |
| 52 | 3 | } | |
| 53 | |||
| 54 | // Fail-closed agreement test for the two quorum signals. A negative tolerance is rejected outright: widening it | ||
| 55 | // through unsigned subtraction would turn -1 into a huge bound that accepts almost any gap and defeat the | ||
| 56 | // corroboration the quorum exists to provide. | ||
| 57 | 11 | [[nodiscard]] bool quorum_values_agree(std::int64_t first, std::int64_t second, Anchors::QuorumMatch match, | |
| 58 | std::int64_t tolerance) noexcept | ||
| 59 | { | ||
| 60 |
2/2✓ Branch 2 → 3 taken 8 times.
✓ Branch 2 → 4 taken 3 times.
|
11 | if (match == Anchors::QuorumMatch::ExactValue) |
| 61 | { | ||
| 62 | 8 | return first == second; | |
| 63 | } | ||
| 64 |
2/2✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 6 taken 2 times.
|
3 | if (tolerance < 0) |
| 65 | { | ||
| 66 | 1 | return false; | |
| 67 | } | ||
| 68 | // Order the pair so the gap is hi - lo, then widen through unsigned subtraction to avoid signed overflow | ||
| 69 | // across a large address span. | ||
| 70 |
1/2✓ Branch 6 → 7 taken 2 times.
✗ Branch 6 → 8 not taken.
|
2 | const std::int64_t lo = (first < second) ? first : second; |
| 71 |
1/2✓ Branch 9 → 10 taken 2 times.
✗ Branch 9 → 11 not taken.
|
2 | const std::int64_t hi = (first < second) ? second : first; |
| 72 | 2 | const auto gap = static_cast<std::uint64_t>(hi) - static_cast<std::uint64_t>(lo); | |
| 73 | 2 | return gap <= static_cast<std::uint64_t>(tolerance); | |
| 74 | } | ||
| 75 | |||
| 76 | // True when two resolvable sub-anchors share the SAME backend AND the SAME backend inputs, so they would decode | ||
| 77 | // the identical site/name/literal and therefore cannot corroborate each other. Compared per kind so only the | ||
| 78 | // fields that kind actually consumes participate. View/span comparison is identity over the underlying storage | ||
| 79 | // (same data pointer and length), not a deep byte compare: two distinct candidate arrays expressing the same | ||
| 80 | // pattern are still two independent scan sites, so only literally-shared storage counts as the same evidence. | ||
| 81 | 14 | [[nodiscard]] bool same_backend_config(const Anchors::Anchor &a, const Anchors::Anchor &b) noexcept | |
| 82 | { | ||
| 83 |
2/2✓ Branch 2 → 3 taken 12 times.
✓ Branch 2 → 4 taken 2 times.
|
14 | if (a.kind != b.kind) |
| 84 | { | ||
| 85 | 12 | return false; | |
| 86 | } | ||
| 87 |
1/6✗ Branch 4 → 5 not taken.
✗ Branch 4 → 14 not taken.
✓ Branch 4 → 23 taken 2 times.
✗ Branch 4 → 35 not taken.
✗ Branch 4 → 48 not taken.
✗ Branch 4 → 49 not taken.
|
2 | switch (a.kind) |
| 88 | { | ||
| 89 | ✗ | case Anchors::AnchorKind::VtableIdentity: | |
| 90 | ✗ | return a.mangled.data() == b.mangled.data() && a.mangled.size() == b.mangled.size(); | |
| 91 | ✗ | case Anchors::AnchorKind::RipGlobal: | |
| 92 | ✗ | return a.site.data() == b.site.data() && a.site.size() == b.site.size(); | |
| 93 | 2 | case Anchors::AnchorKind::CodeOperand: | |
| 94 |
1/2✓ Branch 28 → 29 taken 1 time.
✗ Branch 28 → 33 not taken.
|
3 | return a.site.data() == b.site.data() && a.site.size() == b.site.size() && |
| 95 |
4/6✓ Branch 25 → 26 taken 1 time.
✓ Branch 25 → 33 taken 1 time.
✓ Branch 29 → 30 taken 1 time.
✗ Branch 29 → 33 not taken.
✓ Branch 30 → 31 taken 1 time.
✗ Branch 30 → 33 not taken.
|
4 | a.operand_kind == b.operand_kind && a.operand_index == b.operand_index && |
| 96 |
1/2✓ Branch 31 → 32 taken 1 time.
✗ Branch 31 → 33 not taken.
|
3 | a.byte_width == b.byte_width; |
| 97 | ✗ | case Anchors::AnchorKind::StringXref: | |
| 98 | ✗ | return a.xref_text.data() == b.xref_text.data() && a.xref_text.size() == b.xref_text.size() && | |
| 99 | ✗ | a.xref_encoding == b.xref_encoding && a.xref_return == b.xref_return && | |
| 100 | ✗ | a.xref_require_terminator == b.xref_require_terminator && | |
| 101 | ✗ | a.xref_broad_match == b.xref_broad_match; | |
| 102 | ✗ | case Anchors::AnchorKind::Manual: | |
| 103 | case Anchors::AnchorKind::CallArgHome: | ||
| 104 | case Anchors::AnchorKind::Quorum: | ||
| 105 | // Handled by dedicated rules: a dual-Manual pair is rejected wholesale, CallArgHome cannot resolve, and | ||
| 106 | // a nested Quorum is already rejected upstream. No config-identity verdict is needed here. | ||
| 107 | ✗ | return false; | |
| 108 | } | ||
| 109 | ✗ | return false; | |
| 110 | } | ||
| 111 | |||
| 112 | // Fail-closed independence gate run BEFORE agreement is considered. Two signals are not independent evidence | ||
| 113 | // when: (a) they are the exact same Anchor object (pointer-equal); (b) both are Manual literals -- two | ||
| 114 | // hand-pinned constants agreeing proves only that the author typed the same number twice, not that the live | ||
| 115 | // image corroborates it; or (c) they share backend and inputs (same_backend_config), so they decode one site | ||
| 116 | // twice. Any of these would let a dependent pair masquerade as corroboration, defeating the quorum's purpose. | ||
| 117 | 16 | [[nodiscard]] bool quorum_sub_anchors_independent(const Anchors::Anchor &a, const Anchors::Anchor &b) noexcept | |
| 118 | { | ||
| 119 |
2/2✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 4 taken 15 times.
|
16 | if (&a == &b) |
| 120 | { | ||
| 121 | 1 | return false; | |
| 122 | } | ||
| 123 |
4/4✓ Branch 4 → 5 taken 2 times.
✓ Branch 4 → 7 taken 13 times.
✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 7 taken 1 time.
|
15 | if (a.kind == Anchors::AnchorKind::Manual && b.kind == Anchors::AnchorKind::Manual) |
| 124 | { | ||
| 125 | 1 | return false; | |
| 126 | } | ||
| 127 | 14 | return !same_backend_config(a, b); | |
| 128 | } | ||
| 129 | |||
| 130 | // Commits a backend-resolved value, applying the anchor's optional fail-closed validator. On a validator miss | ||
| 131 | // the anchor is reported Failed with no value, identical to a backend miss, so the caller re-heals by | ||
| 132 | // re-running resolve. | ||
| 133 | 44 | void commit_resolved(const Anchors::Anchor &anchor, Anchors::ResolvedAnchor &result, | |
| 134 | std::int64_t value) noexcept | ||
| 135 | { | ||
| 136 | // Opt-in required-validator policy: a backend-resolved (function/global) target with no domain check is | ||
| 137 | // treated as unverified and fails closed. A Quorum is exempt -- its two-signal corroboration already is the | ||
| 138 | // verification -- so a caller is not forced to also attach a validator to a corroborated anchor. | ||
| 139 |
6/6✓ Branch 2 → 3 taken 3 times.
✓ Branch 2 → 6 taken 41 times.
✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 6 taken 1 time.
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 6 taken 1 time.
|
44 | if (anchor.require_validator && anchor.kind != Anchors::AnchorKind::Quorum && anchor.validator == nullptr) |
| 140 | { | ||
| 141 | 1 | result.status = Anchors::AnchorStatus::Failed; | |
| 142 | 1 | result.value = 0; | |
| 143 | 1 | return; | |
| 144 | } | ||
| 145 |
6/6✓ Branch 6 → 7 taken 8 times.
✓ Branch 6 → 10 taken 35 times.
✓ Branch 8 → 9 taken 4 times.
✓ Branch 8 → 10 taken 4 times.
✓ Branch 11 → 12 taken 4 times.
✓ Branch 11 → 13 taken 39 times.
|
43 | if (anchor.validator != nullptr && !anchor.validator(value, anchor.validator_context)) |
| 146 | { | ||
| 147 | 4 | result.status = Anchors::AnchorStatus::Failed; | |
| 148 | 4 | result.value = 0; | |
| 149 | 4 | return; | |
| 150 | } | ||
| 151 | 39 | result.value = value; | |
| 152 | 39 | result.status = Anchors::AnchorStatus::Resolved; | |
| 153 | } | ||
| 154 | |||
| 155 | 8 | [[nodiscard]] Anchors::ResolvedAnchor failed_anchor_result(const Anchors::Anchor &anchor) noexcept | |
| 156 | { | ||
| 157 | 8 | Anchors::ResolvedAnchor result{}; | |
| 158 | 8 | result.label = anchor.label; | |
| 159 | 8 | result.kind = anchor.kind; | |
| 160 | 8 | result.status = Anchors::AnchorStatus::Failed; | |
| 161 | 8 | result.value = 0; | |
| 162 | 8 | return result; | |
| 163 | } | ||
| 164 | |||
| 165 | // FNV-1a 64 evidence hashing for anchor_fingerprint. The fingerprint must be stable across runs and builds so a | ||
| 166 | // persisted manifest can be diffed, so integers are folded least-significant-byte first (a fixed order | ||
| 167 | // independent of host endianness) and every variable-length field is length-prefixed to keep adjacent fields | ||
| 168 | // unambiguous (the literal "ab" then "" cannot collide with "a" then "b"). | ||
| 169 | inline constexpr std::uint64_t FNV1A64_OFFSET = 14695981039346656037ULL; | ||
| 170 | inline constexpr std::uint64_t FNV1A64_PRIME = 1099511628211ULL; | ||
| 171 | |||
| 172 | 1612 | [[nodiscard]] constexpr std::uint64_t fnv1a_byte(std::uint64_t hash, std::uint8_t value) noexcept | |
| 173 | { | ||
| 174 | 1612 | return (hash ^ value) * FNV1A64_PRIME; | |
| 175 | } | ||
| 176 | |||
| 177 | 134 | template <typename T> [[nodiscard]] constexpr std::uint64_t fnv1a_int(std::uint64_t hash, T value) noexcept | |
| 178 | { | ||
| 179 | 134 | auto bits = static_cast<std::make_unsigned_t<T>>(value); | |
| 180 |
4/4unsigned long long DetourModKit::(anonymous namespace)::fnv1a_int<long long>(unsigned long long, long long):
✓ Branch 5 → 3 taken 496 times.
✓ Branch 5 → 6 taken 62 times.
unsigned long long DetourModKit::(anonymous namespace)::fnv1a_int<unsigned long long>(unsigned long long, unsigned long long):
✓ Branch 5 → 3 taken 576 times.
✓ Branch 5 → 6 taken 72 times.
|
1206 | for (std::size_t i = 0; i < sizeof(T); ++i) |
| 181 | { | ||
| 182 | 1072 | hash = fnv1a_byte(hash, static_cast<std::uint8_t>(bits & 0xFFu)); | |
| 183 | 1072 | bits >>= 8; | |
| 184 | } | ||
| 185 | 134 | return hash; | |
| 186 | } | ||
| 187 | |||
| 188 | // Length-prefixed variable-length byte field, so two adjacent variable fields cannot alias one another. | ||
| 189 | 38 | [[nodiscard]] std::uint64_t fnv1a_field(std::uint64_t hash, std::string_view bytes) noexcept | |
| 190 | { | ||
| 191 | 38 | hash = fnv1a_int(hash, bytes.size()); | |
| 192 |
2/2✓ Branch 8 → 6 taken 379 times.
✓ Branch 8 → 9 taken 38 times.
|
417 | for (const char ch : bytes) |
| 193 | { | ||
| 194 | 379 | hash = fnv1a_byte(hash, static_cast<std::uint8_t>(ch)); | |
| 195 | } | ||
| 196 | 38 | return hash; | |
| 197 | } | ||
| 198 | |||
| 199 | // Folds a cascade's address-independent evidence: each candidate's pattern text as authored plus its resolve | ||
| 200 | // mode, displacement / instruction-length offsets, and uniqueness flag. The pattern is hashed as written rather | ||
| 201 | // than re-parsed to canonical bytes -- that keeps the fingerprint allocation-free and total (a malformed | ||
| 202 | // pattern still hashes), and a cross-version diff reuses the same static anchor table verbatim, so two textual | ||
| 203 | // spellings of one signature never arise in practice. The candidate's cosmetic name is excluded because it | ||
| 204 | // never affects which address the cascade resolves. | ||
| 205 | 18 | [[nodiscard]] std::uint64_t fnv1a_cascade(std::uint64_t hash, | |
| 206 | std::span<const Scanner::AddrCandidate> site) noexcept | ||
| 207 | { | ||
| 208 | 18 | hash = fnv1a_int(hash, site.size()); | |
| 209 |
2/2✓ Branch 22 → 6 taken 20 times.
✓ Branch 22 → 23 taken 18 times.
|
56 | for (const Scanner::AddrCandidate &candidate : site) |
| 210 | { | ||
| 211 | 20 | hash = fnv1a_field(hash, candidate.pattern); | |
| 212 | 20 | hash = fnv1a_byte(hash, static_cast<std::uint8_t>(candidate.mode)); | |
| 213 | 20 | hash = fnv1a_int(hash, static_cast<std::int64_t>(candidate.disp_offset)); | |
| 214 | 20 | hash = fnv1a_int(hash, static_cast<std::int64_t>(candidate.instr_end_offset)); | |
| 215 | 20 | hash = fnv1a_byte(hash, static_cast<std::uint8_t>(candidate.require_unique)); | |
| 216 | } | ||
| 217 | 18 | return hash; | |
| 218 | } | ||
| 219 | |||
| 220 | // Hashes one anchor's own evidence with no quorum recursion. A Quorum reaching here -- which the public entry | ||
| 221 | // point only allows for a malformed sub-anchor, since nesting is rejected at resolve time -- contributes only | ||
| 222 | // its kind, which bounds recursion to a single level. | ||
| 223 | 52 | [[nodiscard]] std::uint64_t fingerprint_evidence(const Anchors::Anchor &anchor) noexcept | |
| 224 | { | ||
| 225 | using Anchors::AnchorKind; | ||
| 226 | 52 | std::uint64_t hash = fnv1a_byte(FNV1A64_OFFSET, static_cast<std::uint8_t>(anchor.kind)); | |
| 227 |
6/7✓ Branch 3 → 4 taken 8 times.
✓ Branch 3 → 6 taken 11 times.
✓ Branch 3 → 8 taken 7 times.
✓ Branch 3 → 13 taken 10 times.
✓ Branch 3 → 19 taken 14 times.
✓ Branch 3 → 21 taken 2 times.
✗ Branch 3 → 22 not taken.
|
52 | switch (anchor.kind) |
| 228 | { | ||
| 229 | 8 | case AnchorKind::VtableIdentity: | |
| 230 | 8 | hash = fnv1a_field(hash, anchor.mangled); | |
| 231 | 8 | break; | |
| 232 | 11 | case AnchorKind::RipGlobal: | |
| 233 | 11 | hash = fnv1a_cascade(hash, anchor.site); | |
| 234 | 11 | break; | |
| 235 | 7 | case AnchorKind::CodeOperand: | |
| 236 | 7 | hash = fnv1a_cascade(hash, anchor.site); | |
| 237 | 7 | hash = fnv1a_byte(hash, static_cast<std::uint8_t>(anchor.operand_kind)); | |
| 238 | 7 | hash = fnv1a_byte(hash, anchor.operand_index); | |
| 239 | 7 | hash = fnv1a_byte(hash, anchor.byte_width); | |
| 240 | 7 | break; | |
| 241 | 10 | case AnchorKind::StringXref: | |
| 242 | 10 | hash = fnv1a_field(hash, anchor.xref_text); | |
| 243 | 10 | hash = fnv1a_byte(hash, static_cast<std::uint8_t>(anchor.xref_encoding)); | |
| 244 | 10 | hash = fnv1a_byte(hash, static_cast<std::uint8_t>(anchor.xref_return)); | |
| 245 | 10 | hash = fnv1a_byte(hash, static_cast<std::uint8_t>(anchor.xref_require_terminator)); | |
| 246 | 10 | hash = fnv1a_byte(hash, static_cast<std::uint8_t>(anchor.xref_broad_match)); | |
| 247 | 10 | break; | |
| 248 | 14 | case AnchorKind::Manual: | |
| 249 | 14 | hash = fnv1a_int(hash, anchor.manual_value); | |
| 250 | 14 | break; | |
| 251 | 2 | case AnchorKind::CallArgHome: | |
| 252 | case AnchorKind::Quorum: | ||
| 253 | // No address-independent evidence beyond the kind byte already folded above. | ||
| 254 | 2 | break; | |
| 255 | } | ||
| 256 | 52 | return hash; | |
| 257 | } | ||
| 258 | } // anonymous namespace | ||
| 259 | |||
| 260 | 95 | Anchors::ResolvedAnchor Anchors::resolve_with_profile(const Anchor &anchor, const ScanProfile &profile, | |
| 261 | Memory::ModuleRange range) | ||
| 262 | { | ||
| 263 | 95 | ResolvedAnchor result{}; | |
| 264 | 95 | result.label = anchor.label; | |
| 265 | 95 | result.kind = anchor.kind; | |
| 266 | 95 | result.status = AnchorStatus::Unresolved; | |
| 267 | 95 | result.value = 0; | |
| 268 | |||
| 269 | // Backend deny-list: a denied kind fails closed before any scan. It is never silently replaced by another | ||
| 270 | // backend, which would risk returning a different, wrong target. An empty profile (the default resolve() path) | ||
| 271 | // denies nothing, so this is a no-op there. | ||
| 272 |
2/2✓ Branch 3 → 4 taken 6 times.
✓ Branch 3 → 5 taken 89 times.
|
95 | if (profile.is_denied(anchor.kind)) |
| 273 | { | ||
| 274 | 6 | result.status = AnchorStatus::Failed; | |
| 275 | 6 | return result; | |
| 276 | } | ||
| 277 | |||
| 278 |
7/8✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 14 taken 13 times.
✓ Branch 5 → 24 taken 24 times.
✓ Branch 5 → 34 taken 2 times.
✓ Branch 5 → 46 taken 26 times.
✓ Branch 5 → 50 taken 5 times.
✓ Branch 5 → 51 taken 18 times.
✗ Branch 5 → 72 not taken.
|
89 | switch (anchor.kind) |
| 279 | { | ||
| 280 | 1 | case AnchorKind::VtableIdentity: | |
| 281 | { | ||
| 282 | 1 | const auto vtable = Rtti::vtable_for_type(anchor.mangled, range); | |
| 283 |
1/2✗ Branch 8 → 9 not taken.
✓ Branch 8 → 11 taken 1 time.
|
1 | if (vtable) |
| 284 | { | ||
| 285 | ✗ | commit_resolved(anchor, result, static_cast<std::int64_t>(*vtable)); | |
| 286 | } | ||
| 287 | else | ||
| 288 | { | ||
| 289 | 1 | result.status = AnchorStatus::Failed; | |
| 290 | } | ||
| 291 | 1 | break; | |
| 292 | } | ||
| 293 | 13 | case AnchorKind::RipGlobal: | |
| 294 | { | ||
| 295 | // The cascade itself selects Direct vs RIP-relative per candidate, so a plain global address and a | ||
| 296 | // RIP-relative one share this backend. | ||
| 297 | 13 | std::vector<Scanner::AddrCandidate> ordered_site; | |
| 298 |
1/2✓ Branch 14 → 15 taken 13 times.
✗ Branch 14 → 74 not taken.
|
13 | const auto site = profiled_candidates(profile, anchor.site, ordered_site); |
| 299 |
1/2✓ Branch 15 → 16 taken 13 times.
✗ Branch 15 → 74 not taken.
|
13 | const auto hit = Scanner::resolve_cascade_in_module(site, anchor.label, range); |
| 300 |
2/2✓ Branch 17 → 18 taken 9 times.
✓ Branch 17 → 20 taken 4 times.
|
13 | if (hit) |
| 301 | { | ||
| 302 | 9 | commit_resolved(anchor, result, static_cast<std::int64_t>(hit->address)); | |
| 303 | } | ||
| 304 | else | ||
| 305 | { | ||
| 306 | 4 | result.status = AnchorStatus::Failed; | |
| 307 | } | ||
| 308 | 13 | break; | |
| 309 | 13 | } | |
| 310 | 24 | case AnchorKind::CodeOperand: | |
| 311 | { | ||
| 312 | 24 | std::vector<Scanner::AddrCandidate> ordered_site; | |
| 313 | 24 | Scanner::CodeConstant code_constant{}; | |
| 314 |
1/2✓ Branch 24 → 25 taken 24 times.
✗ Branch 24 → 77 not taken.
|
24 | code_constant.site = profiled_candidates(profile, anchor.site, ordered_site); |
| 315 | 24 | code_constant.kind = anchor.operand_kind; | |
| 316 | 24 | code_constant.operand_index = anchor.operand_index; | |
| 317 | 24 | code_constant.byte_width = anchor.byte_width; | |
| 318 |
1/2✓ Branch 25 → 26 taken 24 times.
✗ Branch 25 → 77 not taken.
|
24 | const auto constant = Scanner::read_code_constant(code_constant, range); |
| 319 |
1/2✓ Branch 27 → 28 taken 24 times.
✗ Branch 27 → 30 not taken.
|
24 | if (constant) |
| 320 | { | ||
| 321 | 24 | commit_resolved(anchor, result, *constant); | |
| 322 | } | ||
| 323 | else | ||
| 324 | { | ||
| 325 | ✗ | result.status = AnchorStatus::Failed; | |
| 326 | } | ||
| 327 | 24 | break; | |
| 328 | 24 | } | |
| 329 | 2 | case AnchorKind::StringXref: | |
| 330 | { | ||
| 331 | // Anchor on an immutable string literal, then resolve the instruction (or enclosing function) that | ||
| 332 | // references it. The string survives game updates far better than the surrounding code, so this is the most | ||
| 333 | // update-resilient backend; it fails closed on a missing, duplicated, or unreferenced string. | ||
| 334 | 2 | Scanner::StringRefQuery query{}; | |
| 335 | 2 | query.text = anchor.xref_text; | |
| 336 | 2 | query.encoding = anchor.xref_encoding; | |
| 337 | 2 | query.require_terminator = anchor.xref_require_terminator; | |
| 338 | 2 | query.return_mode = anchor.xref_return; | |
| 339 | // The profile can only widen the broad sweep on (never off); a per-anchor xref_broad_match still wins. | ||
| 340 |
2/4✓ Branch 34 → 35 taken 2 times.
✗ Branch 34 → 36 not taken.
✗ Branch 35 → 36 not taken.
✓ Branch 35 → 37 taken 2 times.
|
2 | query.broad_match = anchor.xref_broad_match || profile.default_broad_string_xref; |
| 341 |
1/2✓ Branch 38 → 39 taken 2 times.
✗ Branch 38 → 80 not taken.
|
2 | const auto site = Scanner::find_string_xref(query, range); |
| 342 |
2/2✓ Branch 40 → 41 taken 1 time.
✓ Branch 40 → 43 taken 1 time.
|
2 | if (site) |
| 343 | { | ||
| 344 | 1 | commit_resolved(anchor, result, static_cast<std::int64_t>(*site)); | |
| 345 | } | ||
| 346 | else | ||
| 347 | { | ||
| 348 | 1 | result.status = AnchorStatus::Failed; | |
| 349 | } | ||
| 350 | 2 | break; | |
| 351 | } | ||
| 352 | 26 | case AnchorKind::Manual: | |
| 353 | // A pinned literal always "resolves"; a report should still flag it as at-risk (it cannot self-heal) by | ||
| 354 | // inspecting the kind. By default the validator is skipped (the pinned-literal exemption); a caller that | ||
| 355 | // opts in via validate_manual routes the literal through the same fail-closed validator path as a backend. | ||
| 356 |
2/2✓ Branch 46 → 47 taken 2 times.
✓ Branch 46 → 48 taken 24 times.
|
26 | if (anchor.validate_manual) |
| 357 | { | ||
| 358 | 2 | commit_resolved(anchor, result, anchor.manual_value); | |
| 359 | } | ||
| 360 | else | ||
| 361 | { | ||
| 362 | 24 | result.value = anchor.manual_value; | |
| 363 | 24 | result.status = AnchorStatus::Resolved; | |
| 364 | } | ||
| 365 | 26 | break; | |
| 366 | 5 | case AnchorKind::CallArgHome: | |
| 367 | // Reserved for a future prologue-dataflow backend; no resolver yet. | ||
| 368 | 5 | result.status = AnchorStatus::Unsupported; | |
| 369 | 5 | break; | |
| 370 | 18 | case AnchorKind::Quorum: | |
| 371 | { | ||
| 372 | // A critical target accepts only when two independent signals corroborate. Fail closed on a malformed | ||
| 373 | // declaration (a missing sub-anchor, or a sub-anchor that is itself a Quorum) exactly as the single-signal | ||
| 374 | // backends fail closed on ambiguity; rejecting nested Quorum bounds the recursion to one level. | ||
| 375 | 18 | const Anchor *first = anchor.quorum_a; | |
| 376 | 18 | const Anchor *second = anchor.quorum_b; | |
| 377 |
6/8✓ Branch 51 → 52 taken 18 times.
✗ Branch 51 → 55 not taken.
✓ Branch 52 → 53 taken 17 times.
✓ Branch 52 → 55 taken 1 time.
✓ Branch 53 → 54 taken 16 times.
✓ Branch 53 → 55 taken 1 time.
✗ Branch 54 → 55 not taken.
✓ Branch 54 → 56 taken 16 times.
|
18 | if (!first || !second || first->kind == AnchorKind::Quorum || second->kind == AnchorKind::Quorum) |
| 378 | { | ||
| 379 | 2 | result.status = AnchorStatus::Failed; | |
| 380 | 2 | break; | |
| 381 | } | ||
| 382 | // Independence is a static property of the declaration, so check it before the (potentially expensive) | ||
| 383 | // recursive resolves. A dependent pair (same object, dual Manual, or same backend + inputs) is not | ||
| 384 | // corroboration; report it precisely instead of letting two scans of one site agree and look corroborated. | ||
| 385 |
2/2✓ Branch 57 → 58 taken 3 times.
✓ Branch 57 → 59 taken 13 times.
|
16 | if (!quorum_sub_anchors_independent(*first, *second)) |
| 386 | { | ||
| 387 | 3 | result.status = AnchorStatus::QuorumNotIndependent; | |
| 388 | 3 | break; | |
| 389 | } | ||
| 390 | |||
| 391 | // Recurse with the same profile so a denied sub-anchor kind (or a profile broad-default) threads down and a | ||
| 392 | // denied signal fails the quorum closed. | ||
| 393 |
1/2✓ Branch 59 → 60 taken 13 times.
✗ Branch 59 → 81 not taken.
|
13 | const ResolvedAnchor resolved_first = resolve_with_profile(*first, profile, range); |
| 394 |
1/2✓ Branch 60 → 61 taken 13 times.
✗ Branch 60 → 81 not taken.
|
13 | const ResolvedAnchor resolved_second = resolve_with_profile(*second, profile, range); |
| 395 |
8/8✓ Branch 61 → 62 taken 12 times.
✓ Branch 61 → 66 taken 1 time.
✓ Branch 62 → 63 taken 11 times.
✓ Branch 62 → 66 taken 1 time.
✓ Branch 64 → 65 taken 8 times.
✓ Branch 64 → 66 taken 3 times.
✓ Branch 67 → 68 taken 8 times.
✓ Branch 67 → 69 taken 5 times.
|
24 | if (resolved_first.status == AnchorStatus::Resolved && resolved_second.status == AnchorStatus::Resolved && |
| 396 | 11 | quorum_values_agree(resolved_first.value, resolved_second.value, anchor.quorum_match, | |
| 397 | 11 | anchor.quorum_tolerance)) | |
| 398 | { | ||
| 399 | // Both agree under the policy. Commit through the same path as the single-signal backends so the Quorum | ||
| 400 | // anchor's own validator runs on the corroborated value (each sub-anchor's validator already ran in its | ||
| 401 | // recursive resolve). | ||
| 402 | 8 | commit_resolved(anchor, result, resolved_first.value); | |
| 403 | } | ||
| 404 | else | ||
| 405 | { | ||
| 406 | 5 | result.status = AnchorStatus::Failed; | |
| 407 | } | ||
| 408 | 13 | break; | |
| 409 | } | ||
| 410 | } | ||
| 411 | |||
| 412 | 89 | return result; | |
| 413 | } | ||
| 414 | |||
| 415 | 54 | Anchors::ResolvedAnchor Anchors::resolve(const Anchor &anchor, Memory::ModuleRange range) | |
| 416 | { | ||
| 417 | // An empty profile denies nothing and widens nothing, so this is exactly the un-profiled resolution. | ||
| 418 |
1/2✓ Branch 2 → 3 taken 54 times.
✗ Branch 2 → 6 not taken.
|
54 | return resolve_with_profile(anchor, ScanProfile{}, range); |
| 419 | } | ||
| 420 | |||
| 421 | 4 | std::size_t Anchors::resolve_all(std::span<const Anchor> anchors, std::span<ResolvedAnchor> out, | |
| 422 | Memory::ModuleRange range) | ||
| 423 | { | ||
| 424 |
1/2✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 4 times.
|
4 | const std::size_t count = (anchors.size() < out.size()) ? anchors.size() : out.size(); |
| 425 |
2/2✓ Branch 12 → 8 taken 11 times.
✓ Branch 12 → 13 taken 4 times.
|
15 | for (std::size_t i = 0; i < count; ++i) |
| 426 | { | ||
| 427 | 11 | out[i] = resolve(anchors[i], range); | |
| 428 | } | ||
| 429 | 4 | return count; | |
| 430 | } | ||
| 431 | |||
| 432 | 1 | std::size_t Anchors::resolve_all_parallel(std::span<const Anchor> anchors, std::span<ResolvedAnchor> out, | |
| 433 | Memory::ModuleRange range, std::size_t max_workers) | ||
| 434 | { | ||
| 435 |
1/2✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 1 time.
|
1 | const std::size_t count = (anchors.size() < out.size()) ? anchors.size() : out.size(); |
| 436 | const auto results = DetourModKit::detail::run_fork_join<Anchor, ResolvedAnchor>( | ||
| 437 | anchors.first(count), max_workers, | ||
| 438 | 5 | [range](const Anchor &anchor) -> ResolvedAnchor { return resolve(anchor, range); }, | |
| 439 |
1/2✓ Branch 8 → 9 taken 1 time.
✗ Branch 8 → 17 not taken.
|
6 | [](const Anchor &anchor) noexcept -> ResolvedAnchor { return failed_anchor_result(anchor); }); |
| 440 | |||
| 441 |
2/2✓ Branch 13 → 10 taken 5 times.
✓ Branch 13 → 14 taken 1 time.
|
6 | for (std::size_t i = 0; i < count; ++i) |
| 442 | { | ||
| 443 | 5 | out[i] = results[i]; | |
| 444 | } | ||
| 445 | 1 | return count; | |
| 446 | 1 | } | |
| 447 | |||
| 448 | 2 | std::size_t Anchors::resolve_all_with_profile(std::span<const Anchor> anchors, std::span<ResolvedAnchor> out, | |
| 449 | const ScanProfile &profile, Memory::ModuleRange range) | ||
| 450 | { | ||
| 451 |
1/2✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 2 times.
|
2 | const std::size_t count = (anchors.size() < out.size()) ? anchors.size() : out.size(); |
| 452 |
2/2✓ Branch 12 → 8 taken 5 times.
✓ Branch 12 → 13 taken 2 times.
|
7 | for (std::size_t i = 0; i < count; ++i) |
| 453 | { | ||
| 454 | 5 | out[i] = resolve_with_profile(anchors[i], profile, range); | |
| 455 | } | ||
| 456 | 2 | return count; | |
| 457 | } | ||
| 458 | |||
| 459 | 1 | std::size_t Anchors::resolve_all_with_profile_parallel(std::span<const Anchor> anchors, | |
| 460 | std::span<ResolvedAnchor> out, const ScanProfile &profile, | ||
| 461 | Memory::ModuleRange range, std::size_t max_workers) | ||
| 462 | { | ||
| 463 |
1/2✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 1 time.
|
1 | const std::size_t count = (anchors.size() < out.size()) ? anchors.size() : out.size(); |
| 464 | const auto results = DetourModKit::detail::run_fork_join<Anchor, ResolvedAnchor>( | ||
| 465 | 3 | anchors.first(count), max_workers, [&profile, range](const Anchor &anchor) -> ResolvedAnchor | |
| 466 | 3 | { return resolve_with_profile(anchor, profile, range); }, | |
| 467 |
1/2✓ Branch 8 → 9 taken 1 time.
✗ Branch 8 → 17 not taken.
|
4 | [](const Anchor &anchor) noexcept -> ResolvedAnchor { return failed_anchor_result(anchor); }); |
| 468 | |||
| 469 |
2/2✓ Branch 13 → 10 taken 3 times.
✓ Branch 13 → 14 taken 1 time.
|
4 | for (std::size_t i = 0; i < count; ++i) |
| 470 | { | ||
| 471 | 3 | out[i] = results[i]; | |
| 472 | } | ||
| 473 | 1 | return count; | |
| 474 | 1 | } | |
| 475 | |||
| 476 | 7 | Anchors::AnchorQuality Anchors::assess_quality(std::span<const ResolvedAnchor> report) noexcept | |
| 477 | { | ||
| 478 | 7 | AnchorQuality quality{}; | |
| 479 | 7 | quality.total = report.size(); | |
| 480 |
2/2✓ Branch 27 → 5 taken 10 times.
✓ Branch 27 → 28 taken 7 times.
|
24 | for (const ResolvedAnchor &entry : report) |
| 481 | { | ||
| 482 |
3/6✓ Branch 7 → 8 taken 6 times.
✓ Branch 7 → 9 taken 3 times.
✗ Branch 7 → 10 not taken.
✓ Branch 7 → 11 taken 1 time.
✗ Branch 7 → 12 not taken.
✗ Branch 7 → 13 not taken.
|
10 | switch (entry.status) |
| 483 | { | ||
| 484 | 6 | case AnchorStatus::Resolved: | |
| 485 | 6 | ++quality.resolved; | |
| 486 | 6 | break; | |
| 487 | 3 | case AnchorStatus::Failed: | |
| 488 | 3 | ++quality.failed; | |
| 489 | 3 | break; | |
| 490 | ✗ | case AnchorStatus::Unsupported: | |
| 491 | ✗ | ++quality.unsupported; | |
| 492 | ✗ | break; | |
| 493 | 1 | case AnchorStatus::QuorumNotIndependent: | |
| 494 | 1 | ++quality.not_independent; | |
| 495 | 1 | break; | |
| 496 | ✗ | case AnchorStatus::Unresolved: | |
| 497 | ✗ | break; | |
| 498 | } | ||
| 499 | // A pinned literal is at-risk regardless of status: it "resolves" but cannot self-heal across a patch. | ||
| 500 |
2/2✓ Branch 13 → 14 taken 3 times.
✓ Branch 13 → 15 taken 7 times.
|
10 | if (entry.kind == AnchorKind::Manual) |
| 501 | { | ||
| 502 | 3 | ++quality.manual_at_risk; | |
| 503 | } | ||
| 504 | // A corroborated quorum is the strongest evidence: two independent signals had to agree. | ||
| 505 |
3/4✓ Branch 15 → 16 taken 1 time.
✓ Branch 15 → 18 taken 9 times.
✗ Branch 16 → 17 not taken.
✓ Branch 16 → 18 taken 1 time.
|
10 | if (entry.kind == AnchorKind::Quorum && entry.status == AnchorStatus::Resolved) |
| 506 | { | ||
| 507 | ✗ | ++quality.corroborated; | |
| 508 | } | ||
| 509 | } | ||
| 510 | 7 | return quality; | |
| 511 | } | ||
| 512 | |||
| 513 | 48 | std::uint64_t Anchors::anchor_fingerprint(const Anchor &anchor) noexcept | |
| 514 | { | ||
| 515 |
2/2✓ Branch 2 → 3 taken 40 times.
✓ Branch 2 → 4 taken 8 times.
|
48 | if (anchor.kind != AnchorKind::Quorum) |
| 516 | { | ||
| 517 | 40 | return fingerprint_evidence(anchor); | |
| 518 | } | ||
| 519 | |||
| 520 | // A quorum's evidence is its two sub-anchors, combined order-independently (the resolver treats the pair | ||
| 521 | // symmetrically when checking agreement, so swapping quorum_a / quorum_b must not change the fingerprint). A | ||
| 522 | // null sub-anchor -- which fails closed at resolve time -- contributes a fixed sentinel so the result stays | ||
| 523 | // defined rather than dereferencing through nullptr. | ||
| 524 | 8 | constexpr std::uint64_t NULL_SUB_ANCHOR = 0; | |
| 525 |
2/2✓ Branch 4 → 5 taken 6 times.
✓ Branch 4 → 6 taken 2 times.
|
8 | const std::uint64_t fp_a = anchor.quorum_a ? fingerprint_evidence(*anchor.quorum_a) : NULL_SUB_ANCHOR; |
| 526 |
2/2✓ Branch 7 → 8 taken 6 times.
✓ Branch 7 → 9 taken 2 times.
|
8 | const std::uint64_t fp_b = anchor.quorum_b ? fingerprint_evidence(*anchor.quorum_b) : NULL_SUB_ANCHOR; |
| 527 |
2/2✓ Branch 10 → 11 taken 1 time.
✓ Branch 10 → 12 taken 7 times.
|
8 | const std::uint64_t lo = fp_a < fp_b ? fp_a : fp_b; |
| 528 |
2/2✓ Branch 13 → 14 taken 1 time.
✓ Branch 13 → 15 taken 7 times.
|
8 | const std::uint64_t hi = fp_a < fp_b ? fp_b : fp_a; |
| 529 | |||
| 530 | 8 | std::uint64_t hash = fnv1a_byte(FNV1A64_OFFSET, static_cast<std::uint8_t>(AnchorKind::Quorum)); | |
| 531 | 8 | hash = fnv1a_int(hash, lo); | |
| 532 | 8 | hash = fnv1a_int(hash, hi); | |
| 533 | 8 | hash = fnv1a_byte(hash, static_cast<std::uint8_t>(anchor.quorum_match)); | |
| 534 | 8 | hash = fnv1a_int(hash, anchor.quorum_tolerance); | |
| 535 | 8 | return hash; | |
| 536 | } | ||
| 537 | |||
| 538 | 6 | std::string_view Anchors::anchor_status_to_string(AnchorStatus status) noexcept | |
| 539 | { | ||
| 540 |
5/6✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 4 taken 1 time.
✓ Branch 2 → 5 taken 1 time.
✓ Branch 2 → 6 taken 1 time.
✓ Branch 2 → 7 taken 2 times.
✗ Branch 2 → 8 not taken.
|
6 | switch (status) |
| 541 | { | ||
| 542 | 1 | case AnchorStatus::Unresolved: | |
| 543 | 1 | return "Unresolved"; | |
| 544 | 1 | case AnchorStatus::Resolved: | |
| 545 | 1 | return "Resolved"; | |
| 546 | 1 | case AnchorStatus::Failed: | |
| 547 | 1 | return "Failed"; | |
| 548 | 1 | case AnchorStatus::Unsupported: | |
| 549 | 1 | return "Unsupported"; | |
| 550 | 2 | case AnchorStatus::QuorumNotIndependent: | |
| 551 | 2 | return "QuorumNotIndependent"; | |
| 552 | } | ||
| 553 | ✗ | return "Unknown"; | |
| 554 | } | ||
| 555 | } // namespace DetourModKit | ||
| 556 |