include/DetourModKit/hook.hpp
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | #ifndef DETOURMODKIT_HOOK_HPP | ||
| 2 | #define DETOURMODKIT_HOOK_HPP | ||
| 3 | |||
| 4 | /** | ||
| 5 | * @file hook.hpp | ||
| 6 | * @brief The hooking surface: free verbs returning move-only RAII handles, with the SafetyHook backend hidden. | ||
| 7 | * @details Inline and mid installation is a two-step transaction under `[B-83]`. @ref inline_at, @ref mid_at, and | ||
| 8 | * @ref install_all return a disabled hook, and `Hook::enable()` arms it. @ref vmt_for is live at creation. | ||
| 9 | * @ref inline_at and @ref mid_at own the split in callback responsibility between the two families. A | ||
| 10 | * translation unit that includes only this header pulls in neither SafetyHook nor Zydis. | ||
| 11 | * | ||
| 12 | * LOADER-LOCK PRECONDITION: do not call a hook mutation operation from DllMain or from any thread that | ||
| 13 | * holds the Windows loader lock. Every install, toggle, batch, VMT creation, and VMT mutation entry returns | ||
| 14 | * @ref ErrorCode::LoaderLockActive before its own object-gate, ledger, backend, allocation, or protection | ||
| 15 | * work. Argument construction at the call site stays the caller's. The Hook and VmtHook destructors retain | ||
| 16 | * unsafe state instead of a wait. See their notes. | ||
| 17 | * | ||
| 18 | * LEDGER SCOPE: duplicate detection and same-target layer order live in a ledger held per linked | ||
| 19 | * DetourModKit instance, not per process. DetourModKit is a static archive, so two DLLs that each link it | ||
| 20 | * hold two independent ledgers, and a hook another kit placed on the same target is invisible here. A | ||
| 21 | * ledger duplicate refuses with @ref ErrorCode::TargetAlreadyHookedByThisKit, which the caller answers by | ||
| 22 | * dropping the handle it already holds. @ref Options::fail_if_already_hooked covers part of that blind spot | ||
| 23 | * without the ledger: it decodes the target's prologue for a foreign JMP and refuses with | ||
| 24 | * @ref ErrorCode::TargetAlreadyHookedByAnotherModule. Nothing recovers layer order across instances, so | ||
| 25 | * cross-instance stacking has no defined teardown order. | ||
| 26 | */ | ||
| 27 | |||
| 28 | #include "DetourModKit/address.hpp" | ||
| 29 | #include "DetourModKit/error.hpp" | ||
| 30 | #include "DetourModKit/scan.hpp" | ||
| 31 | |||
| 32 | #include <array> | ||
| 33 | #include <atomic> | ||
| 34 | #include <cstddef> | ||
| 35 | #include <cstdint> | ||
| 36 | #include <cstring> | ||
| 37 | #include <memory> | ||
| 38 | #include <mutex> | ||
| 39 | #include <span> | ||
| 40 | #include <string> | ||
| 41 | #include <string_view> | ||
| 42 | #include <type_traits> | ||
| 43 | #include <utility> | ||
| 44 | #include <variant> | ||
| 45 | #include <vector> | ||
| 46 | |||
| 47 | namespace DetourModKit | ||
| 48 | { | ||
| 49 | namespace hook | ||
| 50 | { | ||
| 51 | /** | ||
| 52 | * @struct MidContext | ||
| 53 | * @brief Opaque handle for the CPU register state captured at a mid-hook site. | ||
| 54 | * @details Deliberately left INCOMPLETE: it is never defined in any translation unit. The accessors | ||
| 55 | * reinterpret_cast a MidContext& to the live backend-context reference and back. This cast is | ||
| 56 | * well-defined ONLY while the type stays incomplete. A CI grep gate forbids a definition. | ||
| 57 | */ | ||
| 58 | struct MidContext; | ||
| 59 | |||
| 60 | /** | ||
| 61 | * @brief DMK-owned mid-hook detour signature. | ||
| 62 | * @details Names only DMK types, so writing a detour pulls in neither SafetyHook nor Zydis. | ||
| 63 | * @warning MUST NOT THROW (`[B-84]`). DMK contains an exception that escapes. It counts the escape, logs once | ||
| 64 | * per site, and treats the callback as complete with the context in its last callback-defined state. | ||
| 65 | * Containment is a safety net for a bug, not a contract to program against. | ||
| 66 | * @note Re-entering the hooked target from inside the callback is supported. | ||
| 67 | * @warning Destroying the callback's own Hook from inside it is permitted but pins the backend; see @ref Hook. | ||
| 68 | */ | ||
| 69 | using MidHookFn = void (*)(MidContext &); | ||
| 70 | |||
| 71 | /** | ||
| 72 | * @enum Gpr | ||
| 73 | * @brief Selects a general-purpose register for gpr() read/write access at a mid-hook site. | ||
| 74 | * @details rsp and rip are intentionally absent from this set. rsp is read-only at the capture point (the | ||
| 75 | * backend documents that writing it has no effect; query it with stack_pointer() and rewrite the | ||
| 76 | * resume stack with resume_stack_pointer()), and rip is control flow with its own writable accessor | ||
| 77 | * instruction_pointer(). Everything here is a plain integer register the detour may both read and | ||
| 78 | * overwrite, and the overwrite survives the trampoline resume. | ||
| 79 | */ | ||
| 80 | enum class Gpr : std::uint8_t | ||
| 81 | { | ||
| 82 | Rax, | ||
| 83 | Rbx, | ||
| 84 | Rcx, | ||
| 85 | Rdx, | ||
| 86 | Rsi, | ||
| 87 | Rdi, | ||
| 88 | Rbp, | ||
| 89 | R8, | ||
| 90 | R9, | ||
| 91 | R10, | ||
| 92 | R11, | ||
| 93 | R12, | ||
| 94 | R13, | ||
| 95 | R14, | ||
| 96 | R15 | ||
| 97 | }; | ||
| 98 | |||
| 99 | /** | ||
| 100 | * @struct XmmView | ||
| 101 | * @brief Read-only by-value snapshot of one 128-bit XMM register captured at the mid-hook site. | ||
| 102 | * @details DMK copies the 16 bytes out by value rather than hand out a mutable reference into the live | ||
| 103 | * context. lane<T>(index) reinterprets the captured bytes as the caller's lane type, for example | ||
| 104 | * lane<float>(0) for the first single-precision lane. | ||
| 105 | */ | ||
| 106 | struct alignas(16) XmmView | ||
| 107 | { | ||
| 108 | std::array<std::byte, 16> bytes; | ||
| 109 | |||
| 110 | /** | ||
| 111 | * @brief Reinterprets the captured bytes as the index-th T-sized lane; an out-of-range lane returns zero. | ||
| 112 | * @tparam T A trivially-copyable scalar lane type (float, double, an integer). memcpy-ing the raw register | ||
| 113 | * bytes into a non-trivially-copyable T would be undefined behaviour, so the type is constrained. | ||
| 114 | * @note Callback-safe: a pure read over the captured context, no allocation, locking, or I/O. | ||
| 115 | */ | ||
| 116 | 5 | template <typename T> [[nodiscard]] T lane(std::size_t index) const noexcept | |
| 117 | { | ||
| 118 | static_assert( | ||
| 119 | std::is_trivially_copyable_v<T>, | ||
| 120 | "XmmView::lane<T> requires a trivially-copyable lane type" | ||
| 121 | ); | ||
| 122 | 5 | T value{}; | |
| 123 | // Fail closed on an out-of-range lane: a bad index must not read past the 16-byte register. | ||
| 124 |
3/4float DetourModKit::hook::XmmView::lane<float>(unsigned long long) const:
✓ Branch 4 → 5 taken 2 times.
✓ Branch 4 → 6 taken 2 times.
unsigned long long DetourModKit::hook::XmmView::lane<unsigned long long>(unsigned long long) const:
✓ Branch 4 → 5 taken 1 time.
✗ Branch 4 → 6 not taken.
|
10 | if (index >= bytes.size() / sizeof(T)) |
| 125 | { | ||
| 126 | 3 | return value; | |
| 127 | } | ||
| 128 | 2 | std::memcpy(&value, bytes.data() + index * sizeof(T), sizeof(T)); | |
| 129 | 2 | return value; | |
| 130 | } | ||
| 131 | }; | ||
| 132 | |||
| 133 | // The mid-hook register set mirrors the Win64 capture context one-to-one; the accessors below reinterpret an | ||
| 134 | // opaque MidContext& as that 64-bit layout, so they are meaningful only on Windows x64. | ||
| 135 | static_assert(sizeof(void *) == 8, "MidContext register set is Windows x64 only"); | ||
| 136 | |||
| 137 | /** | ||
| 138 | * @brief Returns a mutable reference to a captured general-purpose register. | ||
| 139 | * @details Reading observes the live argument/scratch register at the hook point; writing overwrites it and | ||
| 140 | * the new value survives the trampoline resume. Defined in src/hook_mid_context.cpp, inside the | ||
| 141 | * backend island; it reinterpret_casts the opaque MidContext& back to the real captured-context | ||
| 142 | * reference. | ||
| 143 | * @note Callback-safe: a pure register read/write over the captured context, no allocation, locking, or I/O. | ||
| 144 | */ | ||
| 145 | [[nodiscard]] std::uintptr_t &gpr(MidContext &ctx, Gpr reg) noexcept; | ||
| 146 | |||
| 147 | /** | ||
| 148 | * @brief Returns the captured stack pointer (rsp); read-only by backend contract, modifying it has no effect. | ||
| 149 | * @note Callback-safe: a pure register read over the captured context, no allocation, locking, or I/O. | ||
| 150 | */ | ||
| 151 | [[nodiscard]] std::uintptr_t stack_pointer(const MidContext &ctx) noexcept; | ||
| 152 | |||
| 153 | /** | ||
| 154 | * @brief Returns a mutable reference to the captured resume stack pointer (the backend's trampoline_rsp). | ||
| 155 | * @details Unlike rsp (read-only, see stack_pointer), this is the stack pointer the trampoline restores when | ||
| 156 | * it resumes the original code, so writing it relocates the stack the resumed body runs on. A detour | ||
| 157 | * reaches for this accessor when it must adjust where execution resumes. | ||
| 158 | * @note Callback-safe: a pure register read/write over the captured context, no allocation, locking, or I/O. | ||
| 159 | */ | ||
| 160 | [[nodiscard]] std::uintptr_t &resume_stack_pointer(MidContext &ctx) noexcept; | ||
| 161 | |||
| 162 | /** | ||
| 163 | * @brief Returns a mutable reference to the captured instruction pointer (rip). | ||
| 164 | * @details Writing it redirects execution on resume: the trampoline's terminal return pops this (possibly | ||
| 165 | * rewritten) slot, so storing another same-signature function's address makes the resume land there | ||
| 166 | * instead of the original body. | ||
| 167 | * @note Callback-safe: a pure register read/write over the captured context, no allocation, locking, or I/O. | ||
| 168 | */ | ||
| 169 | [[nodiscard]] std::uintptr_t &instruction_pointer(MidContext &ctx) noexcept; | ||
| 170 | |||
| 171 | /** | ||
| 172 | * @brief Returns a mutable reference to the captured flags register (rflags). | ||
| 173 | * @details Writing it alters the condition flags the trampoline restores on resume, so a detour can flip a | ||
| 174 | * comparison result the original code is about to branch on. | ||
| 175 | * @note Callback-safe: a pure register read/write over the captured context, no allocation, locking, or I/O. | ||
| 176 | */ | ||
| 177 | [[nodiscard]] std::uintptr_t &flags(MidContext &ctx) noexcept; | ||
| 178 | |||
| 179 | /** | ||
| 180 | * @brief Read-only by-value snapshot of XMM register @p index (0..15); out-of-range returns a zeroed view. | ||
| 181 | * @note Callback-safe: a pure register read over the captured context, no allocation, locking, or I/O. | ||
| 182 | * @warning The mid-hook frame saves and restores XMM0-15 only. It does not preserve YMM/ZMM upper state, | ||
| 183 | * ZMM16-31, opmask registers, x87, MMX, or complete MXCSR state. A detour must not clobber that state. | ||
| 184 | */ | ||
| 185 | [[nodiscard]] XmmView xmm(const MidContext &ctx, std::size_t index) noexcept; | ||
| 186 | |||
| 187 | /** | ||
| 188 | * @enum Prologue | ||
| 189 | * @brief Escalation policy for a target whose prologue is a breakpoint rather than a function body. | ||
| 190 | * @details A leading 0xCC/0xCD (int3 / int n) means the slot is a breakpoint stub, a patched byte, or alignment | ||
| 191 | * padding. @ref Fail refuses the create with @ref ErrorCode::TargetPrologueUnsafe; @ref Relocate logs | ||
| 192 | * and installs anyway. | ||
| 193 | * @note This policy governs only the prologue's shape. Whether the prologue can be relocated at all is left to | ||
| 194 | * the backend's own decode rather than guessed from its first byte, so a relative call is not refused | ||
| 195 | * here; if the backend cannot relocate it, the create fails with @ref ErrorCode::BackendFailed and the | ||
| 196 | * backend's specific reason is logged rather than returned. A target whose bytes are not readable | ||
| 197 | * executable committed memory is refused under BOTH policies: @ref Relocate cannot authorize decoding | ||
| 198 | * non-code. | ||
| 199 | */ | ||
| 200 | enum class Prologue : std::uint8_t | ||
| 201 | { | ||
| 202 | Fail, | ||
| 203 | Relocate | ||
| 204 | }; | ||
| 205 | |||
| 206 | /** | ||
| 207 | * @enum Severity | ||
| 208 | * @brief Per-row policy for a declarative @ref HookSpec inside @ref install_all. | ||
| 209 | * @details Folds the mandatory-vs-best-effort if-tree of a hand-rolled install loop into a field. A | ||
| 210 | * @ref Mandatory miss fails the whole @ref install_all call; a @ref BestEffort miss warns, records | ||
| 211 | * the per-row Error, skips, and lets the call still succeed. | ||
| 212 | */ | ||
| 213 | enum class Severity : std::uint8_t | ||
| 214 | { | ||
| 215 | BestEffort, | ||
| 216 | Mandatory | ||
| 217 | }; | ||
| 218 | |||
| 219 | /** | ||
| 220 | * @struct Options | ||
| 221 | * @brief Per-hook policy for @ref inline_at / @ref mid_at. | ||
| 222 | */ | ||
| 223 | struct Options | ||
| 224 | { | ||
| 225 | /// Prologue escalation policy; defaults to the safe-by-default Fail (see @ref Prologue). | ||
| 226 | Prologue prologue = Prologue::Fail; | ||
| 227 | |||
| 228 | /** | ||
| 229 | * @brief Refuse the install when the target already appears hooked. | ||
| 230 | * @details The pre-flight first consults this instance's ledger for an exact same-kit hook at the target | ||
| 231 | * address, then falls back to a foreign-JMP heuristic: an E9 rel32 jump, an FF25 indirect jump, | ||
| 232 | * or a mov rax, imm64; jmp rax absolute-jump trampoline planted over the prologue, each decoded | ||
| 233 | * under a fault guard. The default (false) installs anyway and the new hook layers on top. | ||
| 234 | */ | ||
| 235 | bool fail_if_already_hooked = false; | ||
| 236 | }; | ||
| 237 | |||
| 238 | namespace detail | ||
| 239 | { | ||
| 240 | /// Satisfied only by a pointer-to-function type; the valid cast target for Hook::original. | ||
| 241 | template <typename T> | ||
| 242 | concept FunctionPointer = std::is_pointer_v<T> && std::is_function_v<std::remove_pointer_t<T>>; | ||
| 243 | } // namespace detail | ||
| 244 | |||
| 245 | /** | ||
| 246 | * @brief Where a hook installs: an absolute @ref Address, or a deferred @ref scan::OwnedScanRequest. | ||
| 247 | * @details An owning OwnedScanRequest (never a borrowed ScanRequest) is used for the deferred case so the | ||
| 248 | * stored request closes the span-dangling hazard; it is resolved to an address at install time via | ||
| 249 | * scan::resolve. | ||
| 250 | */ | ||
| 251 | using Target = std::variant<Address, scan::OwnedScanRequest>; | ||
| 252 | |||
| 253 | /// A request to install one inline hook by @ref inline_at. | ||
| 254 | struct InlineRequest | ||
| 255 | { | ||
| 256 | std::string name; | ||
| 257 | Target target; | ||
| 258 | Options options{}; | ||
| 259 | }; | ||
| 260 | |||
| 261 | /// A request to install one mid hook by @ref mid_at. | ||
| 262 | struct MidRequest | ||
| 263 | { | ||
| 264 | std::string name; | ||
| 265 | Target target; | ||
| 266 | Options options{}; | ||
| 267 | }; | ||
| 268 | |||
| 269 | class Hook; | ||
| 270 | |||
| 271 | namespace detail | ||
| 272 | { | ||
| 273 | /// The non-template inline-install primitive; @ref inline_at funnels its typed detour through this. | ||
| 274 | [[nodiscard]] Result<Hook> inline_at_raw(InlineRequest request, void *detour); | ||
| 275 | } // namespace detail | ||
| 276 | |||
| 277 | /** | ||
| 278 | * @class Hook | ||
| 279 | * @brief Move-only RAII handle for one installed inline or mid hook; its destructor restores the prologue. | ||
| 280 | * @details Constructed by @ref inline_at, @ref mid_at, or @ref install_all, always DISABLED; @ref enable arms | ||
| 281 | * it. Dropping the handle unhooks; @ref release intentionally leaves the hook installed for the | ||
| 282 | * process lifetime. | ||
| 283 | * @note Teardown ordering: when two hooks are layered on the same target address, the newer one must be | ||
| 284 | * destroyed first. Use @ref HookStack when layered hooks live in a container. If the ledger detects an | ||
| 285 | * inversion, teardown leaks the older installed backend to preserve the newer trampoline chain and logs | ||
| 286 | * a warning. The target remains tracked as hooked. | ||
| 287 | * @note Lock order: a toggle takes the per-hook call gate before it claims the HookLedger target slot. | ||
| 288 | * It releases the target slot before the call gate. Logs and lifecycle events follow both releases. | ||
| 289 | */ | ||
| 290 | class Hook | ||
| 291 | { | ||
| 292 | public: | ||
| 293 | Hook(Hook &&other) noexcept; | ||
| 294 | Hook &operator=(Hook &&other) noexcept; | ||
| 295 | Hook(const Hook &) = delete; | ||
| 296 | Hook &operator=(const Hook &) = delete; | ||
| 297 | |||
| 298 | /** | ||
| 299 | * @brief Restores the patched prologue when safe; published x64 mid routes retain their route | ||
| 300 | * storage. | ||
| 301 | * @details Original bytes authorize backend destruction even after a failed restore. Foreign or | ||
| 302 | * unreadable bytes do not. A published x64 MID route permanently retains its gateway, inline | ||
| 303 | * trampoline, allocator blocks, and unwind metadata. Clean teardown can still reclaim its mid | ||
| 304 | * stub and adapter after rundown. Under the loader lock, below a newer layer, or without an | ||
| 305 | * Original witness, the whole backend and module reference are pinned, the target stays tracked | ||
| 306 | * as hooked, and `[B-73]` books the leak to | ||
| 307 | * @ref DetourModKit::diagnostics::LeakSubsystem::HookManager. | ||
| 308 | * | ||
| 309 | * For a MID hook this also runs the callback down. The callback retires first, so a pinned hook | ||
| 310 | * goes INERT instead of a call into a destroyed owner. An authorized caller waits for callbacks | ||
| 311 | * already in flight on every teardown branch, and on the restoring path also waits for every | ||
| 312 | * adapter body to leave before the stub is freed. After this returns, no new mid-hook callback | ||
| 313 | * begins. | ||
| 314 | * @note An unauthorized teardown, where an unload phase is published or the fail-closed loader-lock probe | ||
| 315 | * vetoes, pins without a wait. A callback that began before teardown can still finish. | ||
| 316 | * @warning Destruction of a mid hook from INSIDE its own callback cannot wait, because the waiter is the | ||
| 317 | * thread it waits for. DMK detects that case, retires the callback, pins the backend, and books | ||
| 318 | * the leak. Destroy from a thread that is not inside the hook. Teardown pins the same way | ||
| 319 | * whenever it cannot prove that no thread is inside the callback, so a pin alone is not evidence | ||
| 320 | * of misuse. | ||
| 321 | * @warning An INLINE hook has no such rundown; quiescence is caller-owned (see @ref inline_at). | ||
| 322 | * @note This runs from DLL_PROCESS_DETACH / loader-lock teardown, where an escaping exception terminates | ||
| 323 | * the host, so every path inside fails closed rather than propagating. | ||
| 324 | * @note Setup/control-plane only: teardown mutates the target and can wait for in-flight mid-hook | ||
| 325 | * callbacks. | ||
| 326 | */ | ||
| 327 | ~Hook() noexcept; | ||
| 328 | |||
| 329 | /// True while this handle owns a live hook (false after a move-out or @ref release). | ||
| 330 | [[nodiscard]] explicit operator bool() const noexcept; | ||
| 331 | |||
| 332 | /// The hook's registered name (empty for a moved-from / released handle). | ||
| 333 | [[nodiscard]] std::string_view name() const noexcept; | ||
| 334 | |||
| 335 | /** | ||
| 336 | * @brief True when the hook is armed or conservatively retained as possibly reachable. | ||
| 337 | * | ||
| 338 | * @details Answers from DMK's published state AND the backend's reconciled view. The backend flag changes | ||
| 339 | * when its mutation commits. If a restore commits but the final byte witness is Foreign or | ||
| 340 | * Indeterminate, DMK retains that flag because a newer layer may still reach the trampoline. A | ||
| 341 | * later retry over exact OwnedPatch bytes can then perform the real restore. Original bytes clear | ||
| 342 | * the flag. The query is serialized with enable/disable through the per-hook call gate because | ||
| 343 | * the backend flag is not atomic. This query never repairs drift. A later toggle reconciles only | ||
| 344 | * an attributable opposite witness. Foreign or indeterminate bytes preserve the published state | ||
| 345 | * and refuse the toggle. | ||
| 346 | * @note Setup/control-plane only: may wait for an in-flight guarded call or hook state transition. | ||
| 347 | */ | ||
| 348 | [[nodiscard]] bool is_enabled() const noexcept; | ||
| 349 | |||
| 350 | /** | ||
| 351 | * @brief Returns the typed original-function trampoline (inline hooks only); the UNGUARDED fast path. | ||
| 352 | * @tparam Fn The full function-pointer type of the original (e.g. `void(*)(void*)`). | ||
| 353 | * @return A trampoline of type Fn, or nullptr for a mid hook, a disengaged handle, or a backend miss. | ||
| 354 | * @details This is the common process-lifetime game-detour path: `h.original<fn>()(args...)` is one | ||
| 355 | * indirection and takes no lock, so the caller MUST guarantee the hook outlives the call. For the | ||
| 356 | * opt-in guarded form used when teardown may race an in-flight call, use @ref call. Mid hooks | ||
| 357 | * have no callable original, so original<Fn>() is nullptr for them. | ||
| 358 | * @note Callback-safe: one indirection, no lock, no allocation; the caller owns the hook-outlives-the-call | ||
| 359 | * guarantee. | ||
| 360 | */ | ||
| 361 | 140 | template <detail::FunctionPointer Fn> [[nodiscard]] Fn original() const noexcept | |
| 362 | { | ||
| 363 | 140 | return reinterpret_cast<Fn>(original_address()); | |
| 364 | } | ||
| 365 | |||
| 366 | /** | ||
| 367 | * @brief Calls the original function through the trampoline under DMK's per-hook guard (inline hooks only). | ||
| 368 | * @tparam Ret The original's return type (defaults to void). | ||
| 369 | * @tparam Args The exact parameter types for the original by-value C ABI. | ||
| 370 | * A movable argument moves into the dispatch. If its ABI type cannot construct from an rvalue, | ||
| 371 | * dispatch preserves the prior lvalue copy path. | ||
| 372 | * @return The original's return value, or a value-initialized Ret when the hook is inactive / not inline. | ||
| 373 | * @details Pins the refcounted call gate before taking its recursive mutex and holds both through the | ||
| 374 | * invocation. Teardown publishes a null trampoline under the same mutex before destroying any | ||
| 375 | * reclaimable backend storage, so a late call fails closed and an in-flight call drains first. Use | ||
| 376 | * @ref original when the hook lifetime is already guaranteed and this guard is unnecessary. | ||
| 377 | * | ||
| 378 | * The Hook object's storage must outlive this member call, although teardown work can race it. | ||
| 379 | * The caller must supply the original function's exact parameter types, because a deduced | ||
| 380 | * reference reconstructs the wrong function-pointer ABI. This guard does not drain a thread that | ||
| 381 | * entered the original by another path. | ||
| 382 | * @note Callback-safe: the atomic `shared_ptr` gate snapshot uses a bounded internal lock, and `call` | ||
| 383 | * performs no allocation or I/O before dispatch. | ||
| 384 | * @warning `call` holds the per-hook recursive gate mutex across the dispatch, so concurrent calls | ||
| 385 | * through one handle serialize, and a second thread blocks for the first call's full duration. | ||
| 386 | * Two threads through one handle measured lower aggregate throughput than one thread | ||
| 387 | * (`docs/analysis/hot_path_bench_v4/`). For a hot target called from several threads, use | ||
| 388 | * @ref original. | ||
| 389 | */ | ||
| 390 | 17 | template <typename Ret = void, typename... Args> Ret call(Args... args) const | |
| 391 | { | ||
| 392 | // GuardedDispatch pins the gate and holds its lock through this invocation. | ||
| 393 | 17 | const GuardedDispatch dispatch{*this}; | |
| 394 |
5/8int DetourModKit::hook::Hook::call<int, (anonymous namespace)::CopyOnlyInt>((anonymous namespace)::CopyOnlyInt) const:
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 1 time.
int DetourModKit::hook::Hook::call<int, std::unique_ptr<int, std::default_delete<int> > >(std::unique_ptr<int, std::default_delete<int> >) const:
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 1 time.
int DetourModKit::hook::Hook::call<int, int>(int) const:
✓ Branch 3 → 4 taken 4 times.
✓ Branch 3 → 5 taken 10 times.
int DetourModKit::hook::Hook::call<int, int, int>(int, int) const:
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 1 time.
|
17 | if (dispatch.trampoline == nullptr) |
| 395 | { | ||
| 396 | if constexpr (!std::is_void_v<Ret>) | ||
| 397 | { | ||
| 398 | 4 | return Ret{}; | |
| 399 | } | ||
| 400 | else | ||
| 401 | { | ||
| 402 | return; | ||
| 403 | } | ||
| 404 | } | ||
| 405 |
4/8int DetourModKit::hook::Hook::call<int, (anonymous namespace)::CopyOnlyInt>((anonymous namespace)::CopyOnlyInt) const:
✓ Branch 6 → 7 taken 1 time.
✗ Branch 6 → 11 not taken.
int DetourModKit::hook::Hook::call<int, std::unique_ptr<int, std::default_delete<int> > >(std::unique_ptr<int, std::default_delete<int> >) const:
✓ Branch 7 → 8 taken 1 time.
✗ Branch 7 → 14 not taken.
int DetourModKit::hook::Hook::call<int, int>(int) const:
✓ Branch 6 → 7 taken 10 times.
✗ Branch 6 → 11 not taken.
int DetourModKit::hook::Hook::call<int, int, int>(int, int) const:
✓ Branch 7 → 8 taken 1 time.
✗ Branch 7 → 12 not taken.
|
13 | return reinterpret_cast<Ret (*)(Args...)>(dispatch.trampoline)(forward_call_argument<Args>(args)...); |
| 406 | 17 | } | |
| 407 | |||
| 408 | /** | ||
| 409 | * @brief The fail-closed-distinguishing sibling of @ref call: dispatches through the original and reports | ||
| 410 | * whether the guarded gate actually let the call through. | ||
| 411 | * @tparam Ret The original's return type (default void), reconstructed by value as in @ref call. | ||
| 412 | * @tparam Args The original's exact by-value parameter types; see @ref call. | ||
| 413 | * @return The original's return value, or InvalidHookState when the guarded gate refuses dispatch. | ||
| 414 | * @details Uses the same lifetime guard as @ref call but preserves a suppressed call in the error channel, | ||
| 415 | * which distinguishes it from a legitimate value-initialized result. `try_call<void>()` reports | ||
| 416 | * whether dispatch occurred. | ||
| 417 | * @note Callback-safe on the same terms as @ref call: the same two locks, and no allocation or I/O before | ||
| 418 | * dispatch. | ||
| 419 | */ | ||
| 420 | 32 | template <typename Ret = void, typename... Args> [[nodiscard]] Result<Ret> try_call(Args... args) const | |
| 421 | { | ||
| 422 | 32 | const GuardedDispatch dispatch{*this}; | |
| 423 |
10/12std::expected<int, DetourModKit::Error> DetourModKit::hook::Hook::try_call<int>() const:
✓ Branch 3 → 4 taken 13 times.
✓ Branch 3 → 7 taken 10 times.
std::expected<int, DetourModKit::Error> DetourModKit::hook::Hook::try_call<int, (anonymous namespace)::CopyOnlyInt>((anonymous namespace)::CopyOnlyInt) const:
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 7 taken 1 time.
std::expected<int, DetourModKit::Error> DetourModKit::hook::Hook::try_call<int, std::unique_ptr<int, std::default_delete<int> > >(std::unique_ptr<int, std::default_delete<int> >) const:
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 7 taken 1 time.
std::expected<int, DetourModKit::Error> DetourModKit::hook::Hook::try_call<int, int>(int) const:
✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 7 taken 1 time.
std::expected<unsigned long, DetourModKit::Error> DetourModKit::hook::Hook::try_call<unsigned long, unsigned long, _XINPUT_STATE*>(unsigned long, _XINPUT_STATE*) const:
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 7 taken 1 time.
std::expected<void, DetourModKit::Error> DetourModKit::hook::Hook::try_call<void, int>(int) const:
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 7 taken 1 time.
|
32 | if (dispatch.trampoline == nullptr) |
| 424 | { | ||
| 425 | 17 | return std::unexpected(Error{ErrorCode::InvalidHookState, "hook::try_call"}); | |
| 426 | } | ||
| 427 | if constexpr (std::is_void_v<Ret>) | ||
| 428 | { | ||
| 429 |
1/2✓ Branch 8 → 9 taken 1 time.
✗ Branch 8 → 13 not taken.
|
1 | reinterpret_cast<void (*)(Args...)>(dispatch.trampoline)(forward_call_argument<Args>(args)...); |
| 430 | 1 | return {}; | |
| 431 | } | ||
| 432 | else | ||
| 433 | { | ||
| 434 |
5/10std::expected<int, DetourModKit::Error> DetourModKit::hook::Hook::try_call<int>() const:
✓ Branch 7 → 8 taken 10 times.
✗ Branch 7 → 13 not taken.
std::expected<int, DetourModKit::Error> DetourModKit::hook::Hook::try_call<int, (anonymous namespace)::CopyOnlyInt>((anonymous namespace)::CopyOnlyInt) const:
✓ Branch 8 → 9 taken 1 time.
✗ Branch 8 → 14 not taken.
std::expected<int, DetourModKit::Error> DetourModKit::hook::Hook::try_call<int, std::unique_ptr<int, std::default_delete<int> > >(std::unique_ptr<int, std::default_delete<int> >) const:
✓ Branch 9 → 10 taken 1 time.
✗ Branch 9 → 16 not taken.
std::expected<int, DetourModKit::Error> DetourModKit::hook::Hook::try_call<int, int>(int) const:
✓ Branch 8 → 9 taken 1 time.
✗ Branch 8 → 14 not taken.
std::expected<unsigned long, DetourModKit::Error> DetourModKit::hook::Hook::try_call<unsigned long, unsigned long, _XINPUT_STATE*>(unsigned long, _XINPUT_STATE*) const:
✓ Branch 9 → 10 taken 1 time.
✗ Branch 9 → 15 not taken.
|
28 | return reinterpret_cast<Ret (*)(Args...)>(dispatch.trampoline)( |
| 435 | 3 | forward_call_argument<Args>(args)... | |
| 436 | 14 | ); | |
| 437 | } | ||
| 438 | 32 | } | |
| 439 | |||
| 440 | /** | ||
| 441 | * @brief Arms the hook: patches the target so the detour begins running. | ||
| 442 | * @return Success if the hook is now active (or already was and is the target's newest live layer). On | ||
| 443 | * failure the Error carries the reason (LoaderLockActive, LayerConflict, BackendFailed, | ||
| 444 | * EnableFailed, DisableFailed, InvalidHookState). LayerConflict changes neither the target bytes | ||
| 445 | * nor the hook's state. An already-armed lower layer stays armed. EnableFailed means this call | ||
| 446 | * published no new arm. An uncommitted or rolled-back arm from Disabled leaves Disabled. A | ||
| 447 | * pre-write refusal preserves the prior published state and target bytes. BackendFailed means the | ||
| 448 | * hook IS active and routes calls. @ref is_enabled reports true, and an inline hook's @ref call | ||
| 449 | * works. The backend's patch transaction reported an error after the patch commit. Target page | ||
| 450 | * protection can remain unrestored. DisableFailed means this call did not prove a disarm after a | ||
| 451 | * rejected or uncertain arm. The handle remains conservatively active. The caller must quiesce or | ||
| 452 | * disable it before teardown. | ||
| 453 | * @details The operation is idempotent and thread-safe without external synchronization. An already-active | ||
| 454 | * hook succeeds only while its own patch is what the target holds. Original bytes under an active | ||
| 455 | * state prove that a third party disarmed the target. The call reconciles state, then uses the | ||
| 456 | * ordinary arm path. Publish everything the detour needs before this call. `[B-83]` owns that | ||
| 457 | * rule. | ||
| 458 | * @details `[B-97]` decides the reported state from the target's bytes, not from the backend result. Only | ||
| 459 | * the saved prologue authorizes Disabled and only the exact committed patch authorizes Active, | ||
| 460 | * so an ambiguous witness stays conservatively Active. This noexcept boundary contains backend | ||
| 461 | * exceptions and reconciles them the same way. | ||
| 462 | * @note The toggle refuses bytes that belong to neither this hook nor its saved prologue. It does not | ||
| 463 | * overwrite them because the backend emits its jmp over present bytes. The refusal is EnableFailed | ||
| 464 | * and writes nothing, so a caller that resolves the conflict can retry. An unreadable target gets | ||
| 465 | * the same refusal. | ||
| 466 | * @note Only the newest live hook on a target can arm it. An arm from underneath a newer layer is refused | ||
| 467 | * with LayerConflict and nothing is written. The layer check precedes the idempotency check, so an | ||
| 468 | * already-armed lower layer also gets LayerConflict instead of the no-op Success it gets on top. To | ||
| 469 | * stack detours, arm the base hook before you create the one above it. A hook created while the | ||
| 470 | * layer below is armed captures the patched prologue and resumes into it. | ||
| 471 | * @note Setup/control-plane only: arming patches the target and serializes on the per-hook call gate. | ||
| 472 | */ | ||
| 473 | [[nodiscard]] Result<void> enable() noexcept; | ||
| 474 | |||
| 475 | /** | ||
| 476 | * @brief Disarms the hook without destroying it. | ||
| 477 | * @return Success if the hook is now disabled (or already was and is the target's newest live layer). On | ||
| 478 | * failure the Error carries the reason (LoaderLockActive, LayerConflict, BackendFailed, | ||
| 479 | * DisableFailed, InvalidHookState). A live lower layer remains armed after LayerConflict and | ||
| 480 | * truthfully reports @ref is_enabled. DisableFailed means this call did not prove a disarm. A | ||
| 481 | * failed transition from Active stays Active. A pre-write refusal from Disabled preserves Disabled | ||
| 482 | * and the target bytes. | ||
| 483 | * BackendFailed means the disarm DID take effect. @ref is_enabled reports false, and the target no | ||
| 484 | * longer redirects. The backend's restore transaction reported an error after the disarm. Target | ||
| 485 | * page protection can remain unrestored. | ||
| 486 | * @details As in @ref enable, the target's bytes decide under `[B-97]`. Disabled publishes once the saved | ||
| 487 | * prologue reads back, even after a backend failure or throw that follows the committed restore. | ||
| 488 | * An ambiguous witness leaves the hook active, so a retry can disarm after the caller restores | ||
| 489 | * this hook's exact patch bytes. An already-disabled hook succeeds only while the saved prologue | ||
| 490 | * is what the target holds. The call reconciles a disabled state over this hook's own patch, then | ||
| 491 | * retries the ordinary restore. | ||
| 492 | * @note The call refuses Foreign or unreadable bytes exactly as in @ref enable. It returns DisableFailed | ||
| 493 | * and writes nothing. Teardown applies the same rule and pins the backend instead of a restore. See | ||
| 494 | * @ref Hook::~Hook. | ||
| 495 | * @note Only the newest live hook on a target can disarm it, for the reason @ref enable gives: this hook's | ||
| 496 | * saved prologue predates a newer layer's patch. Tear down or disable the newer layer first. | ||
| 497 | * @note Setup/control-plane only: disarming restores target bytes and serializes on the per-hook call | ||
| 498 | * gate. | ||
| 499 | */ | ||
| 500 | [[nodiscard]] Result<void> disable() noexcept; | ||
| 501 | |||
| 502 | /** | ||
| 503 | * @brief Detaches the hook from this handle, retaining its backend for the process lifetime. | ||
| 504 | * @details The handle becomes disengaged (operator bool is then false and ~Hook is a no-op). An armed hook | ||
| 505 | * stays patched and dispatching; a disabled hook stays disabled, but its backend and ledger record | ||
| 506 | * are still intentionally retained. This is the explicit "install once, never unhook" pattern; | ||
| 507 | * it is not an error path. | ||
| 508 | * @note Booked by @ref diagnostics::total_intentional_leaks like a defensive pin, and the target stays | ||
| 509 | * recorded: @ref is_target_hooked keeps reporting it hooked, a strict install keeps being refused, | ||
| 510 | * and a layer installed underneath this one can no longer enable, disable, or restore. Every | ||
| 511 | * byte-writing operation that layer attempts is refused with @ref ErrorCode::LayerConflict for the | ||
| 512 | * process lifetime. A layer installed AFTER it still tears down normally. | ||
| 513 | * @note Setup/control-plane only: transfers the backend to process-lifetime retention; do not call from a | ||
| 514 | * hook or input callback. | ||
| 515 | * @warning The detour and everything it reaches must remain mapped for the rest of the process. | ||
| 516 | */ | ||
| 517 | void release() noexcept; | ||
| 518 | |||
| 519 | private: | ||
| 520 | struct Impl; | ||
| 521 | |||
| 522 | template <typename Arg> | ||
| 523 | 20 | [[nodiscard]] static decltype(auto) forward_call_argument(std::remove_reference_t<Arg> &arg) noexcept | |
| 524 | { | ||
| 525 | if constexpr (!std::is_reference_v<Arg> && !std::is_move_constructible_v<Arg>) | ||
| 526 | { | ||
| 527 | 2 | return (arg); | |
| 528 | } | ||
| 529 | else | ||
| 530 | { | ||
| 531 | 18 | return std::forward<Arg>(arg); | |
| 532 | } | ||
| 533 | } | ||
| 534 | |||
| 535 | /** | ||
| 536 | * @brief Refcounted call guard defined in src/internal/hook_backend.hpp. | ||
| 537 | * @details A late @ref call pins it before locking, so concurrent teardown cannot free its mutex. | ||
| 538 | */ | ||
| 539 | struct CallGate; | ||
| 540 | Hook(std::unique_ptr<Impl> impl, std::shared_ptr<CallGate> gate) noexcept; | ||
| 541 | |||
| 542 | /// Raw inline trampoline (or nullptr); the UNGUARDED backend touch behind original<Fn>(). Defined in .cpp. | ||
| 543 | [[nodiscard]] void *original_address() const noexcept; | ||
| 544 | |||
| 545 | /// Copies the atomic call-gate reference into a strong local for @ref call to pin. Defined in .cpp. | ||
| 546 | [[nodiscard]] std::shared_ptr<CallGate> pin_call_gate() const noexcept; | ||
| 547 | |||
| 548 | /** | ||
| 549 | * @brief Locks the gate's recursive_mutex and returns the owning token; the @ref call guard. Defined in | ||
| 550 | * .cpp. | ||
| 551 | * @details noexcept: a recursive_mutex::lock failure yields an unowned lock (the caller checks | ||
| 552 | * owns_lock()) rather than throwing out of the non-noexcept @ref call. | ||
| 553 | */ | ||
| 554 | [[nodiscard]] std::unique_lock<std::recursive_mutex> acquire_call_lock(CallGate *gate) const noexcept; | ||
| 555 | |||
| 556 | /// The gate's published callable trampoline (nullptr when inactive); read with the call lock held. | ||
| 557 | [[nodiscard]] void *active_trampoline(CallGate *gate) const noexcept; | ||
| 558 | |||
| 559 | /** | ||
| 560 | * @brief One entry through the call gate, shared verbatim by @ref call and @ref try_call. | ||
| 561 | * @details Pins the gate, locks it, and snapshots its trampoline. Any failed stage leaves @ref trampoline | ||
| 562 | * null. Retaining the gate and lock prevents teardown from reclaiming an in-flight trampoline. | ||
| 563 | */ | ||
| 564 | struct GuardedDispatch | ||
| 565 | { | ||
| 566 | 49 | explicit GuardedDispatch(const Hook &hook) | |
| 567 | 49 | { | |
| 568 | 49 | gate = hook.pin_call_gate(); | |
| 569 |
2/2✓ Branch 8 → 9 taken 3 times.
✓ Branch 8 → 10 taken 46 times.
|
49 | if (!gate) |
| 570 | 3 | return; | |
| 571 | 46 | guard = hook.acquire_call_lock(gate.get()); | |
| 572 |
1/2✗ Branch 15 → 16 not taken.
✓ Branch 15 → 17 taken 46 times.
|
46 | if (!guard.owns_lock()) |
| 573 | ✗ | return; | |
| 574 | 46 | trampoline = hook.active_trampoline(gate.get()); | |
| 575 | } | ||
| 576 | |||
| 577 | std::shared_ptr<CallGate> gate; | ||
| 578 | std::unique_lock<std::recursive_mutex> guard; | ||
| 579 | /// The live trampoline to dispatch through, or nullptr when any gate stage failed closed. | ||
| 580 | void *trampoline = nullptr; | ||
| 581 | }; | ||
| 582 | |||
| 583 | std::unique_ptr<Impl> m_impl; | ||
| 584 | /** | ||
| 585 | * @brief The shared call gate, held atomically so @ref call can pin it without racing a concurrent | ||
| 586 | * teardown/move that publishes over it. | ||
| 587 | */ | ||
| 588 | std::atomic<std::shared_ptr<CallGate>> m_gate; | ||
| 589 | |||
| 590 | friend Result<Hook> mid_at(MidRequest request, MidHookFn detour); | ||
| 591 | friend Result<Hook> detail::inline_at_raw(InlineRequest request, void *detour); | ||
| 592 | }; | ||
| 593 | |||
| 594 | /** | ||
| 595 | * @class HookStack | ||
| 596 | * @brief Move-only owner of a set of Hook handles that guarantees newest-first (LIFO) teardown. | ||
| 597 | * @details `[B-16]` Same-target layers must unwind newest-first, or the older layer's restore clobbers a | ||
| 598 | * prologue the newer layer's live trampoline still chains through. A bare `std::vector<Hook>` has | ||
| 599 | * unspecified element destruction order. This container restores back-to-front instead. Prefer it | ||
| 600 | * over a bare vector when hooks stay alive together. This rule especially applies to hooks layered on | ||
| 601 | * one address and successes returned by @ref install_all. | ||
| 602 | * Push those successes in table order. Inline/mid @ref Hook handles only: @ref VmtHook already | ||
| 603 | * unwinds its objects newest-first. | ||
| 604 | * @note Move-only, mirroring @ref Hook. Not internally synchronized: build and tear it down on the setup | ||
| 605 | * thread, exactly like the hooks it holds. | ||
| 606 | */ | ||
| 607 | class HookStack | ||
| 608 | { | ||
| 609 | public: | ||
| 610 | /** | ||
| 611 | * @brief Constructs an empty hook stack. | ||
| 612 | * @note Setup/control-plane only: build hook ownership during init/shutdown or worker setup, not from a | ||
| 613 | * hook callback. | ||
| 614 | */ | ||
| 615 | 11 | HookStack() noexcept = default; | |
| 616 | |||
| 617 | /** | ||
| 618 | * @brief Move-constructs by adopting @p other's hooks without tearing them down. | ||
| 619 | * @note Setup/control-plane only: moving a stack transfers ownership and is not internally synchronized | ||
| 620 | * with concurrent reads or teardown. | ||
| 621 | */ | ||
| 622 | 2 | HookStack(HookStack &&other) noexcept : m_hooks(std::move(other.m_hooks)) { other.m_hooks.clear(); } | |
| 623 | |||
| 624 | /** | ||
| 625 | * @brief Move-assigns by tearing down this stack's current hooks newest-first, then adopting @p other's. | ||
| 626 | * @details Deliberately not defaulted: a defaulted move-assignment destroys the replaced hooks in a | ||
| 627 | * container-defined order. The moved-from source is cleared, so empty() remains a stable | ||
| 628 | * post-move query. | ||
| 629 | * @note Setup/control-plane only: move-assignment may restore existing hooks and is not synchronized with | ||
| 630 | * hook callbacks or concurrent stack access. | ||
| 631 | */ | ||
| 632 | 1 | HookStack &operator=(HookStack &&other) noexcept | |
| 633 | { | ||
| 634 |
1/2✓ Branch 2 → 3 taken 1 time.
✗ Branch 2 → 8 not taken.
|
1 | if (this != &other) |
| 635 | { | ||
| 636 | 1 | teardown_newest_first(); | |
| 637 | 2 | m_hooks = std::move(other.m_hooks); | |
| 638 | 1 | other.m_hooks.clear(); | |
| 639 | } | ||
| 640 | 1 | return *this; | |
| 641 | } | ||
| 642 | |||
| 643 | HookStack(const HookStack &) = delete; | ||
| 644 | HookStack &operator=(const HookStack &) = delete; | ||
| 645 | |||
| 646 | /** | ||
| 647 | * @brief Restores every owned hook's prologue, newest-first. | ||
| 648 | * @note Setup/control-plane only: destroy the stack after detour entry points and worker calls that might | ||
| 649 | * use its hooks are quiescent. | ||
| 650 | * @note Explicitly noexcept: like @ref Hook::~Hook this can run from DLL_PROCESS_DETACH / loader-lock | ||
| 651 | * teardown, where an escaping exception terminates the host. Every ~Hook it invokes already fails | ||
| 652 | * closed, so no exception escapes. | ||
| 653 | */ | ||
| 654 | 7 | ~HookStack() noexcept { teardown_newest_first(); } | |
| 655 | |||
| 656 | /** | ||
| 657 | * @brief Moves @p hook onto the top of the stack and returns a reference to the stored handle. | ||
| 658 | * @return A reference to the just-stored @ref Hook, valid until the next @ref push / @ref clear / move. | ||
| 659 | * Use it to capture the trampoline immediately after a push, for example | ||
| 660 | * `stack.push(std::move(h)).original<Fn>()`. | ||
| 661 | * @details Push order IS layer order: push the base hook first. If storage growth throws `std::bad_alloc`, | ||
| 662 | * the stack unwinds @p hook (which restores its prologue) and leaves the stored hooks | ||
| 663 | * intact. | ||
| 664 | * @note Setup/control-plane only: may allocate and may publish a new hook owner. Do not call from a hook | ||
| 665 | * callback. | ||
| 666 | */ | ||
| 667 | 123 | Hook &push(Hook hook) | |
| 668 | { | ||
| 669 | 246 | m_hooks.push_back(std::move(hook)); | |
| 670 | 123 | return m_hooks.back(); | |
| 671 | } | ||
| 672 | |||
| 673 | /** | ||
| 674 | * @brief Reserves storage for @p capacity hooks so a batch of @ref push calls does not reallocate. | ||
| 675 | * @note Setup/control-plane only: may allocate. | ||
| 676 | */ | ||
| 677 | void reserve(std::size_t capacity) { m_hooks.reserve(capacity); } | ||
| 678 | |||
| 679 | /** | ||
| 680 | * @brief Returns the number of hooks currently owned. | ||
| 681 | * @note Callback-safe: non-blocking and non-allocating when no thread mutates or destroys this stack | ||
| 682 | * concurrently. | ||
| 683 | */ | ||
| 684 | 9 | [[nodiscard]] std::size_t size() const noexcept { return m_hooks.size(); } | |
| 685 | |||
| 686 | /** | ||
| 687 | * @brief Reports whether the stack owns no hooks. | ||
| 688 | * @note Callback-safe: non-blocking and non-allocating when no thread mutates or destroys this stack | ||
| 689 | * concurrently. | ||
| 690 | */ | ||
| 691 | 6 | [[nodiscard]] bool empty() const noexcept { return m_hooks.empty(); } | |
| 692 | |||
| 693 | /** | ||
| 694 | * @brief Tears down every owned hook newest-first, leaving the stack empty and retaining capacity. | ||
| 695 | * @note Setup/control-plane only: restores hooks and is not synchronized with callbacks or concurrent | ||
| 696 | * stack access. | ||
| 697 | */ | ||
| 698 | 125 | void clear() noexcept { teardown_newest_first(); } | |
| 699 | |||
| 700 | private: | ||
| 701 | /// Restores the owned hooks strictly back-to-front (newest layer first): pop_back destroys the newest. | ||
| 702 | 133 | void teardown_newest_first() noexcept | |
| 703 | { | ||
| 704 |
2/2✓ Branch 5 → 3 taken 122 times.
✓ Branch 5 → 6 taken 133 times.
|
255 | while (!m_hooks.empty()) |
| 705 | { | ||
| 706 | 122 | m_hooks.pop_back(); | |
| 707 | } | ||
| 708 | 133 | } | |
| 709 | |||
| 710 | std::vector<Hook> m_hooks; | ||
| 711 | }; | ||
| 712 | |||
| 713 | /** | ||
| 714 | * @brief Installs a DISABLED inline hook at the request's target; call @ref Hook::enable to arm it. | ||
| 715 | * @tparam Fn The detour's function type. The function-to-void* cast happens here, behind a word-size | ||
| 716 | * static_assert. | ||
| 717 | * @param request Name, target (absolute or deferred scan), and policy. | ||
| 718 | * @param detour Pointer to the detour function. | ||
| 719 | * @return The RAII @ref Hook on success, with the target unpatched, or an Error. | ||
| 720 | * @details This call builds the trampoline and validates the target, so it reports an install failure here. | ||
| 721 | * Only the arming is deferred. Publish the returned handle where the detour can reach it, then | ||
| 722 | * enable. | ||
| 723 | * @warning The detour MUST NOT THROW. The patched target calls it directly, so an escaping exception unwinds | ||
| 724 | * through a caller that never expected one and terminates the host. The type does not enforce this, | ||
| 725 | * because `Fn *` accepts an ordinary function pointer. | ||
| 726 | * @warning Quiescence before teardown is CALLER-OWNED. An inline detour replaces the target and runs with DMK | ||
| 727 | * nowhere in the call path, so DMK cannot know whether a thread is still inside the detour and | ||
| 728 | * cannot wait for one. Prove that no thread can execute the detour before the handle dies. | ||
| 729 | * @ref mid_at owns this instead of the caller. | ||
| 730 | * @warning When the detour lives in a Logic DLL, that ownership extends to the unload. The required order is | ||
| 731 | * stop every thread that can reach the target, JOIN them, destroy the handle, and only then unmap | ||
| 732 | * the provider. Destroying the handle first leaves a thread inside a detour whose prologue is being | ||
| 733 | * restored; unmapping first leaves it executing freed pages. Neither is detectable from here. | ||
| 734 | * @note Setup/control-plane only: the install allocates the trampoline and validates the target. | ||
| 735 | */ | ||
| 736 | 332 | template <class Fn> [[nodiscard]] Result<Hook> inline_at(InlineRequest request, Fn *detour) | |
| 737 | { | ||
| 738 | static_assert(sizeof(Fn *) == sizeof(void *), "function pointer must be word-sized"); | ||
| 739 |
7/14std::expected<DetourModKit::hook::Hook, DetourModKit::Error> DetourModKit::hook::inline_at<int ((anonymous namespace)::CopyOnlyInt) noexcept>(DetourModKit::hook::InlineRequest, int (*)((anonymous namespace)::CopyOnlyInt) noexcept):
✓ Branch 5 → 6 taken 2 times.
✗ Branch 5 → 10 not taken.
std::expected<DetourModKit::hook::Hook, DetourModKit::Error> DetourModKit::hook::inline_at<int (std::unique_ptr<int, std::default_delete<int> >) noexcept>(DetourModKit::hook::InlineRequest, int (*)(std::unique_ptr<int, std::default_delete<int> >) noexcept):
✓ Branch 5 → 6 taken 2 times.
✗ Branch 5 → 10 not taken.
std::expected<DetourModKit::hook::Hook, DetourModKit::Error> DetourModKit::hook::inline_at<int (int, int) noexcept>(DetourModKit::hook::InlineRequest, int (*)(int, int) noexcept):
✓ Branch 5 → 6 taken 1 time.
✗ Branch 5 → 10 not taken.
std::expected<DetourModKit::hook::Hook, DetourModKit::Error> DetourModKit::hook::inline_at<unsigned long (unsigned long, _XINPUT_STATE*) noexcept>(DetourModKit::hook::InlineRequest, unsigned long (*)(unsigned long, _XINPUT_STATE*) noexcept):
✓ Branch 5 → 6 taken 3 times.
✗ Branch 5 → 10 not taken.
std::expected<DetourModKit::hook::Hook, DetourModKit::Error> DetourModKit::hook::inline_at<int (int)>(DetourModKit::hook::InlineRequest, int (*)(int)):
✓ Branch 5 → 6 taken 58 times.
✗ Branch 5 → 10 not taken.
std::expected<DetourModKit::hook::Hook, DetourModKit::Error> DetourModKit::hook::inline_at<int (int, int)>(DetourModKit::hook::InlineRequest, int (*)(int, int)):
✓ Branch 5 → 6 taken 189 times.
✗ Branch 5 → 10 not taken.
std::expected<DetourModKit::hook::Hook, DetourModKit::Error> DetourModKit::hook::inline_at<void ()>(DetourModKit::hook::InlineRequest, void (*)()):
✓ Branch 5 → 6 taken 77 times.
✗ Branch 5 → 10 not taken.
|
332 | return detail::inline_at_raw(std::move(request), reinterpret_cast<void *>(detour)); |
| 740 | } | ||
| 741 | |||
| 742 | /** | ||
| 743 | * @brief Installs a DISABLED mid-function hook at the request's target; call @ref Hook::enable to arm it. | ||
| 744 | * @param request Name, target (absolute or deferred scan), and policy. | ||
| 745 | * @param detour The DMK-typed mid-hook detour (keeps its MidHookFn type; no raw cast at the call site). | ||
| 746 | * @return The RAII @ref Hook on success, with the target unpatched, or an Error. | ||
| 747 | * `ErrorCode::MidHookCapacityExhausted` means every mid-hook adapter is in use and nothing was patched. | ||
| 748 | * @details See @ref inline_at for the two-step install transaction; it applies identically here. | ||
| 749 | * | ||
| 750 | * Unlike @ref inline_at, DMK reaches a mid-hook callback through its own adapter, so DMK owns | ||
| 751 | * exception containment and ordinary off-loader-lock rundown. Tombstoning is unconditional: no | ||
| 752 | * callback begins after ~Hook returns. A wait is not unconditional. Off the loader lock and outside | ||
| 753 | * the callback, destruction also waits out every admitted callback. An entrant that the adapter | ||
| 754 | * could not record cannot be ruled out as the destroying thread, so that case pins instead. | ||
| 755 | * @note A mid hook holds one adapter from a fixed pool for its lifetime. A clean teardown returns the | ||
| 756 | * adapter. A teardown that pins the backend instead (see @ref Hook::~Hook), and a hook retained by | ||
| 757 | * @ref Hook::release, keep theirs for the process lifetime, because the stub stays reachable. Loader- | ||
| 758 | * lock teardown and destruction from inside the callback both pin by design, so a host that does | ||
| 759 | * either at scale spends pool capacity permanently. | ||
| 760 | * @note On x64, first publication also commits a permanently retained routed gateway and inline trampoline. | ||
| 761 | * The backend reserves their bounded logical and allocator-block capacity before the hook can publish. | ||
| 762 | * Clean destruction restores the target but does not reclaim that routed chain. | ||
| 763 | * @note After ordinary off-loader-lock destruction returns, a pinned backend that remains patched is inert: | ||
| 764 | * its live recheck refuses later callbacks, and @ref is_target_hooked stays true for the patched target. | ||
| 765 | * @warning Every teardown that pins (loader lock, self-destruction, or an unrecordable entrant) tombstones but | ||
| 766 | * does not wait, so the callback provider must remain mapped until the admitted callback returns. | ||
| 767 | * @ref Hook::release bypasses tombstoning and keeps dispatching for the process lifetime. | ||
| 768 | * @note Setup/control-plane only: the install claims an adapter and builds the routed chain. | ||
| 769 | */ | ||
| 770 | [[nodiscard]] Result<Hook> mid_at(MidRequest request, MidHookFn detour); | ||
| 771 | |||
| 772 | struct InstallOutcome; | ||
| 773 | |||
| 774 | /// Internal tag carrying the one audited function-to-void* cast for a declarative inline @ref HookSpec. | ||
| 775 | struct InlineDetour | ||
| 776 | { | ||
| 777 | void *fn = nullptr; | ||
| 778 | }; | ||
| 779 | |||
| 780 | /** | ||
| 781 | * @class HookSpec | ||
| 782 | * @brief One row of a declarative install table consumed by @ref install_all. | ||
| 783 | * @details The factories are the SOLE constructor, so a forgotten name or target is a COMPILE error. The | ||
| 784 | * detour uses a typed variant: an @ref InlineDetour from the inline_hook factory's one audited cast, | ||
| 785 | * or a typed MidHookFn. A table author therefore never writes a reinterpret_cast. Each row carries | ||
| 786 | * @ref Options, which @ref install_all applies verbatim. One row can request @ref Prologue::Relocate | ||
| 787 | * or fail_if_already_hooked while its neighbours keep the safe default. | ||
| 788 | */ | ||
| 789 | class HookSpec | ||
| 790 | { | ||
| 791 | public: | ||
| 792 | /** | ||
| 793 | * @brief Builds an inline-hook row; performs the single audited function-to-void* cast. | ||
| 794 | * @tparam Fn The detour's function type (word-size static_assert). | ||
| 795 | * @param name Row name, forwarded to the eventual @ref InlineRequest. | ||
| 796 | * @param target Owned scan request that resolves the hook target. | ||
| 797 | * @param detour Typed inline detour function. | ||
| 798 | * @param severity Mandatory rows abort @ref install_all on failure; best-effort rows report the error and | ||
| 799 | * let later rows continue. | ||
| 800 | * @param options Per-row install policy (@ref Prologue escalation, fail_if_already_hooked). Defaults to the | ||
| 801 | * safe @ref Options default, so an existing table needs no change; set it to give one row a | ||
| 802 | * different policy than the rest without an out-of-band install call. | ||
| 803 | * @return A declarative table row consumed by @ref install_all. | ||
| 804 | * @note Setup/control-plane only: table construction may allocate through @p name and @p target. | ||
| 805 | */ | ||
| 806 | template <class Fn> | ||
| 807 | 13 | [[nodiscard]] static HookSpec inline_hook( | |
| 808 | std::string name, | ||
| 809 | scan::OwnedScanRequest target, | ||
| 810 | Fn *detour, | ||
| 811 | Severity severity = Severity::Mandatory, | ||
| 812 | Options options = {} | ||
| 813 | ) | ||
| 814 | { | ||
| 815 | static_assert(sizeof(Fn *) == sizeof(void *), "function pointer must be word-sized"); | ||
| 816 | return HookSpec{ | ||
| 817 | 13 | std::move(name), | |
| 818 | 13 | std::move(target), | |
| 819 | InlineDetour{reinterpret_cast<void *>(detour)}, | ||
| 820 | severity, | ||
| 821 | options | ||
| 822 | 26 | }; | |
| 823 | } | ||
| 824 | |||
| 825 | /** | ||
| 826 | * @brief Builds a mid-hook row; the @ref MidHookFn stays typed, with no raw cast at the call site. | ||
| 827 | * @param name Row name, forwarded to the eventual @ref MidRequest. | ||
| 828 | * @param target Owned scan request that resolves the hook target. | ||
| 829 | * @param detour Typed mid-hook detour. | ||
| 830 | * @param severity Mandatory rows abort @ref install_all on failure; best-effort rows report the error and | ||
| 831 | * let later rows continue. | ||
| 832 | * @param options Per-row install policy applied by @ref install_all. | ||
| 833 | * @return A declarative table row consumed by @ref install_all. | ||
| 834 | * @note Setup/control-plane only: table construction may allocate through @p name and @p target. | ||
| 835 | */ | ||
| 836 | [[nodiscard]] static HookSpec mid_hook( | ||
| 837 | std::string name, | ||
| 838 | scan::OwnedScanRequest target, | ||
| 839 | MidHookFn detour, | ||
| 840 | Severity severity = Severity::Mandatory, | ||
| 841 | Options options = {} | ||
| 842 | ) | ||
| 843 | { | ||
| 844 | return HookSpec{std::move(name), std::move(target), detour, severity, options}; | ||
| 845 | } | ||
| 846 | |||
| 847 | /// Returns the row name forwarded to the eventual install request. | ||
| 848 | [[nodiscard]] std::string_view name() const noexcept { return m_name; } | ||
| 849 | /// Returns whether this row is mandatory or best-effort. | ||
| 850 | [[nodiscard]] Severity severity() const noexcept { return m_severity; } | ||
| 851 | /// Returns the per-row install policy applied by @ref install_all. | ||
| 852 | [[nodiscard]] const Options &options() const noexcept { return m_options; } | ||
| 853 | |||
| 854 | private: | ||
| 855 | 13 | HookSpec( | |
| 856 | std::string name, | ||
| 857 | scan::OwnedScanRequest target, | ||
| 858 | std::variant<InlineDetour, MidHookFn> detour, | ||
| 859 | Severity severity, | ||
| 860 | Options options | ||
| 861 | ) noexcept | ||
| 862 | 39 | : m_name(std::move(name)), m_target(std::move(target)), m_detour(std::move(detour)), | |
| 863 | 13 | m_severity(severity), m_options(options) | |
| 864 | { | ||
| 865 | 13 | } | |
| 866 | |||
| 867 | std::string m_name; | ||
| 868 | scan::OwnedScanRequest m_target; | ||
| 869 | /// Inline vs mid is encoded by the active alternative. | ||
| 870 | std::variant<InlineDetour, MidHookFn> m_detour; | ||
| 871 | Severity m_severity; | ||
| 872 | /// Per-row install policy applied verbatim by @ref install_all. | ||
| 873 | Options m_options; | ||
| 874 | |||
| 875 | friend Result<std::vector<InstallOutcome>> install_all(std::span<const HookSpec> table) noexcept; | ||
| 876 | }; | ||
| 877 | |||
| 878 | /** | ||
| 879 | * @struct InstallOutcome | ||
| 880 | * @brief Per-row result of @ref install_all, in table order, so a mod can correlate which optional hooks | ||
| 881 | * landed. | ||
| 882 | * @warning A `std::vector<InstallOutcome>` has unspecified element destruction order. That order cannot prove | ||
| 883 | * newest-first teardown for hooks layered on one target (`[B-16]`, see @ref HookStack). Move | ||
| 884 | * successful hooks into a @ref HookStack in table order for clean teardown. | ||
| 885 | */ | ||
| 886 | struct InstallOutcome | ||
| 887 | { | ||
| 888 | std::string name; | ||
| 889 | Severity severity; | ||
| 890 | /// The installed Hook on success; an Error (e.g. NoMatch) when the row was skipped. | ||
| 891 | Result<Hook> hook; | ||
| 892 | }; | ||
| 893 | |||
| 894 | /** | ||
| 895 | * @brief Installs a whole declarative table of DISABLED hooks, returning one outcome per row. | ||
| 896 | * @param table The spec rows. Taken as a const span so a `const k_hook_table` binds; install_all copies each | ||
| 897 | * OwnedScanRequest it needs and never moves out of the caller's table. | ||
| 898 | * @return The per-row outcomes on success, with every successful row unpatched. LoaderLockActive fails before | ||
| 899 | * all rows. The first @ref Severity::Mandatory miss also fails the outer Result. | ||
| 900 | * @details Every row is installed disabled (see @ref inline_at), so a table lands as one unarmed unit: take | ||
| 901 | * ownership of the outcomes, then arm the rows you want by calling @ref Hook::enable on each. Rolling | ||
| 902 | * back a partial table therefore never has to disarm a live hook. noexcept, matching | ||
| 903 | * scan::resolve_batch: it catches bad_alloc / backend failure internally and reports it per row rather | ||
| 904 | * than throwing across the init path. | ||
| 905 | * @warning The returned vector has unspecified element destruction order. See the @ref InstallOutcome | ||
| 906 | * warning. Move successful hooks into a @ref HookStack in table order for newest-first teardown. | ||
| 907 | * @note Setup/control-plane only: a batch install that resolves scans and allocates per row. | ||
| 908 | */ | ||
| 909 | [[nodiscard]] Result<std::vector<InstallOutcome>> install_all(std::span<const HookSpec> table) noexcept; | ||
| 910 | |||
| 911 | /** | ||
| 912 | * @brief Reports whether a DMK hook (this kit) currently owns or is installing @p target. | ||
| 913 | * @details Consults this instance's ledger only; it is the exact same-kit query, not the foreign-JMP | ||
| 914 | * heuristic. Hooks installed by other statically-linked DMK consumers in the same process are not | ||
| 915 | * visible. During a concurrent install it may report true after the target is reserved but before the | ||
| 916 | * backend patch is committed; that fail-closed bias prevents a redundant racing install from treating | ||
| 917 | * the target as free. Use it to short-circuit a redundant install; to also catch foreign hooks, set | ||
| 918 | * Options::fail_if_already_hooked on the install instead. | ||
| 919 | * @note Setup/control-plane only: the query takes the ledger's exclusive mutex, which installs and teardowns | ||
| 920 | * contend on. | ||
| 921 | */ | ||
| 922 | [[nodiscard]] bool is_target_hooked(Address target) noexcept; | ||
| 923 | |||
| 924 | /** | ||
| 925 | * @struct VmtOptions | ||
| 926 | * @brief Policy for @ref vmt_for and @ref VmtHook::apply_to, symmetric with @ref Options. | ||
| 927 | */ | ||
| 928 | struct VmtOptions | ||
| 929 | { | ||
| 930 | /** | ||
| 931 | * @brief Refuse to clone/apply onto an object whose vptr already points at a vtable cloned by this kit. | ||
| 932 | * @details Cloning an object that is already on a clone reads the first clone as if it were the original | ||
| 933 | * vtable, so the first mod's hooked methods get baked into the second's "original" - the silent | ||
| 934 | * double-hook failure mode. Default false preserves the permissive behaviour. | ||
| 935 | */ | ||
| 936 | bool fail_if_already_hooked = false; | ||
| 937 | |||
| 938 | /** | ||
| 939 | * @brief Pre-flight-decode the first byte of the original vtable slot and refuse a breakpoint/jump-stub. | ||
| 940 | * @details A 0xCC/0xCD first byte is a breakpoint pad, not a function; a same-module `jmp rel8/rel32` is a | ||
| 941 | * jump stub (e.g. an incremental-link ILT entry). Both are rejected; MSVC adjustor thunks and | ||
| 942 | * real functions pass. Default false. Known false positive: a /INCREMENTAL consumer routes every | ||
| 943 | * function through an ILT stub, which this rejects. | ||
| 944 | */ | ||
| 945 | bool fail_on_non_function_pointer = false; | ||
| 946 | }; | ||
| 947 | |||
| 948 | class VmtHook; | ||
| 949 | |||
| 950 | /** | ||
| 951 | * @brief Clones the seed object's vtable and swaps the seed onto the clone, returning the owning handle. | ||
| 952 | * @param name A descriptive name for the hook. | ||
| 953 | * @param object The seed object whose vtable is cloned and whose vptr is swapped to the clone. | ||
| 954 | * @param options Create-time policy (fail-if-already-hooked, pre-flight slot decode). | ||
| 955 | * @return The RAII @ref VmtHook on success, or an Error (LoaderLockActive, InvalidArg, InvalidObject, | ||
| 956 | * HookAlreadyExists, BackendFailed, OutOfMemory, SystemCallFailed, or UnknownError). InvalidObject | ||
| 957 | * covers an unreadable, non-writable, or unaligned object word. It also covers an unreadable vtable or | ||
| 958 | * RTTI header prefix. A protection change, unmap, or displaced object word also returns InvalidObject. | ||
| 959 | * @warning Clone during setup or a host-quiesced window. Fault containment does not synchronize virtual | ||
| 960 | * dispatch or make concurrent object destruction safe. | ||
| 961 | * @note Setup/control-plane only: the clone allocates and mutates the seed object's vptr. | ||
| 962 | */ | ||
| 963 | [[nodiscard]] Result<VmtHook> vmt_for(std::string name, void *object, VmtOptions options = {}); | ||
| 964 | |||
| 965 | /** | ||
| 966 | * @class VmtHook | ||
| 967 | * @brief Move-only RAII handle for a cloned (hooked) vtable applied to one or more live objects. | ||
| 968 | * @details One clone may serve multiple objects; a @ref hook_method affects all of them. VMT hooks have no | ||
| 969 | * enable/disable operation. | ||
| 970 | * @warning The caller must quiesce virtual dispatch across create/apply/remove and keep every applied object | ||
| 971 | * alive through removal. Guarded vptr access is fault containment, not an ownership protocol. | ||
| 972 | * @note Concurrency: object-vptr transitions in @ref vmt_for / @ref apply_to / @ref remove_from / teardown are | ||
| 973 | * serialized by a setup-time object gate so duplicate create/apply checks and swaps are one ordered | ||
| 974 | * operation. @ref original copies the pre-hook slot pointer out under a shared-read lock and returns it, | ||
| 975 | * so TAKING that snapshot is serialised against a concurrent @ref apply_to / @ref hook_method / | ||
| 976 | * @ref remove_method (each takes the matching exclusive write) and never reads a torn mutation. The lock | ||
| 977 | * guards the snapshot, not the call: the returned pointer is then invoked lock-free, so the caller still | ||
| 978 | * owns the hook-outlives-the-call guarantee, exactly as with @ref Hook::original. | ||
| 979 | */ | ||
| 980 | class VmtHook | ||
| 981 | { | ||
| 982 | public: | ||
| 983 | VmtHook(VmtHook &&other) noexcept; | ||
| 984 | VmtHook &operator=(VmtHook &&other) noexcept; | ||
| 985 | VmtHook(const VmtHook &) = delete; | ||
| 986 | VmtHook &operator=(const VmtHook &) = delete; | ||
| 987 | |||
| 988 | /** | ||
| 989 | * @brief Restores the original vptr on every applied object, unless released, moved-from, or outranked. | ||
| 990 | * @details A writable object still on this clone is restored to its binding's original vptr. | ||
| 991 | * An object already at that original needs no write and releases the binding safely even when its | ||
| 992 | * word is not writable. Any other or unreadable value retains the dependency because a successor | ||
| 993 | * may still record this clone as the table it will restore. Safely restorable peers are restored, | ||
| 994 | * then an unresolved dependency leaks the clone rather than free a table still in use. The leak | ||
| 995 | * is counted on @ref diagnostics::LeakSubsystem::HookManager and logged with the hook's name. | ||
| 996 | * Destroy VMT hooks newest-first to get the original table back. | ||
| 997 | * @note Explicitly noexcept (a destructor is implicitly noexcept already): like @ref Hook::~Hook it runs | ||
| 998 | * from loader-lock teardown, so the no-throw contract is pinned at the declaration. | ||
| 999 | * @note Setup/control-plane only: teardown restores object vptrs; quiesce virtual dispatch first. | ||
| 1000 | */ | ||
| 1001 | ~VmtHook() noexcept; | ||
| 1002 | |||
| 1003 | /// True while this handle owns a live cloned vtable (false after a move-out or @ref release). | ||
| 1004 | [[nodiscard]] explicit operator bool() const noexcept; | ||
| 1005 | |||
| 1006 | /// The hook's registered name (empty for a moved-from / released handle). | ||
| 1007 | [[nodiscard]] std::string_view name() const noexcept; | ||
| 1008 | |||
| 1009 | /** | ||
| 1010 | * @brief Applies the cloned vtable to an additional live object, swapping its vptr. | ||
| 1011 | * @param object The object to put on the clone. | ||
| 1012 | * @param options Apply-time policy (fail-if-already-hooked, pre-flight slot decode). | ||
| 1013 | * @return Success, or an Error (LoaderLockActive, InvalidHookState, InvalidObject, HookAlreadyExists, | ||
| 1014 | * OutOfMemory, or UnknownError). InvalidObject covers an unreadable, non-writable, or unaligned | ||
| 1015 | * object word. It also covers a protection change, displacement, or unmap before publication. | ||
| 1016 | * HookAlreadyExists is likewise returned under every @p options value when this | ||
| 1017 | * handle cannot name what it would displace: @p object already carries this clone but was never | ||
| 1018 | * applied here, or @p object has since moved off the vptr this handle recorded for it (usually a | ||
| 1019 | * newer @ref VmtHook layered on it). Re-applying either would leave teardown restoring a vptr | ||
| 1020 | * @p object never had. Applying an object this handle already tracks and already published is a | ||
| 1021 | * success no-op. | ||
| 1022 | * @warning Apply only while @p object is host-quiesced; the atomic vptr update does not synchronize | ||
| 1023 | * dispatch. | ||
| 1024 | * @note Setup/control-plane only: the apply mutates @p object's vptr under the exclusive object gate. | ||
| 1025 | */ | ||
| 1026 | [[nodiscard]] Result<void> apply_to(void *object, VmtOptions options = {}); | ||
| 1027 | |||
| 1028 | /** | ||
| 1029 | * @brief Restores the original vptr on one applied object. | ||
| 1030 | * @param object The object to restore. | ||
| 1031 | * @return Success, or LoaderLockActive for a loader-lock caller / InvalidObject for a null @p object / | ||
| 1032 | * InvalidHookState for a disengaged handle / UnknownError when the exclusive object gate could not | ||
| 1033 | * be acquired. | ||
| 1034 | * @details Success does not assert that @p object was applied here, nor that a restore happened: an | ||
| 1035 | * untracked object is a harmless no-op, and a tracked one releases its binding only once its word | ||
| 1036 | * is observed at the recorded original. A writable object on this clone is swapped back to that | ||
| 1037 | * original unless a protection change or unmap defeats the swap. An object already at the original | ||
| 1038 | * needs no write and releases the binding even when its word is not writable. Any other or | ||
| 1039 | * unreadable value is left unchanged and retains the dependency, so teardown can restore it if it | ||
| 1040 | * returns to this clone or leak the clone rather than free a table a successor may still restore. | ||
| 1041 | * @warning Quiesce @p object before restoring it; fault containment does not drain in-flight dispatch. | ||
| 1042 | * @note Setup/control-plane only: the restore mutates @p object's vptr under the exclusive object gate. | ||
| 1043 | */ | ||
| 1044 | [[nodiscard]] Result<void> remove_from(void *object); | ||
| 1045 | |||
| 1046 | /** | ||
| 1047 | * @brief Redirects the virtual method at vtable @p index to @p detour in this handle's cloned vtable. | ||
| 1048 | * @tparam Fn The detour's function-pointer type; the function-to-void* cast happens here, once, behind a | ||
| 1049 | * word-size static_assert, so the call site never writes a reinterpret_cast. | ||
| 1050 | * @param index The zero-based vtable index of the method to hook. Count only virtual functions: the | ||
| 1051 | * ABI-specific vtable header (the Itanium offset-to-top + RTTI pointers, or the MSVC RTTI locator) is not | ||
| 1052 | * part of the index. Index 0 is the first virtual method as declared. | ||
| 1053 | * @param detour The replacement function, installed straight into a vtable slot. Its ABI must match the | ||
| 1054 | * original virtual method's true signature. The object pointer arrives as the first integer argument | ||
| 1055 | * (`this` in rcx under the Win64 ABI), followed by the declared parameters. hook_method cannot validate | ||
| 1056 | * that signature. A mismatch is silent ABI corruption. | ||
| 1057 | * @return Success, or an Error: LoaderLockActive (loader-lock caller), InvalidHookState (disengaged | ||
| 1058 | * handle), InvalidArg (null @p detour or an out-of-range @p index), MethodAlreadyHooked (occupied index), | ||
| 1059 | * BackendFailed, or OutOfMemory. | ||
| 1060 | * @warning The detour MUST NOT THROW. The slot holds it directly. | ||
| 1061 | * No DMK frame can contain an exception. An exception crosses a caller that expects none and | ||
| 1062 | * terminates the host. The type cannot enforce this rule because the slot accepts an ordinary | ||
| 1063 | * function pointer. `[B-84]` owns the rule. | ||
| 1064 | * @note Setup/control-plane only: mutates the clone under the exclusive write lock. | ||
| 1065 | * Do not call it from a hooked method's detour while another thread reads the same handle. Install | ||
| 1066 | * all method hooks during setup. | ||
| 1067 | */ | ||
| 1068 | 42 | template <detail::FunctionPointer Fn> [[nodiscard]] Result<void> hook_method(std::size_t index, Fn detour) | |
| 1069 | { | ||
| 1070 | static_assert(sizeof(Fn) == sizeof(void *), "function pointer must be word-sized"); | ||
| 1071 | 42 | return hook_method_raw(index, reinterpret_cast<void *>(detour)); | |
| 1072 | } | ||
| 1073 | |||
| 1074 | /** | ||
| 1075 | * @brief Returns the pre-hook function pointer for the method at vtable @p index, typed as Fn. | ||
| 1076 | * @tparam Fn The full function-pointer type of the original method, including the leading object pointer | ||
| 1077 | * as the first parameter (the Win64 ABI passes it in rcx). | ||
| 1078 | * @param index The zero-based vtable index used at @ref hook_method time. | ||
| 1079 | * @return A function pointer of type Fn to the original method's slot, or nullptr for an unhooked @p index | ||
| 1080 | * or a disengaged handle. | ||
| 1081 | * @details The per-method analogue of @ref Hook::original: the pre-hook slot value is copied out under a | ||
| 1082 | * shared-read lock, then invoked lock-free through the returned pointer. The slot pointer is fixed for the | ||
| 1083 | * hook's lifetime. The caller only has to keep the hook alive across the call. | ||
| 1084 | * @note Callback-safe on the read side (a shared-lock snapshot copy, no allocation or I/O); the returned | ||
| 1085 | * pointer's invocation is the caller's responsibility. | ||
| 1086 | */ | ||
| 1087 | 23 | template <detail::FunctionPointer Fn> [[nodiscard]] Fn original(std::size_t index) const noexcept | |
| 1088 | { | ||
| 1089 | 23 | return reinterpret_cast<Fn>(method_original_address(index)); | |
| 1090 | } | ||
| 1091 | |||
| 1092 | /** | ||
| 1093 | * @brief Lifts the method hook at vtable @p index, restoring the cloned vtable slot to the original. | ||
| 1094 | * @param index The zero-based vtable index previously passed to @ref hook_method. | ||
| 1095 | * @return Success, or an Error: LoaderLockActive (loader-lock caller) / InvalidHookState (disengaged | ||
| 1096 | * handle) / MethodNotFound (@p index is not hooked on this handle). | ||
| 1097 | * @note Setup/control-plane only: rewrites the cloned vtable slot back to the original function pointer | ||
| 1098 | * under the exclusive write lock. This clone-slot restore is a bare pointer write with no thread | ||
| 1099 | * protection against an in-flight dispatch through the slot; quiesce the method before lifting it. | ||
| 1100 | */ | ||
| 1101 | [[nodiscard]] Result<void> remove_method(std::size_t index); | ||
| 1102 | |||
| 1103 | /** | ||
| 1104 | * @brief Detaches the cloned vtable for the process lifetime (no vptr is restored; handle disengages). | ||
| 1105 | * @note Booked by @ref diagnostics::total_intentional_leaks, and the clone base stays recorded so | ||
| 1106 | * @ref VmtOptions::fail_if_already_hooked keeps recognising it. | ||
| 1107 | * @note Setup/control-plane only: transfers the clone to process-lifetime retention; do not call from a | ||
| 1108 | * hook or input callback. | ||
| 1109 | * @warning Applied objects continue to use the retained clone. | ||
| 1110 | * Each method detour and its callees must remain mapped until process exit. DMK pins its own | ||
| 1111 | * module, not the detour provider. A detour in a Logic DLL requires that DLL to stay loaded. If | ||
| 1112 | * code unmaps it, the clone slot points at unmapped code. | ||
| 1113 | */ | ||
| 1114 | void release() noexcept; | ||
| 1115 | |||
| 1116 | private: | ||
| 1117 | struct Impl; | ||
| 1118 | explicit VmtHook(std::unique_ptr<Impl> impl) noexcept; | ||
| 1119 | |||
| 1120 | /// The non-template method-install primitive behind @ref hook_method; defined in src/hook.cpp. | ||
| 1121 | [[nodiscard]] Result<void> hook_method_raw(std::size_t index, void *detour); | ||
| 1122 | |||
| 1123 | /** | ||
| 1124 | * @brief Snapshots the original slot pointer for @p index under the shared-read lock; the backend touch | ||
| 1125 | * behind @ref original. Returns nullptr for an unhooked index or a disengaged handle. Defined in | ||
| 1126 | * src/hook.cpp. | ||
| 1127 | */ | ||
| 1128 | [[nodiscard]] void *method_original_address(std::size_t index) const noexcept; | ||
| 1129 | |||
| 1130 | std::unique_ptr<Impl> m_impl; | ||
| 1131 | |||
| 1132 | friend Result<VmtHook> vmt_for(std::string name, void *object, VmtOptions options); | ||
| 1133 | }; | ||
| 1134 | } // namespace hook | ||
| 1135 | } // namespace DetourModKit | ||
| 1136 | |||
| 1137 | #endif // DETOURMODKIT_HOOK_HPP | ||
| 1138 |