./
Coverage:
low: ≥ 0%
medium: ≥ 75.0%
high: ≥ 90.0%
Lines:
241 / 256
94.1%
Branches:
178 / 212
84.0%
Functions:
22 / 22
100.0%

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/rtti_shared.hpp"
17 #include "internal/scan_engine.hpp"
18 #include "internal/scan_exclusions.hpp"
19 #include "internal/scan_pages.hpp"
20 #include "internal/scan_prologue_recovery.hpp"
21 #include "internal/scan_shared.hpp"
22
23 #include "DetourModKit/format.hpp"
24 #include "DetourModKit/logger.hpp"
25 #include "DetourModKit/memory.hpp"
26 #include "DetourModKit/rtti.hpp"
27
28 #include "fork_join.hpp"
29
30 #include <cstddef>
31 #include <cstdint>
32 #include <new>
33 #include <optional>
34 #include <span>
35 #include <string>
36 #include <string_view>
37 #include <utility>
38 #include <variant>
39 #include <vector>
40
41 namespace DetourModKit
42 {
43 #if defined(DMK_ENABLE_TEST_SEAMS)
44 namespace detail
45 {
46 void (*g_scan_after_byte_sweep_test_hook)() noexcept = nullptr;
47 } // namespace detail
48 #endif
49
50 namespace scan
51 {
52 namespace
53 {
54 363 [[nodiscard]] bool accepts_resolved_address(const ScanRequest &request, Address address) noexcept
55 {
56
4/4
✓ Branch 2 → 3 taken 148 times.
✓ Branch 2 → 6 taken 215 times.
✓ Branch 5 → 6 taken 145 times.
✓ Branch 5 → 7 taken 3 times.
363 return !request.require_executable_result || detail::is_executable_address(address.raw());
57 }
58
59 458 [[nodiscard]] constexpr bool valid_candidate_order(CandidateOrder order) noexcept
60 {
61
4/4
✓ Branch 2 → 3 taken 12 times.
✓ Branch 2 → 4 taken 446 times.
✓ Branch 3 → 4 taken 8 times.
✓ Branch 3 → 5 taken 4 times.
458 return order == CandidateOrder::AsDeclared || order == CandidateOrder::UniqueFirst;
62 }
63
64 454 [[nodiscard]] constexpr bool valid_fallback_policy(FallbackPolicy policy) noexcept
65 {
66
2/2
✓ Branch 2 → 3 taken 453 times.
✓ Branch 2 → 4 taken 1 time.
454 switch (policy)
67 {
68 453 case FallbackPolicy::Off:
69 case FallbackPolicy::WarnOnly:
70 case FallbackPolicy::RequireIdentity:
71 453 return true;
72 }
73 1 return false;
74 }
75
76 461 [[nodiscard]] bool valid_candidate_enums(const Candidate &candidate) noexcept
77 {
78 461 const StringXref *xref = candidate.as_string_xref();
79
4/4
✓ Branch 3 → 4 taken 50 times.
✓ Branch 3 → 8 taken 412 times.
✓ Branch 5 → 6 taken 49 times.
✓ Branch 5 → 9 taken 1 time.
561 return xref == nullptr ||
80
2/2
✓ Branch 7 → 8 taken 48 times.
✓ Branch 7 → 9 taken 1 time.
561 (detail::valid_string_encoding(xref->encoding) && detail::valid_xref_return(xref->return_mode));
81 }
82
83 // Resolve a byte-tier candidate's match to its final address: a Direct walk-back, or a RipRelative disp32
84 // read. Both screen the result through the plausible-userspace floor (in the shared helpers), so a faulted
85 // read or a crafted displacement is a miss, never a hit at a near-null or kernel-range address.
86 341 std::optional<std::uintptr_t> resolve_byte_candidate(
87 std::uintptr_t match,
88 const Candidate &candidate,
89 std::span<const std::byte> instruction
90 ) noexcept
91 {
92
2/2
✓ Branch 3 → 4 taken 315 times.
✓ Branch 3 → 5 taken 26 times.
341 if (const DirectPattern *direct = candidate.as_direct())
93 {
94 315 return detail::resolve_direct(match, *direct);
95 }
96
1/2
✓ Branch 6 → 7 taken 26 times.
✗ Branch 6 → 8 not taken.
26 if (const RipRelativePattern *rip = candidate.as_rip_relative())
97 {
98 26 return detail::resolve_rip_relative_candidate(match, *rip, instruction);
99 }
100 ✗ return std::nullopt;
101 }
102
103 // Resolver observability: one line names the winning candidate on success, a distinct line marks
104 // hooked-prologue recovery, and a miss records how many ladder rows were tried. These helpers do not alter
105 // the address or outcome: formatting/allocation failures are swallowed so a diagnostic cannot turn a
106 // genuine hit into a failure. The request label is echoed so callers can correlate logs with the site they
107 // asked to resolve.
108 360 void log_resolved(const ScanRequest &request, const Hit &hit, bool via_prologue_recovery) noexcept
109 {
110 try
111 {
112
2/2
✓ Branch 3 → 4 taken 358 times.
✓ Branch 3 → 14 taken 2 times.
360 const std::string where = format::format_address(hit.address.raw());
113
2/2
✓ Branch 4 → 5 taken 13 times.
✓ Branch 4 → 8 taken 345 times.
358 if (via_prologue_recovery)
114 {
115 26 (void)DetourModKit::log().try_log(
116 LogLevel::Debug,
117 "scan::resolve: '{}' recovered {} via hooked-prologue reconstruction of candidate '{}'.",
118 13 request.label,
119 where,
120 13 hit.winning_name
121 );
122 }
123 else
124 {
125 690 (void)DetourModKit::log().try_log(
126 LogLevel::Debug,
127 "scan::resolve: '{}' resolved {} via candidate '{}'.",
128 345 request.label,
129 where,
130 345 hit.winning_name
131 );
132 }
133 358 }
134 2 catch (...)
135 {
136 // Best-effort diagnostic only: never let a logging allocation perturb the resolve() result.
137 2 }
138 360 }
139
140 47 void log_unresolved(const ScanRequest &request, std::string_view reason) noexcept
141 {
142 try
143 {
144 47 (void)DetourModKit::log().try_log(
145 LogLevel::Warning,
146 "scan::resolve: '{}' matched no candidate across {} tried ({}).",
147 47 request.label,
148 47 request.ladder.size(),
149 reason
150 );
151 }
152 catch (...)
153 {
154 }
155 47 }
156
157 // A WarnOnly recovery whose identity witness disagreed: surface the possible near-twin at Warning level
158 // while still returning the address. Formatting/allocation failures are swallowed like the other
159 // diagnostics so a log cannot turn an accepted recovery into a failure.
160 1 void log_identity_warning(const ScanRequest &request, const Hit &hit) noexcept
161 {
162 try
163 {
164 1 (void)DetourModKit::log().try_log(
165 LogLevel::Warning,
166 "scan::resolve: '{}' recovered {} via hooked-prologue reconstruction, but its identity witness "
167 "disagreed (WarnOnly); the site may be a near-twin.",
168 1 request.label,
169
1/2
✓ Branch 4 → 5 taken 1 time.
✗ Branch 4 → 9 not taken.
2 format::format_address(hit.address.raw())
170 );
171 }
172 ✗ catch (...)
173 {
174 ✗ }
175 1 }
176 } // namespace
177
178 namespace
179 {
180 /**
181 * @brief Collects the query storage of the ladder and the caller exclusions into @p ladder_exclusions.
182 * @details The query storage is the Candidate array and each owned text literal that the text tiers search
183 * for verbatim. The Candidate array holds each inline Pattern buffer. The restriction to the
184 * scanned range comes first. It drops every span that the sweep does not read, so a large ladder
185 * still costs one or two slots.
186 */
187 442 void collect_ladder_exclusions(
188 const ScanRequest &request,
189 const detail::ModuleSpan &range,
190 detail::ScanExclusions &ladder_exclusions
191 ) noexcept
192 {
193 442 ladder_exclusions.restrict_to(range.base, range.end);
194 441 ladder_exclusions.add_object_span(request.ladder);
195
2/2
✓ Branch 25 → 6 taken 451 times.
✓ Branch 25 → 26 taken 441 times.
1333 for (const Candidate &entry : request.ladder)
196 {
197
2/2
✓ Branch 9 → 10 taken 5 times.
✓ Branch 9 → 12 taken 446 times.
451 if (const RttiVtable *rtti_payload = entry.as_rtti_vtable())
198 {
199 5 ladder_exclusions.add_text(rtti_payload->mangled);
200 }
201
2/2
✓ Branch 13 → 14 taken 48 times.
✓ Branch 13 → 16 taken 398 times.
446 else if (const StringXref *xref_payload = entry.as_string_xref())
202 {
203 48 ladder_exclusions.add_text(xref_payload->text);
204 }
205 }
206 441 detail::add_regions(ladder_exclusions, request.exclusions);
207 442 }
208
209 /// Resolves @p xref and carries the ladder and caller exclusions through phase 1.
210 46 [[nodiscard]] Result<Address> find_string_xref_site(
211 const StringXref &xref,
212 const ScanRequest &request,
213 const detail::ScanExclusions &ladder_exclusions,
214 Region &reference_span
215 )
216 {
217 const StringRefQuery query{
218 46 .text = xref.text,
219 46 .encoding = xref.encoding,
220 46 .require_terminator = xref.require_terminator,
221 46 .return_mode = xref.return_mode,
222 46 .broad_match = xref.broad_match,
223 46 };
224
2/2
✓ Branch 3 → 4 taken 18 times.
✓ Branch 3 → 7 taken 28 times.
46 return detail::find_string_xref_with_exclusions(
225 query,
226 request.scope,
227 &ladder_exclusions,
228 request.exclusions,
229 &reference_span
230 36 );
231 }
232
233 /**
234 * @brief Sweeps @p range in one traversal for the first match of @p compiled and, when the request requires
235 * uniqueness, a second match.
236 * @details The single traversal means that a concurrent write cannot produce a hit and a uniqueness verdict
237 * that no single view of memory had. The inline Pattern of @p candidate needs no exclusion of its
238 * own. It is a subobject of the ladder array, and collect_ladder_exclusions() excludes the whole
239 * array.
240 */
241 392 [[nodiscard]] detail::MatchResult sweep_byte_candidate(
242 const ScanRequest &request,
243 const detail::ModuleSpan &range,
244 const Candidate &candidate,
245 const detail::EnginePattern &compiled,
246 const detail::ScanExclusions &ladder_exclusions
247 ) noexcept
248 {
249 392 const RipRelativePattern *rip = candidate.as_rip_relative();
250 365 const std::uint8_t instruction_snapshot_length =
251
2/2
✓ Branch 3 → 4 taken 27 times.
✓ Branch 3 → 5 taken 365 times.
392 rip != nullptr ? static_cast<std::uint8_t>(rip->instruction_length) : std::uint8_t{0};
252 392 return detail::scan_module_pages(
253 compiled,
254 range,
255 392 request.pages,
256 392 detail::ScanQuery{
257 .occurrence = 1,
258 392 .count_beyond = request.require_unique,
259 .exclusions = &ladder_exclusions,
260 .capture_evidence = true,
261 .instruction_snapshot_length = instruction_snapshot_length,
262 }
263 392 );
264 }
265
266 /**
267 * @brief Accepts a complete byte match that the sweep counted once, whose resolved address is in scope and
268 * passes accepts_resolved_address(), and publishes its provenance.
269 * @return The hit, or std::nullopt so that the ladder falls through to the next candidate.
270 */
271 392 [[nodiscard]] std::optional<Hit> accept_byte_match(
272 const ScanRequest &request,
273 const detail::ModuleSpan &range,
274 const Candidate &candidate,
275 const detail::MatchResult &found,
276 std::size_t winning_index,
277 detail::ResolvedScanHit *provenance
278 )
279 {
280
2/2
✓ Branch 2 → 3 taken 48 times.
✓ Branch 2 → 4 taken 344 times.
392 if (found.match == nullptr)
281 {
282 48 return std::nullopt;
283 }
284
1/2
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 344 times.
344 if (found.truncated())
285 {
286 // A skipped faulted region or a bounded-jump budget truncation makes the occurrence count a
287 // lower bound. Unscanned bytes can hide an earlier match or a duplicate, so a hit from an
288 // incomplete sweep can name a wrong address.
289 ✗ return std::nullopt;
290 }
291
2/2
✓ Branch 7 → 8 taken 3 times.
✓ Branch 7 → 9 taken 341 times.
344 if (found.count > 1)
292 {
293 // The lowest-address match of an ambiguous sweep is not provably the intended target.
294 3 return std::nullopt;
295 }
296 341 const std::optional<std::uintptr_t> resolved = resolve_byte_candidate(
297 341 reinterpret_cast<std::uintptr_t>(found.match),
298 candidate,
299 found.instruction.span()
300 );
301
8/8
✓ Branch 12 → 13 taken 335 times.
✓ Branch 12 → 20 taken 6 times.
✓ Branch 15 → 16 taken 333 times.
✓ Branch 15 → 20 taken 2 times.
✓ Branch 19 → 20 taken 3 times.
✓ Branch 19 → 21 taken 330 times.
✓ Branch 22 → 23 taken 11 times.
✓ Branch 22 → 24 taken 330 times.
341 if (!resolved || !range.contains(*resolved) || !accepts_resolved_address(request, Address{*resolved}))
302 {
303 // A RipRelative displacement can resolve outside the scanned scope, for example to an import
304 // thunk in another module.
305 11 return std::nullopt;
306 }
307 // The evidence comes from the sweep that produced this match. The address can be a RIP-relative
308 // target elsewhere, but the witnessed bytes are always the matched span of the pattern.
309
2/4
✓ Branch 27 → 28 taken 330 times.
✗ Branch 27 → 44 not taken.
✗ Branch 29 → 30 not taken.
✓ Branch 29 → 31 taken 330 times.
330 Hit hit{Address{*resolved}, candidate.name(), candidate.mode(), found.evidence};
310
2/2
✓ Branch 31 → 32 taken 217 times.
✓ Branch 31 → 39 taken 113 times.
330 if (provenance != nullptr)
311 {
312 // The authored match span alone understates the evidence of a RIP winner. The target came from
313 // the whole instruction that the snapshot decode consumed, and a signature can omit the trailing
314 // immediates that it authorizes. The published span therefore extends to the decoded end.
315 // Otherwise a second selector that matches those immediates abuts this span rather than overlaps
316 // it, and one instruction votes twice. The offset-applied match point sits inside the match span,
317 // so the union stays one contiguous extent.
318 217 Region source = found.physical_span;
319
2/2
✓ Branch 32 → 33 taken 10 times.
✓ Branch 32 → 38 taken 207 times.
217 if (found.instruction.length != 0)
320 {
321 10 const std::uintptr_t decoded_end =
322 10 reinterpret_cast<std::uintptr_t>(found.match) + found.instruction.length;
323
2/2
✓ Branch 35 → 36 taken 1 time.
✓ Branch 35 → 38 taken 9 times.
10 if (decoded_end > source.end().raw())
324 {
325 1 source.size = static_cast<std::size_t>(decoded_end - source.base.raw());
326 }
327 }
328 217 provenance->physical_source = source;
329 217 provenance->winning_index = winning_index;
330 217 provenance->match_span = found.physical_span;
331 }
332 330 log_resolved(request, hit, false);
333 330 return hit;
334 330 }
335
336 /**
337 * @brief Tries each candidate in @p order and returns the first hit, or std::nullopt when every candidate
338 * misses.
339 * @details The first byte-sweep coverage failure latches into @p coverage_error, and the first text-tier
340 * failure latches into @p text_error. resolve_impl() states what each latch proves.
341 */
342 435 [[nodiscard]] std::optional<Hit> resolve_ladder_rungs(
343 const ScanRequest &request,
344 const detail::ModuleSpan &range,
345 const detail::ScanExclusions &ladder_exclusions,
346 const std::vector<std::size_t> &order,
347 std::size_t ordered_count,
348 std::optional<ErrorCode> &coverage_error,
349 std::optional<ErrorCode> &text_error,
350 detail::ResolvedScanHit *provenance
351 )
352 {
353 // The first byte candidate samples the haystack histogram. Every later byte candidate reuses it,
354 // because all of them scan the same scope.
355 435 std::optional<detail::HaystackHistogram> histogram;
356 2 const auto remember_coverage_error = [&coverage_error](ErrorCode code) noexcept -> void
357 {
358
1/2
✓ Branch 3 → 4 taken 2 times.
✗ Branch 3 → 6 not taken.
2 if (!coverage_error)
359 {
360 2 coverage_error = code;
361 }
362 437 };
363 1 const auto remember_text_error = [&text_error](ErrorCode code) noexcept -> void
364 {
365
1/2
✓ Branch 3 → 4 taken 1 time.
✗ Branch 3 → 6 not taken.
1 if (!text_error)
366 {
367 1 text_error = code;
368 }
369 436 };
370
371
2/2
✓ Branch 113 → 3 taken 443 times.
✓ Branch 113 → 114 taken 60 times.
503 for (std::size_t k = 0; k < ordered_count; ++k)
372 {
373 443 const Candidate &candidate = request.ladder[order[k]];
374
375
2/2
✓ Branch 6 → 7 taken 5 times.
✓ Branch 6 → 36 taken 438 times.
443 if (const RttiVtable *rtti = candidate.as_rtti_vtable())
376 {
377 // Fully qualify the namespace: the local `rtti` pointer would otherwise shadow the `rtti`
378 // module namespace and make `rtti::detail` name the variable instead.
379 const DetourModKit::rtti::detail::PrimaryVtable primary =
380 5 DetourModKit::rtti::detail::primary_vtable_checked(rtti->mangled, request.scope);
381 5 const std::optional<Address> &vtable = primary.vtable;
382
6/8
✓ Branch 10 → 11 taken 1 time.
✓ Branch 10 → 19 taken 4 times.
✓ Branch 14 → 15 taken 1 time.
✗ Branch 14 → 19 not taken.
✓ Branch 17 → 18 taken 1 time.
✗ Branch 17 → 19 not taken.
✓ Branch 20 → 21 taken 1 time.
✓ Branch 20 → 33 taken 4 times.
5 if (vtable && range.contains(vtable->raw()) && accepts_resolved_address(request, *vtable))
383 {
384
2/4
✓ Branch 23 → 24 taken 1 time.
✗ Branch 23 → 117 not taken.
✗ Branch 24 → 25 not taken.
✓ Branch 24 → 26 taken 1 time.
1 Hit hit{*vtable, candidate.name(), Mode::RttiVtable};
385
1/2
✗ Branch 26 → 27 not taken.
✓ Branch 26 → 29 taken 1 time.
1 if (provenance != nullptr)
386 {
387 ✗ provenance->winning_index = order[k];
388 }
389 1 log_resolved(request, hit, false);
390 1 return hit;
391 1 }
392
2/2
✓ Branch 33 → 34 taken 1 time.
✓ Branch 33 → 35 taken 3 times.
4 if (primary.completeness != DetourModKit::rtti::Traversal::Complete)
393 {
394 1 remember_text_error(ErrorCode::IncompleteScan);
395 }
396 4 continue;
397 4 }
398
2/2
✓ Branch 37 → 38 taken 46 times.
✓ Branch 37 → 77 taken 392 times.
438 if (const StringXref *xref = candidate.as_string_xref())
399 {
400 46 Region reference_span{};
401 const Result<Address> site =
402
2/2
✓ Branch 38 → 39 taken 18 times.
✓ Branch 38 → 120 taken 28 times.
46 find_string_xref_site(*xref, request, ladder_exclusions, reference_span);
403
6/8
✓ Branch 40 → 41 taken 16 times.
✓ Branch 40 → 49 taken 2 times.
✓ Branch 44 → 45 taken 16 times.
✗ Branch 44 → 49 not taken.
✓ Branch 47 → 48 taken 16 times.
✗ Branch 47 → 49 not taken.
✓ Branch 50 → 51 taken 16 times.
✓ Branch 50 → 63 taken 2 times.
18 if (site && range.contains(site->raw()) && accepts_resolved_address(request, *site))
404 {
405
2/4
✓ Branch 53 → 54 taken 16 times.
✗ Branch 53 → 119 not taken.
✗ Branch 54 → 55 not taken.
✓ Branch 54 → 56 taken 16 times.
16 Hit hit{*site, candidate.name(), Mode::StringXref};
406
2/2
✓ Branch 56 → 57 taken 2 times.
✓ Branch 56 → 59 taken 14 times.
16 if (provenance != nullptr)
407 {
408 2 provenance->physical_source = reference_span;
409 2 provenance->winning_index = order[k];
410 }
411 16 log_resolved(request, hit, false);
412 16 return hit;
413 15 }
414
3/6
✓ Branch 64 → 65 taken 2 times.
✗ Branch 64 → 72 not taken.
✓ Branch 66 → 67 taken 2 times.
✗ Branch 66 → 71 not taken.
✗ Branch 73 → 74 not taken.
✓ Branch 73 → 76 taken 2 times.
4 if (!site && (site.error().code == ErrorCode::IncompleteScan ||
415
1/2
✓ Branch 68 → 69 taken 2 times.
✗ Branch 68 → 71 not taken.
2 site.error().code == ErrorCode::NotAuthoritative ||
416
1/2
✗ Branch 70 → 71 not taken.
✓ Branch 70 → 72 taken 2 times.
2 site.error().code == ErrorCode::MalformedQueryText))
417 {
418 ✗ remember_text_error(site.error().code);
419 }
420 2 continue;
421 2 }
422
423 // Byte tiers (Direct / RipRelative).
424 392 const Pattern *pattern = detail::byte_pattern_of(candidate);
425
1/2
✗ Branch 78 → 79 not taken.
✓ Branch 78 → 80 taken 392 times.
392 if (pattern == nullptr)
426 {
427 // Unreachable through the factories (every alternative is handled above); skip defensively.
428 ✗ continue;
429 }
430
2/2
✓ Branch 81 → 82 taken 386 times.
✓ Branch 81 → 85 taken 6 times.
392 if (!histogram)
431 {
432 386 histogram = detail::sample_haystack(request.scope);
433 }
434
1/2
✓ Branch 86 → 87 taken 392 times.
✗ Branch 86 → 124 not taken.
392 const detail::EnginePattern compiled = detail::to_engine_pattern(*pattern, *histogram);
435 const detail::MatchResult found =
436 392 sweep_byte_candidate(request, range, candidate, compiled, ladder_exclusions);
437 #if defined(DMK_ENABLE_TEST_SEAMS)
438
2/2
✓ Branch 88 → 89 taken 12 times.
✓ Branch 88 → 90 taken 380 times.
392 if (auto *const hook = detail::g_scan_after_byte_sweep_test_hook)
439 {
440 12 hook();
441 }
442 #endif
443
2/2
✓ Branch 90 → 91 taken 1 time.
✓ Branch 90 → 92 taken 391 times.
392 if (found.budget_exhausted)
444 {
445 1 remember_coverage_error(ErrorCode::BudgetExceeded);
446 }
447
2/2
✓ Branch 92 → 93 taken 1 time.
✓ Branch 92 → 94 taken 390 times.
391 else if (found.incomplete)
448 {
449 1 remember_coverage_error(ErrorCode::IncompleteScan);
450 }
451
2/2
✓ Branch 97 → 98 taken 329 times.
✓ Branch 97 → 100 taken 62 times.
391 if (std::optional<Hit> hit =
452
1/2
✓ Branch 95 → 96 taken 391 times.
✗ Branch 95 → 121 not taken.
392 accept_byte_match(request, range, candidate, found, order[k], provenance))
453 {
454 329 return hit;
455
2/2
✓ Branch 102 → 103 taken 62 times.
✓ Branch 102 → 105 taken 330 times.
392 }
456
2/2
✓ Branch 107 → 108 taken 62 times.
✓ Branch 107 → 110 taken 330 times.
392 }
457 60 return std::nullopt;
458 }
459
460 /**
461 * @brief Runs hooked-prologue recovery after a full direct miss.
462 * @return The recovered hit, else the latched @p text_error, else the first recovery diagnostic, or
463 * std::nullopt when none applies.
464 */
465 25 [[nodiscard]] std::optional<Result<Hit>> try_prologue_fallback(
466 const ScanRequest &request,
467 const std::vector<std::size_t> &order,
468 std::size_t ordered_count,
469 const detail::ModuleSpan &range,
470 const std::optional<ErrorCode> &text_error,
471 detail::ResolvedScanHit *provenance
472 )
473 {
474 const detail::FallbackOutcome fallback = detail::resolve_prologue_fallback(
475 request,
476 25 std::span<const std::size_t>{order.data(), ordered_count},
477 range
478
1/2
✓ Branch 4 → 5 taken 25 times.
✗ Branch 4 → 61 not taken.
25 );
479
5/6
✓ Branch 6 → 7 taken 13 times.
✓ Branch 6 → 11 taken 12 times.
✓ Branch 9 → 10 taken 13 times.
✗ Branch 9 → 11 not taken.
✓ Branch 12 → 13 taken 13 times.
✓ Branch 12 → 23 taken 12 times.
25 if (fallback.hit && accepts_resolved_address(request, fallback.hit->address))
480 {
481
1/2
✗ Branch 13 → 14 not taken.
✓ Branch 13 → 15 taken 13 times.
13 if (provenance != nullptr)
482 {
483 ✗ provenance->physical_source = fallback.physical_source;
484 }
485
2/2
✓ Branch 15 → 16 taken 1 time.
✓ Branch 15 → 18 taken 12 times.
13 if (fallback.identity_warned)
486 {
487 1 log_identity_warning(request, *fallback.hit);
488 }
489 13 log_resolved(request, *fallback.hit, true);
490
1/2
✓ Branch 21 → 22 taken 13 times.
✗ Branch 21 → 62 not taken.
13 return *fallback.hit;
491 }
492
1/2
✗ Branch 24 → 25 not taken.
✓ Branch 24 → 32 taken 12 times.
12 if (text_error)
493 {
494 // The text-tier code precedes the prologue diagnostics. An unencodable literal or an unconfined
495 // text scope is a defect in the request. An identity rejection or a missing rebuildable Direct
496 // candidate is a property of the recovery attempt. The caller must act on the request-level code.
497 ✗ log_unresolved(request, DetourModKit::to_string(*text_error));
498 ✗ return std::unexpected(Error{*text_error, "scan::resolve"});
499 }
500
2/2
✓ Branch 32 → 33 taken 4 times.
✓ Branch 32 → 38 taken 8 times.
12 if (fallback.identity_rejected)
501 {
502 // RequireIdentity refused every structurally recovered site. The rebuilt prologue matched
503 // uniquely, but no recovered address passed the witness. Unlike a plain miss, this code tells the
504 // caller that a hooked near-twin exists. The signature then needs a sharper witness or a
505 // corroborating landmark.
506 4 log_unresolved(request, "prologue recovery rejected by identity gate");
507 4 return std::unexpected(Error{ErrorCode::PrologueIdentityRejected, "scan::resolve"});
508 }
509
2/2
✓ Branch 38 → 39 taken 1 time.
✓ Branch 38 → 44 taken 7 times.
8 if (fallback.ambiguous)
510 {
511 // A rebuilt hook shape matched more than one executable site, so recovery cannot name a single
512 // redirect. This code follows the identity gate, which judges a uniquely found site. It precedes
513 // the incomplete and applicability diagnostics, because a proven multiplicity is more specific
514 // than a truncated sweep or a too-short tail. Unlike a plain miss, this code tells the caller that
515 // the surviving tail of the signature is not unique.
516 1 log_unresolved(request, DetourModKit::to_string(ErrorCode::PrologueFallbackAmbiguous));
517 1 return std::unexpected(Error{ErrorCode::PrologueFallbackAmbiguous, "scan::resolve"});
518 }
519
1/2
✗ Branch 44 → 45 not taken.
✓ Branch 44 → 50 taken 7 times.
7 if (fallback.incomplete)
520 {
521 // The recovery sweep over the executable pages went short, so "no rebuildable shape matched" is
522 // not a proven absence. This code follows the identity gate, which requires a recovered
523 // site. It precedes the applicability diagnostics, which read as a proven miss.
524 ✗ log_unresolved(request, DetourModKit::to_string(ErrorCode::IncompleteScan));
525 ✗ return std::unexpected(Error{ErrorCode::IncompleteScan, "scan::resolve"});
526 }
527
4/4
✓ Branch 50 → 51 taken 6 times.
✓ Branch 50 → 57 taken 1 time.
✓ Branch 51 → 52 taken 5 times.
✓ Branch 51 → 57 taken 1 time.
7 if (fallback.had_direct && fallback.not_applicable)
528 {
529 // At least one Direct candidate existed, and no shape rebuilt a usable pattern from any of them.
530 // This code differs from a plain miss, where the ladder carries no Direct candidate to rebuild.
531 5 log_unresolved(request, "prologue recovery had no rebuildable Direct candidate");
532 5 return std::unexpected(Error{ErrorCode::PrologueFallbackNotApplicable, "scan::resolve"});
533 }
534 2 return std::nullopt;
535 25 }
536
537 459 Result<Hit> resolve_impl(const ScanRequest &request, detail::ResolvedScanHit *provenance)
538 {
539
2/2
✓ Branch 2 → 3 taken 235 times.
✓ Branch 2 → 6 taken 224 times.
459 if (provenance != nullptr)
540 {
541 235 provenance->physical_source = Region{};
542 235 provenance->winning_index = static_cast<std::size_t>(-1);
543 235 provenance->match_span = Region{};
544 }
545
4/4
✓ Branch 6 → 7 taken 185 times.
✓ Branch 6 → 11 taken 274 times.
✓ Branch 7 → 8 taken 1 time.
✓ Branch 7 → 11 taken 184 times.
459 if (request.pages != Pages::Readable && request.pages != Pages::Executable)
546 {
547 1 return std::unexpected(Error{ErrorCode::InvalidArg, "scan::resolve"});
548 }
549
6/6
✓ Branch 12 → 13 taken 454 times.
✓ Branch 12 → 15 taken 4 times.
✓ Branch 14 → 15 taken 1 time.
✓ Branch 14 → 16 taken 452 times.
✓ Branch 17 → 18 taken 5 times.
✓ Branch 17 → 21 taken 452 times.
458 if (!valid_candidate_order(request.order) || !valid_fallback_policy(request.fallback_policy))
550 {
551 5 return std::unexpected(Error{ErrorCode::InvalidArg, "scan::resolve"});
552 }
553
2/2
✓ Branch 39 → 23 taken 461 times.
✓ Branch 39 → 40 taken 451 times.
1364 for (const Candidate &candidate : request.ladder)
554 {
555
2/2
✓ Branch 26 → 27 taken 2 times.
✓ Branch 26 → 30 taken 460 times.
461 if (!valid_candidate_enums(candidate))
556 {
557 2 return std::unexpected(Error{ErrorCode::InvalidArg, "scan::resolve"});
558 }
559 }
560
2/2
✓ Branch 41 → 42 taken 3 times.
✓ Branch 41 → 45 taken 447 times.
451 if (request.ladder.empty())
561 {
562 3 return std::unexpected(Error{ErrorCode::EmptyCandidates, "scan::resolve"});
563 }
564 447 const detail::ModuleSpan range = detail::module_span(request.scope);
565
2/2
✓ Branch 47 → 48 taken 3 times.
✓ Branch 47 → 51 taken 444 times.
447 if (!range.valid())
566 {
567 3 return std::unexpected(Error{ErrorCode::InvalidRange, "scan::resolve"});
568 }
569
2/2
✓ Branch 52 → 53 taken 3 times.
✓ Branch 52 → 56 taken 442 times.
444 if (!detail::readable_scan_is_authoritative(range, request.pages, request.exclusions))
570 {
571 // The Readable authority rule of scan::Pages refuses the request before any tier is graded.
572 3 return std::unexpected(Error{ErrorCode::NotAuthoritative, "scan::resolve"});
573 }
574
575 442 detail::ScanExclusions ladder_exclusions;
576 442 collect_ladder_exclusions(request, range, ladder_exclusions);
577
2/2
✓ Branch 58 → 59 taken 1 time.
✓ Branch 58 → 62 taken 441 times.
442 if (ladder_exclusions.overflowed())
578 {
579 1 return std::unexpected(Error{ErrorCode::NotAuthoritative, "scan::resolve"});
580 }
581
582
2/2
✓ Branch 65 → 66 taken 435 times.
✓ Branch 65 → 118 taken 6 times.
447 std::vector<std::size_t> order(request.ladder.size());
583 435 const std::size_t ordered_count = order_candidates(request.order, request.ladder, order);
584 // The two latches split a candidate failure by what it proves, not by its code. A byte candidate whose
585 // sweep went short leaves the module-executable pages that hooked-prologue recovery searches only
586 // partly read. A full direct miss is then unproven, so recovery must not run. A text candidate fails on
587 // an unencodable literal, or on an unconfined or truncated readable phase-1 sweep. That failure says
588 // nothing about executable-page coverage, and recovery acts only on Direct candidates. The text-tier
589 // code therefore replaces the generic miss and does not suppress recovery.
590 435 std::optional<ErrorCode> coverage_error;
591 435 std::optional<ErrorCode> text_error;
592 std::optional<Hit> hit = resolve_ladder_rungs(
593 request,
594 range,
595 ladder_exclusions,
596 order,
597 ordered_count,
598 coverage_error,
599 text_error,
600 provenance
601
2/2
✓ Branch 69 → 70 taken 407 times.
✓ Branch 69 → 124 taken 28 times.
435 );
602
2/2
✓ Branch 71 → 72 taken 347 times.
✓ Branch 71 → 76 taken 60 times.
407 if (hit)
603 {
604 693 return std::move(*hit);
605 }
606
607
2/2
✓ Branch 77 → 78 taken 2 times.
✓ Branch 77 → 85 taken 58 times.
60 if (coverage_error)
608 {
609 // A byte rung was budget-bound or truncated, so the executable pages recovery would search were not
610 // fully covered and a rebuilt-prologue hit could not be read as "the direct scan missed". Fail
611 // closed before the fallback, and ahead of every other verdict so a coverage code can never become
612 // NoMatch.
613 2 log_unresolved(request, DetourModKit::to_string(*coverage_error));
614 2 return std::unexpected(Error{*coverage_error, "scan::resolve"});
615 }
616
617
2/2
✓ Branch 85 → 86 taken 25 times.
✓ Branch 85 → 100 taken 33 times.
58 if (request.fallback_policy != FallbackPolicy::Off)
618 {
619 std::optional<Result<Hit>> verdict =
620
1/2
✓ Branch 86 → 87 taken 25 times.
✗ Branch 86 → 121 not taken.
25 try_prologue_fallback(request, order, ordered_count, range, text_error, provenance);
621
2/2
✓ Branch 88 → 89 taken 23 times.
✓ Branch 88 → 94 taken 2 times.
25 if (verdict)
622 {
623 46 return std::move(*verdict);
624 }
625
2/2
✓ Branch 96 → 97 taken 2 times.
✓ Branch 96 → 99 taken 23 times.
25 }
626
627
2/2
✓ Branch 101 → 102 taken 1 time.
✓ Branch 101 → 109 taken 34 times.
35 if (text_error)
628 {
629 // Reached when the fallback is Off or produced no verdict of its own. The typed text-tier code is
630 // more actionable than a generic miss, and a truncated text sweep must never read as a proven
631 // absence.
632 1 log_unresolved(request, DetourModKit::to_string(*text_error));
633 1 return std::unexpected(Error{*text_error, "scan::resolve"});
634 }
635
636 34 log_unresolved(request, "no ladder candidate resolved uniquely in scope");
637 34 return std::unexpected(Error{ErrorCode::NoMatch, "scan::resolve"});
638 434 }
639 } // namespace
640
641 224 Result<Hit> resolve(const ScanRequest &request)
642 {
643 224 return resolve_impl(request, nullptr);
644 }
645
646 Result<std::vector<Result<Hit>>>
647 30 resolve_batch(std::span<const ScanRequest> requests, std::size_t max_workers) noexcept
648 {
649 try
650 {
651
2/2
✓ Branch 2 → 3 taken 28 times.
✓ Branch 2 → 7 taken 2 times.
58 return detail::run_fork_join<ScanRequest, Result<Hit>>(
652 requests,
653 max_workers,
654 100 [](const ScanRequest &request) -> Result<Hit>
655 {
656 try
657 {
658
2/2
✓ Branch 2 → 3 taken 79 times.
✓ Branch 2 → 5 taken 19 times.
100 return resolve(request);
659 }
660
1/2
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 19 times.
19 catch (const std::bad_alloc &)
661 {
662 19 return std::unexpected(Error{ErrorCode::OutOfMemory, "scan::resolve_batch"});
663 19 }
664 },
665 100 [](const ScanRequest &) noexcept -> Result<Hit>
666 100 { return std::unexpected(Error{ErrorCode::NoMatch, "scan::resolve_batch"}); }
667 28 );
668 }
669
1/2
✓ Branch 8 → 9 taken 2 times.
✗ Branch 8 → 13 not taken.
2 catch (const std::bad_alloc &)
670 {
671 // The resolve_batch docblock in scan.hpp owns this outer-Result arm. Error holds a const char*, so
672 // this path allocates nothing.
673 2 return std::unexpected(Error{ErrorCode::OutOfMemory, "scan::resolve_batch"});
674 2 }
675 ✗ catch (...)
676 {
677 ✗ return std::unexpected(Error{ErrorCode::Unknown, "scan::resolve_batch"});
678 ✗ }
679 }
680 } // namespace scan
681
682 235 Result<detail::ResolvedScanHit> detail::resolve_scan_with_provenance(const scan::ScanRequest &request)
683 {
684 235 ResolvedScanHit resolved;
685
1/2
✓ Branch 3 → 4 taken 235 times.
✗ Branch 3 → 19 not taken.
235 Result<scan::Hit> hit = scan::resolve_impl(request, &resolved);
686
2/2
✓ Branch 5 → 6 taken 16 times.
✓ Branch 5 → 10 taken 219 times.
235 if (!hit)
687 {
688 16 return std::unexpected(hit.error());
689 }
690 438 resolved.hit = std::move(*hit);
691 219 return resolved;
692 235 }
693 } // namespace DetourModKit
694