src/internal/memory_guarded.hpp
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | #ifndef DETOURMODKIT_INTERNAL_MEMORY_GUARDED_HPP | ||
| 2 | #define DETOURMODKIT_INTERNAL_MEMORY_GUARDED_HPP | ||
| 3 | |||
| 4 | /** | ||
| 5 | * @file memory_guarded.hpp | ||
| 6 | * @brief Private engine interface for the guarded byte primitives that back the public memory::read / write / walk. | ||
| 7 | * | ||
| 8 | * MSVC uses frame-based __try / __except filters in memory_guarded.cpp. Scanner TUs route their __try filters through | ||
| 9 | * detail::guarded_range_fault_filter. MinGW uses its process-wide vectored handler in memory_guarded.cpp. An armed | ||
| 10 | * foreign-range fault returns a clean failure through __builtin_longjmp, unless the guard-page re-arm in [B-20] fails. | ||
| 11 | * The page-protection ledger and patch path live in memory_protect_ledger.cpp. Public memory TUs call only this | ||
| 12 | * private seam. This keeps installed headers free of Win32 and structured-exception constructs. The chain resolver | ||
| 13 | * accepts public memory::ChainStep values by pointer. This passes each offset and plausibility floor without a | ||
| 14 | * parallel-array copy. | ||
| 15 | */ | ||
| 16 | |||
| 17 | #include "DetourModKit/address.hpp" | ||
| 18 | #include "DetourModKit/memory.hpp" | ||
| 19 | #include "DetourModKit/region.hpp" | ||
| 20 | |||
| 21 | #include <array> | ||
| 22 | #include <bit> | ||
| 23 | #include <cstddef> | ||
| 24 | #include <cstdint> | ||
| 25 | #include <optional> | ||
| 26 | #include <type_traits> | ||
| 27 | |||
| 28 | namespace DetourModKit | ||
| 29 | { | ||
| 30 | namespace detail | ||
| 31 | { | ||
| 32 | /** | ||
| 33 | * @struct ModuleSpan | ||
| 34 | * @brief The engine's raw half-open module range [base, end), in plain integers for hot arithmetic. | ||
| 35 | * @details The scan engine, the RTTI walk, and the hooked-prologue recovery all work in raw addresses inside | ||
| 36 | * their inner loops, so they carry a module's extent as two uintptr_t rather than the public Region. | ||
| 37 | * A Region (the public scope vocabulary) converts to a ModuleSpan at each public boundary via | ||
| 38 | * module_span(); an empty Region yields an invalid span that contains() rejects. | ||
| 39 | */ | ||
| 40 | struct ModuleSpan | ||
| 41 | { | ||
| 42 | std::uintptr_t base = 0; | ||
| 43 | std::uintptr_t end = 0; | ||
| 44 | |||
| 45 | /// True iff this span is populated (base != 0 && end > base). | ||
| 46 | 16720083 | [[nodiscard]] constexpr bool valid() const noexcept { return base != 0 && end > base; } | |
| 47 | |||
| 48 | /// True iff the span is valid and @p address lies in [base, end). | ||
| 49 | 13639521 | [[nodiscard]] constexpr bool contains(std::uintptr_t address) const noexcept | |
| 50 | { | ||
| 51 | 13639521 | return valid() && address >= base && address < end; | |
| 52 | } | ||
| 53 | }; | ||
| 54 | |||
| 55 | /// Converts a public Region scope into the engine's raw ModuleSpan; an empty Region yields an invalid span. | ||
| 56 | 2845 | [[nodiscard]] inline ModuleSpan module_span(Region scope) noexcept | |
| 57 | { | ||
| 58 | 2845 | return ModuleSpan{scope.base.raw(), scope.end().raw()}; | |
| 59 | } | ||
| 60 | |||
| 61 | /// Raw-integer form of memory::is_plausible_ptr for engine code that works in uintptr_t inside its hot loops. | ||
| 62 | 1740 | [[nodiscard]] inline constexpr bool is_plausible_ptr(std::uintptr_t address) noexcept | |
| 63 | { | ||
| 64 | 1740 | return memory::is_plausible_ptr(Address{address}); | |
| 65 | } | ||
| 66 | |||
| 67 | /** | ||
| 68 | * @brief Resolves a loaded module's base address to the Region spanning its full mapped image. | ||
| 69 | * @param module_base The module's base address (its HMODULE value); null yields an empty Region. | ||
| 70 | * @return The module image span, or an empty Region when @p module_base is null or its PE headers do not | ||
| 71 | * validate. | ||
| 72 | * @details The single canonical "module base -> Region" resolver, shared by region.cpp's Region factories | ||
| 73 | * (host/module_named/own) and memory::module_of so the PE-header walk (DOS magic, a bounded | ||
| 74 | * e_lfanew, the NT signature, and OptionalHeader.SizeOfImage) lives in one place rather than a | ||
| 75 | * raw-deref copy in each. The headers are read through the guarded engine, so a partially-mapped or | ||
| 76 | * corrupt image fails closed to an empty Region instead of faulting the host. Each call re-reads the | ||
| 77 | * live headers: an HMODULE is its image base and the loader may hand the same base to a replacement | ||
| 78 | * image, so a memoized span would be a claim about an identity that can change underneath it. | ||
| 79 | */ | ||
| 80 | [[nodiscard]] Region module_image_region(Address module_base) noexcept; | ||
| 81 | |||
| 82 | /** | ||
| 83 | * @brief Resolves the current loader owner of @p address and reads its image span. | ||
| 84 | * @return The live module span, or an empty Region when the loader lookup or PE-header read fails. | ||
| 85 | * @note Setup/control-plane only: performs a loader query and guarded PE-header reads. The returned Region is | ||
| 86 | * non-owning: it does not pin the module against an unload after the call returns. | ||
| 87 | */ | ||
| 88 | [[nodiscard]] Region live_module_region(Address address) noexcept; | ||
| 89 | |||
| 90 | /** | ||
| 91 | * @brief Guarded copy of @p bytes bytes from @p address into @p out. | ||
| 92 | * @param address Source address. Below memory::USERSPACE_PTR_MIN, or an end that wraps the address space, is | ||
| 93 | * rejected without a read. | ||
| 94 | * @param out Destination buffer; null is rejected. | ||
| 95 | * @param bytes Byte count; zero is a successful no-op. | ||
| 96 | * @param fault_address_out When non-null and the read faults, receives the faulting address, letting a caller | ||
| 97 | * report which byte of the span was unreadable rather than only that some byte was. | ||
| 98 | * Left untouched when the span is rejected without a read, and on the MinGW fallback | ||
| 99 | * path that validates through VirtualQuery instead of faulting. | ||
| 100 | * @return true on full success. Otherwise false on a rejected argument or a fault, unless the guard-page re-arm | ||
| 101 | * in [B-20] fails. On failure the contents of @p out are unspecified. | ||
| 102 | */ | ||
| 103 | [[nodiscard]] bool guarded_read_bytes( | ||
| 104 | std::uintptr_t address, | ||
| 105 | void *out, | ||
| 106 | std::size_t bytes, | ||
| 107 | volatile std::uintptr_t *fault_address_out = nullptr | ||
| 108 | ) noexcept; | ||
| 109 | |||
| 110 | /** | ||
| 111 | * @brief Guarded typed read for engine code: a representation-safe @p T at @p address, or nullopt on fault. | ||
| 112 | * @tparam T A trivially copyable type for which every bit pattern is a valid object representation | ||
| 113 | * (@ref is_representation_safe_v). Read through untyped storage + bit_cast, so it need not be default | ||
| 114 | * constructible, but a representation-sensitive type such as bool is excluded: forming it from an | ||
| 115 | * arbitrary foreign byte would be undefined behaviour before the optional could report failure. | ||
| 116 | * Decode such a type from raw bytes instead (memory::read_bool for bool). | ||
| 117 | * @details The engine-side counterpart of public memory::read<T>, returning std::optional instead of Result so | ||
| 118 | * the scan / RTTI inner loops keep the lightweight optional checks they already used. A top-level | ||
| 119 | * bounded built-in array is returned as the equivalent nested `std::array`. Forwards to | ||
| 120 | * guarded_read_bytes, so the __try frame stays in the engine TU. | ||
| 121 | */ | ||
| 122 | template <class T> | ||
| 123 | requires(std::is_trivially_copyable_v<T> && is_representation_safe_v<T>) | ||
| 124 | 3081590 | [[nodiscard]] std::optional<representation_read_value_t<T>> guarded_read(std::uintptr_t address) noexcept | |
| 125 | { | ||
| 126 | 3081590 | std::array<std::byte, sizeof(T)> storage{}; | |
| 127 | 3081590 | if (!guarded_read_bytes(address, storage.data(), sizeof(T))) | |
| 128 | { | ||
| 129 | 22 | return std::nullopt; | |
| 130 | } | ||
| 131 | 3081568 | return decode_foreign_representation<T>(storage); | |
| 132 | } | ||
| 133 | |||
| 134 | /** @brief Outcome of a guarded byte write that never changes protection. */ | ||
| 135 | enum class GuardedWriteStatus | ||
| 136 | { | ||
| 137 | Ok, | ||
| 138 | NotWritten, | ||
| 139 | MayBePartial | ||
| 140 | }; | ||
| 141 | |||
| 142 | /** | ||
| 143 | * @brief Guarded copy of @p bytes bytes from @p source into @p address, changing no page protection. | ||
| 144 | * @param address Destination address. Below memory::USERSPACE_PTR_MIN, or a wrapping end, is rejected. | ||
| 145 | * @param source Source buffer; null is rejected. | ||
| 146 | * @param bytes Byte count; zero is a successful no-op. | ||
| 147 | * @return Whether all bytes landed, the first byte faulted (`NotWritten`), or the fault address lay past the | ||
| 148 | * first byte (`MayBePartial`). | ||
| 149 | * @details The primitive exposes whether a fault occurred at byte zero or past it. | ||
| 150 | * This keeps `NotWritten` truthful without a racy permission query. `MayBePartial` bounds the damage | ||
| 151 | * to the requested span, but it does not prove that any byte changed. A fixed-width store faults as | ||
| 152 | * one instruction. This is memory::write_bytes' no-protect path. | ||
| 153 | */ | ||
| 154 | [[nodiscard]] GuardedWriteStatus | ||
| 155 | guarded_write_bytes(std::uintptr_t address, const void *source, std::size_t bytes) noexcept; | ||
| 156 | |||
| 157 | /** | ||
| 158 | * @brief Atomically replaces one aligned pointer word when it still equals @p expected, under the fault guard. | ||
| 159 | * @param address Address of the pointer-sized word; rejected unless naturally aligned and inside user space. | ||
| 160 | * @param expected Value that must still be present. | ||
| 161 | * @param replacement Value stored when the comparison succeeds. | ||
| 162 | * @return True only when the comparison and replacement both complete; false on mismatch, fault, or rejection. | ||
| 163 | */ | ||
| 164 | [[nodiscard]] bool guarded_compare_exchange_word( | ||
| 165 | std::uintptr_t address, | ||
| 166 | std::uintptr_t expected, | ||
| 167 | std::uintptr_t replacement | ||
| 168 | ) noexcept; | ||
| 169 | |||
| 170 | /** | ||
| 171 | * @struct ProtectionSegment | ||
| 172 | * @brief One VirtualQuery region within a protection-changed span, plus the protection to restore it to. | ||
| 173 | * @details A write or a ProtectGuard may cover a span that crosses a `.rdata`/`.text` (or any) protection seam, | ||
| 174 | * so it is not one uniform-protection block. VirtualProtect over the whole span reports only the | ||
| 175 | * first page's prior protection, so restoring the whole span to that single value flattens an | ||
| 176 | * executable page adjacent to a read-only seam down to PAGE_READONLY (its next execution then AVs | ||
| 177 | * under DEP). The span is therefore changed and restored one VirtualQuery region at a time; each | ||
| 178 | * region's own prior protection is captured here so the restore is exact per region. | ||
| 179 | */ | ||
| 180 | struct ProtectionSegment | ||
| 181 | { | ||
| 182 | // First byte of the touched sub-span within this region. | ||
| 183 | std::uintptr_t base = 0; | ||
| 184 | // Length of the touched sub-span (VirtualProtect operates on whole pages). | ||
| 185 | std::size_t size = 0; | ||
| 186 | // Whether the segment was executable when this transaction began. | ||
| 187 | bool originally_executable = false; | ||
| 188 | // Identifies this transaction in the page ledger. | ||
| 189 | std::uint64_t transaction_id = 0; | ||
| 190 | }; | ||
| 191 | |||
| 192 | /** @brief Outcome of a multi-region protection change. */ | ||
| 193 | enum class ProtectionChangeStatus | ||
| 194 | { | ||
| 195 | Ok, | ||
| 196 | ChangeFailed, | ||
| 197 | RestoreFailed | ||
| 198 | }; | ||
| 199 | |||
| 200 | /** @brief Result of changing a span's protection. */ | ||
| 201 | struct ProtectionChangeOutcome | ||
| 202 | { | ||
| 203 | std::size_t segment_count = 0; | ||
| 204 | ProtectionChangeStatus status = ProtectionChangeStatus::ChangeFailed; | ||
| 205 | std::uint32_t os_error = 0; | ||
| 206 | }; | ||
| 207 | |||
| 208 | /** | ||
| 209 | * @brief Upper bound on distinct protection regions a single span may cross before @ref protect_across_regions | ||
| 210 | * fails closed. | ||
| 211 | * @details A real write or guard site spans one to a few regions; a span crossing this many alternating | ||
| 212 | * protection blocks is not a legitimate patch, so the cap is a defensive ceiling, not a functional | ||
| 213 | * limit. | ||
| 214 | */ | ||
| 215 | inline constexpr std::size_t MAX_PROTECTION_SEGMENTS = 64; | ||
| 216 | |||
| 217 | /** | ||
| 218 | * @brief Changes every VirtualQuery region overlapping the requested span and records it for restoration. | ||
| 219 | * @param address First byte of the validated span. | ||
| 220 | * @param bytes Validated non-zero span length. | ||
| 221 | * @param new_protection Fixed Win32 PAGE_* value. Ignored when writable protection is derived. | ||
| 222 | * @param out Caller-owned buffer receiving one @ref ProtectionSegment per region changed. | ||
| 223 | * @param out_cap Capacity of @p out in elements. | ||
| 224 | * @param derive_writable_preserving_execute When true, derive writable protection per region while | ||
| 225 | * preserving execute; otherwise use @p new_protection. | ||
| 226 | * @return The captured segment count and whether the change, or its rollback, failed. | ||
| 227 | * @details A process-wide ledger serializes protection transactions. Each page records its original | ||
| 228 | * protection and live holders in acquisition order. Removing an inner guard restores the newest | ||
| 229 | * surviving holder; removing the last restores the original. The span is walked by VirtualQuery | ||
| 230 | * region so protection seams restore exactly. Query, protection, capacity, and allocation failures | ||
| 231 | * fail closed. A rollback failure is reported separately because a temporary protection may remain. | ||
| 232 | * The walk holds the backend process coordinator, so a backend trap window cannot lend its transient | ||
| 233 | * protection to the capture (`[B-18]`). A refused coordinator fails closed with `ERROR_BUSY`. | ||
| 234 | */ | ||
| 235 | [[nodiscard]] ProtectionChangeOutcome protect_across_regions( | ||
| 236 | std::uintptr_t address, | ||
| 237 | std::size_t bytes, | ||
| 238 | std::uint32_t new_protection, | ||
| 239 | ProtectionSegment *out, | ||
| 240 | std::size_t out_cap, | ||
| 241 | bool derive_writable_preserving_execute = false | ||
| 242 | ) noexcept; | ||
| 243 | |||
| 244 | /** | ||
| 245 | * @brief Restores every segment captured by @ref protect_across_regions to its recorded prior protection. | ||
| 246 | * @param segments The segments to restore (as filled by @ref protect_across_regions). | ||
| 247 | * @param count Number of valid entries in @p segments. | ||
| 248 | * @param os_error Receives the OS error from the first failing VirtualProtect (captured before any later call | ||
| 249 | * or FlushInstructionCache can overwrite GetLastError). | ||
| 250 | * @return true if every segment restored; false if any VirtualProtect failed (best-effort: it still attempts | ||
| 251 | * the remaining segments so a single failure does not strand the rest in the changed protection). | ||
| 252 | * @details Runs under the backend process coordinator. A refused coordinator still restores. | ||
| 253 | */ | ||
| 254 | [[nodiscard]] bool | ||
| 255 | restore_across_regions(const ProtectionSegment *segments, std::size_t count, std::uint32_t &os_error) noexcept; | ||
| 256 | |||
| 257 | /** | ||
| 258 | * @brief Drops @p segments from the protection ledger WITHOUT restoring their protection. | ||
| 259 | * @param segments The segments to stop tracking (as filled by @ref protect_across_regions). | ||
| 260 | * @param count Number of valid entries in @p segments. | ||
| 261 | * @details The counterpart to @ref restore_across_regions for a guard that is intentionally abandoned | ||
| 262 | * (ProtectGuard::release keeps the changed protection permanently): the page stays at its changed | ||
| 263 | * protection, but its ledger depth must be released so the ledger does not carry a phantom transaction | ||
| 264 | * that would block a later overlapping guard from ever restoring, or make a reused page address | ||
| 265 | * resolve to a stale original. A page whose depth reaches zero is simply forgotten; the changed | ||
| 266 | * protection it is left at becomes the baseline a future guard captures. | ||
| 267 | */ | ||
| 268 | void abandon_protection_tracking(const ProtectionSegment *segments, std::size_t count) noexcept; | ||
| 269 | |||
| 270 | /** | ||
| 271 | * @enum PatchStatus | ||
| 272 | * @brief Outcome of patch_bytes (the protection-changing slow path of memory::write_bytes). | ||
| 273 | */ | ||
| 274 | enum class PatchStatus | ||
| 275 | { | ||
| 276 | /// The bytes were written, the instruction cache flushed for executable regions, and protection restored. | ||
| 277 | Ok, | ||
| 278 | /// The page could not be made writable; nothing was written. | ||
| 279 | ProtectionChangeFailed, | ||
| 280 | /// The first target byte faulted after protection changed, so nothing was written. | ||
| 281 | WriteFaulted, | ||
| 282 | /** | ||
| 283 | * @brief The guarded copy faulted past the first target byte. | ||
| 284 | * @details Required cache maintenance was attempted and protection was restored. The changed prefix has an | ||
| 285 | * unknown length and can be empty. See @ref ErrorCode::WriteMayBePartial for the caller contract. | ||
| 286 | */ | ||
| 287 | WriteMayBePartial, | ||
| 288 | /// Bytes written and protection restored, but an executable region's instruction-cache flush failed. | ||
| 289 | InstructionFlushFailed, | ||
| 290 | /// The bytes were written but the original protection could not be restored. | ||
| 291 | ProtectionRestoreFailed | ||
| 292 | }; | ||
| 293 | |||
| 294 | /** | ||
| 295 | * @brief Makes a span writable, performs a guarded copy and required cache flushes, then restores protection. | ||
| 296 | * @param address Validated destination address. | ||
| 297 | * @param source Source buffer. | ||
| 298 | * @param bytes Validated non-zero byte count. | ||
| 299 | * @param os_error Receives a failing VirtualProtect error for change or restoration failures. | ||
| 300 | * @param flush_all_regions Flush every touched region when true; otherwise flush executable regions. | ||
| 301 | * @return The outcome of the protect, copy, flush, and restore transaction. | ||
| 302 | * @details Writable protection is derived per region, preserving execute without granting it to data. | ||
| 303 | * With @p flush_all_regions false, read-only data writes issue no cache flush. The guarded copy | ||
| 304 | * contains a concurrent reprotect or unmap fault, after which flush and restoration are still tried. | ||
| 305 | * Restoration failure outranks partial copy, which outranks cache-flush failure. The caller owns | ||
| 306 | * protection-cache invalidation. | ||
| 307 | */ | ||
| 308 | [[nodiscard]] PatchStatus patch_bytes( | ||
| 309 | std::uintptr_t address, | ||
| 310 | const void *source, | ||
| 311 | std::size_t bytes, | ||
| 312 | std::uint32_t &os_error, | ||
| 313 | bool flush_all_regions = false | ||
| 314 | ) noexcept; | ||
| 315 | |||
| 316 | /** | ||
| 317 | * @brief Flushes the instruction cache over [@p address, @p address + @p bytes), returning whether it | ||
| 318 | * succeeded. | ||
| 319 | * @param address First byte to flush. | ||
| 320 | * @param bytes Byte count. | ||
| 321 | * @return true on success; false if FlushInstructionCache failed (or a test seam forced a failure). | ||
| 322 | * @details Code-patch paths call this after every possibly modifying write. Ordinary data writes call it only | ||
| 323 | * on a protection-changing slow path that touched an executable region; their already-writable fast | ||
| 324 | * path and read-only non-executable slow path remain flush-free. | ||
| 325 | */ | ||
| 326 | [[nodiscard]] bool flush_instruction_cache(std::uintptr_t address, std::size_t bytes) noexcept; | ||
| 327 | |||
| 328 | /** | ||
| 329 | * @brief Flushes [@p address, @p address + @p bytes) when any region the request covers is executable. | ||
| 330 | * @param address First byte of the possibly changed span. | ||
| 331 | * @param bytes Byte count of the validated request. | ||
| 332 | * @details The data-route counterpart of @ref flush_instruction_cache for a guarded attempt that may have | ||
| 333 | * changed a prefix. @ref patch_bytes flushes the executable regions it made writable, but a protection | ||
| 334 | * setup failure produces no segments, so a caller whose no-reprotect attempt already changed | ||
| 335 | * executable bytes must cover the request itself. The decision is taken over every region the request | ||
| 336 | * covers, because a forward copy that begins in data can reach code before it faults. Best-effort: the | ||
| 337 | * flush result is not reportable because a partial write outranks a flush-only failure, and a request | ||
| 338 | * with no executable region keeps the flush-free data contract. Portable copy order cannot reveal the | ||
| 339 | * exact prefix, so the whole validated request is flushed. | ||
| 340 | */ | ||
| 341 | void flush_if_executable(std::uintptr_t address, std::size_t bytes) noexcept; | ||
| 342 | |||
| 343 | /** | ||
| 344 | * @struct ChainWalkOutcome | ||
| 345 | * @brief Result of guarded_resolve_chain: the resolved leaf, or the hop index at which the walk failed. | ||
| 346 | */ | ||
| 347 | struct ChainWalkOutcome | ||
| 348 | { | ||
| 349 | /// The resolved leaf address; meaningful only when @ref ok is true. | ||
| 350 | Address address{}; | ||
| 351 | /// Index of the hop that faulted or yielded an implausible link; meaningful only when @ref ok is false. | ||
| 352 | std::size_t fail_index{0}; | ||
| 353 | /// True when the whole chain resolved. | ||
| 354 | bool ok{false}; | ||
| 355 | }; | ||
| 356 | |||
| 357 | /** | ||
| 358 | * @brief Resolves a Cheat-Engine-style pointer chain under a single fault guard, capturing intermediates. | ||
| 359 | * @param base Root address of the chain. | ||
| 360 | * @param steps One memory::ChainStep per hop (offset + per-hop plausibility floor). | ||
| 361 | * @param count Number of hops. | ||
| 362 | * @param trace Optional out-buffer; when non-null, trace[i] receives the value resolved at hop i for the first | ||
| 363 | * @p trace_cap hops, populated for the successfully-walked prefix even on failure. | ||
| 364 | * @param trace_cap Capacity of @p trace in elements (0 when @p trace is null). | ||
| 365 | * @return A ChainWalkOutcome: ok + leaf on success, or !ok + the failing hop index. | ||
| 366 | * @details Every offset except the last is added and dereferenced to obtain the next link. | ||
| 367 | * The last is added but not dereferenced. Each intermediate link is screened against its hop's floor | ||
| 368 | * and the user-mode ceiling. A torn or sentinel pointer stops the walk before the next dereference. | ||
| 369 | */ | ||
| 370 | [[nodiscard]] ChainWalkOutcome guarded_resolve_chain( | ||
| 371 | Address base, | ||
| 372 | const memory::ChainStep *steps, | ||
| 373 | std::size_t count, | ||
| 374 | Address *trace, | ||
| 375 | std::size_t trace_cap | ||
| 376 | ) noexcept; | ||
| 377 | |||
| 378 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 379 | /** | ||
| 380 | * @struct InstructionFlushObservation | ||
| 381 | * @brief Test-only record of the most recent instruction-cache flush and the number of observed calls. | ||
| 382 | */ | ||
| 383 | struct InstructionFlushObservation | ||
| 384 | { | ||
| 385 | std::uintptr_t address{}; | ||
| 386 | std::size_t bytes{}; | ||
| 387 | std::size_t call_count{}; | ||
| 388 | bool succeeded{}; | ||
| 389 | }; | ||
| 390 | |||
| 391 | /** | ||
| 392 | * @struct GuardedAccessObservation | ||
| 393 | * @brief Test-only call counts for guarded reads, guarded writes, and page-protection changes. | ||
| 394 | */ | ||
| 395 | struct GuardedAccessObservation | ||
| 396 | { | ||
| 397 | std::size_t read_calls{}; | ||
| 398 | std::size_t write_calls{}; | ||
| 399 | std::size_t protection_calls{}; | ||
| 400 | }; | ||
| 401 | |||
| 402 | /** | ||
| 403 | * @brief Counts one page-protection change into the guarded-access observation. | ||
| 404 | * @details memory_guarded.cpp owns the counters. memory_protect_ledger.cpp reports its VirtualProtect calls | ||
| 405 | * through this hook. | ||
| 406 | */ | ||
| 407 | void note_protection_call_for_test() noexcept; | ||
| 408 | |||
| 409 | /// Clears and enables the process-wide guarded-access call observation. | ||
| 410 | void reset_guarded_access_observation_for_test() noexcept; | ||
| 411 | |||
| 412 | /// Returns the process-wide guarded-access call counts. | ||
| 413 | [[nodiscard]] GuardedAccessObservation guarded_access_observation_for_test() noexcept; | ||
| 414 | |||
| 415 | /// Disarms guarded-access observation after the observed call completes. | ||
| 416 | void stop_guarded_access_observation_for_test() noexcept; | ||
| 417 | |||
| 418 | /** | ||
| 419 | * @brief Test seam: forces instruction-cache flushes to report failure. | ||
| 420 | * @details Set on the calling thread only, disabled by default, and compiled out of shipping archives. | ||
| 421 | */ | ||
| 422 | void set_flush_failure_seam(bool fail) noexcept; | ||
| 423 | |||
| 424 | /// Clears the current thread's instruction-cache flush observation. | ||
| 425 | void reset_instruction_flush_observation_for_test() noexcept; | ||
| 426 | |||
| 427 | /// Returns the current thread's instruction-cache flush observation. | ||
| 428 | [[nodiscard]] InstructionFlushObservation instruction_flush_observation_for_test() noexcept; | ||
| 429 | |||
| 430 | /// Forces @ref patch_bytes to report that its post-protection copy wrote no bytes. | ||
| 431 | void set_patch_write_not_written_for_test(bool fail) noexcept; | ||
| 432 | |||
| 433 | /** | ||
| 434 | * @brief Test seam: makes @ref guarded_write_bytes copy one byte at a time and record the prefix it wrote. | ||
| 435 | * @details Enabling it turns the guarded copy into a forward byte-at-a-time loop, making a faulting prefix | ||
| 436 | * deterministic. Portable copy ordering is otherwise unspecified. The seam is thread-local and | ||
| 437 | * compiled out of shipping archives. | ||
| 438 | */ | ||
| 439 | void set_forward_copy_seam(bool enable) noexcept; | ||
| 440 | |||
| 441 | /// Test seam: bytes the most recent @ref guarded_write_bytes committed before a fault (forward-copy seam). | ||
| 442 | [[nodiscard]] std::size_t last_forward_copy_prefix() noexcept; | ||
| 443 | |||
| 444 | /** @brief Fails selected subsequent VirtualProtect calls by zero-based call-index bits. */ | ||
| 445 | void set_virtual_protect_failure_mask(std::uint64_t call_mask) noexcept; | ||
| 446 | |||
| 447 | /// Test seam: forces PAGE_GUARD restoration to report failure. | ||
| 448 | void set_guard_rearm_failure_seam(bool fail) noexcept; | ||
| 449 | |||
| 450 | /// Resets the current thread's best-effort restoration diagnostic count. | ||
| 451 | void reset_restore_diagnostic_count() noexcept; | ||
| 452 | |||
| 453 | /// Returns the current thread's best-effort restoration diagnostic count. | ||
| 454 | [[nodiscard]] std::size_t restore_diagnostic_count() noexcept; | ||
| 455 | #endif | ||
| 456 | |||
| 457 | #if !defined(_MSC_VER) && defined(_WIN64) | ||
| 458 | /** | ||
| 459 | * @brief Installs the MinGW handler when its epoch permits it. | ||
| 460 | * @details A failed installation leaves byte access on its fallback and region scans closed. | ||
| 461 | */ | ||
| 462 | void ensure_guarded_engine_installed() noexcept; | ||
| 463 | |||
| 464 | /** | ||
| 465 | * @brief Drains guarded accesses, removes the MinGW handler, and returns its TLS index. | ||
| 466 | * @details A later access can reserve a fresh index unless Session retirement closed the epoch. | ||
| 467 | * Process termination skips the release before any control lock or stripe wait. | ||
| 468 | */ | ||
| 469 | void release_guarded_engine() noexcept; | ||
| 470 | |||
| 471 | /** @brief Retires the Session's handler epoch until an explicit reopen. */ | ||
| 472 | void retire_session_guarded_engine() noexcept; | ||
| 473 | |||
| 474 | /** @brief Permits handler installation for a new Session or cache epoch. */ | ||
| 475 | void reopen_guarded_engine() noexcept; | ||
| 476 | |||
| 477 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 478 | /** @brief Runs a proof callback with the VEH control lock held. */ | ||
| 479 | void with_guarded_engine_lock_for_test(void (*callback)(void *) noexcept, void *context) noexcept; | ||
| 480 | |||
| 481 | /// Returns the guarded-read TLS index, or 0xFFFFFFFF while none is reserved. | ||
| 482 | [[nodiscard]] std::uint32_t guarded_engine_tls_index_for_test() noexcept; | ||
| 483 | |||
| 484 | /** | ||
| 485 | * @brief Makes the calling thread's guard-arm store report failure, or clears that state. | ||
| 486 | * @details No host can fail the arm store on demand. A refused arm routes a copy or write to its fallback and | ||
| 487 | * fails a region closed. | ||
| 488 | */ | ||
| 489 | void set_guard_arm_failure_for_test(bool fail) noexcept; | ||
| 490 | #endif | ||
| 491 | #endif | ||
| 492 | } // namespace detail | ||
| 493 | } // namespace DetourModKit | ||
| 494 | |||
| 495 | #endif // DETOURMODKIT_INTERNAL_MEMORY_GUARDED_HPP | ||
| 496 |