GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 88.7% 219 / 0 / 247
Functions: 100.0% 21 / 0 / 21
Branches: 80.0% 108 / 0 / 135

src/anchor_evidence.cpp
Line Branch Exec Source
1 /**
2 * @file anchor_evidence.cpp
3 * @brief This TU owns anchor evidence identity: the drift fingerprints and the quorum independence atoms.
4 *
5 * The resolution engine stays in anchor.cpp and reaches the independence gate through internal/anchor_evidence.hpp.
6 * Fingerprint results stay stable across runs and builds.
7 */
8
9 #include "DetourModKit/anchor.hpp"
10
11 #include "internal/anchor_evidence.hpp"
12
13 #include <cstddef>
14 #include <cstdint>
15 #include <span>
16 #include <string_view>
17 #include <type_traits>
18 #include <vector>
19
20 namespace DetourModKit
21 {
22 namespace anchor
23 {
24 namespace
25 {
26 // FNV-1a 64 hashes evidence for anchor_fingerprint. Integers use least-significant-byte order. Every
27 // variable-length field has a length prefix.
28 inline constexpr std::uint64_t FNV1A64_OFFSET = 14695981039346656037ULL;
29 inline constexpr std::uint64_t FNV1A64_PRIME = 1099511628211ULL;
30
31 18852 [[nodiscard]] std::uint64_t fnv1a_byte(std::uint64_t hash, std::uint8_t value) noexcept
32 {
33 18852 return (hash ^ value) * FNV1A64_PRIME;
34 }
35
36 // Widen to u64 before each shift so a 1-byte type never hits a shift-width edge case.
37 1471 template <typename T> [[nodiscard]] std::uint64_t fnv1a_int(std::uint64_t hash, T value) noexcept
38 {
39 1471 auto bits = static_cast<std::uint64_t>(static_cast<std::make_unsigned_t<T>>(value));
40
4/4
unsigned long long DetourModKit::anchor::(anonymous namespace)::fnv1a_int<long long>(unsigned long long, long long):
✓ Branch 5 → 3 taken 2584 times.
✓ Branch 5 → 6 taken 323 times.
unsigned long long DetourModKit::anchor::(anonymous namespace)::fnv1a_int<unsigned long long>(unsigned long long, unsigned long long):
✓ Branch 5 → 3 taken 9184 times.
✓ Branch 5 → 6 taken 1148 times.
13239 for (std::size_t i = 0; i < sizeof(T); ++i)
41 {
42 11768 hash = fnv1a_byte(hash, static_cast<std::uint8_t>(bits & 0xFFu));
43 11768 bits >>= 8;
44 }
45 1471 return hash;
46 }
47
48 // Prefixes each string field with its length so adjacent fields never alias.
49 243 [[nodiscard]] std::uint64_t fnv1a_field(std::uint64_t hash, std::string_view field) noexcept
50 {
51 243 hash = fnv1a_int(hash, static_cast<std::uint64_t>(field.size()));
52
2/2
✓ Branch 8 → 6 taken 2236 times.
✓ Branch 8 → 9 taken 243 times.
2479 for (const char c : field)
53 {
54 2236 hash = fnv1a_byte(hash, static_cast<std::uint8_t>(c));
55 }
56 243 return hash;
57 }
58
59 // Uses ASCII case-insensitive module basenames. Case variants of one DLL must not cast independent export
60 // votes.
61 12 [[nodiscard]] std::uint64_t fnv1a_module_field(std::uint64_t hash, std::string_view module_name) noexcept
62 {
63 12 hash = fnv1a_int(hash, static_cast<std::uint64_t>(module_name.size()));
64
2/2
✓ Branch 12 → 6 taken 186 times.
✓ Branch 12 → 13 taken 12 times.
198 for (const char c : module_name)
65 {
66 186 const auto byte = static_cast<std::uint8_t>(c);
67 186 const std::uint8_t folded =
68
2/2
✓ Branch 7 → 8 taken 16 times.
✓ Branch 7 → 9 taken 160 times.
176 (byte >= static_cast<std::uint8_t>('A') && byte <= static_cast<std::uint8_t>('Z'))
69
2/2
✓ Branch 6 → 7 taken 176 times.
✓ Branch 6 → 9 taken 10 times.
186 ? static_cast<std::uint8_t>(byte + ('a' - 'A'))
70 : byte;
71 186 hash = fnv1a_byte(hash, folded);
72 }
73 12 return hash;
74 }
75
76 // A length prefix delimits the raw-byte field for a compiled Pattern's byte or mask span.
77 454 [[nodiscard]] std::uint64_t fnv1a_bytes(std::uint64_t hash, std::span<const std::byte> data) noexcept
78 {
79 454 hash = fnv1a_int(hash, static_cast<std::uint64_t>(data.size()));
80
2/2
✓ Branch 18 → 6 taken 4016 times.
✓ Branch 18 → 19 taken 454 times.
4924 for (const std::byte b : data)
81 {
82 4016 hash = fnv1a_byte(hash, static_cast<std::uint8_t>(b));
83 }
84 454 return hash;
85 }
86
87 // Folds the bounded-jump gap structure. bytes()/mask() carry only fixed segments. Without gap data,
88 // patterns that differ only by variable gaps share a fingerprint. Jump-free patterns retain their prior
89 // fingerprint.
90 227 [[nodiscard]] std::uint64_t fnv1a_pattern_jumps(std::uint64_t hash, const scan::Pattern &pattern) noexcept
91 {
92 227 const detail::PatternBuffer &buffer = detail::pattern_buffer(pattern);
93
2/2
✓ Branch 3 → 4 taken 208 times.
✓ Branch 3 → 5 taken 19 times.
227 if (buffer.jump_count == 0)
94 {
95 208 return hash;
96 }
97 19 hash = fnv1a_int(hash, static_cast<std::uint64_t>(buffer.jump_count));
98
2/2
✓ Branch 14 → 7 taken 19 times.
✓ Branch 14 → 15 taken 19 times.
38 for (std::size_t index = 0; index < buffer.jump_count; ++index)
99 {
100 19 hash = fnv1a_int(hash, static_cast<std::uint64_t>(buffer.jumps[index].position));
101 19 hash = fnv1a_int(hash, static_cast<std::uint64_t>(buffer.jumps[index].min_skip));
102 19 hash = fnv1a_int(hash, static_cast<std::uint64_t>(buffer.jumps[index].max_skip));
103 }
104 19 return hash;
105 }
106
107 // Hashes one candidate's address-independent content. It includes compiled bytes, mask, offset, and decode
108 // parameters for byte tiers. It includes owned name, literal, and shape flags for text tiers.
109 49 [[nodiscard]] std::uint64_t fnv1a_candidate(std::uint64_t hash, const scan::Candidate &candidate) noexcept
110 {
111 49 hash = fnv1a_byte(hash, static_cast<std::uint8_t>(candidate.mode()));
112
1/5
✓ Branch 5 → 6 taken 49 times.
✗ Branch 5 → 16 not taken.
✗ Branch 5 → 27 not taken.
✗ Branch 5 → 31 not taken.
✗ Branch 5 → 45 not taken.
49 switch (candidate.mode())
113 {
114 49 case scan::Mode::Direct:
115 {
116 49 const scan::DirectPattern &direct = *candidate.as_direct();
117 49 hash = fnv1a_bytes(hash, direct.pattern.bytes());
118 49 hash = fnv1a_bytes(hash, direct.pattern.mask());
119 49 hash = fnv1a_int(hash, static_cast<std::uint64_t>(direct.pattern.offset()));
120 49 hash = fnv1a_int(hash, static_cast<std::int64_t>(direct.walk_back));
121 49 hash = fnv1a_pattern_jumps(hash, direct.pattern);
122 49 break;
123 }
124 case scan::Mode::RipRelative:
125 {
126 const scan::RipRelativePattern &rip = *candidate.as_rip_relative();
127 hash = fnv1a_bytes(hash, rip.pattern.bytes());
128 hash = fnv1a_bytes(hash, rip.pattern.mask());
129 hash = fnv1a_int(hash, static_cast<std::uint64_t>(rip.pattern.offset()));
130 hash = fnv1a_int(hash, static_cast<std::int64_t>(rip.displacement_at));
131 hash = fnv1a_int(hash, static_cast<std::uint64_t>(rip.instruction_length));
132 hash = fnv1a_pattern_jumps(hash, rip.pattern);
133 break;
134 }
135 case scan::Mode::RttiVtable:
136 hash = fnv1a_field(hash, candidate.as_rtti_vtable()->mangled);
137 break;
138 case scan::Mode::StringXref:
139 {
140 const scan::StringXref &xref = *candidate.as_string_xref();
141 hash = fnv1a_field(hash, xref.text);
142 hash = fnv1a_byte(hash, static_cast<std::uint8_t>(xref.encoding));
143 hash = fnv1a_byte(hash, static_cast<std::uint8_t>(xref.return_mode));
144 hash = fnv1a_byte(hash, xref.require_terminator ? 1U : 0U);
145 hash = fnv1a_byte(hash, xref.broad_match ? 1U : 0U);
146 break;
147 }
148 }
149 49 return hash;
150 }
151
152 [[nodiscard]] std::uint64_t
153 46 fnv1a_cascade(std::uint64_t hash, std::span<const scan::Candidate> site) noexcept
154 {
155 46 hash = fnv1a_int(hash, static_cast<std::uint64_t>(site.size()));
156
2/2
✓ Branch 18 → 6 taken 49 times.
✓ Branch 18 → 19 taken 46 times.
141 for (const scan::Candidate &candidate : site)
157 {
158 49 hash = fnv1a_candidate(hash, candidate);
159 }
160 46 return hash;
161 }
162
163 // Hashes one anchor's evidence without quorum recursion. If a malformed sub-anchor sends Quorum here,
164 // only its kind contributes. This limits recursion to one level.
165 306 [[nodiscard]] std::uint64_t fingerprint_evidence(const Anchor &anchor) noexcept
166 {
167 306 std::uint64_t hash = fnv1a_byte(FNV1A64_OFFSET, static_cast<std::uint8_t>(anchor.kind));
168
7/8
✓ Branch 3 → 4 taken 184 times.
✓ Branch 3 → 6 taken 44 times.
✓ Branch 3 → 10 taken 2 times.
✓ Branch 3 → 15 taken 2 times.
✓ Branch 3 → 27 taken 12 times.
✓ Branch 3 → 30 taken 59 times.
✓ Branch 3 → 32 taken 3 times.
✗ Branch 3 → 33 not taken.
306 switch (anchor.kind)
169 {
170 184 case AnchorKind::VtableIdentity:
171 184 hash = fnv1a_field(hash, anchor.mangled);
172 184 break;
173 44 case AnchorKind::RipGlobal:
174 44 hash = fnv1a_cascade(hash, anchor.site);
175 // Preserve legacy fingerprints for the default Readable policy. Treat any other page policy as a
176 // declarative signature change that a persisted baseline can detect.
177
2/2
✓ Branch 7 → 8 taken 2 times.
✓ Branch 7 → 9 taken 42 times.
44 if (anchor.pages != scan::Pages::Readable)
178 {
179 2 hash = fnv1a_byte(hash, static_cast<std::uint8_t>(anchor.pages));
180 }
181 44 break;
182 2 case AnchorKind::CodeOperand:
183 2 hash = fnv1a_cascade(hash, anchor.site);
184 2 hash = fnv1a_byte(hash, static_cast<std::uint8_t>(anchor.operand_kind));
185 2 hash = fnv1a_byte(hash, anchor.operand_index);
186 2 hash = fnv1a_byte(hash, anchor.byte_width);
187 2 break;
188 2 case AnchorKind::StringXref:
189 2 hash = fnv1a_field(hash, anchor.xref_text);
190 2 hash = fnv1a_byte(hash, static_cast<std::uint8_t>(anchor.xref_encoding));
191 2 hash = fnv1a_byte(hash, static_cast<std::uint8_t>(anchor.xref_return));
192
1/2
✓ Branch 18 → 19 taken 2 times.
✗ Branch 18 → 20 not taken.
2 hash = fnv1a_byte(hash, anchor.xref_require_terminator ? 1U : 0U);
193
2/2
✓ Branch 22 → 23 taken 1 time.
✓ Branch 22 → 24 taken 1 time.
2 hash = fnv1a_byte(hash, anchor.xref_broad_match ? 1U : 0U);
194 2 break;
195 12 case AnchorKind::ExportName:
196 // The module and export name are the whole declarative signature.
197 12 hash = fnv1a_field(hash, anchor.export_module);
198 12 hash = fnv1a_field(hash, anchor.export_name);
199 12 break;
200 59 case AnchorKind::Manual:
201 59 hash = fnv1a_int(hash, anchor.manual_value);
202 59 break;
203 3 case AnchorKind::CallArgHome:
204 case AnchorKind::Quorum:
205 case AnchorKind::Unset:
206 // No address-independent evidence beyond the kind byte already folded above.
207 3 break;
208 }
209 306 return hash;
210 }
211
212 // Independence evidence asks whether two anchors can decode one site, while drift evidence asks whether a
213 // declaration changed. Each anchor becomes a set of site evidence atoms. Scan policy and AnchorKind
214 // wrappers do not alter the site. Thus policy variants and a flat StringXref versus a one-rung RipGlobal
215 // over the same literal count as one signal. EvidenceClass, rather than AnchorKind or scan::Mode, tags each
216 // atom.
217 enum class EvidenceClass : std::uint8_t
218 {
219 ByteDirect = 1,
220 ByteRip = 2,
221 Vtable = 3,
222 String = 4,
223 Manual = 5,
224 Empty = 6,
225 Export = 7,
226 };
227
228 // A located literal uses its bytes and storage format as identity. Utf8 "foo" and Utf16le "foo" are
229 // different image literals. Scan facets are not evidence.
230 [[nodiscard]] std::uint64_t
231 11 string_evidence_atom(std::string_view text, scan::StringEncoding encoding) noexcept
232 {
233 11 std::uint64_t hash = fnv1a_byte(FNV1A64_OFFSET, static_cast<std::uint8_t>(EvidenceClass::String));
234 11 hash = fnv1a_field(hash, text);
235 11 return fnv1a_byte(hash, static_cast<std::uint8_t>(encoding));
236 }
237
238 // The mangled type name identifies vtable evidence.
239 4 [[nodiscard]] std::uint64_t vtable_evidence_atom(std::string_view mangled) noexcept
240 {
241 4 std::uint64_t hash = fnv1a_byte(FNV1A64_OFFSET, static_cast<std::uint8_t>(EvidenceClass::Vtable));
242 4 return fnv1a_field(hash, mangled);
243 }
244
245 // A named export uses its declared module and export name as identity. The live table decides which names
246 // alias, so backend provenance resolves aliases later. Use the declared module name, not a resolved base.
247 // A base key collapses distinct unloaded modules onto one empty base. quorum_sub_anchors_independent
248 // catches the empty and explicit module overlap.
249 [[nodiscard]] std::uint64_t
250 12 export_evidence_atom(std::string_view module_name, std::string_view export_name) noexcept
251 {
252 12 std::uint64_t hash = fnv1a_byte(FNV1A64_OFFSET, static_cast<std::uint8_t>(EvidenceClass::Export));
253 12 hash = fnv1a_module_field(hash, module_name);
254 12 return fnv1a_field(hash, export_name);
255 }
256
257 // Builds one candidate rung's site identity atom without kind or policy data. A byte tier keeps every field
258 // that moves its address. A text tier reduces to its flat AnchorKind atom.
259 180 [[nodiscard]] std::uint64_t candidate_evidence_atom(const scan::Candidate &candidate) noexcept
260 {
261
3/5
✓ Branch 3 → 4 taken 171 times.
✓ Branch 3 → 14 taken 7 times.
✗ Branch 3 → 25 not taken.
✓ Branch 3 → 28 taken 2 times.
✗ Branch 3 → 31 not taken.
180 switch (candidate.mode())
262 {
263 171 case scan::Mode::Direct:
264 {
265 171 const scan::DirectPattern &direct = *candidate.as_direct();
266 std::uint64_t hash =
267 171 fnv1a_byte(FNV1A64_OFFSET, static_cast<std::uint8_t>(EvidenceClass::ByteDirect));
268 171 hash = fnv1a_bytes(hash, direct.pattern.bytes());
269 171 hash = fnv1a_bytes(hash, direct.pattern.mask());
270 171 hash = fnv1a_int(hash, static_cast<std::uint64_t>(direct.pattern.offset()));
271 171 hash = fnv1a_int(hash, static_cast<std::int64_t>(direct.walk_back));
272 171 return fnv1a_pattern_jumps(hash, direct.pattern);
273 }
274 7 case scan::Mode::RipRelative:
275 {
276 7 const scan::RipRelativePattern &rip = *candidate.as_rip_relative();
277 7 std::uint64_t hash = fnv1a_byte(FNV1A64_OFFSET, static_cast<std::uint8_t>(EvidenceClass::ByteRip));
278 7 hash = fnv1a_bytes(hash, rip.pattern.bytes());
279 7 hash = fnv1a_bytes(hash, rip.pattern.mask());
280 7 hash = fnv1a_int(hash, static_cast<std::uint64_t>(rip.pattern.offset()));
281 7 hash = fnv1a_int(hash, static_cast<std::int64_t>(rip.displacement_at));
282 7 hash = fnv1a_int(hash, static_cast<std::uint64_t>(rip.instruction_length));
283 7 return fnv1a_pattern_jumps(hash, rip.pattern);
284 }
285 case scan::Mode::RttiVtable:
286 return vtable_evidence_atom(candidate.as_rtti_vtable()->mangled);
287 2 case scan::Mode::StringXref:
288 {
289 2 const scan::StringXref &xref = *candidate.as_string_xref();
290 2 return string_evidence_atom(xref.text, xref.encoding);
291 }
292 }
293 return fnv1a_byte(FNV1A64_OFFSET, static_cast<std::uint8_t>(EvidenceClass::Empty));
294 }
295
296 // Collects one site evidence atom per resolvable rung, or one for a flat kind. Do not reuse the drift
297 // fingerprint here. It keeps policy and order because a facet edit or reorder is signature drift.
298 222 void collect_independence_atoms(const Anchor &anchor, std::vector<std::uint64_t> &out)
299 {
300 222 const std::size_t start = out.size();
301
5/7
✓ Branch 3 → 4 taken 4 times.
✓ Branch 3 → 7 taken 9 times.
✓ Branch 3 → 10 taken 12 times.
✓ Branch 3 → 13 taken 21 times.
✓ Branch 3 → 17 taken 176 times.
✗ Branch 3 → 34 not taken.
✗ Branch 3 → 35 not taken.
222 switch (anchor.kind)
302 {
303 4 case AnchorKind::VtableIdentity:
304
1/2
✓ Branch 5 → 6 taken 4 times.
✗ Branch 5 → 42 not taken.
4 out.push_back(vtable_evidence_atom(anchor.mangled));
305 4 break;
306 9 case AnchorKind::StringXref:
307
1/2
✓ Branch 8 → 9 taken 9 times.
✗ Branch 8 → 43 not taken.
9 out.push_back(string_evidence_atom(anchor.xref_text, anchor.xref_encoding));
308 9 break;
309 12 case AnchorKind::ExportName:
310
1/2
✓ Branch 11 → 12 taken 12 times.
✗ Branch 11 → 44 not taken.
12 out.push_back(export_evidence_atom(anchor.export_module, anchor.export_name));
311 12 break;
312 21 case AnchorKind::Manual:
313 {
314 21 std::uint64_t atom = fnv1a_byte(FNV1A64_OFFSET, static_cast<std::uint8_t>(EvidenceClass::Manual));
315
1/2
✓ Branch 15 → 16 taken 21 times.
✗ Branch 15 → 45 not taken.
21 out.push_back(fnv1a_int(atom, anchor.manual_value));
316 21 break;
317 }
318 176 case AnchorKind::RipGlobal:
319 case AnchorKind::CodeOperand:
320 // Both kinds resolve through a rung site, which defines the failure domain. Each rung contributes
321 // only its site atom. The atom deliberately omits a CodeOperand selector. Two selectors over one
322 // site form one witness.
323
2/2
✓ Branch 32 → 19 taken 180 times.
✓ Branch 32 → 33 taken 176 times.
532 for (const scan::Candidate &candidate : anchor.site)
324 {
325
1/2
✓ Branch 22 → 23 taken 180 times.
✗ Branch 22 → 46 not taken.
180 out.push_back(candidate_evidence_atom(candidate));
326 }
327 176 break;
328 case AnchorKind::CallArgHome:
329 case AnchorKind::Quorum:
330 case AnchorKind::Unset:
331 // No resolvable evidence exists. The post-switch guard contributes a kind-tagged Empty atom.
332 break;
333 }
334
1/2
✗ Branch 36 → 37 not taken.
✓ Branch 36 → 41 taken 222 times.
222 if (out.size() == start)
335 {
336 // Contribute one kind-tagged Empty atom so the set is never empty. Two degenerate anchors of the
337 // same kind remain dependent.
338 std::uint64_t atom = fnv1a_byte(FNV1A64_OFFSET, static_cast<std::uint8_t>(EvidenceClass::Empty));
339 out.push_back(fnv1a_byte(atom, static_cast<std::uint8_t>(anchor.kind)));
340 }
341 222 }
342
343 // Returns true when two resolvable sub-anchors can decode one site. Such anchors share at least one
344 // evidence atom, so one physical signal can satisfy both. Set intersection catches partial overlap.
345 // Two ladders with one shared rung can land on one site and cast two votes. collect_independence_atoms
346 // defines the canonical axes. An atom collision rejects a valid pair and therefore fails closed.
347 111 [[nodiscard]] bool same_backend_config(const Anchor &a, const Anchor &b)
348 {
349 111 std::vector<std::uint64_t> atoms_a;
350
1/2
✓ Branch 2 → 3 taken 111 times.
✗ Branch 2 → 41 not taken.
111 collect_independence_atoms(a, atoms_a);
351 111 std::vector<std::uint64_t> atoms_b;
352
1/2
✓ Branch 3 → 4 taken 111 times.
✗ Branch 3 → 39 not taken.
111 collect_independence_atoms(b, atoms_b);
353
2/2
✓ Branch 33 → 6 taken 112 times.
✓ Branch 33 → 34 taken 99 times.
322 for (const std::uint64_t atom_a : atoms_a)
354 {
355
2/2
✓ Branch 23 → 10 taken 115 times.
✓ Branch 23 → 24 taken 100 times.
327 for (const std::uint64_t atom_b : atoms_b)
356 {
357
2/2
✓ Branch 12 → 13 taken 12 times.
✓ Branch 12 → 14 taken 103 times.
115 if (atom_a == atom_b)
358 {
359 12 return true;
360 }
361 }
362 }
363 99 return false;
364 111 }
365
366 // This independence gate fails closed before agreement. The same Anchor object, two Manual literals, or
367 // one shared backend config are not independent. Two Manual values prove no live-image corroboration.
368 114 [[nodiscard]] bool quorum_sub_anchors_independent(const Anchor &a, const Anchor &b)
369 {
370
2/2
✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 4 taken 113 times.
114 if (&a == &b)
371 {
372 1 return false;
373 }
374
4/4
✓ Branch 4 → 5 taken 13 times.
✓ Branch 4 → 7 taken 100 times.
✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 7 taken 12 times.
113 if (a.kind == AnchorKind::Manual && b.kind == AnchorKind::Manual)
375 {
376 1 return false;
377 }
378 // Two same-name ExportName members are one witness if the modules match or either module is empty.
379 // The empty module can resolve inside the named module. The static atom cannot detect this overlap.
380
4/4
✓ Branch 8 → 9 taken 4 times.
✓ Branch 8 → 18 taken 6 times.
✓ Branch 10 → 11 taken 2 times.
✓ Branch 10 → 18 taken 2 times.
14 if (a.kind == AnchorKind::ExportName && b.kind == AnchorKind::ExportName &&
381
6/6
✓ Branch 7 → 8 taken 10 times.
✓ Branch 7 → 18 taken 102 times.
✓ Branch 12 → 13 taken 1 time.
✓ Branch 12 → 17 taken 1 time.
✓ Branch 19 → 20 taken 1 time.
✓ Branch 19 → 21 taken 111 times.
127 a.export_name == b.export_name &&
382
2/4
✓ Branch 14 → 15 taken 1 time.
✗ Branch 14 → 17 not taken.
✗ Branch 16 → 17 not taken.
✓ Branch 16 → 18 taken 1 time.
3 (a.export_module.empty() || b.export_module.empty() || a.export_module == b.export_module))
383 {
384 1 return false;
385 }
386 111 return !same_backend_config(a, b);
387 }
388
389 constexpr std::uint64_t NULL_SUB_ANCHOR = 0;
390
391 184 [[nodiscard]] std::uint64_t quorum_member_evidence(const Anchor *member) noexcept
392 {
393
2/2
✓ Branch 2 → 3 taken 176 times.
✓ Branch 2 → 4 taken 8 times.
184 return member != nullptr ? fingerprint_evidence(*member) : NULL_SUB_ANCHOR;
394 }
395 } // anonymous namespace
396
397 namespace internal
398 {
399 // Contract in internal/anchor_evidence.hpp.
400 71 bool quorum_members_pairwise_independent(std::span<const Anchor *const> members)
401 {
402
2/2
✓ Branch 14 → 3 taken 147 times.
✓ Branch 14 → 15 taken 56 times.
203 for (std::size_t i = 0; i < members.size(); ++i)
403 {
404
2/2
✓ Branch 11 → 4 taken 114 times.
✓ Branch 11 → 12 taken 132 times.
246 for (std::size_t j = i + 1; j < members.size(); ++j)
405 {
406
2/2
✓ Branch 7 → 8 taken 15 times.
✓ Branch 7 → 9 taken 99 times.
114 if (!quorum_sub_anchors_independent(*members[i], *members[j]))
407 {
408 15 return false;
409 }
410 }
411 }
412 56 return true;
413 }
414 } // namespace internal
415
416 146 std::uint64_t anchor_fingerprint(const Anchor &anchor) noexcept
417 {
418
2/2
✓ Branch 2 → 3 taken 130 times.
✓ Branch 2 → 4 taken 16 times.
146 if (anchor.kind != AnchorKind::Quorum)
419 {
420 130 return fingerprint_evidence(anchor);
421 }
422
423 // A quorum folds member evidence without order because the vote is symmetric. It also folds the effective
424 // threshold and agreement policy. Member hashes use sorted order without allocation. A null member adds a
425 // fixed sentinel.
426 16 const std::span<const Anchor *const> members = anchor.quorum_members;
427
428 16 std::uint64_t hash = fnv1a_byte(FNV1A64_OFFSET, static_cast<std::uint8_t>(AnchorKind::Quorum));
429 16 hash = fnv1a_int(hash, static_cast<std::uint64_t>(members.size()));
430
431 16 std::uint64_t previous = 0;
432 16 bool have_previous = false;
433 16 std::size_t emitted = 0;
434
2/2
✓ Branch 54 → 8 taken 36 times.
✓ Branch 54 → 55 taken 16 times.
52 while (emitted < members.size())
435 {
436 36 std::uint64_t next = 0;
437 36 bool found_next = false;
438
2/2
✓ Branch 28 → 10 taken 92 times.
✓ Branch 28 → 29 taken 36 times.
164 for (const Anchor *member : members)
439 {
440 92 const std::uint64_t evidence = quorum_member_evidence(member);
441
4/4
✓ Branch 13 → 14 taken 52 times.
✓ Branch 13 → 16 taken 40 times.
✓ Branch 14 → 15 taken 27 times.
✓ Branch 14 → 16 taken 25 times.
92 if (have_previous && evidence <= previous)
442 {
443 27 continue;
444 }
445
4/4
✓ Branch 16 → 17 taken 29 times.
✓ Branch 16 → 18 taken 36 times.
✓ Branch 17 → 18 taken 3 times.
✓ Branch 17 → 19 taken 26 times.
65 if (!found_next || evidence < next)
446 {
447 39 next = evidence;
448 39 found_next = true;
449 }
450 }
451
452
1/2
✗ Branch 29 → 30 not taken.
✓ Branch 29 → 31 taken 36 times.
36 if (!found_next)
453 {
454 break;
455 }
456
457 36 std::size_t duplicate_count = 0;
458
2/2
✓ Branch 47 → 33 taken 92 times.
✓ Branch 47 → 48 taken 36 times.
164 for (const Anchor *member : members)
459 {
460
2/2
✓ Branch 36 → 37 taken 40 times.
✓ Branch 36 → 38 taken 52 times.
92 if (quorum_member_evidence(member) == next)
461 {
462 40 ++duplicate_count;
463 }
464 }
465
2/2
✓ Branch 51 → 49 taken 40 times.
✓ Branch 51 → 52 taken 36 times.
76 for (std::size_t i = 0; i < duplicate_count; ++i)
466 {
467 40 hash = fnv1a_int(hash, next);
468 }
469
470 36 previous = next;
471 36 have_previous = true;
472 36 emitted += duplicate_count;
473 }
474 const std::size_t effective_threshold =
475
2/2
✓ Branch 55 → 56 taken 14 times.
✓ Branch 55 → 57 taken 2 times.
16 (anchor.quorum_threshold == 0) ? members.size() : anchor.quorum_threshold;
476 16 hash = fnv1a_int(hash, static_cast<std::uint64_t>(effective_threshold));
477 16 hash = fnv1a_byte(hash, static_cast<std::uint8_t>(anchor.quorum_match));
478 16 hash = fnv1a_int(hash, anchor.quorum_tolerance);
479 16 return hash;
480 }
481
482 11 std::uint64_t anchor_trust_fingerprint(const Anchor &anchor, scan::ImageIdentity scope_identity) noexcept
483 {
484 11 std::uint64_t hash = 0;
485
2/2
✓ Branch 2 → 3 taken 6 times.
✓ Branch 2 → 5 taken 5 times.
11 if (anchor.kind == AnchorKind::ExportName)
486 {
487 // Bind the effective module by identity, not by the declared export_module text. An empty module from
488 // scope and an explicit name for that module therefore fold to one key.
489 6 hash = fnv1a_byte(FNV1A64_OFFSET, static_cast<std::uint8_t>(AnchorKind::ExportName));
490 6 hash = fnv1a_field(hash, anchor.export_name);
491 }
492 else
493 {
494 5 hash = anchor_fingerprint(anchor);
495 }
496 // Fold the effective image identity last: a same-base remap changes it, while ASLR alone does not.
497 11 return fnv1a_int(hash, scope_identity.token());
498 }
499 } // namespace anchor
500 } // namespace DetourModKit
501