GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 90.4% 206 / 0 / 228
Functions: 93.8% 15 / 0 / 16
Branches: 81.4% 158 / 0 / 194

src/scan_resolution.cpp
Line Branch Exec Source
1 /**
2 * @file scan_resolution.cpp
3 * @brief The candidate-ladder resolver: resolve() and resolve_batch().
4 * @details Dispatches each Candidate on its active variant payload (Direct / RipRelative byte tiers, RttiVtable /
5 * StringXref text tiers) and returns the first that resolves uniquely to an in-scope, plausible address. One
6 * resolve(ScanRequest) carries scope, ordering, uniqueness, prologue fallback, and the ladder as fields. The
7 * byte tiers reuse the page-gated SIMD engine with the bounded
8 * haystack-frequency anchor override (sampled lazily on the first byte candidate, shared across the ladder);
9 * the text tiers resolve through their unique-only backends. On a full direct miss with a non-Off
10 * fallback_policy, hooked-prologue recovery is attempted under that policy's identity gate.
11 */
12
13 #include "DetourModKit/scan.hpp"
14
15 #include "internal/memory_guarded.hpp"
16 #include "internal/scan_engine.hpp"
17 #include "internal/scan_exclusions.hpp"
18 #include "internal/scan_pages.hpp"
19 #include "internal/scan_prologue_recovery.hpp"
20 #include "internal/scan_shared.hpp"
21
22 #include "DetourModKit/format.hpp"
23 #include "DetourModKit/logger.hpp"
24 #include "DetourModKit/memory.hpp"
25 #include "DetourModKit/rtti.hpp"
26
27 #include "fork_join.hpp"
28
29 #include <cstddef>
30 #include <cstdint>
31 #include <new>
32 #include <optional>
33 #include <span>
34 #include <string>
35 #include <string_view>
36 #include <utility>
37 #include <variant>
38 #include <vector>
39
40 namespace DetourModKit
41 {
42 #if defined(DMK_ENABLE_TEST_SEAMS)
43 namespace detail
44 {
45 void (*g_scan_after_byte_sweep_test_hook)() noexcept = nullptr;
46 } // namespace detail
47 #endif
48
49 namespace scan
50 {
51 namespace
52 {
53 349 [[nodiscard]] bool accepts_resolved_address(const ScanRequest &request, Address address) noexcept
54 {
55
4/4
✓ Branch 2 → 3 taken 148 times.
✓ Branch 2 → 6 taken 201 times.
✓ Branch 5 → 6 taken 145 times.
✓ Branch 5 → 7 taken 3 times.
349 return !request.require_executable_result || detail::is_executable_address(address.raw());
56 }
57
58 410 [[nodiscard]] constexpr bool valid_candidate_order(CandidateOrder order) noexcept
59 {
60
4/4
✓ Branch 2 → 3 taken 12 times.
✓ Branch 2 → 4 taken 398 times.
✓ Branch 3 → 4 taken 8 times.
✓ Branch 3 → 5 taken 4 times.
410 return order == CandidateOrder::AsDeclared || order == CandidateOrder::UniqueFirst;
61 }
62
63 406 [[nodiscard]] constexpr bool valid_fallback_policy(FallbackPolicy policy) noexcept
64 {
65
2/2
✓ Branch 2 → 3 taken 405 times.
✓ Branch 2 → 4 taken 1 time.
406 switch (policy)
66 {
67 405 case FallbackPolicy::Off:
68 case FallbackPolicy::WarnOnly:
69 case FallbackPolicy::RequireIdentity:
70 405 return true;
71 }
72 1 return false;
73 }
74
75 414 [[nodiscard]] bool valid_candidate_enums(const Candidate &candidate) noexcept
76 {
77 414 const StringXref *xref = candidate.as_string_xref();
78
4/4
✓ Branch 3 → 4 taken 12 times.
✓ Branch 3 → 8 taken 402 times.
✓ Branch 5 → 6 taken 11 times.
✓ Branch 5 → 9 taken 1 time.
437 return xref == nullptr ||
79
2/2
✓ Branch 7 → 8 taken 10 times.
✓ Branch 7 → 9 taken 1 time.
437 (detail::valid_string_encoding(xref->encoding) && detail::valid_xref_return(xref->return_mode));
80 }
81
82 // Resolve a byte-tier candidate's match to its final address: a Direct walk-back, or a RipRelative disp32
83 // read. Both screen the result through the plausible-userspace floor (in the shared helpers), so a faulted
84 // read or a crafted displacement is a miss, never a hit at a near-null or kernel-range address.
85 333 std::optional<std::uintptr_t> resolve_byte_candidate(
86 std::uintptr_t match,
87 const Candidate &candidate,
88 std::span<const std::byte> instruction
89 ) noexcept
90 {
91
2/2
✓ Branch 3 → 4 taken 310 times.
✓ Branch 3 → 5 taken 23 times.
333 if (const DirectPattern *direct = candidate.as_direct())
92 {
93 310 return detail::resolve_direct(match, *direct);
94 }
95
1/2
✓ Branch 6 → 7 taken 23 times.
✗ Branch 6 → 8 not taken.
23 if (const RipRelativePattern *rip = candidate.as_rip_relative())
96 {
97 23 return detail::resolve_rip_relative_candidate(match, *rip, instruction);
98 }
99 return std::nullopt;
100 }
101
102 // Resolver observability: one line names the winning candidate on success, a distinct line marks
103 // hooked-prologue recovery, and a miss records how many ladder rows were tried. These helpers do not alter
104 // the address or outcome: formatting/allocation failures are swallowed so a diagnostic cannot turn a
105 // genuine hit into a failure. The request label is echoed so callers can correlate logs with the site they
106 // asked to resolve.
107 346 void log_resolved(const ScanRequest &request, const Hit &hit, bool via_prologue_recovery) noexcept
108 {
109 try
110 {
111
1/2
✓ Branch 3 → 4 taken 346 times.
✗ Branch 3 → 14 not taken.
346 const std::string where = format::format_address(hit.address.raw());
112
2/2
✓ Branch 4 → 5 taken 13 times.
✓ Branch 4 → 8 taken 333 times.
346 if (via_prologue_recovery)
113 {
114 26 (void)DetourModKit::log().try_log(
115 LogLevel::Debug,
116 "scan::resolve: '{}' recovered {} via hooked-prologue reconstruction of candidate '{}'.",
117 13 request.label,
118 where,
119 13 hit.winning_name
120 );
121 }
122 else
123 {
124 666 (void)DetourModKit::log().try_log(
125 LogLevel::Debug,
126 "scan::resolve: '{}' resolved {} via candidate '{}'.",
127 333 request.label,
128 where,
129 333 hit.winning_name
130 );
131 }
132 346 }
133 catch (...)
134 {
135 // Best-effort diagnostic only: never let a logging allocation perturb the resolve() result.
136 }
137 346 }
138
139 43 void log_unresolved(const ScanRequest &request, std::string_view reason) noexcept
140 {
141 try
142 {
143 43 (void)DetourModKit::log().try_log(
144 LogLevel::Warning,
145 "scan::resolve: '{}' matched no candidate across {} tried ({}).",
146 43 request.label,
147 43 request.ladder.size(),
148 reason
149 );
150 }
151 catch (...)
152 {
153 }
154 43 }
155
156 // A WarnOnly recovery whose identity witness disagreed: surface the possible near-twin at Warning level
157 // while still returning the address. Formatting/allocation failures are swallowed like the other
158 // diagnostics so a log cannot turn an accepted recovery into a failure.
159 1 void log_identity_warning(const ScanRequest &request, const Hit &hit) noexcept
160 {
161 try
162 {
163 1 (void)DetourModKit::log().try_log(
164 LogLevel::Warning,
165 "scan::resolve: '{}' recovered {} via hooked-prologue reconstruction, but its identity witness "
166 "disagreed (WarnOnly); the site may be a near-twin.",
167 1 request.label,
168
1/2
✓ Branch 4 → 5 taken 1 time.
✗ Branch 4 → 9 not taken.
2 format::format_address(hit.address.raw())
169 );
170 }
171 catch (...)
172 {
173 }
174 1 }
175 } // namespace
176
177 namespace
178 {
179 410 Result<Hit> resolve_impl(const ScanRequest &request, detail::ResolvedScanHit *provenance)
180 {
181
2/2
✓ Branch 2 → 3 taken 232 times.
✓ Branch 2 → 6 taken 178 times.
410 if (provenance != nullptr)
182 {
183 232 provenance->physical_source = Region{};
184 232 provenance->winning_index = static_cast<std::size_t>(-1);
185 232 provenance->match_span = Region{};
186 }
187
4/4
✓ Branch 6 → 7 taken 185 times.
✓ Branch 6 → 11 taken 225 times.
✓ Branch 7 → 8 taken 1 time.
✓ Branch 7 → 11 taken 184 times.
410 if (request.pages != Pages::Readable && request.pages != Pages::Executable)
188 {
189 1 return std::unexpected(Error{ErrorCode::InvalidArg, "scan::resolve"});
190 }
191
6/6
✓ Branch 12 → 13 taken 406 times.
✓ Branch 12 → 15 taken 4 times.
✓ Branch 14 → 15 taken 1 time.
✓ Branch 14 → 16 taken 405 times.
✓ Branch 17 → 18 taken 5 times.
✓ Branch 17 → 21 taken 405 times.
409 if (!valid_candidate_order(request.order) || !valid_fallback_policy(request.fallback_policy))
192 {
193 5 return std::unexpected(Error{ErrorCode::InvalidArg, "scan::resolve"});
194 }
195
2/2
✓ Branch 39 → 23 taken 414 times.
✓ Branch 39 → 40 taken 402 times.
1221 for (const Candidate &candidate : request.ladder)
196 {
197
2/2
✓ Branch 26 → 27 taken 3 times.
✓ Branch 26 → 30 taken 411 times.
414 if (!valid_candidate_enums(candidate))
198 {
199 3 return std::unexpected(Error{ErrorCode::InvalidArg, "scan::resolve"});
200 }
201 }
202
2/2
✓ Branch 41 → 42 taken 3 times.
✓ Branch 41 → 45 taken 397 times.
402 if (request.ladder.empty())
203 {
204 3 return std::unexpected(Error{ErrorCode::EmptyCandidates, "scan::resolve"});
205 }
206 397 const detail::ModuleSpan range = detail::module_span(request.scope);
207
2/2
✓ Branch 47 → 48 taken 3 times.
✓ Branch 47 → 51 taken 396 times.
399 if (!range.valid())
208 {
209 3 return std::unexpected(Error{ErrorCode::InvalidRange, "scan::resolve"});
210 }
211
2/2
✓ Branch 52 → 53 taken 3 times.
✓ Branch 52 → 56 taken 394 times.
396 if (!detail::readable_scan_is_authoritative(range, request.pages, request.exclusions))
212 {
213 // A readable scope wider than one image contains the memory the caller's own copies of the ladder's
214 // query bytes live on, and DMK cannot enumerate those copies. Every candidate would resolve under
215 // that doubt, so refuse the request rather than grade each tier against evidence it cannot trust.
216 3 return std::unexpected(Error{ErrorCode::NotAuthoritative, "scan::resolve"});
217 }
218
219 // Query storage shared by every byte candidate: the Candidate array (each inline Pattern buffer lives
220 // in it) plus each owned text literal, which the text tiers search for verbatim. Restricting to the
221 // scanned range first drops every span the sweep will not read, so a large ladder still costs one or
222 // two slots.
223 394 detail::ScanExclusions ladder_exclusions;
224 394 ladder_exclusions.restrict_to(range.base, range.end);
225 392 ladder_exclusions.add_object_span(request.ladder);
226
2/2
✓ Branch 79 → 60 taken 403 times.
✓ Branch 79 → 80 taken 394 times.
1190 for (const Candidate &entry : request.ladder)
227 {
228
2/2
✓ Branch 63 → 64 taken 3 times.
✓ Branch 63 → 66 taken 399 times.
403 if (const RttiVtable *rtti_payload = entry.as_rtti_vtable())
229 {
230 3 ladder_exclusions.add_text(rtti_payload->mangled);
231 }
232
2/2
✓ Branch 67 → 68 taken 9 times.
✓ Branch 67 → 70 taken 391 times.
399 else if (const StringXref *xref_payload = entry.as_string_xref())
233 {
234 9 ladder_exclusions.add_text(xref_payload->text);
235 }
236 }
237 394 detail::add_regions(ladder_exclusions, request.exclusions);
238
2/2
✓ Branch 82 → 83 taken 1 time.
✓ Branch 82 → 86 taken 393 times.
394 if (ladder_exclusions.overflowed())
239 {
240 1 return std::unexpected(Error{ErrorCode::NotAuthoritative, "scan::resolve"});
241 }
242
243 // Lay out the try order once. The haystack histogram is sampled lazily on the first byte candidate and
244 // shared across every byte candidate in the ladder, since they all scan the same scope.
245
2/2
✓ Branch 89 → 90 taken 388 times.
✓ Branch 89 → 323 taken 4 times.
397 std::vector<std::size_t> order(request.ladder.size());
246 388 const std::size_t ordered_count = order_candidates(request.order, request.ladder, order);
247 388 std::optional<detail::HaystackHistogram> histogram;
248 // Two latches, split by what a failure proves rather than by its code. A byte rung whose own sweep went
249 // short leaves the module-executable pages hooked-prologue recovery searches only partly read, so "the
250 // direct candidates fully missed" is not established and recovery must not run. A text rung's failure
251 // (an unencodable literal, or that tier's own readable phase-1 sweep being unconfined or truncated)
252 // says nothing about executable-page coverage, and recovery acts only on Direct rungs, so it is
253 // reported in place of the generic miss without suppressing recovery.
254 388 std::optional<ErrorCode> coverage_error;
255 388 std::optional<ErrorCode> text_error;
256 2 const auto remember_coverage_error = [&coverage_error](ErrorCode code) noexcept -> void
257 {
258
1/2
✓ Branch 3 → 4 taken 2 times.
✗ Branch 3 → 6 not taken.
2 if (!coverage_error)
259 {
260 2 coverage_error = code;
261 }
262 390 };
263 const auto remember_text_error = [&text_error](ErrorCode code) noexcept -> void
264 {
265 if (!text_error)
266 {
267 text_error = code;
268 }
269 388 };
270
271
2/2
✓ Branch 234 → 94 taken 396 times.
✓ Branch 234 → 235 taken 56 times.
452 for (std::size_t k = 0; k < ordered_count; ++k)
272 {
273 396 const Candidate &candidate = request.ladder[order[k]];
274
275
2/2
✓ Branch 97 → 98 taken 3 times.
✓ Branch 97 → 125 taken 392 times.
397 if (const RttiVtable *rtti = candidate.as_rtti_vtable())
276 {
277 // Fully qualify the namespace: the local `rtti` pointer would otherwise shadow the `rtti`
278 // module namespace and make `rtti::vtable_for_type` name the variable instead.
279 const std::optional<Address> vtable =
280 3 DetourModKit::rtti::vtable_for_type(rtti->mangled, request.scope);
281
6/8
✓ Branch 101 → 102 taken 1 time.
✓ Branch 101 → 110 taken 2 times.
✓ Branch 105 → 106 taken 1 time.
✗ Branch 105 → 110 not taken.
✓ Branch 108 → 109 taken 1 time.
✗ Branch 108 → 110 not taken.
✓ Branch 111 → 112 taken 1 time.
✓ Branch 111 → 124 taken 2 times.
3 if (vtable && range.contains(vtable->raw()) && accepts_resolved_address(request, *vtable))
282 {
283
2/4
✓ Branch 114 → 115 taken 1 time.
✗ Branch 114 → 326 not taken.
✗ Branch 115 → 116 not taken.
✓ Branch 115 → 117 taken 1 time.
1 Hit hit{*vtable, candidate.name(), Mode::RttiVtable};
284
1/2
✗ Branch 117 → 118 not taken.
✓ Branch 117 → 120 taken 1 time.
1 if (provenance != nullptr)
285 {
286 provenance->winning_index = order[k];
287 }
288 1 log_resolved(request, hit, false);
289 1 return hit;
290 1 }
291 2 continue;
292 2 }
293
2/2
✓ Branch 126 → 127 taken 10 times.
✓ Branch 126 → 167 taken 384 times.
392 if (const StringXref *xref = candidate.as_string_xref())
294 {
295 // Rebuild a borrowed StringRefQuery view over the candidate's owned literal and facets. The
296 // resolver-specific entry point carries the ladder and caller exclusions through phase 1.
297 const StringRefQuery query{
298 10 .text = xref->text,
299 10 .encoding = xref->encoding,
300 10 .require_terminator = xref->require_terminator,
301 10 .return_mode = xref->return_mode,
302 10 .broad_match = xref->broad_match,
303 10 };
304 10 Region reference_span{};
305
1/2
✓ Branch 128 → 129 taken 10 times.
✗ Branch 128 → 329 not taken.
10 const Result<Address> site = detail::find_string_xref_with_exclusions(
306 query,
307 request.scope,
308 &ladder_exclusions,
309 request.exclusions,
310 &reference_span
311 );
312
6/8
✓ Branch 130 → 131 taken 8 times.
✓ Branch 130 → 139 taken 2 times.
✓ Branch 134 → 135 taken 8 times.
✗ Branch 134 → 139 not taken.
✓ Branch 137 → 138 taken 8 times.
✗ Branch 137 → 139 not taken.
✓ Branch 140 → 141 taken 8 times.
✓ Branch 140 → 153 taken 2 times.
10 if (site && range.contains(site->raw()) && accepts_resolved_address(request, *site))
313 {
314
2/4
✓ Branch 143 → 144 taken 8 times.
✗ Branch 143 → 328 not taken.
✗ Branch 144 → 145 not taken.
✓ Branch 144 → 146 taken 8 times.
8 Hit hit{*site, candidate.name(), Mode::StringXref};
315
2/2
✓ Branch 146 → 147 taken 2 times.
✓ Branch 146 → 149 taken 6 times.
8 if (provenance != nullptr)
316 {
317 2 provenance->physical_source = reference_span;
318 2 provenance->winning_index = order[k];
319 }
320 8 log_resolved(request, hit, false);
321 8 return hit;
322 8 }
323
3/6
✓ Branch 154 → 155 taken 2 times.
✗ Branch 154 → 162 not taken.
✓ Branch 156 → 157 taken 2 times.
✗ Branch 156 → 161 not taken.
✗ Branch 163 → 164 not taken.
✓ Branch 163 → 166 taken 2 times.
4 if (!site && (site.error().code == ErrorCode::IncompleteScan ||
324
1/2
✓ Branch 158 → 159 taken 2 times.
✗ Branch 158 → 161 not taken.
2 site.error().code == ErrorCode::NotAuthoritative ||
325
1/2
✗ Branch 160 → 161 not taken.
✓ Branch 160 → 162 taken 2 times.
2 site.error().code == ErrorCode::MalformedQueryText))
326 {
327 remember_text_error(site.error().code);
328 }
329 2 continue;
330 2 }
331
332 // Byte tiers (Direct / RipRelative).
333 384 const Pattern *pattern = detail::byte_pattern_of(candidate);
334
1/2
✗ Branch 168 → 169 not taken.
✓ Branch 168 → 170 taken 384 times.
384 if (pattern == nullptr)
335 {
336 // Unreachable through the factories (every alternative is handled above); skip defensively.
337 continue;
338 }
339
2/2
✓ Branch 171 → 172 taken 377 times.
✓ Branch 171 → 175 taken 6 times.
384 if (!histogram)
340 {
341 377 histogram = detail::sample_haystack(request.scope);
342 }
343
1/2
✓ Branch 176 → 177 taken 383 times.
✗ Branch 176 → 332 not taken.
384 const detail::EnginePattern compiled = detail::to_engine_pattern(*pattern, *histogram);
344
345 // Honour the request's page class: Readable sweeps code + data, while Executable narrows to code
346 // pages so an instruction signature cannot alias an identical run in data. One traversal answers
347 // both "where is the first match" and "is there a second", so a concurrent write cannot produce a
348 // hit/uniqueness pair that no single view of memory ever had. The candidate's inline Pattern needs
349 // no exclusion of its own: it is a subobject of the ladder array, whose whole span is already
350 // excluded above.
351 383 const RipRelativePattern *rip = candidate.as_rip_relative();
352 359 const std::uint8_t instruction_snapshot_length =
353
2/2
✓ Branch 178 → 179 taken 24 times.
✓ Branch 178 → 180 taken 359 times.
383 rip != nullptr ? static_cast<std::uint8_t>(rip->instruction_length) : std::uint8_t{0};
354 383 const detail::MatchResult found = detail::scan_module_pages(
355 compiled,
356 range,
357 383 request.pages,
358 767 detail::ScanQuery{
359 .occurrence = 1,
360 383 .count_beyond = request.require_unique,
361 .exclusions = &ladder_exclusions,
362 .capture_evidence = true,
363 .instruction_snapshot_length = instruction_snapshot_length,
364 }
365 );
366 #if defined(DMK_ENABLE_TEST_SEAMS)
367
2/2
✓ Branch 182 → 183 taken 12 times.
✓ Branch 182 → 184 taken 372 times.
384 if (auto *const hook = detail::g_scan_after_byte_sweep_test_hook)
368 {
369 12 hook();
370 }
371 #endif
372
2/2
✓ Branch 184 → 185 taken 1 time.
✓ Branch 184 → 186 taken 383 times.
384 if (found.budget_exhausted)
373 {
374 1 remember_coverage_error(ErrorCode::BudgetExceeded);
375 }
376
2/2
✓ Branch 186 → 187 taken 1 time.
✓ Branch 186 → 188 taken 382 times.
383 else if (found.incomplete)
377 {
378 1 remember_coverage_error(ErrorCode::IncompleteScan);
379 }
380
2/2
✓ Branch 188 → 189 taken 48 times.
✓ Branch 188 → 190 taken 336 times.
384 if (found.match == nullptr)
381 {
382 48 continue;
383 }
384
1/2
✗ Branch 191 → 192 not taken.
✓ Branch 191 → 193 taken 336 times.
336 if (found.truncated())
385 {
386 // A skipped faulted region or a bounded-jump budget truncation makes the occurrence count a
387 // lower bound. A hidden earlier match or duplicate could exist in unscanned bytes, so accepting
388 // this candidate would turn an incomplete sweep into a wrong address.
389 continue;
390 }
391
2/2
✓ Branch 193 → 194 taken 3 times.
✓ Branch 193 → 195 taken 333 times.
336 if (found.count > 1)
392 {
393 // Ambiguous in scope: the lowest-address match is not provably the intended target, so fall
394 // through to the next candidate rather than commit to an arbitrary site.
395 3 continue;
396 }
397 333 const std::optional<std::uintptr_t> resolved = resolve_byte_candidate(
398 333 reinterpret_cast<std::uintptr_t>(found.match),
399 candidate,
400 found.instruction.span()
401 );
402
4/4
✓ Branch 198 → 199 taken 329 times.
✓ Branch 198 → 206 taken 4 times.
✓ Branch 201 → 202 taken 327 times.
✓ Branch 201 → 206 taken 2 times.
660 if (!resolved || !range.contains(*resolved) ||
403
4/4
✓ Branch 205 → 206 taken 3 times.
✓ Branch 205 → 207 taken 324 times.
✓ Branch 208 → 209 taken 9 times.
✓ Branch 208 → 210 taken 324 times.
660 !accepts_resolved_address(request, Address{*resolved}))
404 {
405 // A RipRelative displacement can resolve outside the scanned scope (e.g. an import thunk in
406 // another module); reject it here so the ladder falls through instead of committing out of
407 // scope.
408 9 continue;
409 }
410 // The evidence rides out of the sweep that produced this match; the address may be a RIP-relative
411 // target elsewhere, but the witnessed bytes are always the pattern's own matched span.
412
2/4
✓ Branch 213 → 214 taken 324 times.
✗ Branch 213 → 330 not taken.
✗ Branch 215 → 216 not taken.
✓ Branch 215 → 217 taken 324 times.
324 Hit hit{Address{*resolved}, candidate.name(), candidate.mode(), found.evidence};
413
2/2
✓ Branch 217 → 218 taken 214 times.
✓ Branch 217 → 226 taken 110 times.
324 if (provenance != nullptr)
414 {
415 // The authored match span alone understates a RIP winner's evidence: the target was computed
416 // from the whole instruction the snapshot decode consumed, and a signature need not cover the
417 // trailing immediates it authorizes. Publishing only the matched bytes would leave those
418 // immediates unclaimed, so a second selector matching them would abut this span instead of
419 // overlapping it and would double-vote one instruction. The point sits inside the match, so
420 // the union stays one contiguous extent.
421 214 Region source = found.physical_span;
422
2/2
✓ Branch 218 → 219 taken 10 times.
✓ Branch 218 → 224 taken 204 times.
214 if (found.instruction.length != 0)
423 {
424 10 const std::uintptr_t decoded_end =
425 10 reinterpret_cast<std::uintptr_t>(found.match) + found.instruction.length;
426
2/2
✓ Branch 221 → 222 taken 1 time.
✓ Branch 221 → 224 taken 9 times.
10 if (decoded_end > source.end().raw())
427 {
428 1 source.size = static_cast<std::size_t>(decoded_end - source.base.raw());
429 }
430 }
431 214 provenance->physical_source = source;
432 214 provenance->winning_index = order[k];
433 214 provenance->match_span = found.physical_span;
434 }
435 324 log_resolved(request, hit, false);
436 324 return hit;
437
2/2
✓ Branch 231 → 232 taken 324 times.
✓ Branch 231 → 233 taken 60 times.
708 }
438
439
2/2
✓ Branch 236 → 237 taken 2 times.
✓ Branch 236 → 244 taken 54 times.
56 if (coverage_error)
440 {
441 // A byte rung was budget-bound or truncated, so the executable pages recovery would search were not
442 // fully covered and a rebuilt-prologue hit could not be read as "the direct scan missed". Fail
443 // closed before the fallback, and ahead of every other verdict so a coverage code can never become
444 // NoMatch.
445 2 log_unresolved(request, DetourModKit::to_string(*coverage_error));
446 2 return std::unexpected(Error{*coverage_error, "scan::resolve"});
447 }
448
449
2/2
✓ Branch 244 → 245 taken 25 times.
✓ Branch 244 → 306 taken 29 times.
54 if (request.fallback_policy != FallbackPolicy::Off)
450 {
451 const detail::FallbackOutcome fallback = detail::resolve_prologue_fallback(
452 request,
453 25 std::span<const std::size_t>{order.data(), ordered_count},
454 range
455
1/2
✓ Branch 247 → 248 taken 25 times.
✗ Branch 247 → 333 not taken.
25 );
456
5/6
✓ Branch 249 → 250 taken 13 times.
✓ Branch 249 → 254 taken 12 times.
✓ Branch 252 → 253 taken 13 times.
✗ Branch 252 → 254 not taken.
✓ Branch 255 → 256 taken 13 times.
✓ Branch 255 → 266 taken 12 times.
25 if (fallback.hit && accepts_resolved_address(request, fallback.hit->address))
457 {
458
1/2
✗ Branch 256 → 257 not taken.
✓ Branch 256 → 258 taken 13 times.
13 if (provenance != nullptr)
459 {
460 provenance->physical_source = fallback.physical_source;
461 }
462
2/2
✓ Branch 258 → 259 taken 1 time.
✓ Branch 258 → 261 taken 12 times.
13 if (fallback.identity_warned)
463 {
464 1 log_identity_warning(request, *fallback.hit);
465 }
466 13 log_resolved(request, *fallback.hit, true);
467
1/2
✓ Branch 264 → 265 taken 13 times.
✗ Branch 264 → 334 not taken.
13 return *fallback.hit;
468 }
469
1/2
✗ Branch 267 → 268 not taken.
✓ Branch 267 → 275 taken 12 times.
12 if (text_error)
470 {
471 // Reported ahead of the prologue diagnostics: an unencodable literal or an unconfined text
472 // scope is a defect in the request, while an identity rejection or a missing rebuildable Direct
473 // row is a property of the recovery attempt, so the request-level code is the one the caller
474 // must act on.
475 log_unresolved(request, DetourModKit::to_string(*text_error));
476 return std::unexpected(Error{*text_error, "scan::resolve"});
477 }
478
2/2
✓ Branch 275 → 276 taken 4 times.
✓ Branch 275 → 281 taken 8 times.
12 if (fallback.identity_rejected)
479 {
480 // RequireIdentity refused every structurally-recovered site: the rebuilt prologue matched
481 // uniquely, but no recovered address passed the witness. Distinct from a plain miss so the
482 // caller learns that a hooked near-twin exists and the signature needs a sharper witness or
483 // corroborating landmark.
484 4 log_unresolved(request, "prologue recovery rejected by identity gate");
485 4 return std::unexpected(Error{ErrorCode::PrologueIdentityRejected, "scan::resolve"});
486 }
487
2/2
✓ Branch 281 → 282 taken 1 time.
✓ Branch 281 → 287 taken 7 times.
8 if (fallback.ambiguous)
488 {
489 // A rebuilt hook shape matched more than one executable site, so recovery cannot name a single
490 // redirect. Reported after the identity gate (a verdict about a uniquely-found site) and ahead
491 // of the incomplete/applicability diagnostics: a proven multiplicity is more specific than
492 // either a truncated sweep or a too-short tail, and distinct from a plain miss so the caller
493 // learns the signature's surviving tail is not unique.
494 1 log_unresolved(request, DetourModKit::to_string(ErrorCode::PrologueFallbackAmbiguous));
495 1 return std::unexpected(Error{ErrorCode::PrologueFallbackAmbiguous, "scan::resolve"});
496 }
497
1/2
✗ Branch 287 → 288 not taken.
✓ Branch 287 → 293 taken 7 times.
7 if (fallback.incomplete)
498 {
499 // Recovery's own sweep over the executable pages went short, so "no rebuildable shape matched"
500 // is not a proven absence either. Reported after the identity gate, which requires a recovered
501 // site, and ahead of the applicability diagnostics, which would read as a proven miss.
502 log_unresolved(request, DetourModKit::to_string(ErrorCode::IncompleteScan));
503 return std::unexpected(Error{ErrorCode::IncompleteScan, "scan::resolve"});
504 }
505
4/4
✓ Branch 293 → 294 taken 6 times.
✓ Branch 293 → 300 taken 1 time.
✓ Branch 294 → 295 taken 5 times.
✓ Branch 294 → 300 taken 1 time.
7 if (fallback.had_direct && fallback.not_applicable)
506 {
507 // At least one Direct candidate existed, and no shape rebuilt a usable pattern from any of
508 // them. Distinct from a plain miss, where a ladder carries no Direct row to rebuild.
509 5 log_unresolved(request, "prologue recovery had no rebuildable Direct candidate");
510 5 return std::unexpected(Error{ErrorCode::PrologueFallbackNotApplicable, "scan::resolve"});
511 }
512
2/2
✓ Branch 302 → 303 taken 2 times.
✓ Branch 302 → 305 taken 23 times.
25 }
513
514
1/2
✗ Branch 307 → 308 not taken.
✓ Branch 307 → 315 taken 31 times.
31 if (text_error)
515 {
516 // Reached when the fallback is Off or produced no verdict of its own. The typed text-tier code is
517 // more actionable than a generic miss, and a truncated text sweep must never read as a proven
518 // absence.
519 log_unresolved(request, DetourModKit::to_string(*text_error));
520 return std::unexpected(Error{*text_error, "scan::resolve"});
521 }
522
523 31 log_unresolved(request, "no ladder candidate resolved uniquely in scope");
524 31 return std::unexpected(Error{ErrorCode::NoMatch, "scan::resolve"});
525 389 }
526 } // namespace
527
528 178 Result<Hit> resolve(const ScanRequest &request)
529 {
530 178 return resolve_impl(request, nullptr);
531 }
532
533 Result<std::vector<Result<Hit>>>
534 11 resolve_batch(std::span<const ScanRequest> requests, std::size_t max_workers) noexcept
535 {
536 try
537 {
538
2/2
✓ Branch 2 → 3 taken 10 times.
✓ Branch 2 → 7 taken 1 time.
21 return detail::run_fork_join<ScanRequest, Result<Hit>>(
539 requests,
540 max_workers,
541 81 [](const ScanRequest &request) -> Result<Hit>
542 {
543 try
544 {
545
2/2
✓ Branch 2 → 3 taken 78 times.
✓ Branch 2 → 5 taken 4 times.
81 return resolve(request);
546 }
547
1/2
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 4 times.
4 catch (const std::bad_alloc &)
548 {
549 4 return std::unexpected(Error{ErrorCode::OutOfMemory, "scan::resolve_batch"});
550 4 }
551 },
552 82 [](const ScanRequest &) noexcept -> Result<Hit>
553 82 { return std::unexpected(Error{ErrorCode::NoMatch, "scan::resolve_batch"}); }
554 10 );
555 }
556
1/2
✓ Branch 8 → 9 taken 1 time.
✗ Branch 8 → 13 not taken.
1 catch (const std::bad_alloc &)
557 {
558 // The per-request result container itself could not be allocated under true out-of-memory, so there is
559 // no batch to hand back. The whole-batch failure rides the OUTER Result, which a caller must unwrap
560 // before touching any per-request slot. There is no silently-undersized vector to index. Error is
561 // const-char*-backed, so building it here allocates nothing and keeps this path no-throw.
562 1 return std::unexpected(Error{ErrorCode::OutOfMemory, "scan::resolve_batch"});
563 1 }
564 catch (...)
565 {
566 return std::unexpected(Error{ErrorCode::Unknown, "scan::resolve_batch"});
567 }
568 }
569 } // namespace scan
570
571 232 Result<detail::ResolvedScanHit> detail::resolve_scan_with_provenance(const scan::ScanRequest &request)
572 {
573 232 ResolvedScanHit resolved;
574
1/2
✓ Branch 3 → 4 taken 232 times.
✗ Branch 3 → 19 not taken.
232 Result<scan::Hit> hit = scan::resolve_impl(request, &resolved);
575
2/2
✓ Branch 5 → 6 taken 16 times.
✓ Branch 5 → 10 taken 216 times.
232 if (!hit)
576 {
577 16 return std::unexpected(hit.error());
578 }
579 432 resolved.hit = std::move(*hit);
580 216 return resolved;
581 232 }
582 } // namespace DetourModKit
583