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 |