GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 85.8% 830 / 0 / 967
Functions: 98.0% 99 / 0 / 101
Branches: 66.3% 486 / 0 / 733

src/hook.cpp
Line Branch Exec Source
1 /**
2 * @file hook.cpp
3 * @brief This TU implements hook lifecycle: install verbs, RAII handle teardown, and the VMT surface.
4 * @details The hook sibling TUs (this file, hook_toggle.cpp, hook_mid_context.cpp, internal/mid_hook_adapter.cpp)
5 * and their private backend headers form the only layer that names the SafetyHook backend.
6 */
7
8 #include "DetourModKit/hook.hpp"
9
10 #include "internal/hook_backend.hpp"
11 #include "internal/hook_backend_visit.hpp"
12 #include "internal/hook_emission.hpp"
13 #include "internal/hook_fault_boundary.hpp"
14 #include "internal/hook_ledger.hpp"
15 #include "internal/hook_patch_witness.hpp"
16 #include "internal/hook_publication.hpp"
17 #include "internal/lifecycle_context.hpp"
18
19 #include "internal/drain_backoff.hpp"
20 #include "internal/memory_guarded.hpp"
21 #include "internal/mid_hook_adapter.hpp"
22 #include "internal/scan_pages.hpp"
23
24 #include "DetourModKit/diagnostics.hpp"
25 #include "DetourModKit/format.hpp"
26 #include "DetourModKit/logger.hpp"
27
28 #include "platform.hpp"
29 #include "x86_decode.hpp"
30
31 #include <windows.h>
32
33 #include <algorithm>
34 #include <array>
35 #include <atomic>
36 #include <chrono>
37 #include <cstddef>
38 #include <cstdint>
39 #include <expected>
40 #include <functional>
41 #include <limits>
42 #include <memory>
43 #include <mutex>
44 #include <new>
45 #include <optional>
46 #include <shared_mutex>
47 #include <string>
48 #include <string_view>
49 #include <utility>
50 #include <variant>
51 #include <vector>
52
53 namespace DetourModKit::detail
54 {
55 #if defined(DMK_ENABLE_TEST_SEAMS)
56 namespace
57 {
58 std::atomic<std::uint64_t> s_hook_impl_destructions{0};
59 } // namespace
60
61 std::atomic<std::size_t> g_backend_toggle_exception_catches{0};
62
63 4 std::uint64_t hook_impl_destruction_count_for_test() noexcept
64 {
65 4 return s_hook_impl_destructions.load(std::memory_order_relaxed);
66 }
67
68 476 void note_hook_impl_destruction_for_test() noexcept
69 {
70 s_hook_impl_destructions.fetch_add(1, std::memory_order_relaxed);
71 476 }
72
73 // The test-only acquire_hook_self_ref() override lets the suite drive the otherwise-unreachable acquire failure
74 // branch. The override must SetLastError before a nullptr return to satisfy error.hpp's `detail = GetLastError()`
75 // contract.
76 HMODULE (*g_hook_module_ref_override)() noexcept = nullptr;
77
78 // Overrides the byte witness Hook::enable() takes after the backend reports a successful patch. The suite can then
79 // drive the negative branch a real backend does not produce on demand.
80 bool (*g_hook_enable_witness_override)(bool) noexcept = nullptr;
81 // Runs after a managed backend disable returns or throws and before DMK witnesses its target bytes.
82 void (*g_hook_backend_disable_probe)() noexcept = nullptr;
83 // Overrides whether ~Hook attempts backend disable. A false return or exception models a pre-mutation failure.
84 // It leaves the target patched. Post-process after a completed disable makes the pin unobservable.
85 bool (*g_hook_teardown_restore_override)() = nullptr;
86 // Fires at each inline/mid publication step after that step's state is visible. It is not noexcept on purpose.
87 // A probe exception exercises the same rollback as a real bad_alloc.
88 void (*g_hook_publish_probe)(HookPublishStep) = nullptr;
89 // HookTogglePublicationOrder.* owns this proof seam.
90 void (*g_hook_toggle_publication_probe)(bool, bool, bool, bool) noexcept = nullptr;
91 // Fires at the first operation boundary after one mutation entry passes its loader-lock veto.
92 void (*g_hook_post_loader_veto_probe)(HookLoaderEntry) noexcept = nullptr;
93 // This probe fires after the vtable pre-count and before the guarded snapshot capture.
94 void (*g_vmt_before_capture_probe)() noexcept = nullptr;
95 // This probe fires after the captured slot count becomes fixed and before the backend sizes its clone.
96 void (*g_vmt_before_backend_clone_probe)() noexcept = nullptr;
97 // This probe fires after VMT validation and before the guarded atomic publication attempt.
98 void (*g_vmt_before_publish_probe)(void *) noexcept = nullptr;
99 // This probe fires after the VMT object gate release and before the leak warning reaches the logger.
100 void (*g_vmt_teardown_warning_probe)() noexcept = nullptr;
101
102 // Arms the backend's post-commit transaction seam for one target, or disarms it with nullptr. The backend can
103 // return an error over a fully committed patch. enable() and disable() reconcile that exact state. This translation
104 // unit forwards the seam because only it names the backend.
105 12 void set_backend_reprotect_failure_target(void *target) noexcept
106 {
107 12 safetyhook::g_trap_restore_failure_override.store(
108 static_cast<std::uint8_t *>(target),
109 std::memory_order_release
110 );
111 12 }
112
113 // Arms a backend bad_alloc after transaction setup but before its mutation callback, or immediately after that
114 // callback. trap_threads still restores protections and removes its trap before it rethrows into DMK's boundary.
115 48 void set_backend_toggle_exception_for_test(void *target, bool after_mutation) noexcept
116 {
117
2/2
✓ Branch 2 → 3 taken 26 times.
✓ Branch 2 → 6 taken 22 times.
48 if (target == nullptr)
118 {
119 26 safetyhook::g_trap_exception_stage_override.store(
120 safetyhook::TrapExceptionStage::NONE,
121 std::memory_order_release
122 );
123 26 safetyhook::g_trap_exception_target_override.store(nullptr, std::memory_order_relaxed);
124 26 return;
125 }
126
127 g_backend_toggle_exception_catches.store(0, std::memory_order_relaxed);
128 22 safetyhook::g_trap_exception_target_override.store(
129 static_cast<std::uint8_t *>(target),
130 std::memory_order_relaxed
131 );
132
2/2
✓ Branch 15 → 16 taken 13 times.
✓ Branch 15 → 17 taken 9 times.
22 safetyhook::g_trap_exception_stage_override.store(
133 after_mutation ? safetyhook::TrapExceptionStage::AFTER_MUTATION
134 : safetyhook::TrapExceptionStage::BEFORE_MUTATION,
135 std::memory_order_release
136 );
137 }
138
139 /// Reports how many managed backend exceptions the current test arm reached and contained.
140 22 std::size_t backend_toggle_exception_catches_for_test() noexcept
141 {
142 22 return g_backend_toggle_exception_catches.load(std::memory_order_relaxed);
143 }
144
145 2 void set_backend_trap_transaction_hold_for_test(bool hold) noexcept
146 {
147
2/2
✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 4 taken 1 time.
2 if (hold)
148 {
149 1 safetyhook::g_trap_transaction_reached.store(false, std::memory_order_relaxed);
150 }
151 2 safetyhook::g_trap_transaction_hold.store(hold, std::memory_order_release);
152 2 }
153
154 3350 bool backend_trap_transaction_reached_for_test() noexcept
155 {
156 3350 return safetyhook::g_trap_transaction_reached.load(std::memory_order_acquire);
157 }
158
159 14 std::size_t backend_trap_protect_calls_for_test() noexcept
160 {
161 14 return safetyhook::g_trap_protect_calls.load(std::memory_order_relaxed);
162 }
163
164 1 void retire_backend_trap_runtime_for_test() noexcept
165 {
166 1 safetyhook::retire_trap_runtime_for_test();
167 1 }
168
169 10 TrapTransactionOutcome drive_backend_trap_transaction_for_test(
170 void *from,
171 void *to,
172 std::size_t len,
173 const std::function<void()> &run_fn
174 ) noexcept
175 {
176 try
177 {
178 10 safetyhook::reset_trap_restore_trace_for_test();
179
2/2
✓ Branch 3 → 4 taken 9 times.
✓ Branch 3 → 11 taken 1 time.
10 const std::expected<void, safetyhook::OsError> result = safetyhook::trap_threads(
180 static_cast<std::uint8_t *>(from),
181 static_cast<std::uint8_t *>(to),
182 len,
183 run_fn
184 );
185
2/2
✓ Branch 5 → 6 taken 4 times.
✓ Branch 5 → 7 taken 5 times.
9 return result ? TrapTransactionOutcome::Restored : TrapTransactionOutcome::ReportedFailure;
186 }
187 1 catch (...)
188 {
189 1 return TrapTransactionOutcome::Threw;
190 1 }
191 }
192
193 10 void set_backend_trap_change_failure_target_for_test(void *segment_address) noexcept
194 {
195 10 safetyhook::g_trap_change_failure_override.store(
196 static_cast<std::uint8_t *>(segment_address),
197 std::memory_order_release
198 );
199 10 }
200
201 10 void set_backend_trap_segment_restore_failure_target_for_test(void *segment_address) noexcept
202 {
203 10 safetyhook::g_trap_segment_restore_failure_override.store(
204 static_cast<std::uint8_t *>(segment_address),
205 std::memory_order_release
206 );
207 10 }
208
209 2 std::size_t backend_trap_restore_trace_size_for_test() noexcept
210 {
211 2 return safetyhook::trap_restore_trace_size_for_test();
212 }
213
214 5 void *backend_trap_restore_trace_address_for_test(std::size_t index) noexcept
215 {
216 5 return safetyhook::trap_restore_trace_address_for_test(index);
217 }
218
219 9 void reset_backend_instruction_flush_trace_for_test() noexcept
220 {
221 9 safetyhook::reset_instruction_cache_flush_trace_for_test();
222 9 }
223
224 7 void set_backend_instruction_flush_failure_call_for_test(std::size_t call) noexcept
225 {
226 safetyhook::g_instruction_cache_flush_failure_call.store(call, std::memory_order_release);
227 7 }
228
229 9 std::size_t backend_instruction_flush_trace_size_for_test() noexcept
230 {
231 9 return safetyhook::instruction_cache_flush_trace_size_for_test();
232 }
233
234 14 BackendInstructionFlushObservation backend_instruction_flush_trace_for_test(std::size_t index) noexcept
235 {
236 const safetyhook::InstructionCacheFlushObservation observation =
237 14 safetyhook::instruction_cache_flush_trace_for_test(index);
238 return BackendInstructionFlushObservation{
239 14 .address = observation.address,
240 14 .size = observation.size,
241 14 .protect_calls_before = observation.protect_calls_before,
242 14 .succeeded = observation.succeeded,
243 14 };
244 }
245
246 5 void force_backend_ff_hook_for_test(bool force) noexcept
247 {
248 5 safetyhook::force_ff_hook_for_test(force);
249 5 }
250
251 1 std::size_t backend_instruction_boundary_trace_size_for_test() noexcept
252 {
253 1 return safetyhook::instruction_boundary_trace_size_for_test();
254 }
255
256 3 std::array<std::size_t, 2> backend_instruction_boundary_trace_for_test(std::size_t index) noexcept
257 {
258 3 const safetyhook::InstructionBoundary boundary = safetyhook::instruction_boundary_trace_for_test(index);
259 return {
260 3 boundary.original_offset,
261 3 boundary.trampoline_offset,
262 3 };
263 }
264
265 2 std::uint8_t backend_last_inline_error_type_for_test() noexcept
266 {
267 2 return safetyhook::last_inline_hook_error_type_for_test();
268 }
269
270 2 void *backend_non_executable_transaction_marker_for_test() noexcept
271 {
272 2 return safetyhook::non_executable_transaction_marker_for_test();
273 }
274 #endif
275 } // namespace DetourModKit::detail
276
277 namespace DetourModKit
278 {
279 // File-local helpers live at DetourModKit scope, outside namespace hook. A bare `detail::` therefore resolves to
280 // DetourModKit::detail, the memory/x86/platform engine. A hook::detail subnamespace otherwise shadows it and
281 // breaks the unqualified lookup.
282 namespace
283 {
284 /**
285 * @brief Takes a counted reference on this module for an install path and honors the test override.
286 * @details On failure the primitive restores the thread's last-error, so the caller can read GetLastError()
287 * immediately after a null return.
288 */
289 676 [[nodiscard]] HMODULE acquire_hook_self_ref() noexcept
290 {
291 #if defined(DMK_ENABLE_TEST_SEAMS)
292
2/2
✓ Branch 2 → 3 taken 16 times.
✓ Branch 2 → 4 taken 660 times.
676 if (auto *override_fn = DetourModKit::detail::g_hook_module_ref_override)
293 {
294 16 return override_fn();
295 }
296 #endif
297 660 return DetourModKit::detail::acquire_module_ref(diagnostics::ModulePinReason::Hook);
298 }
299 /// Reports whether the foreign-inline-hook preflight found a present redirect and its destination.
300 enum class PrehookState : std::uint8_t
301 {
302 NotHooked,
303 HookedBySameModule,
304 HookedByOtherModule
305 };
306
307 struct PrehookDetection
308 {
309 PrehookState state{PrehookState::NotHooked};
310 std::uintptr_t jmp_destination{0};
311 };
312
313 /// Releases a module reference automatically unless ownership is handed to a hook Impl.
314 class ModuleRefGuard
315 {
316 public:
317 676 explicit ModuleRefGuard(HMODULE module) noexcept : m_module(module) {}
318
319 676 ~ModuleRefGuard() noexcept { detail::release_module_ref(m_module, diagnostics::ModulePinReason::Hook); }
320
321 ModuleRefGuard(const ModuleRefGuard &) = delete;
322 ModuleRefGuard &operator=(const ModuleRefGuard &) = delete;
323 ModuleRefGuard(ModuleRefGuard &&) = delete;
324 ModuleRefGuard &operator=(ModuleRefGuard &&) = delete;
325
326 608 [[nodiscard]] HMODULE release() noexcept { return std::exchange(m_module, nullptr); }
327
328 676 [[nodiscard]] HMODULE get() const noexcept { return m_module; }
329
330 private:
331 HMODULE m_module{nullptr};
332 };
333
334 /// Returns a claimed mid-adapter slot to the pool unless the install transaction commits it to an Impl.
335 class MidAdapterSlotGuard
336 {
337 public:
338 227 explicit MidAdapterSlotGuard(std::size_t index) noexcept : m_index(index) {}
339
340 // The guard is safe without a rundown because it fires only before hook arm. No adapter entry occurred.
341 // Once the Impl owns the slot, teardown runs its rundown instead.
342 227 ~MidAdapterSlotGuard() noexcept { detail::release_mid_adapter_slot(m_index); }
343
344 MidAdapterSlotGuard(const MidAdapterSlotGuard &) = delete;
345 MidAdapterSlotGuard &operator=(const MidAdapterSlotGuard &) = delete;
346 MidAdapterSlotGuard(MidAdapterSlotGuard &&) = delete;
347 MidAdapterSlotGuard &operator=(MidAdapterSlotGuard &&) = delete;
348
349 221 [[nodiscard]] std::size_t release() noexcept
350 {
351 221 return std::exchange(m_index, detail::MID_ADAPTER_CAPACITY);
352 }
353
354 private:
355 std::size_t m_index{detail::MID_ADAPTER_CAPACITY};
356 };
357
358 /**
359 * @brief Decodes an initial inline-hook redirect at @p target_address and returns its destination.
360 * @details Recognizes three redirect shapes that a foreign hook plants over a prologue. These shapes are E9
361 * rel32, FF 25 [rip+disp32], and 48 B8 imm64 plus FF E0. Returns nullopt for any other prologue.
362 */
363 523 std::optional<std::uintptr_t> decode_prehook_destination(std::uintptr_t target_address) noexcept
364 {
365 523 std::array<std::uint8_t, 2> opcode{};
366
1/2
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 523 times.
1046 if (!detail::guarded_read_bytes(target_address, opcode.data(), opcode.size()))
367 {
368 return std::nullopt;
369 }
370
371 // EB (jmp rel8) reaches at most +/-127 bytes. This range is too short to land in a foreign hook stub. An
372 // initial 0xEB is ordinary code and deliberately does not match.
373
2/2
✓ Branch 10 → 11 taken 6 times.
✓ Branch 10 → 12 taken 517 times.
523 if (opcode[0] == 0xE9)
374 {
375 6 return detail::decode_e9_rel32(target_address);
376 }
377
2/6
✗ Branch 13 → 14 not taken.
✓ Branch 13 → 17 taken 517 times.
✗ Branch 15 → 16 not taken.
✗ Branch 15 → 17 not taken.
✗ Branch 18 → 19 not taken.
✓ Branch 18 → 20 taken 517 times.
517 if (opcode[0] == 0xFF && opcode[1] == 0x25)
378 {
379 return detail::decode_ff25_indirect(target_address);
380 }
381
5/6
✓ Branch 21 → 22 taken 7 times.
✓ Branch 21 → 25 taken 510 times.
✓ Branch 23 → 24 taken 7 times.
✗ Branch 23 → 25 not taken.
✓ Branch 26 → 27 taken 7 times.
✓ Branch 26 → 28 taken 510 times.
517 if (opcode[0] == 0x48 && opcode[1] == 0xB8)
382 {
383 7 return detail::decode_mov_rax_imm64_jmp_rax(target_address);
384 }
385 510 return std::nullopt;
386 }
387
388 /// Detects whether @p target_address is already inline-hooked and classifies the module that owns the redirect.
389 523 PrehookDetection detect_existing_inline_hook(std::uintptr_t target_address) noexcept
390 {
391 523 PrehookDetection result;
392
1/2
✗ Branch 2 → 3 not taken.
✓ Branch 2 → 4 taken 523 times.
523 if (target_address == 0)
393 {
394 return result;
395 }
396 523 const auto destination_opt = decode_prehook_destination(target_address);
397
2/2
✓ Branch 6 → 7 taken 510 times.
✓ Branch 6 → 8 taken 13 times.
523 if (!destination_opt)
398 {
399 510 return result;
400 }
401 13 const auto destination = *destination_opt;
402 13 result.jmp_destination = destination;
403
404 13 HMODULE target_module = nullptr;
405 13 HMODULE dest_module = nullptr;
406
2/2
✓ Branch 10 → 11 taken 7 times.
✓ Branch 10 → 12 taken 6 times.
13 if (!GetModuleHandleExW(
407 GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
408 reinterpret_cast<LPCWSTR>(target_address),
409 &target_module
410 ))
411 {
412 7 target_module = nullptr;
413 }
414
2/2
✓ Branch 13 → 14 taken 7 times.
✓ Branch 13 → 15 taken 6 times.
13 if (!GetModuleHandleExW(
415 GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
416 reinterpret_cast<LPCWSTR>(destination),
417 &dest_module
418 ))
419 {
420 7 dest_module = nullptr;
421 }
422
3/4
✓ Branch 15 → 16 taken 6 times.
✓ Branch 15 → 18 taken 7 times.
✗ Branch 16 → 17 not taken.
✓ Branch 16 → 18 taken 6 times.
13 result.state = (dest_module != nullptr && target_module == dest_module) ? PrehookState::HookedBySameModule
423 : PrehookState::HookedByOtherModule;
424 13 return result;
425 }
426
427 /// PrologueRisk classifies the target's first byte during inline or mid preflight.
428 enum class PrologueRisk : std::uint8_t
429 {
430 None,
431 // Prologue::Fail refuses a 0xCC int3 or 0xCD int n.
432 Breakpoint,
433 // Every policy refuses an unreadable first byte.
434 Unreadable
435 };
436
437 // An initial rel32 call is left to the backend, which relocates it or fails typed. An unreadable first byte
438 // stays a distinct fail-closed result because the target can change after window validation.
439 537 PrologueRisk classify_prologue_risk(std::uintptr_t target_address) noexcept
440 {
441
1/2
✗ Branch 2 → 3 not taken.
✓ Branch 2 → 4 taken 537 times.
537 if (target_address == 0)
442 {
443 return PrologueRisk::None;
444 }
445 537 std::uint8_t first_byte = 0;
446
1/2
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 537 times.
537 if (!detail::guarded_read_bytes(target_address, &first_byte, sizeof(first_byte)))
447 {
448 return PrologueRisk::Unreadable;
449 }
450
2/2
✓ Branch 7 → 8 taken 8 times.
✓ Branch 7 → 9 taken 529 times.
537 switch (first_byte)
451 {
452 8 case 0xCC:
453 case 0xCD:
454 8 return PrologueRisk::Breakpoint;
455 529 default:
456 529 return PrologueRisk::None;
457 }
458 }
459
460 /// Returns a human-readable fragment for a flagged prologue risk in diagnostic log lines.
461 5 [[nodiscard]] std::string_view prologue_risk_description(PrologueRisk risk) noexcept
462 {
463
1/4
✓ Branch 2 → 3 taken 5 times.
✗ Branch 2 → 4 not taken.
✗ Branch 2 → 5 not taken.
✗ Branch 2 → 6 not taken.
5 switch (risk)
464 {
465 5 case PrologueRisk::Breakpoint:
466 5 return "a breakpoint (0xCC/0xCD)";
467 case PrologueRisk::Unreadable:
468 return "an unreadable byte";
469 case PrologueRisk::None:
470 return "an unremarkable byte";
471 }
472 return "an unremarkable byte";
473 }
474
475 // These formatters preserve each backend reason in the diagnostic log after failures map to
476 // ErrorCode::BackendFailed.
477 4 std::string backend_error_string(const safetyhook::InlineHook::Error &err)
478 {
479 4 const int type_int = static_cast<int>(err.type);
480
1/2
✓ Branch 2 → 3 taken 4 times.
✗ Branch 2 → 46 not taken.
4 const auto ip_str = format::format_address(reinterpret_cast<std::uintptr_t>(err.ip));
481
1/12
✗ Branch 3 → 4 not taken.
✗ Branch 3 → 6 not taken.
✗ Branch 3 → 8 not taken.
✗ Branch 3 → 10 not taken.
✗ Branch 3 → 12 not taken.
✗ Branch 3 → 14 not taken.
✗ Branch 3 → 16 not taken.
✗ Branch 3 → 18 not taken.
✗ Branch 3 → 20 not taken.
✓ Branch 3 → 22 taken 4 times.
✗ Branch 3 → 24 not taken.
✗ Branch 3 → 26 not taken.
4 switch (err.type)
482 {
483 case safetyhook::InlineHook::Error::BAD_ALLOCATION:
484 return std::format(
485 "InlineHook backend error ({}): bad allocation (allocator error {})",
486 type_int,
487 static_cast<int>(err.allocator_error)
488 );
489 case safetyhook::InlineHook::Error::FAILED_TO_DECODE_INSTRUCTION:
490 return std::format(
491 "InlineHook backend error ({}): failed to decode instruction at {}",
492 type_int,
493 ip_str
494 );
495 case safetyhook::InlineHook::Error::SHORT_JUMP_IN_TRAMPOLINE:
496 return std::format("InlineHook backend error ({}): short jump in trampoline at {}", type_int, ip_str);
497 case safetyhook::InlineHook::Error::IP_RELATIVE_INSTRUCTION_OUT_OF_RANGE:
498 return std::format(
499 "InlineHook backend error ({}): IP-relative instruction out of range at {}",
500 type_int,
501 ip_str
502 );
503 case safetyhook::InlineHook::Error::UNSUPPORTED_INSTRUCTION_IN_TRAMPOLINE:
504 return std::format(
505 "InlineHook backend error ({}): unsupported instruction in trampoline at {}",
506 type_int,
507 ip_str
508 );
509 case safetyhook::InlineHook::Error::FAILED_TO_UNPROTECT:
510 return std::format("InlineHook backend error ({}): failed to unprotect memory at {}", type_int, ip_str);
511 case safetyhook::InlineHook::Error::NOT_ENOUGH_SPACE:
512 return std::format(
513 "InlineHook backend error ({}): prologue too short for the hook at {}",
514 type_int,
515 ip_str
516 );
517 case safetyhook::InlineHook::Error::FAILED_TO_REGISTER_UNWIND:
518 return std::format(
519 "InlineHook backend error ({}): the platform refused unwind metadata for the routed "
520 "wrapper at {}",
521 type_int,
522 ip_str
523 );
524 case safetyhook::InlineHook::Error::ROUTE_RETENTION_EXHAUSTED:
525 return std::format(
526 "InlineHook backend error ({}): the routed retention ceiling refused the permanent "
527 "chain for {}",
528 type_int,
529 ip_str
530 );
531 4 case safetyhook::InlineHook::Error::FAILED_TO_FLUSH_INSTRUCTION_CACHE:
532 return std::format(
533 "InlineHook backend error ({}): failed to flush the instruction cache at {}",
534 type_int,
535 ip_str
536
1/2
✓ Branch 22 → 23 taken 4 times.
✗ Branch 22 → 41 not taken.
4 );
537 case safetyhook::InlineHook::Error::NON_EXECUTABLE_TRANSACTION_UNAVAILABLE:
538 return std::format(
539 "InlineHook backend error ({}): a non-executable patch transaction is unavailable at {}",
540 type_int,
541 ip_str
542 );
543 default:
544 return std::format("InlineHook backend error ({}): unknown error type", type_int);
545 }
546 4 }
547
548 2 std::string backend_error_string(const safetyhook::MidHook::Error &err)
549 {
550 2 const int type_int = static_cast<int>(err.type);
551
1/3
✗ Branch 2 → 3 not taken.
✓ Branch 2 → 5 taken 2 times.
✗ Branch 2 → 9 not taken.
2 switch (err.type)
552 {
553 case safetyhook::MidHook::Error::BAD_ALLOCATION:
554 return std::format(
555 "MidHook backend error ({}): bad allocation (allocator error {})",
556 type_int,
557 static_cast<int>(err.allocator_error)
558 );
559 2 case safetyhook::MidHook::Error::BAD_INLINE_HOOK:
560 return std::format(
561 "MidHook backend error ({}): bad underlying inline hook. {}",
562 type_int,
563
1/2
✓ Branch 5 → 6 taken 2 times.
✗ Branch 5 → 18 not taken.
4 backend_error_string(err.inline_hook_error)
564
1/2
✓ Branch 6 → 7 taken 2 times.
✗ Branch 6 → 15 not taken.
2 );
565 default:
566 return std::format("MidHook backend error ({}): unknown error type", type_int);
567 }
568 }
569
570 /**
571 * @brief Serializes VMT object-vptr transitions.
572 * @details A namespace-scope VmtHook can outlive ordinary hook statics. The mutex uses never-destroyed
573 * storage (`[B-47]`).
574 */
575 290 [[nodiscard]] std::mutex &vmt_object_mutex() noexcept
576 {
577 alignas(std::mutex) static unsigned char storage[sizeof(std::mutex)];
578
4/6
✓ Branch 2 → 3 taken 59 times.
✓ Branch 2 → 10 taken 231 times.
✓ Branch 4 → 5 taken 59 times.
✗ Branch 4 → 10 not taken.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 59 times.
290 static std::mutex *const gate = ::new (static_cast<void *>(storage)) std::mutex();
579 290 return *gate;
580 }
581
582 /**
583 * @brief Acquires the VMT object gate and returns an unowned lock if the OS mutex acquisition fails.
584 * @details Callers fail closed on an unowned lock. A restore without the gate races another object-vptr
585 * transition.
586 */
587 290 [[nodiscard]] std::unique_lock<std::mutex> acquire_vmt_object_lock() noexcept
588 {
589 try
590 {
591
1/2
✓ Branch 3 → 4 taken 290 times.
✗ Branch 3 → 6 not taken.
290 return std::unique_lock<std::mutex>(vmt_object_mutex());
592 }
593 catch (...)
594 {
595 return std::unique_lock<std::mutex>{};
596 }
597 }
598
599 // Decides whether @p slot_value, the first qword of a vtable slot, resembles a callable function body. A 0x00
600 // first byte marks an uninitialized page. Bytes 0xCC/0xCD are int3/int padding. Bytes 0xC2/0xC3 are bare RETs.
601 // An EB/E9 same-module jump is a stub, such as an incremental-link ILT entry or a patched slot. Its clone makes
602 // the new "original" a forwarder. MSVC adjustor thunks start with byte 0x48 and pass. Tail calls to a foreign
603 // module pass. The reads use fault guards.
604 5 bool looks_like_function_vmt_slot(std::uintptr_t slot_value) noexcept
605 {
606
1/2
✗ Branch 2 → 3 not taken.
✓ Branch 2 → 4 taken 5 times.
5 if (slot_value == 0)
607 {
608 return false;
609 }
610 5 std::uint8_t first_byte = 0;
611
1/2
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 5 times.
5 if (!detail::guarded_read_bytes(slot_value, &first_byte, sizeof(first_byte)))
612 {
613 return false;
614 }
615
2/2
✓ Branch 7 → 8 taken 3 times.
✓ Branch 7 → 9 taken 2 times.
5 switch (first_byte)
616 {
617 3 case 0x00:
618 case 0xCC:
619 case 0xCD:
620 case 0xC2:
621 case 0xC3:
622 3 return false;
623 2 default:
624 2 break;
625 }
626
627
3/4
✓ Branch 10 → 11 taken 2 times.
✗ Branch 10 → 12 not taken.
✓ Branch 11 → 12 taken 1 time.
✓ Branch 11 → 29 taken 1 time.
2 if (first_byte == 0xEB || first_byte == 0xE9)
628 {
629 1 HMODULE slot_module = nullptr;
630 1 HMODULE jmp_module = nullptr;
631 1 if (GetModuleHandleExW(
632 GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
633 reinterpret_cast<LPCWSTR>(slot_value),
634 &slot_module
635
1/2
✗ Branch 13 → 14 not taken.
✓ Branch 13 → 15 taken 1 time.
1 ) == 0)
636 {
637 1 return false;
638 }
639 const std::optional<std::uintptr_t> jmp_target =
640
1/2
✓ Branch 15 → 16 taken 1 time.
✗ Branch 15 → 17 not taken.
1 (first_byte == 0xE9) ? detail::decode_e9_rel32(slot_value) : detail::decode_eb_rel8(slot_value);
641
1/2
✗ Branch 19 → 20 not taken.
✓ Branch 19 → 21 taken 1 time.
1 if (!jmp_target)
642 {
643 return false;
644 }
645 2 if (GetModuleHandleExW(
646 GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
647 1 reinterpret_cast<LPCWSTR>(*jmp_target),
648 &jmp_module
649
1/2
✗ Branch 23 → 24 not taken.
✓ Branch 23 → 25 taken 1 time.
1 ) == 0)
650 {
651 return false;
652 }
653
1/2
✓ Branch 25 → 26 taken 1 time.
✗ Branch 25 → 27 not taken.
1 if (slot_module == jmp_module)
654 {
655 1 return false;
656 }
657 }
658 1 return true;
659 }
660
661 /**
662 * @brief Defines the hard cap on the vtable slot walk, which matches the bounded RTTI walkers.
663 * @details No real vtable approaches this many virtual methods. A walk that reaches the cap treats the seed
664 * object as malformed and fails closed.
665 */
666 constexpr std::size_t MAX_VMT_SLOTS = 4096;
667
668 // SafetyHook sizes a clone through an executable check for each slot target. This module-owned code address
669 // fixes that answer after DMK counts the captured words. The detached clone receives the captured function
670 // pointers before any host object can observe it.
671 void vmt_snapshot_executable_marker() noexcept {}
672
673 /**
674 * @brief Counts callable slots from the object's current vptr, guarded and capped at @ref MAX_VMT_SLOTS.
675 * @note The result bounds the guarded capture in @ref clone_vmt_snapshot. It is not the clone's slot count:
676 * the backend derives that from the captured snapshot, which is the only bound a slot write respects.
677 */
678 119 [[nodiscard]] std::optional<std::size_t> count_vmt_method_slots(std::uintptr_t vptr) noexcept
679 {
680 119 std::size_t count = 0;
681 for (;;)
682 {
683
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 4647 times.
4648 if (count >= MAX_VMT_SLOTS)
684 {
685 119 return std::nullopt;
686 }
687
1/2
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 4647 times.
4647 if (count > (std::numeric_limits<std::uintptr_t>::max() - vptr) / sizeof(std::uintptr_t))
688 {
689 return std::nullopt;
690 }
691
692 4647 const std::uintptr_t slot_address = vptr + (count * sizeof(std::uintptr_t));
693 4647 const std::optional<std::uintptr_t> slot = detail::guarded_read<std::uintptr_t>(slot_address);
694
1/2
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 4647 times.
4647 if (!slot)
695 {
696 return std::nullopt;
697 }
698 // detail::is_executable_address classifies the slot from VirtualQuery page state alone (B-66): the
699 // backend's executable query parses the owning module's PE headers unguarded and faults the host
700 // when that header page is unreadable. Lifecycle.VmtUnreadableModuleHeader pins the contract.
701
2/2
✓ Branch 14 → 15 taken 118 times.
✓ Branch 14 → 16 taken 4529 times.
4647 if (!detail::is_executable_address(*slot))
702 {
703 118 return count;
704 }
705 4529 ++count;
706 4529 }
707 }
708
709 /**
710 * @struct DetachedVmtBackend
711 * @brief Stores a backend clone that no host object points at yet and the facts its publisher needs.
712 * @details method_count matches the clone the backend allocated, not the caller's pre-count. It is the only
713 * bound between a caller's index and an unchecked slot write.
714 */
715 struct DetachedVmtBackend
716 {
717 safetyhook::VmtHook backend;
718 std::uintptr_t cloned_vptr_base{0};
719 std::size_t method_count{0};
720 };
721
722 /**
723 * @brief Clones an owned, count-normalized vtable snapshot. The backend remains unattached to any object.
724 * @return The detached backend, InvalidObject on a bad prefix/capture/empty table, or BackendFailed.
725 */
726 117 [[nodiscard]] Result<DetachedVmtBackend> clone_vmt_snapshot(std::uintptr_t vptr, std::size_t slot_budget)
727 {
728 117 constexpr std::size_t header_count = safetyhook::VMT_HEADER;
729 117 constexpr std::uintptr_t header_bytes = header_count * sizeof(std::uintptr_t);
730
1/2
✗ Branch 2 → 3 not taken.
✓ Branch 2 → 6 taken 117 times.
117 if (vptr < header_bytes)
731 {
732 return std::unexpected(Error{ErrorCode::InvalidObject, "hook::vmt_for", vptr});
733 }
734
735 // The final zero is a private non-executable sentinel for the backend slot walk.
736
2/2
✓ Branch 8 → 9 taken 115 times.
✓ Branch 8 → 88 taken 2 times.
119 std::vector<std::uintptr_t> snapshot(header_count + slot_budget + 1, 0);
737 115 const std::uintptr_t snapshot_source = vptr - header_bytes;
738 115 const std::size_t snapshot_bytes = (header_count + slot_budget) * sizeof(std::uintptr_t);
739
2/2
✓ Branch 12 → 13 taken 1 time.
✓ Branch 12 → 16 taken 114 times.
115 if (!detail::guarded_read_bytes(snapshot_source, snapshot.data(), snapshot_bytes))
740 {
741 1 return std::unexpected(Error{ErrorCode::InvalidObject, "hook::vmt_for", snapshot_source});
742 }
743
744 // Walk the captured words again rather than trust slot_budget. The budget came from foreign memory a
745 // moment earlier. The backend sizes its clone from THIS buffer. The two can disagree if the vtable changes
746 // between those steps. The backend bounds-checks no slot write, and hook_method admits any index below the
747 // count published here. A count for slots absent from the clone therefore causes an out-of-bounds write.
748 114 std::size_t cloned_slots = 0;
749 // Page-state classification only; see count_vmt_method_slots (B-66).
750
6/6
✓ Branch 18 → 19 taken 423 times.
✓ Branch 18 → 23 taken 112 times.
✓ Branch 21 → 22 taken 421 times.
✓ Branch 21 → 23 taken 2 times.
✓ Branch 24 → 17 taken 421 times.
✓ Branch 24 → 25 taken 114 times.
535 while (cloned_slots < slot_budget && detail::is_executable_address(snapshot[header_count + cloned_slots]))
751 {
752 421 ++cloned_slots;
753 }
754
1/2
✗ Branch 25 → 26 not taken.
✓ Branch 25 → 29 taken 114 times.
114 if (cloned_slots == 0)
755 {
756 return std::unexpected(Error{ErrorCode::InvalidObject, "hook::vmt_for", vptr});
757 }
758 // Terminate the counted run so no later step can reach past it.
759 114 snapshot[header_count + cloned_slots] = 0;
760
761 // The captured pointer words are stable, but the execute protections of the pages they name are not. If a
762 // target loses execute permission between the walk above and SafetyHook's walk, the backend can allocate
763 // fewer slots than method_count permits. Give the backend an equally-sized run of a module-owned marker,
764 // then restore the captured pointers into its detached clone before publication.
765
2/2
✓ Branch 30 → 31 taken 113 times.
✓ Branch 30 → 104 taken 1 time.
114 std::vector<std::uintptr_t> backend_snapshot = snapshot;
766 113 const std::uintptr_t executable_marker = reinterpret_cast<std::uintptr_t>(&vmt_snapshot_executable_marker);
767 113 std::fill_n(
768 113 backend_snapshot.begin() + static_cast<std::ptrdiff_t>(header_count),
769 cloned_slots,
770 executable_marker
771 );
772 #if defined(DMK_ENABLE_TEST_SEAMS)
773
2/2
✓ Branch 58 → 59 taken 1 time.
✓ Branch 58 → 60 taken 112 times.
113 if (auto *probe = DetourModKit::detail::g_vmt_before_backend_clone_probe)
774 {
775 1 probe();
776 }
777 #endif
778 113 auto *surrogate_vptr = reinterpret_cast<std::uint8_t **>(backend_snapshot.data() + header_count);
779
2/2
✓ Branch 61 → 62 taken 109 times.
✓ Branch 61 → 102 taken 4 times.
113 auto created = safetyhook::VmtHook::create(static_cast<void *>(&surrogate_vptr));
780
1/2
✗ Branch 63 → 64 not taken.
✓ Branch 63 → 67 taken 109 times.
109 if (!created)
781 {
782 return std::unexpected(Error{ErrorCode::BackendFailed, "hook::vmt_for", vptr});
783 }
784
785
2/4
✓ Branch 67 → 68 taken 109 times.
✗ Branch 67 → 100 not taken.
✓ Branch 70 → 71 taken 109 times.
✗ Branch 70 → 100 not taken.
218 safetyhook::VmtHook backend = std::move(created.value());
786 109 const std::uintptr_t cloned_vptr_base = reinterpret_cast<std::uintptr_t>(surrogate_vptr);
787
1/2
✓ Branch 72 → 73 taken 109 times.
✗ Branch 72 → 98 not taken.
109 std::copy_n(
788 109 snapshot.data() + header_count,
789 cloned_slots,
790 reinterpret_cast<std::uintptr_t *>(cloned_vptr_base)
791 );
792 // Erase the stack surrogate from the backend before it leaves scope. Real host objects are published and
793 // restored only through DMK's guarded swaps, so the backend never retains a foreign object pointer.
794
1/2
✓ Branch 73 → 74 taken 109 times.
✗ Branch 73 → 98 not taken.
109 backend.remove(static_cast<void *>(&surrogate_vptr));
795
3/8
✓ Branch 76 → 77 taken 109 times.
✗ Branch 76 → 97 not taken.
✓ Branch 77 → 78 taken 109 times.
✗ Branch 77 → 92 not taken.
✗ Branch 80 → 81 not taken.
✓ Branch 80 → 82 taken 109 times.
✗ Branch 94 → 95 not taken.
✗ Branch 94 → 96 not taken.
109 return DetachedVmtBackend{std::move(backend), cloned_vptr_base, cloned_slots};
796 119 }
797
798 [[nodiscard]] bool
799 222 publish_vmt_object_word(void *object, std::uintptr_t expected, std::uintptr_t replacement) noexcept
800 {
801 #if defined(DMK_ENABLE_TEST_SEAMS)
802
2/2
✓ Branch 2 → 3 taken 6 times.
✓ Branch 2 → 4 taken 216 times.
222 if (auto *probe = DetourModKit::detail::g_vmt_before_publish_probe)
803 {
804 6 probe(object);
805 }
806 #endif
807 222 return detail::guarded_compare_exchange_word(
808 reinterpret_cast<std::uintptr_t>(object),
809 expected,
810 replacement
811 222 );
812 }
813
814 /// Resolves a hook Target to an absolute address, through scan::resolve for a deferred OwnedScanRequest.
815 573 Result<std::uintptr_t> resolve_target(const hook::Target &target) noexcept
816 {
817
2/2
✓ Branch 3 → 4 taken 562 times.
✓ Branch 3 → 7 taken 11 times.
573 if (const Address *absolute = std::get_if<Address>(&target))
818 {
819 562 return absolute->raw();
820 }
821 11 const auto *request = std::get_if<scan::OwnedScanRequest>(&target);
822
1/2
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 12 taken 11 times.
11 if (request == nullptr)
823 {
824 return std::unexpected(Error{ErrorCode::InvalidTargetAddress, "hook::resolve_target"});
825 }
826 // scan::resolve can allocate and throw. This helper is noexcept, so it contains the exception and reports
827 // an Error instead of host termination.
828 try
829 {
830
1/2
✓ Branch 13 → 14 taken 11 times.
✗ Branch 13 → 27 not taken.
11 Result<scan::Hit> hit = scan::resolve(request->view());
831
2/2
✓ Branch 15 → 16 taken 3 times.
✓ Branch 15 → 20 taken 8 times.
11 if (!hit)
832 {
833 3 return std::unexpected(hit.error());
834 }
835 8 return hit->address.raw();
836 11 }
837 catch (const std::bad_alloc &)
838 {
839 return std::unexpected(Error{ErrorCode::OutOfMemory, "hook::resolve_target"});
840 }
841 catch (...)
842 {
843 return std::unexpected(Error{ErrorCode::Unknown, "hook::resolve_target"});
844 }
845 }
846
847 /// ReservedTarget contains the resolved target and its ledger id from @ref preflight_target.
848 struct PreflightResult
849 {
850 std::uintptr_t address{0};
851 std::uint64_t ledger_id{0};
852 };
853
854 /**
855 * @brief Resolves, validates, checks, and reserves a ledger slot for an inline or mid hook target.
856 * @return The target address and its reserved ledger id, or the Error that fails the install.
857 * @details Waits until the reservation is first in the target queue. Backend patches for one target
858 * follow creation order. On success, the caller owns the returned ledger id. Commit it before handle
859 * publication, or roll it back through HookLedger::release_hook.
860 */
861 573 Result<PreflightResult> preflight_target(
862 const hook::Target &target,
863 const hook::Options &options,
864 std::string_view name,
865 const char *where
866 ) noexcept
867 {
868 573 Result<std::uintptr_t> resolved = resolve_target(target);
869
2/2
✓ Branch 4 → 5 taken 3 times.
✓ Branch 4 → 9 taken 570 times.
573 if (!resolved)
870 {
871 3 return std::unexpected(resolved.error());
872 }
873 570 const std::uintptr_t address = *resolved;
874
2/2
✓ Branch 10 → 11 taken 2 times.
✓ Branch 10 → 14 taken 568 times.
570 if (address == 0)
875 {
876 2 return std::unexpected(Error{ErrorCode::InvalidTargetAddress, where});
877 }
878
879 // The backend capability floor runs before any reservation, so a refusal needs no rollback.
880 // Options::prologue does not alter this check. Prologue::Relocate must not authorize an unreadable
881 // prologue.
882 568 const detail::TargetWindowResult window = detail::validate_backend_steal_window(address);
883
2/2
✓ Branch 15 → 16 taken 12 times.
✓ Branch 15 → 25 taken 556 times.
568 if (window.verdict != detail::TargetWindowVerdict::Ok)
884 {
885 12 (void)log().try_log(
886 LogLevel::Warning,
887 "hook: '{}' refused target 0x{:0{}X}: {}.",
888 name,
889 address,
890 12 sizeof(std::uintptr_t) * 2,
891 12 detail::target_window_description(window.verdict)
892 );
893 12 return std::unexpected(
894 12 Error{
895
1/2
✗ Branch 19 → 20 not taken.
✓ Branch 19 → 21 taken 12 times.
12 window.verdict == detail::TargetWindowVerdict::Unreadable ? ErrorCode::ReadFaulted
896 : ErrorCode::TargetPrologueUnsafe,
897 where,
898 12 window.detail
899 }
900 12 );
901 }
902
903 // try_reserve_hook folds the same-kit duplicate check and the id record into one locked step, so a
904 // concurrent same-target install cannot slip between them.
905 const detail::HookLedger::Reservation reservation =
906 556 detail::HookLedger::instance().try_reserve_hook(address, options.fail_if_already_hooked);
907
2/2
✓ Branch 27 → 28 taken 6 times.
✓ Branch 27 → 31 taken 550 times.
556 if (reservation.status == detail::HookLedger::ReserveStatus::OutOfMemory)
908 {
909 // The ledger allocation failed. Fail closed instead of an install of a live but unledgered hook.
910 6 return std::unexpected(Error{ErrorCode::OutOfMemory, where, address});
911 }
912
2/2
✓ Branch 31 → 32 taken 9 times.
✓ Branch 31 → 35 taken 541 times.
550 if (reservation.status == detail::HookLedger::ReserveStatus::AlreadyHooked)
913 {
914 9 return std::unexpected(Error{ErrorCode::TargetAlreadyHookedByThisKit, where, address});
915 }
916
917
2/2
✓ Branch 35 → 36 taken 18 times.
✓ Branch 35 → 39 taken 523 times.
541 if (reservation.preexisting)
918 {
919 // If this install layers on a same-kit hook, warn and continue.
920 18 (void)log().try_log(
921 LogLevel::Warning,
922 "hook: '{}' layers on a hook this kit already placed at 0x{:0{}X}. Destroy "
923 "layered hooks newest-first.",
924 name,
925 address,
926 18 sizeof(std::uintptr_t) * 2
927 );
928 }
929 else
930 {
931 // Consult the foreign-JMP heuristic. On a strict refusal, roll back the reservation before failure.
932 523 const PrehookDetection prehook = detect_existing_inline_hook(address);
933
2/2
✓ Branch 40 → 41 taken 13 times.
✓ Branch 40 → 50 taken 510 times.
523 if (prehook.state == PrehookState::HookedByOtherModule)
934 {
935
2/2
✓ Branch 41 → 42 taken 4 times.
✓ Branch 41 → 47 taken 9 times.
13 if (options.fail_if_already_hooked)
936 {
937 4 (void)detail::HookLedger::instance().release_hook(address, reservation.id);
938 4 return std::unexpected(Error{ErrorCode::TargetAlreadyHookedByAnotherModule, where, address});
939 }
940 9 (void)log().try_log(
941 LogLevel::Warning,
942 "hook: '{}' detects another module's inline hook at target 0x{:0{}X} "
943 "(JMP -> 0x{:0{}X}). The new hook layers on top.",
944 name,
945 address,
946 18 sizeof(std::uintptr_t) * 2,
947 prehook.jmp_destination,
948 9 sizeof(std::uintptr_t) * 2
949 );
950 }
951 }
952
953 // Prologue preflight is independent of the layer checks. An unhooked target can still start with a call
954 // thunk or patched int3. On a Fail-policy refusal, roll back the reservation before failure.
955 537 const PrologueRisk risk = classify_prologue_risk(address);
956
1/2
✗ Branch 52 → 53 not taken.
✓ Branch 52 → 58 taken 537 times.
537 if (risk == PrologueRisk::Unreadable)
957 {
958 (void)detail::HookLedger::instance().release_hook(address, reservation.id);
959 return std::unexpected(Error{ErrorCode::ReadFaulted, where, address});
960 }
961
2/2
✓ Branch 58 → 59 taken 8 times.
✓ Branch 58 → 69 taken 529 times.
537 if (risk == PrologueRisk::Breakpoint)
962 {
963
2/2
✓ Branch 59 → 60 taken 3 times.
✓ Branch 59 → 65 taken 5 times.
8 if (options.prologue == hook::Prologue::Fail)
964 {
965 3 (void)detail::HookLedger::instance().release_hook(address, reservation.id);
966 3 return std::unexpected(Error{ErrorCode::TargetPrologueUnsafe, where, address});
967 }
968 5 (void)log().try_log(
969 LogLevel::Warning,
970 "hook: '{}' target 0x{:0{}X} begins with {}. Installation continues under the "
971 "Relocate prologue policy.",
972 name,
973 address,
974 5 sizeof(std::uintptr_t) * 2,
975 10 prologue_risk_description(risk)
976 );
977 }
978 534 return PreflightResult{address, reservation.id};
979 }
980
981 using detail::apply_backend;
982 using detail::backend_value_or;
983 using detail::emit_lifecycle;
984 using detail::inline_trampoline;
985 using detail::PatchWitness;
986 using detail::RemovalPopulationState;
987 using detail::try_backend_disable;
988 using detail::witness_description;
989 using detail::witness_of;
990 using detail::witness_permits_write;
991
992 /**
993 * @brief Runs the teardown backend disable and reports the later owner of the target bytes.
994 * @details The witness is taken whatever the disable reported, because the two disagree in both directions.
995 * The byte class is the complete verdict. Only @ref PatchWitness::Original authorizes backend
996 * destruction.
997 */
998 template <class BackendVariant>
999 487 [[nodiscard]] PatchWitness run_teardown_restore(BackendVariant &backend) noexcept
1000 {
1001 // Classify before the restore, so foreign bytes are refused rather than overwritten.
1002 487 const PatchWitness before = witness_of(backend);
1003
2/2
✓ Branch 4 → 5 taken 6 times.
✓ Branch 4 → 6 taken 481 times.
487 if (!witness_permits_write(before))
1004 {
1005 6 return before;
1006 }
1007
1008 #if defined(DMK_ENABLE_TEST_SEAMS)
1009 // A false result or exception models a pre-mutation failure because it suppresses the backend call. The
1010 // witness below reconciles real backend exceptions after try_backend_disable contains them.
1011 481 bool run_restore = true;
1012 try
1013 {
1014
2/2
✓ Branch 6 → 7 taken 5 times.
✓ Branch 6 → 9 taken 476 times.
481 if (auto *override_fn = DetourModKit::detail::g_hook_teardown_restore_override)
1015 {
1016
2/2
✓ Branch 7 → 8 taken 4 times.
✓ Branch 7 → 18 taken 1 time.
5 run_restore = override_fn();
1017 }
1018 }
1019 1 catch (...)
1020 {
1021 1 run_restore = false;
1022 }
1023
2/2
✓ Branch 9 → 10 taken 476 times.
✓ Branch 9 → 12 taken 5 times.
481 if (run_restore)
1024 #endif
1025 {
1026 952 (void)backend_value_or(backend, false, [](auto &one) noexcept { return try_backend_disable(one); });
1027 }
1028 481 const PatchWitness after = witness_of(backend);
1029
2/2
✓ Branch 13 → 14 taken 474 times.
✓ Branch 13 → 16 taken 7 times.
481 if (after == PatchWitness::Original)
1030 {
1031 // The byte witness proves the target cannot reach this trampoline. Clear a stale backend flag so its
1032 // destructor cannot retry the caught operation outside this noexcept containment boundary.
1033 948 (void)apply_backend(backend, [](auto &one) noexcept { one.reconcile_enabled(false); });
1034 }
1035 481 return after;
1036 }
1037
1038 /**
1039 * @brief Bounds the wait for backend-route entrants admitted before target restoration.
1040 * @details Only the generated stub's own instructions remain here, so expiry is evidence of a parked or
1041 * indefinitely descheduled thread, not a slow one.
1042 */
1043 constexpr auto ROUTE_DRAIN_TIMEOUT = std::chrono::seconds{1};
1044
1045 /**
1046 * @brief Waits for the backend route to empty, or reports that proof of an empty route timed out.
1047 * @note A false return never licenses reclamation. The caller must retain the backend.
1048 */
1049 471 template <class BackendVariant> [[nodiscard]] bool drain_backend_route(BackendVariant &backend) noexcept
1050 {
1051 471 return DetourModKit::detail::drain_until_zero(
1052 471 [&backend]() noexcept
1053 {
1054 922 return backend_value_or(
1055 backend,
1056 std::size_t{1},
1057 1844 [](const auto &one) noexcept { return one.route_entries(); }
1058 922 );
1059 },
1060 942 std::chrono::steady_clock::now() + ROUTE_DRAIN_TIMEOUT
1061 471 );
1062 }
1063
1064 #if defined(DMK_ENABLE_TEST_SEAMS)
1065 /** @brief Fires the publication probe after @p step is complete. */
1066 2043 void note_publish_step(DetourModKit::detail::HookPublishStep step)
1067 {
1068
2/2
✓ Branch 2 → 3 taken 31 times.
✓ Branch 2 → 4 taken 2012 times.
2043 if (auto *probe = DetourModKit::detail::g_hook_publish_probe)
1069 {
1070 31 probe(step);
1071 }
1072 2035 }
1073 #endif
1074
1075 /**
1076 * @brief Checks the backend steal window immediately before the patch and releases @p ledger_id on failure.
1077 * @details The self-reference acquire takes the loader lock exactly when another thread can complete an unload.
1078 * @warning This check narrows the window but does not close it (see hook_fault_boundary.hpp). It provides error
1079 * attribution, not a safety property.
1080 */
1081 std::optional<Error>
1082 519 revalidate_before_patch(std::uintptr_t target, std::uint64_t ledger_id, const char *where) noexcept
1083 {
1084 519 const detail::TargetWindowResult window = detail::validate_backend_steal_window(target);
1085
1/2
✓ Branch 3 → 4 taken 519 times.
✗ Branch 3 → 5 not taken.
519 if (window.verdict == detail::TargetWindowVerdict::Ok)
1086 {
1087 519 return std::nullopt;
1088 }
1089 (void)DetourModKit::detail::HookLedger::instance().release_hook(target, ledger_id);
1090 return Error{
1091 window.verdict == detail::TargetWindowVerdict::Unreadable ? ErrorCode::ReadFaulted
1092 : ErrorCode::TargetPrologueUnsafe,
1093 where,
1094 window.detail
1095 };
1096 }
1097 } // namespace
1098
1099 namespace hook
1100 {
1101 #if defined(DMK_ENABLE_TEST_SEAMS)
1102 476 Hook::Impl::~Impl() noexcept
1103 {
1104 476 DetourModKit::detail::note_hook_impl_destruction_for_test();
1105 476 }
1106 #endif
1107
1108 534 const std::shared_ptr<safetyhook::Allocator> &backend_allocator() noexcept
1109 {
1110 // One allocator hold exists per linked DMK instance. It occupies static storage and is never released. A
1111 // plain function-local static registers a destructor. A later Hook destructor can otherwise free its
1112 // trampoline into a destroyed allocator arena.
1113 alignas(
1114 std::shared_ptr<safetyhook::Allocator>
1115 ) static unsigned char storage[sizeof(std::shared_ptr<safetyhook::Allocator>)];
1116 301 static const std::shared_ptr<safetyhook::Allocator> *const allocator = ::new (static_cast<void *>(storage))
1117
4/6
✓ Branch 2 → 3 taken 301 times.
✓ Branch 2 → 10 taken 233 times.
✓ Branch 4 → 5 taken 301 times.
✗ Branch 4 → 10 not taken.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 301 times.
534 std::shared_ptr<safetyhook::Allocator>(safetyhook::Allocator::global());
1118 534 return *allocator;
1119 }
1120
1121 // Hook is the RAII handle for one inline or mid hook.
1122 1010 Hook::Hook(std::unique_ptr<Impl> impl, std::shared_ptr<CallGate> gate) noexcept : m_impl(std::move(impl))
1123 {
1124 1010 m_gate.store(std::move(gate), std::memory_order_release);
1125 505 }
1126
1127 2132 Hook::Hook(Hook &&other) noexcept : m_impl(std::move(other.m_impl))
1128 {
1129 // std::atomic is not movable. exchange leaves the source's gate empty, so a moved-from handle is fully
1130 // disengaged and fails closed.
1131 1066 m_gate.store(other.m_gate.exchange(nullptr, std::memory_order_acq_rel), std::memory_order_release);
1132 1066 }
1133
1134 1 Hook &Hook::operator=(Hook &&other) noexcept
1135 {
1136
1/2
✓ Branch 2 → 3 taken 1 time.
✗ Branch 2 → 16 not taken.
1 if (this != &other)
1137 {
1138 // Adopt the current hook into a temporary whose ~Hook runs the loader-lock-aware teardown. A
1139 // concurrent call() that pinned this handle's gate keeps the old trampoline alive until it returns.
1140 1 Hook discard(std::move(*this));
1141 2 m_impl = std::move(other.m_impl);
1142 1 m_gate.store(other.m_gate.exchange(nullptr, std::memory_order_acq_rel), std::memory_order_release);
1143 1 }
1144 1 return *this;
1145 }
1146
1147 2039 Hook::~Hook() noexcept
1148 {
1149 // Take the gate reference out for the complete teardown. A pinned caller keeps the gate alive through its
1150 // own reference. A null callable below makes a late caller fail closed.
1151 1570 std::shared_ptr<CallGate> gate = m_gate.exchange(nullptr, std::memory_order_acq_rel);
1152
2/2
✓ Branch 6 → 7 taken 1077 times.
✓ Branch 6 → 8 taken 493 times.
1570 if (!m_impl)
1153 {
1154 1077 return;
1155 }
1156
1157 // Tombstone the mid-hook adapter before any teardown decision below. A late entrant exits at the adapter's
1158 // second live check. A pinned stub then becomes inert instead of a call to a destroyed owner. Inline hooks
1159 // have no slot. Their detour is the user function.
1160 493 const std::size_t mid_slot = m_impl->mid_slot;
1161 493 const bool has_mid_slot = mid_slot < DetourModKit::detail::MID_ADAPTER_CAPACITY;
1162
2/2
✓ Branch 9 → 10 taken 220 times.
✓ Branch 9 → 12 taken 273 times.
493 if (has_mid_slot)
1163 {
1164 220 DetourModKit::detail::mid_adapter_slots()[mid_slot].live.store(false, std::memory_order_seq_cst);
1165 }
1166
1167 // Loader-lock leaf discipline forbids a prologue restore or backend destruction under the loader lock.
1168 // Either action can deadlock, so leak the Impl instead. Its module reference keeps the trampoline code
1169 // pages mapped, so the gate callable stays valid.
1170
1/2
✗ Branch 13 → 14 not taken.
✓ Branch 13 → 17 taken 493 times.
493 if (!DetourModKit::detail::blocking_teardown_permitted())
1171 {
1172 diagnostics::record_intentional_leak(diagnostics::LeakSubsystem::HookManager);
1173 (void)m_impl.release();
1174 return;
1175 }
1176
1177 const DetourModKit::detail::MidRundown mid_rundown =
1178 has_mid_slot
1179
2/2
✓ Branch 17 → 18 taken 220 times.
✓ Branch 17 → 20 taken 273 times.
493 ? DetourModKit::detail::run_down_mid_slot(DetourModKit::detail::mid_adapter_slots()[mid_slot])
1180 273 : DetourModKit::detail::MidRundown::Drained;
1181
1182 493 const std::uintptr_t target = m_impl->target;
1183 493 const std::uint64_t ledger_id = m_impl->ledger_id;
1184 const diagnostics::HookKind kind =
1185
2/2
✓ Branch 24 → 25 taken 273 times.
✓ Branch 24 → 26 taken 220 times.
493 m_impl->is_inline ? diagnostics::HookKind::Inline : diagnostics::HookKind::Mid;
1186 // Capture the armed state before the gate block below forces status to Disabled. Otherwise, the Removed
1187 // emission leaves the enable armed unit on the tally forever.
1188 493 const bool was_active = m_impl->status.load(std::memory_order_acquire) == HookState::Active;
1189 // Copy the name out before reset() destroys its storage. The copy can throw under OOM inside a noexcept
1190 // destructor, so contain it and degrade to an empty name.
1191 493 std::string name;
1192 try
1193 {
1194
2/2
✓ Branch 31 → 32 taken 492 times.
✓ Branch 31 → 142 taken 1 time.
493 name = m_impl->name;
1195 }
1196 1 catch (...)
1197 {
1198 1 }
1199
1200 // Serialize with any in-flight call() through the shared gate. A caller that owns the lock drains to
1201 // completion. A caller with only a pin reads the null callable under the same mutex and returns the
1202 // inactive default.
1203
1/2
✓ Branch 33 → 34 taken 493 times.
✗ Branch 33 → 50 not taken.
493 if (gate)
1204 {
1205 493 std::unique_lock<std::recursive_mutex> guard = acquire_call_lock(gate.get());
1206
1/2
✗ Branch 37 → 38 not taken.
✓ Branch 37 → 41 taken 493 times.
493 if (!guard.owns_lock())
1207 {
1208 // An unowned guard makes restore safety unprovable. Leak the backend instead of a free of a
1209 // trampoline that a guarded caller can still use.
1210 diagnostics::record_intentional_leak(diagnostics::LeakSubsystem::HookManager);
1211 (void)m_impl.release();
1212 return;
1213 }
1214 493 gate->callable = nullptr;
1215 493 m_impl->status.store(HookState::Disabled, std::memory_order_release);
1216
1/2
✓ Branch 46 → 47 taken 493 times.
✗ Branch 46 → 49 not taken.
493 }
1217
1218 // Decide leak or restore under this target's install-serialization slot. The slot makes the decision and
1219 // restore atomic against a concurrent same-target install. Restore is sound only for the newest layer
1220 // (newer == 0). Newer layers still chain through this trampoline. A pristine prologue below them causes a
1221 // trampoline use-after-free. The count under the slot also closes the race between a peek and restore.
1222 493 auto &ledger = DetourModKit::detail::HookLedger::instance();
1223 493 const std::size_t newer = ledger.acquire_target_slot(target, ledger_id);
1224
2/2
✓ Branch 52 → 53 taken 6 times.
✓ Branch 52 → 61 taken 487 times.
493 if (newer > 0)
1225 {
1226 // For out-of-order, oldest-first teardown, leak this backend instead of a restore. This preserves the
1227 // newer layer chain into this trampoline. The Impl module reference remains held, so the trampoline
1228 // pages stay mapped. release_target_slot keeps the ledger order entry: the target remains physically
1229 // hooked and must not be reported clean.
1230 6 diagnostics::record_intentional_leak(diagnostics::LeakSubsystem::HookManager);
1231 6 (void)m_impl.release();
1232 6 ledger.release_target_slot(target, ledger_id);
1233 6 (void)log().try_log(
1234 LogLevel::Warning,
1235 "hook: '{}' at 0x{:0{}X} destroyed while {} newer hook(s) remain layered on the same target; "
1236 "leaked the older backend to avoid a trampoline use-after-free. Tear layered hooks down "
1237 "newest-first (hold them in a HookStack).",
1238 name,
1239 target,
1240 6 sizeof(std::uintptr_t) * 2,
1241 newer
1242 );
1243 6 emit_lifecycle(
1244 name,
1245 ledger_id,
1246 kind,
1247 diagnostics::HookTransition::Removed,
1248 RemovalPopulationState{
1249 .remains_live = true,
1250 }
1251 );
1252 6 return;
1253 }
1254
1255 // Close the backend-owned route before restore, so admitted callers stay counted across the generated
1256 // stub. An Unwaitable self-owned mid teardown deliberately skips the drain. The pin below keeps its route
1257 // alive.
1258 974 (void)apply_backend(m_impl->backend, [](auto &backend) noexcept { backend.begin_route_rundown(); });
1259 // Disable the backend here instead of in ~Impl. Its backend destructor discards a failed disable and
1260 // reclaims storage regardless. Only a prologue at its original bytes authorizes backend destruction.
1261 // Foreign and Indeterminate fail closed to the pin (see run_teardown_restore).
1262 487 const PatchWitness restore = run_teardown_restore(m_impl->backend);
1263
2/2
✓ Branch 65 → 66 taken 13 times.
✓ Branch 65 → 77 taken 474 times.
487 if (restore != PatchWitness::Original)
1264 {
1265 26 (void)apply_backend(m_impl->backend, [](auto &backend) noexcept { backend.cancel_route_rundown(); });
1266 // The target can still dispatch through this trampoline. Pin the Impl to keep its pages mapped. Book
1267 // the leak and keep the creation-order entry, so is_target_hooked stays true.
1268 13 diagnostics::record_intentional_leak(diagnostics::LeakSubsystem::HookManager);
1269 13 (void)m_impl.release();
1270 13 ledger.release_target_slot(target, ledger_id);
1271 13 (void)log().try_log(
1272 LogLevel::Warning,
1273 "hook: '{}' at 0x{:0{}X} could not restore its target's prologue during teardown ({}); leaked the "
1274 "backend to keep the possibly reachable trampoline mapped. The target remains tracked as hooked.",
1275 name,
1276 target,
1277 13 sizeof(std::uintptr_t) * 2,
1278 13 witness_description(restore)
1279 );
1280 13 emit_lifecycle(
1281 name,
1282 ledger_id,
1283 kind,
1284 diagnostics::HookTransition::Removed,
1285 RemovalPopulationState{
1286 .remains_live = true,
1287 }
1288 );
1289 13 return;
1290 }
1291 948 (void)apply_backend(m_impl->backend, [](auto &backend) noexcept { backend.finish_route_rundown(); });
1292
1293 // A successful restore stops new target entries. Reclamation still uses a bounded wait for the backend
1294 // route.
1295 // Expiry retains the backend exactly as an unprovable adapter rundown does. The short circuit expresses
1296 // "no wait was owed": an unproven rundown is handled by the pin branch below and must not be waited on.
1297 const bool route_drained =
1298
4/4
✓ Branch 79 → 80 taken 471 times.
✓ Branch 79 → 83 taken 3 times.
✓ Branch 82 → 83 taken 470 times.
✓ Branch 82 → 84 taken 1 time.
474 mid_rundown != DetourModKit::detail::MidRundown::Drained || drain_backend_route(m_impl->backend);
1299
1300 // Newest-first teardown occurs under the target install-serialization slot. Restore the prologue and
1301 // destroy the backend first. Release the ledger entry next and the module reference last.
1302 // release_module_ref calls FreeLibrary, which takes the loader lock. A prior slot release prevents a
1303 // lock-order inversion against a DllMain install parked on this slot. The caller still executes this
1304 // module's code and the host holds its own load reference, so this release is never the terminal one.
1305
4/4
✓ Branch 85 → 86 taken 471 times.
✓ Branch 85 → 87 taken 3 times.
✓ Branch 86 → 87 taken 1 time.
✓ Branch 86 → 100 taken 470 times.
474 if (mid_rundown != DetourModKit::detail::MidRundown::Drained || !route_drained)
1306 {
1307 // An entrant remains counted after its drain and can still return through the stub. Pin the Impl to
1308 // keep the stub mapped and leave the slot claimed. This case applies only to mid hooks.
1309 // A managed inline hook route count stays zero, so its drain cannot expire.
1310
2/2
✓ Branch 87 → 88 taken 2 times.
✓ Branch 87 → 91 taken 2 times.
6 const char *blocked_stage = mid_rundown == DetourModKit::detail::MidRundown::Unwaitable ? "callback"
1311 : mid_rundown == DetourModKit::detail::MidRundown::Expired
1312
2/2
✓ Branch 88 → 89 taken 1 time.
✓ Branch 88 → 90 taken 1 time.
2 ? "callback past its bounded drain"
1313 : "backend route after a bounded wait";
1314 4 diagnostics::record_intentional_leak(diagnostics::LeakSubsystem::HookManager);
1315 4 (void)m_impl.release();
1316 4 (void)ledger.release_hook(target, ledger_id);
1317 4 (void)log().try_log(
1318 LogLevel::Warning,
1319 "hook: mid hook '{}' at 0x{:0{}X} was torn down while a thread can still be inside its {}. "
1320 "The target was restored, but the backend is pinned so that thread can return through its stub. "
1321 "The callback will not be entered again, and the adapter is not reclaimed.",
1322 name,
1323 target,
1324 4 sizeof(std::uintptr_t) * 2,
1325 blocked_stage
1326 );
1327 4 emit_lifecycle(
1328 name,
1329 ledger_id,
1330 kind,
1331 diagnostics::HookTransition::Removed,
1332 RemovalPopulationState{
1333 .was_active = was_active,
1334 }
1335 );
1336 4 return;
1337 }
1338
1339
4/4
✓ Branch 100 → 101 taken 211 times.
✓ Branch 100 → 105 taken 259 times.
✓ Branch 106 → 107 taken 1 time.
✓ Branch 106 → 115 taken 469 times.
681 if (has_mid_slot &&
1340
2/2
✓ Branch 103 → 104 taken 1 time.
✓ Branch 103 → 105 taken 210 times.
211 !DetourModKit::detail::drain_mid_adapter_entries(DetourModKit::detail::mid_adapter_slots()[mid_slot]))
1341 {
1342 // A thread remains inside the adapter body past the bounded wait. This counter is the slot-reuse
1343 // authority, so the slot and stub stay retained. The ledger entry is clean.
1344 1 diagnostics::record_intentional_leak(diagnostics::LeakSubsystem::HookManager);
1345 1 (void)m_impl.release();
1346 1 (void)ledger.release_hook(target, ledger_id);
1347 1 (void)log().try_log(
1348 LogLevel::Warning,
1349 "hook: mid hook '{}' at 0x{:0{}X} was torn down while a thread can still be inside its adapter "
1350 "body past its bounded drain. The target was restored, but the backend and adapter slot are "
1351 "pinned so that thread can return through its stub.",
1352 name,
1353 target,
1354 1 sizeof(std::uintptr_t) * 2
1355 );
1356 1 emit_lifecycle(
1357 name,
1358 ledger_id,
1359 kind,
1360 diagnostics::HookTransition::Removed,
1361 RemovalPopulationState{
1362 .was_active = was_active,
1363 }
1364 );
1365 1 return;
1366 }
1367
1368 469 const HMODULE self_ref = static_cast<HMODULE>(m_impl->self_ref);
1369 469 m_impl.reset();
1370 // The drain completed, or this was never a mid hook. No thread is inside the adapter, so slot contents can
1371 // be reused.
1372
2/2
✓ Branch 117 → 118 taken 210 times.
✓ Branch 117 → 119 taken 259 times.
469 if (has_mid_slot)
1373 {
1374 210 DetourModKit::detail::release_mid_adapter_slot(mid_slot);
1375 }
1376 469 (void)ledger.release_hook(target, ledger_id);
1377 469 DetourModKit::detail::release_module_ref(self_ref, diagnostics::ModulePinReason::Hook);
1378 469 emit_lifecycle(
1379 name,
1380 ledger_id,
1381 kind,
1382 diagnostics::HookTransition::Removed,
1383 RemovalPopulationState{
1384 .was_active = was_active,
1385 }
1386 );
1387
8/8
✓ Branch 125 → 126 taken 469 times.
✓ Branch 125 → 127 taken 24 times.
✓ Branch 129 → 130 taken 469 times.
✓ Branch 129 → 132 taken 1101 times.
✓ Branch 134 → 135 taken 469 times.
✓ Branch 134 → 136 taken 1101 times.
✓ Branch 138 → 139 taken 469 times.
✓ Branch 138 → 140 taken 1101 times.
4265 }
1388
1389 22 Hook::operator bool() const noexcept
1390 {
1391 22 return m_impl != nullptr;
1392 }
1393
1394 3 std::string_view Hook::name() const noexcept
1395 {
1396
1/2
✓ Branch 3 → 4 taken 3 times.
✗ Branch 3 → 6 not taken.
3 return m_impl ? std::string_view{m_impl->name} : std::string_view{};
1397 }
1398
1399 22519 bool Hook::is_enabled() const noexcept
1400 {
1401 22519 const std::shared_ptr<CallGate> gate = m_gate.load(std::memory_order_acquire);
1402
1/2
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 22776 times.
22781 if (!gate)
1403 {
1404 return false;
1405 }
1406 22776 std::unique_lock<std::recursive_mutex> guard = acquire_call_lock(gate.get());
1407
3/6
✓ Branch 9 → 10 taken 22786 times.
✗ Branch 9 → 12 not taken.
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 13 taken 22786 times.
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 16 taken 22786 times.
22786 if (!guard.owns_lock() || !m_impl)
1408 {
1409 return false;
1410 }
1411 // Both the published state and the reconciled backend view must agree. The gate serializes this read
1412 // with every backend flag update.
1413
3/4
✓ Branch 18 → 19 taken 1086 times.
✓ Branch 18 → 23 taken 21700 times.
✓ Branch 21 → 22 taken 1086 times.
✗ Branch 21 → 23 not taken.
23872 return m_impl->status.load(std::memory_order_acquire) == HookState::Active &&
1414 1086 backend_value_or(
1415 1086 m_impl->backend,
1416 false,
1417
4/8
auto DetourModKit::hook::Hook::is_enabled() const::{lambda(auto:1&)#1}::operator()<safetyhook::InlineHook>(safetyhook::InlineHook&) const:
✓ Branch 3 → 4 taken 1071 times.
✗ Branch 3 → 7 not taken.
✓ Branch 5 → 6 taken 1071 times.
✗ Branch 5 → 7 not taken.
auto DetourModKit::hook::Hook::is_enabled() const::{lambda(auto:1&)#1}::operator()<safetyhook::MidHook>(safetyhook::MidHook&) const:
✓ Branch 3 → 4 taken 15 times.
✗ Branch 3 → 7 not taken.
✓ Branch 5 → 6 taken 15 times.
✗ Branch 5 → 7 not taken.
23872 [](auto &backend) noexcept { return static_cast<bool>(backend) && backend.enabled(); }
1418 22786 );
1419 22786 }
1420
1421 140 void *Hook::original_address() const noexcept
1422 {
1423 // The original<Fn>() path uses no gate or atomic shared-pointer load. The caller guarantees that the hook
1424 // outlives the call.
1425
2/2
✓ Branch 3 → 4 taken 139 times.
✓ Branch 3 → 6 taken 1 time.
140 return m_impl ? inline_trampoline(m_impl->backend) : nullptr;
1426 }
1427
1428 49 std::shared_ptr<Hook::CallGate> Hook::pin_call_gate() const noexcept
1429 {
1430 // Copy the gate reference atomically into a strong local. call() can then keep the trampoline and mutex
1431 // alive across a concurrent teardown that drops the handle's own reference.
1432 49 return m_gate.load(std::memory_order_acquire);
1433 }
1434
1435 30699 std::unique_lock<std::recursive_mutex> Hook::acquire_call_lock(CallGate *gate) const noexcept
1436 {
1437 try
1438 {
1439
1/2
✓ Branch 2 → 3 taken 30722 times.
✗ Branch 2 → 5 not taken.
30699 return std::unique_lock<std::recursive_mutex>(gate->mutex);
1440 }
1441 catch (...)
1442 {
1443 // recursive_mutex::lock can throw std::system_error. Fail closed: an unowned lock makes call()
1444 // return the inactive default.
1445 return std::unique_lock<std::recursive_mutex>{};
1446 }
1447 }
1448
1449 46 void *Hook::active_trampoline(CallGate *gate) const noexcept
1450 {
1451 // Every writer publishes gate->callable under the mutex the caller already holds, so this observes the
1452 // live trampoline or nullptr, never a stale pointer.
1453 46 return gate->callable;
1454 }
1455
1456 11 void Hook::release() noexcept
1457 {
1458
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 11 times.
11 if (!m_impl)
1459 {
1460 return;
1461 }
1462 // Leak the backend hook intentionally: it stays installed for the process lifetime and the ledger entry
1463 // stays so is_target_hooked still reports it. A gate clear disengages the handle, which matches the
1464 // moved-from contract. The ledger records it like every defensive pin in ~Hook.
1465 11 diagnostics::record_intentional_leak(diagnostics::LeakSubsystem::HookManager);
1466 11 (void)m_impl.release();
1467 11 m_gate.store(nullptr, std::memory_order_release);
1468 }
1469
1470 namespace detail
1471 {
1472 343 Result<Hook> inline_at_raw(InlineRequest request, void *detour)
1473 {
1474
2/2
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 9 taken 342 times.
343 if (std::optional<Error> vetoed = refuse_on_loader_lock("hook::inline_at"))
1475 {
1476 1 return std::unexpected(*vetoed);
1477 }
1478 #if defined(DMK_ENABLE_TEST_SEAMS)
1479 342 note_loader_veto_passed(DetourModKit::detail::HookLoaderEntry::InlineAt);
1480 #endif
1481
2/2
✓ Branch 11 → 12 taken 2 times.
✓ Branch 11 → 15 taken 340 times.
342 if (request.name.empty())
1482 {
1483 2 return std::unexpected(Error{ErrorCode::InvalidArg, "hook::inline_at"});
1484 }
1485
2/2
✓ Branch 15 → 16 taken 1 time.
✓ Branch 15 → 19 taken 339 times.
340 if (detour == nullptr)
1486 {
1487 1 return std::unexpected(Error{ErrorCode::InvalidDetourFunction, "hook::inline_at"});
1488 }
1489 Result<PreflightResult> preflight =
1490 339 preflight_target(request.target, request.options, request.name, "hook::inline_at");
1491
2/2
✓ Branch 22 → 23 taken 34 times.
✓ Branch 22 → 27 taken 305 times.
339 if (!preflight)
1492 {
1493 34 return std::unexpected(preflight.error());
1494 }
1495 305 const std::uintptr_t target = preflight->address;
1496 // Every failure path below rolls back this reservation. Success commits it after all fallible setup.
1497 305 const std::uint64_t ledger_id = preflight->ledger_id;
1498
1499 305 const std::shared_ptr<safetyhook::Allocator> &allocator = backend_allocator();
1500
1/2
✗ Branch 31 → 32 not taken.
✓ Branch 31 → 37 taken 305 times.
305 if (!allocator)
1501 {
1502 (void)DetourModKit::detail::HookLedger::instance().release_hook(target, ledger_id);
1503 return std::unexpected(Error{ErrorCode::AllocatorNotAvailable, "hook::inline_at"});
1504 }
1505 305 ModuleRefGuard self_ref(acquire_hook_self_ref());
1506
2/2
✓ Branch 40 → 41 taken 14 times.
✓ Branch 40 → 47 taken 291 times.
305 if (self_ref.get() == nullptr)
1507 {
1508 // Capture the acquire's last-error before release_hook can clobber it (error.hpp documents
1509 // SystemCallFailed's detail = GetLastError()).
1510
1/2
✓ Branch 41 → 42 taken 14 times.
✗ Branch 41 → 154 not taken.
14 const DWORD acquire_error = ::GetLastError();
1511 14 (void)DetourModKit::detail::HookLedger::instance().release_hook(target, ledger_id);
1512 14 return std::unexpected(Error{ErrorCode::SystemCallFailed, "hook::inline_at", acquire_error});
1513 }
1514
1/2
✗ Branch 49 → 50 not taken.
✓ Branch 49 → 54 taken 291 times.
291 if (const std::optional<Error> stale = revalidate_before_patch(target, ledger_id, "hook::inline_at"))
1515 {
1516 return std::unexpected(*stale);
1517 }
1518 try
1519 {
1520 // StartDisabled makes this an install transaction. The detour stays unreachable while the fallible
1521 // steps below publish the caller state. No fault boundary wraps this code. See
1522 // hook_fault_boundary.hpp.
1523 auto created = safetyhook::InlineHook::create(
1524 allocator,
1525 reinterpret_cast<void *>(target),
1526 detour,
1527 safetyhook::InlineHook::StartDisabled
1528
1/2
✓ Branch 54 → 55 taken 291 times.
✗ Branch 54 → 140 not taken.
291 );
1529
2/2
✓ Branch 56 → 57 taken 2 times.
✓ Branch 56 → 69 taken 289 times.
291 if (!created)
1530 {
1531 2 log().error(
1532 "hook::inline_at: backend create failed for '{}' at {}: {}",
1533
1/2
✓ Branch 61 → 62 taken 2 times.
✗ Branch 61 → 120 not taken.
2 request.name,
1534
1/2
✓ Branch 60 → 61 taken 2 times.
✗ Branch 60 → 123 not taken.
4 format::format_address(target),
1535
1/2
✓ Branch 59 → 60 taken 2 times.
✗ Branch 59 → 126 not taken.
4 backend_error_string(created.error())
1536 );
1537 2 (void)DetourModKit::detail::HookLedger::instance().release_hook(target, ledger_id);
1538 2 return std::unexpected(Error{ErrorCode::BackendFailed, "hook::inline_at", target});
1539 }
1540
1/2
✓ Branch 69 → 70 taken 289 times.
✗ Branch 69 → 138 not taken.
578 auto backend_hook = std::move(created.value());
1541 #if defined(DMK_ENABLE_TEST_SEAMS)
1542
2/2
✓ Branch 73 → 74 taken 288 times.
✓ Branch 73 → 136 taken 1 time.
289 note_publish_step(DetourModKit::detail::HookPublishStep::BackendCreated);
1543 #endif
1544 auto impl = std::make_unique<Hook::Impl>(
1545 288 std::move(backend_hook),
1546 288 std::move(request.name),
1547 target,
1548 ledger_id,
1549 288 HookState::Disabled
1550
1/2
✓ Branch 78 → 79 taken 288 times.
✗ Branch 78 → 127 not taken.
288 );
1551 #if defined(DMK_ENABLE_TEST_SEAMS)
1552
2/2
✓ Branch 79 → 80 taken 287 times.
✓ Branch 79 → 134 taken 1 time.
288 note_publish_step(DetourModKit::detail::HookPublishStep::ImplConstructed);
1553 #endif
1554 // The gate starts null-callable, exactly as disable() leaves it. enable() publishes the
1555 // trampoline once the target is armed.
1556
1/2
✓ Branch 80 → 81 taken 287 times.
✗ Branch 80 → 134 not taken.
287 auto gate = std::make_shared<Hook::CallGate>();
1557 #if defined(DMK_ENABLE_TEST_SEAMS)
1558
2/2
✓ Branch 81 → 82 taken 286 times.
✓ Branch 81 → 132 taken 1 time.
287 note_publish_step(DetourModKit::detail::HookPublishStep::GatePublished);
1559 #endif
1560 286 const std::string_view created_name = impl->name;
1561
2/2
✓ Branch 86 → 87 taken 1 time.
✓ Branch 86 → 92 taken 285 times.
286 if (!DetourModKit::detail::HookLedger::instance().commit_hook(target, ledger_id))
1562 {
1563 1 (void)DetourModKit::detail::HookLedger::instance().release_hook(target, ledger_id);
1564 1 return std::unexpected(Error{ErrorCode::OutOfMemory, "hook::inline_at", target});
1565 }
1566
1/2
✓ Branch 94 → 95 taken 285 times.
✗ Branch 94 → 128 not taken.
570 log().info(
1567 "hook::inline_at: created inline hook '{}' at {} (disabled).",
1568 created_name,
1569
1/2
✓ Branch 93 → 94 taken 285 times.
✗ Branch 93 → 131 not taken.
570 format::format_address(target)
1570 );
1571 #if defined(DMK_ENABLE_TEST_SEAMS)
1572
2/2
✓ Branch 96 → 97 taken 284 times.
✓ Branch 96 → 132 taken 1 time.
285 note_publish_step(DetourModKit::detail::HookPublishStep::LedgerCommitted);
1573 #endif
1574 284 emit_lifecycle(
1575 created_name,
1576 ledger_id,
1577 diagnostics::HookKind::Inline,
1578 diagnostics::HookTransition::Created
1579 );
1580 // Hand the module reference to the Impl only after completion of every fallible setup step.
1581 284 impl->self_ref = self_ref.release();
1582 568 return Hook(std::move(impl), std::move(gate));
1583 300 }
1584
1/2
✓ Branch 141 → 142 taken 4 times.
✗ Branch 141 → 148 not taken.
4 catch (const std::bad_alloc &)
1585 {
1586 4 (void)DetourModKit::detail::HookLedger::instance().release_hook(target, ledger_id);
1587 4 return std::unexpected(Error{ErrorCode::OutOfMemory, "hook::inline_at", target});
1588 4 }
1589 catch (...)
1590 {
1591 (void)DetourModKit::detail::HookLedger::instance().release_hook(target, ledger_id);
1592 return std::unexpected(Error{ErrorCode::UnknownError, "hook::inline_at", target});
1593 }
1594 305 }
1595 } // namespace detail
1596
1597 238 Result<Hook> mid_at(MidRequest request, MidHookFn detour)
1598 {
1599
2/2
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 9 taken 237 times.
238 if (std::optional<Error> vetoed = refuse_on_loader_lock("hook::mid_at"))
1600 {
1601 1 return std::unexpected(*vetoed);
1602 }
1603 #if defined(DMK_ENABLE_TEST_SEAMS)
1604 237 note_loader_veto_passed(DetourModKit::detail::HookLoaderEntry::MidAt);
1605 #endif
1606
2/2
✓ Branch 11 → 12 taken 2 times.
✓ Branch 11 → 15 taken 235 times.
237 if (request.name.empty())
1607 {
1608 2 return std::unexpected(Error{ErrorCode::InvalidArg, "hook::mid_at"});
1609 }
1610
2/2
✓ Branch 15 → 16 taken 1 time.
✓ Branch 15 → 19 taken 234 times.
235 if (detour == nullptr)
1611 {
1612 1 return std::unexpected(Error{ErrorCode::InvalidDetourFunction, "hook::mid_at"});
1613 }
1614 Result<PreflightResult> preflight =
1615 234 preflight_target(request.target, request.options, request.name, "hook::mid_at");
1616
2/2
✓ Branch 22 → 23 taken 5 times.
✓ Branch 22 → 27 taken 229 times.
234 if (!preflight)
1617 {
1618 5 return std::unexpected(preflight.error());
1619 }
1620 229 const std::uintptr_t target = preflight->address;
1621 // Every failure path below rolls back this reservation. Success commits it after all fallible setup.
1622 229 const std::uint64_t ledger_id = preflight->ledger_id;
1623
1624 229 const std::shared_ptr<safetyhook::Allocator> &allocator = backend_allocator();
1625
1/2
✗ Branch 31 → 32 not taken.
✓ Branch 31 → 37 taken 229 times.
229 if (!allocator)
1626 {
1627 (void)DetourModKit::detail::HookLedger::instance().release_hook(target, ledger_id);
1628 return std::unexpected(Error{ErrorCode::AllocatorNotAvailable, "hook::mid_at"});
1629 }
1630 // Reserve the entry TLS index before dispatch becomes possible. A later acquire allocates on a host thread
1631 // during a callback.
1632
1/2
✗ Branch 38 → 39 not taken.
✓ Branch 38 → 45 taken 229 times.
229 if (!DetourModKit::detail::ensure_mid_entry_tls())
1633 {
1634 const DWORD tls_error = ::GetLastError();
1635 (void)DetourModKit::detail::HookLedger::instance().release_hook(target, ledger_id);
1636 return std::unexpected(Error{ErrorCode::SystemCallFailed, "hook::mid_at", tls_error});
1637 }
1638 229 ModuleRefGuard self_ref(acquire_hook_self_ref());
1639
2/2
✓ Branch 48 → 49 taken 1 time.
✓ Branch 48 → 55 taken 228 times.
229 if (self_ref.get() == nullptr)
1640 {
1641 // Capture the acquire's last-error before release_hook can clobber it (see inline_at_raw).
1642
1/2
✓ Branch 49 → 50 taken 1 time.
✗ Branch 49 → 195 not taken.
1 const DWORD acquire_error = ::GetLastError();
1643 1 (void)DetourModKit::detail::HookLedger::instance().release_hook(target, ledger_id);
1644 1 return std::unexpected(Error{ErrorCode::SystemCallFailed, "hook::mid_at", acquire_error});
1645 }
1646
1/2
✗ Branch 57 → 58 not taken.
✓ Branch 57 → 62 taken 228 times.
228 if (const std::optional<Error> stale = revalidate_before_patch(target, ledger_id, "hook::mid_at"))
1647 {
1648 return std::unexpected(*stale);
1649 }
1650 // One adapter exists per live mid hook. MidAdapterSlotGuard releases the slot on every failure path below.
1651 // No adapter entry occurred because StartDisabled leaves the target unpatched until enable().
1652 228 const std::size_t slot_index = DetourModKit::detail::claim_mid_adapter_slot();
1653
2/2
✓ Branch 63 → 64 taken 1 time.
✓ Branch 63 → 69 taken 227 times.
228 if (slot_index >= DetourModKit::detail::MID_ADAPTER_CAPACITY)
1654 {
1655 1 (void)DetourModKit::detail::HookLedger::instance().release_hook(target, ledger_id);
1656 1 return std::unexpected(Error{ErrorCode::MidHookCapacityExhausted, "hook::mid_at", target});
1657 }
1658 227 MidAdapterSlotGuard slot_guard(slot_index);
1659 227 DetourModKit::detail::MidAdapterSlot &slot = DetourModKit::detail::mid_adapter_slots()[slot_index];
1660 227 slot.target.store(target, std::memory_order_relaxed);
1661 227 slot.detour.store(detour, std::memory_order_relaxed);
1662 227 slot.contained_exceptions.store(0, std::memory_order_relaxed);
1663 // Publish the callback before the adapter address reaches the backend.
1664 227 slot.live.store(true, std::memory_order_release);
1665 try
1666 {
1667 // This is the StartDisabled install transaction. See inline_at_raw and hook_fault_boundary.hpp. The
1668 // destination is the pool slot_index adapter, a real void(safetyhook::Context&).
1669 auto created = safetyhook::MidHook::create(
1670 allocator,
1671 reinterpret_cast<void *>(target),
1672 227 DetourModKit::detail::MID_ADAPTER_TABLE[slot_index],
1673 safetyhook::MidHook::StartDisabled
1674
1/2
✓ Branch 90 → 91 taken 227 times.
✗ Branch 90 → 179 not taken.
227 );
1675
2/2
✓ Branch 92 → 93 taken 2 times.
✓ Branch 92 → 105 taken 225 times.
227 if (!created)
1676 {
1677 2 log().error(
1678 "hook::mid_at: backend create failed for '{}' at {}: {}",
1679
1/2
✓ Branch 97 → 98 taken 2 times.
✗ Branch 97 → 159 not taken.
2 request.name,
1680
1/2
✓ Branch 96 → 97 taken 2 times.
✗ Branch 96 → 162 not taken.
4 format::format_address(target),
1681
1/2
✓ Branch 95 → 96 taken 2 times.
✗ Branch 95 → 165 not taken.
4 backend_error_string(created.error())
1682 );
1683 2 (void)DetourModKit::detail::HookLedger::instance().release_hook(target, ledger_id);
1684 2 return std::unexpected(Error{ErrorCode::BackendFailed, "hook::mid_at", target});
1685 }
1686
1/2
✓ Branch 105 → 106 taken 225 times.
✗ Branch 105 → 177 not taken.
450 auto backend_hook = std::move(created.value());
1687 #if defined(DMK_ENABLE_TEST_SEAMS)
1688
2/2
✓ Branch 109 → 110 taken 224 times.
✓ Branch 109 → 175 taken 1 time.
225 note_publish_step(DetourModKit::detail::HookPublishStep::BackendCreated);
1689 #endif
1690 auto impl = std::make_unique<Hook::Impl>(
1691 224 std::move(backend_hook),
1692 224 std::move(request.name),
1693 target,
1694 ledger_id,
1695 224 HookState::Disabled
1696
1/2
✓ Branch 114 → 115 taken 224 times.
✗ Branch 114 → 166 not taken.
224 );
1697 #if defined(DMK_ENABLE_TEST_SEAMS)
1698
2/2
✓ Branch 115 → 116 taken 223 times.
✓ Branch 115 → 173 taken 1 time.
224 note_publish_step(DetourModKit::detail::HookPublishStep::ImplConstructed);
1699 #endif
1700 // A mid hook gate is null-callable for life. It still serializes enable, disable, and teardown.
1701
1/2
✓ Branch 116 → 117 taken 223 times.
✗ Branch 116 → 173 not taken.
223 auto gate = std::make_shared<Hook::CallGate>();
1702 #if defined(DMK_ENABLE_TEST_SEAMS)
1703
2/2
✓ Branch 117 → 118 taken 222 times.
✓ Branch 117 → 171 taken 1 time.
223 note_publish_step(DetourModKit::detail::HookPublishStep::GatePublished);
1704 #endif
1705 222 const std::string_view created_name = impl->name;
1706
1/2
✗ Branch 122 → 123 not taken.
✓ Branch 122 → 128 taken 222 times.
222 if (!DetourModKit::detail::HookLedger::instance().commit_hook(target, ledger_id))
1707 {
1708 (void)DetourModKit::detail::HookLedger::instance().release_hook(target, ledger_id);
1709 return std::unexpected(Error{ErrorCode::OutOfMemory, "hook::mid_at", target});
1710 }
1711
1/2
✓ Branch 130 → 131 taken 222 times.
✗ Branch 130 → 167 not taken.
444 log().info(
1712 "hook::mid_at: created mid hook '{}' at {} (disabled).",
1713 created_name,
1714
1/2
✓ Branch 129 → 130 taken 222 times.
✗ Branch 129 → 170 not taken.
444 format::format_address(target)
1715 );
1716 #if defined(DMK_ENABLE_TEST_SEAMS)
1717
2/2
✓ Branch 132 → 133 taken 221 times.
✓ Branch 132 → 171 taken 1 time.
222 note_publish_step(DetourModKit::detail::HookPublishStep::LedgerCommitted);
1718 #endif
1719 221 emit_lifecycle(
1720 created_name,
1721 ledger_id,
1722 diagnostics::HookKind::Mid,
1723 diagnostics::HookTransition::Created
1724 );
1725 // Hand the module reference and adapter slot to the Impl. Teardown owns both after this point.
1726 221 impl->self_ref = self_ref.release();
1727 221 impl->mid_slot = slot_guard.release();
1728 442 return Hook(std::move(impl), std::move(gate));
1729 236 }
1730
1/2
✓ Branch 180 → 181 taken 4 times.
✗ Branch 180 → 187 not taken.
4 catch (const std::bad_alloc &)
1731 {
1732 4 (void)DetourModKit::detail::HookLedger::instance().release_hook(target, ledger_id);
1733 4 return std::unexpected(Error{ErrorCode::OutOfMemory, "hook::mid_at", target});
1734 4 }
1735 catch (...)
1736 {
1737 (void)DetourModKit::detail::HookLedger::instance().release_hook(target, ledger_id);
1738 return std::unexpected(Error{ErrorCode::UnknownError, "hook::mid_at", target});
1739 }
1740 229 }
1741
1742 9 Result<std::vector<InstallOutcome>> install_all(std::span<const HookSpec> table) noexcept
1743 {
1744
2/2
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 9 taken 8 times.
9 if (std::optional<Error> vetoed = refuse_on_loader_lock("hook::install_all"))
1745 {
1746 1 return std::unexpected(*vetoed);
1747 }
1748 #if defined(DMK_ENABLE_TEST_SEAMS)
1749 8 note_loader_veto_passed(DetourModKit::detail::HookLoaderEntry::InstallAll);
1750 #endif
1751 // InstallRollback removes a partial install newest-first. A vector's front-to-back destruction is unsafe
1752 // for layered hooks on one target. InstallRollback handles mandatory misses and exceptions unless commit()
1753 // moves the rows out.
1754 class InstallRollback
1755 {
1756 public:
1757 InstallRollback() = default;
1758 InstallRollback(const InstallRollback &) = delete;
1759 InstallRollback &operator=(const InstallRollback &) = delete;
1760 8 ~InstallRollback()
1761 {
1762
2/2
✓ Branch 5 → 3 taken 3 times.
✓ Branch 5 → 6 taken 8 times.
11 while (!m_rows.empty())
1763 {
1764 3 m_rows.pop_back();
1765 }
1766 8 }
1767
1768 17 [[nodiscard]] std::vector<InstallOutcome> &rows() noexcept { return m_rows; }
1769 10 [[nodiscard]] std::vector<InstallOutcome> commit() noexcept { return std::move(m_rows); }
1770
1771 private:
1772 std::vector<InstallOutcome> m_rows;
1773 };
1774
1775 try
1776 {
1777 8 InstallRollback rollback;
1778
2/2
✓ Branch 12 → 13 taken 7 times.
✓ Branch 12 → 139 taken 1 time.
8 rollback.rows().reserve(table.size());
1779
2/2
✓ Branch 90 → 15 taken 11 times.
✓ Branch 90 → 91 taken 5 times.
23 for (const HookSpec &spec : table)
1780 {
1781 // The OwnedScanRequest copy preserves the caller table entries for install_all. Each row's Options
1782 // value carries its install policy.
1783
1/2
✓ Branch 17 → 18 taken 11 times.
✗ Branch 17 → 137 not taken.
11 Target target = spec.m_target;
1784 11 Result<Hook> installed = std::holds_alternative<InlineDetour>(spec.m_detour)
1785 11 ? detail::inline_at_raw(
1786
5/20
✓ Branch 40 → 41 taken 11 times.
✗ Branch 40 → 42 not taken.
✓ Branch 42 → 43 taken 11 times.
✗ Branch 42 → 45 not taken.
✗ Branch 43 → 44 not taken.
✓ Branch 43 → 45 taken 11 times.
✗ Branch 45 → 46 not taken.
✓ Branch 45 → 47 taken 11 times.
✓ Branch 48 → 49 taken 11 times.
✗ Branch 48 → 50 not taken.
✗ Branch 110 → 111 not taken.
✗ Branch 110 → 112 not taken.
✗ Branch 114 → 115 not taken.
✗ Branch 114 → 117 not taken.
✗ Branch 115 → 116 not taken.
✗ Branch 115 → 117 not taken.
✗ Branch 118 → 119 not taken.
✗ Branch 118 → 120 not taken.
✗ Branch 121 → 122 not taken.
✗ Branch 121 → 123 not taken.
44 InlineRequest{spec.m_name, std::move(target), spec.m_options},
1787
2/4
✓ Branch 20 → 21 taken 11 times.
✗ Branch 20 → 98 not taken.
✓ Branch 21 → 22 taken 11 times.
✗ Branch 21 → 98 not taken.
11 std::get<InlineDetour>(spec.m_detour).fn
1788 )
1789 : mid_at(
1790
3/20
✗ Branch 32 → 33 not taken.
✓ Branch 32 → 34 taken 11 times.
✗ Branch 34 → 35 not taken.
✓ Branch 34 → 37 taken 11 times.
✗ Branch 35 → 36 not taken.
✗ Branch 35 → 37 not taken.
✗ Branch 37 → 38 not taken.
✓ Branch 37 → 40 taken 11 times.
✗ Branch 38 → 39 not taken.
✗ Branch 38 → 40 not taken.
✗ Branch 98 → 99 not taken.
✗ Branch 98 → 100 not taken.
✗ Branch 102 → 103 not taken.
✗ Branch 102 → 105 not taken.
✗ Branch 103 → 104 not taken.
✗ Branch 103 → 105 not taken.
✗ Branch 106 → 107 not taken.
✗ Branch 106 → 109 not taken.
✗ Branch 107 → 108 not taken.
✗ Branch 107 → 109 not taken.
11 MidRequest{spec.m_name, std::move(target), spec.m_options},
1791 std::get<MidHookFn>(spec.m_detour)
1792
2/8
✓ Branch 19 → 20 taken 11 times.
✗ Branch 19 → 26 not taken.
✓ Branch 25 → 32 taken 11 times.
✗ Branch 25 → 98 not taken.
✗ Branch 27 → 28 not taken.
✗ Branch 27 → 98 not taken.
✗ Branch 31 → 32 not taken.
✗ Branch 31 → 98 not taken.
22 );
1793
1794
6/6
✓ Branch 54 → 55 taken 4 times.
✓ Branch 54 → 57 taken 7 times.
✓ Branch 55 → 56 taken 2 times.
✓ Branch 55 → 57 taken 2 times.
✓ Branch 58 → 59 taken 2 times.
✓ Branch 58 → 63 taken 9 times.
11 if (!installed && spec.m_severity == Severity::Mandatory)
1795 {
1796 // Fail fast: ~InstallRollback unhooks every already-installed row newest-first before the
1797 // error propagates.
1798 2 return std::unexpected(installed.error());
1799 }
1800
3/8
✓ Branch 64 → 65 taken 9 times.
✗ Branch 64 → 132 not taken.
✓ Branch 68 → 69 taken 9 times.
✗ Branch 68 → 127 not taken.
✗ Branch 70 → 71 not taken.
✓ Branch 70 → 72 taken 9 times.
✗ Branch 129 → 130 not taken.
✗ Branch 129 → 131 not taken.
18 rollback.rows().push_back(InstallOutcome{spec.m_name, spec.m_severity, std::move(installed)});
1801
4/4
✓ Branch 74 → 75 taken 9 times.
✓ Branch 74 → 76 taken 2 times.
✓ Branch 78 → 79 taken 9 times.
✓ Branch 78 → 83 taken 2 times.
13 }
1802 5 return rollback.commit();
1803 8 }
1804
1/2
✓ Branch 142 → 143 taken 1 time.
✗ Branch 142 → 147 not taken.
1 catch (const std::bad_alloc &)
1805 {
1806 1 return std::unexpected(Error{ErrorCode::OutOfMemory, "hook::install_all"});
1807 1 }
1808 catch (...)
1809 {
1810 return std::unexpected(Error{ErrorCode::UnknownError, "hook::install_all"});
1811 }
1812 }
1813
1814 82 bool is_target_hooked(Address target) noexcept
1815 {
1816 82 return DetourModKit::detail::HookLedger::instance().is_target_hooked(target.raw());
1817 }
1818
1819 // VmtHook is the RAII handle for a cloned vtable and its object-level clone lifecycle.
1820 206 VmtHook::VmtHook(std::unique_ptr<Impl> impl) noexcept : m_impl(std::move(impl)) {}
1821
1822 418 VmtHook::VmtHook(VmtHook &&other) noexcept : m_impl(std::move(other.m_impl)) {}
1823
1824 VmtHook &VmtHook::operator=(VmtHook &&other) noexcept
1825 {
1826 if (this != &other)
1827 {
1828 VmtHook discard(std::move(*this));
1829 m_impl = std::move(other.m_impl);
1830 }
1831 return *this;
1832 }
1833
1834 407 VmtHook::~VmtHook() noexcept
1835 {
1836
2/2
✓ Branch 3 → 4 taken 211 times.
✓ Branch 3 → 5 taken 100 times.
311 if (!m_impl)
1837 {
1838 215 return;
1839 }
1840 // Loader-lock leaf discipline requires an Impl leak instead of vptr restoration here. Its module reference
1841 // keeps the clone code pages mapped.
1842
1/2
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 10 taken 100 times.
100 if (!DetourModKit::detail::blocking_teardown_permitted())
1843 {
1844 diagnostics::record_intentional_leak(diagnostics::LeakSubsystem::HookManager);
1845 (void)m_impl.release();
1846 return;
1847 }
1848 100 const std::uint64_t ledger_id = m_impl->ledger_id;
1849 // Copy the name out before reset destroys its storage. Contain an OOM exception and degrade to an empty
1850 // name (noexcept destructor).
1851 100 std::string name;
1852 try
1853 {
1854
1/2
✓ Branch 13 → 14 taken 100 times.
✗ Branch 13 → 86 not taken.
100 name = m_impl->name;
1855 }
1856 catch (...)
1857 {
1858 }
1859 // Teardown restores every applied object before it releases the ledger entry per Hook::~Hook order. This
1860 // order prevents a race between a vmt_for or apply_to operation and clone removal.
1861 100 HMODULE self_ref = nullptr;
1862 {
1863 100 std::unique_lock<std::mutex> object_gate = acquire_vmt_object_lock();
1864
1/2
✗ Branch 16 → 17 not taken.
✓ Branch 16 → 20 taken 100 times.
100 if (!object_gate.owns_lock())
1865 {
1866 // Leak the Impl rather than restore vptrs without the gate.
1867 diagnostics::record_intentional_leak(diagnostics::LeakSubsystem::HookManager);
1868 (void)m_impl.release();
1869 return;
1870 }
1871 // Restore every object with known state. A different vptr can belong to a successor that recorded this
1872 // clone as its original. An unreadable or non-writable word is equally unsafe to overwrite.
1873 100 std::size_t unrestorable = 0;
1874
2/2
✓ Branch 50 → 23 taken 99 times.
✓ Branch 50 → 51 taken 100 times.
299 for (const auto &binding : m_impl->object_bindings)
1875 {
1876 99 const DetourModKit::detail::ObjectWordResult word = DetourModKit::detail::validate_vmt_object_word(
1877 99 reinterpret_cast<std::uintptr_t>(binding.object)
1878 );
1879
1/2
✓ Branch 26 → 27 taken 99 times.
✗ Branch 26 → 29 not taken.
99 if (word.verdict != DetourModKit::detail::ObjectWordVerdict::Unreadable &&
1880
2/2
✓ Branch 27 → 28 taken 1 time.
✓ Branch 27 → 29 taken 98 times.
99 word.vptr == binding.original_vptr)
1881 {
1882 95 continue;
1883 }
1884
3/4
✓ Branch 29 → 30 taken 98 times.
✗ Branch 29 → 33 not taken.
✓ Branch 34 → 35 taken 94 times.
✓ Branch 34 → 39 taken 4 times.
196 if (word.verdict == DetourModKit::detail::ObjectWordVerdict::Ok &&
1885
2/2
✓ Branch 31 → 32 taken 94 times.
✓ Branch 31 → 33 taken 4 times.
98 word.vptr == m_impl->cloned_vptr_base)
1886 {
1887
1/2
✓ Branch 37 → 38 taken 94 times.
✗ Branch 37 → 39 not taken.
94 if (publish_vmt_object_word(binding.object, m_impl->cloned_vptr_base, binding.original_vptr))
1888 {
1889 94 continue;
1890 }
1891 }
1892 4 ++unrestorable;
1893 }
1894
2/2
✓ Branch 51 → 52 taken 4 times.
✓ Branch 51 → 63 taken 96 times.
100 if (unrestorable > 0)
1895 {
1896 4 const std::size_t object_count = m_impl->object_bindings.size();
1897 4 diagnostics::record_intentional_leak(diagnostics::LeakSubsystem::HookManager);
1898 4 (void)m_impl.release();
1899 4 object_gate.unlock();
1900 #if defined(DMK_ENABLE_TEST_SEAMS)
1901
2/2
✓ Branch 57 → 58 taken 1 time.
✓ Branch 57 → 59 taken 3 times.
4 if (auto *probe = DetourModKit::detail::g_vmt_teardown_warning_probe)
1902 {
1903 1 probe();
1904 }
1905 #endif
1906 8 (void)log().try_log(
1907 LogLevel::Warning,
1908 "hook::~VmtHook: VMT hook '{}' destroyed while {} of its {} object(s) could "
1909 "not be provably restored to their original vtable; leaked this clone to "
1910 "avoid a vtable use-after-free. Destroy VMT hooks newest-first to restore "
1911 "the original table.",
1912 4 std::string_view{name},
1913 unrestorable,
1914 object_count
1915 );
1916 4 return;
1917 }
1918 96 self_ref = static_cast<HMODULE>(m_impl->self_ref);
1919 96 m_impl.reset();
1920
2/2
✓ Branch 67 → 68 taken 96 times.
✓ Branch 67 → 75 taken 4 times.
100 }
1921 // Release outside the object gate: FreeLibrary takes the loader lock, which must not nest inside the
1922 // process-wide VMT gate.
1923 96 DetourModKit::detail::release_module_ref(self_ref, diagnostics::ModulePinReason::Hook);
1924 96 DetourModKit::detail::HookLedger::instance().release_vmt(ledger_id);
1925 // A VMT hook is live from creation and has no enable/disable transition, so it is always counted armed.
1926 96 emit_lifecycle(
1927 name,
1928 ledger_id,
1929 diagnostics::HookKind::Vmt,
1930 diagnostics::HookTransition::Removed,
1931 RemovalPopulationState{
1932 .was_active = true,
1933 }
1934 );
1935
4/4
✓ Branch 77 → 78 taken 96 times.
✓ Branch 77 → 80 taken 4 times.
✓ Branch 82 → 83 taken 96 times.
✓ Branch 82 → 84 taken 215 times.
411 }
1936
1937 8 VmtHook::operator bool() const noexcept
1938 {
1939 8 return m_impl != nullptr;
1940 }
1941
1942 2 std::string_view VmtHook::name() const noexcept
1943 {
1944
1/2
✓ Branch 3 → 4 taken 2 times.
✗ Branch 3 → 6 not taken.
2 return m_impl ? std::string_view{m_impl->name} : std::string_view{};
1945 }
1946
1947 41 Result<void> VmtHook::apply_to(void *object, VmtOptions options)
1948 {
1949
2/2
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 9 taken 40 times.
41 if (std::optional<Error> vetoed = refuse_on_loader_lock("hook::vmt_apply"))
1950 {
1951 1 return std::unexpected(*vetoed);
1952 }
1953 #if defined(DMK_ENABLE_TEST_SEAMS)
1954 40 note_loader_veto_passed(DetourModKit::detail::HookLoaderEntry::VmtApply);
1955 #endif
1956
2/2
✓ Branch 11 → 12 taken 1 time.
✓ Branch 11 → 15 taken 39 times.
40 if (!m_impl)
1957 {
1958 1 return std::unexpected(Error{ErrorCode::InvalidHookState, "hook::vmt_apply"});
1959 }
1960
2/2
✓ Branch 15 → 16 taken 1 time.
✓ Branch 15 → 19 taken 38 times.
39 if (object == nullptr)
1961 {
1962 1 return std::unexpected(Error{ErrorCode::InvalidObject, "hook::vmt_apply"});
1963 }
1964 38 std::unique_lock<std::mutex> object_gate = acquire_vmt_object_lock();
1965
1/2
✗ Branch 21 → 22 not taken.
✓ Branch 21 → 25 taken 38 times.
38 if (!object_gate.owns_lock())
1966 {
1967 return std::unexpected(Error{ErrorCode::UnknownError, "hook::vmt_apply"});
1968 }
1969 // Exclusive write keeps the policy decision and guarded swap atomic against this handle's readers. The
1970 // process-wide object gate serializes the vptr transition against other DMK VMT handles.
1971
1/2
✓ Branch 26 → 27 taken 38 times.
✗ Branch 26 → 125 not taken.
38 std::unique_lock<DetourModKit::detail::SrwSharedMutex> gate(m_impl->method_mutex);
1972 // Object-word validation is not a policy: every option set requires a capturable writable word, and the
1973 // later guarded compare-exchange closes a protection/unmap race.
1974 const DetourModKit::detail::ObjectWordResult word =
1975 38 DetourModKit::detail::validate_vmt_object_word(reinterpret_cast<std::uintptr_t>(object));
1976
2/2
✓ Branch 28 → 29 taken 18 times.
✓ Branch 28 → 32 taken 20 times.
38 if (word.verdict != DetourModKit::detail::ObjectWordVerdict::Ok)
1977 {
1978 18 return std::unexpected(Error{ErrorCode::InvalidObject, "hook::vmt_apply", word.detail});
1979 }
1980 20 const std::uintptr_t current_vptr = word.vptr;
1981 // Locate this object's restoration binding before any policy branch. Teardown restores from the
1982 // binding. Refusal is the only outcome that keeps every recorded original true.
1983
1/2
✓ Branch 36 → 37 taken 20 times.
✗ Branch 36 → 123 not taken.
40 const auto binding = std::find_if(
1984 20 m_impl->object_bindings.begin(),
1985 20 m_impl->object_bindings.end(),
1986 20 [object](const auto &entry) -> bool { return entry.object == object; }
1987 );
1988 20 const bool already_tracked = binding != m_impl->object_bindings.end();
1989
2/2
✓ Branch 46 → 47 taken 3 times.
✓ Branch 46 → 52 taken 17 times.
20 if (current_vptr == m_impl->cloned_vptr_base)
1990 {
1991
2/2
✓ Branch 47 → 48 taken 2 times.
✓ Branch 47 → 51 taken 1 time.
3 if (!already_tracked)
1992 {
1993 // This handle holds no original vptr for this object. A new binding records the clone base as its
1994 // own original, and teardown then frees the clone below it.
1995 2 return std::unexpected(Error{ErrorCode::HookAlreadyExists, "hook::vmt_apply", current_vptr});
1996 }
1997 // If the object already uses this handle's clone, every policy treats the apply as a no-op.
1998 1 return {};
1999 }
2000
5/6
✓ Branch 52 → 53 taken 1 time.
✓ Branch 52 → 57 taken 16 times.
✓ Branch 55 → 56 taken 1 time.
✗ Branch 55 → 57 not taken.
✓ Branch 58 → 59 taken 1 time.
✓ Branch 58 → 62 taken 16 times.
18 else if (already_tracked && current_vptr != binding->original_vptr)
2001 {
2002 // Another actor moved the object off the recorded vptr, usually through a newer layer. A new
2003 // publication displaces state that this binding does not name.
2004 1 return std::unexpected(Error{ErrorCode::HookAlreadyExists, "hook::vmt_apply", current_vptr});
2005 }
2006
4/4
✓ Branch 62 → 63 taken 15 times.
✓ Branch 62 → 64 taken 1 time.
✓ Branch 63 → 64 taken 1 time.
✓ Branch 63 → 88 taken 14 times.
16 if (options.fail_if_already_hooked || options.fail_on_non_function_pointer)
2007 {
2008
2/2
✓ Branch 64 → 65 taken 1 time.
✓ Branch 64 → 71 taken 1 time.
2 if (options.fail_if_already_hooked)
2009 {
2010
1/2
✓ Branch 67 → 68 taken 1 time.
✗ Branch 67 → 71 not taken.
1 if (DetourModKit::detail::HookLedger::instance().is_vmt_clone_base(current_vptr))
2011 {
2012 // If a different same-kit VmtHook owns the clone, refuse another layer.
2013 1 return std::unexpected(Error{ErrorCode::HookAlreadyExists, "hook::vmt_apply", current_vptr});
2014 }
2015 }
2016
1/2
✓ Branch 71 → 72 taken 1 time.
✗ Branch 71 → 87 not taken.
1 if (options.fail_on_non_function_pointer)
2017 {
2018 const std::optional<std::uintptr_t> slot0 =
2019 1 DetourModKit::detail::guarded_read<std::uintptr_t>(current_vptr);
2020
1/2
✗ Branch 74 → 75 not taken.
✓ Branch 74 → 78 taken 1 time.
1 if (!slot0)
2021 {
2022 return std::unexpected(Error{ErrorCode::InvalidObject, "hook::vmt_apply", current_vptr});
2023 }
2024
1/2
✓ Branch 80 → 81 taken 1 time.
✗ Branch 80 → 85 not taken.
1 if (!looks_like_function_vmt_slot(*slot0))
2025 {
2026 1 return std::unexpected(Error{ErrorCode::InvalidObject, "hook::vmt_apply", *slot0});
2027 }
2028 }
2029 }
2030
2/2
✓ Branch 90 → 91 taken 4 times.
✓ Branch 90 → 96 taken 10 times.
14 else if (DetourModKit::detail::HookLedger::instance().is_vmt_clone_base(current_vptr))
2031 {
2032 // The permissive default permits a chain on another kit clone. This copies its hooked slots into this
2033 // handle "original" snapshot, which creates the silent double hook. Proceed per contract but warn.
2034 8 (void)log().try_log(
2035 LogLevel::Warning,
2036 "hook::vmt_apply: VMT hook '{}' targets object 0x{:0{}X} with vptr 0x{:0{}X}. Another DMK VMT "
2037 "hook owns that clone. That clone's hooked slots become this hook's "
2038 "original. Set VmtOptions::fail_if_already_hooked to refuse instead.",
2039 4 std::string_view{m_impl->name},
2040 8 reinterpret_cast<std::uintptr_t>(object),
2041 8 sizeof(std::uintptr_t) * 2,
2042 current_vptr,
2043 8 sizeof(std::uintptr_t) * 2
2044 );
2045 }
2046 // Reserve the restoration binding before publication. Capacity growth after publication can throw with
2047 // the object already on the clone but absent from the state that teardown needs.
2048
1/2
✓ Branch 96 → 97 taken 14 times.
✗ Branch 96 → 101 not taken.
14 if (!already_tracked)
2049 {
2050 try
2051 {
2052
2/2
✓ Branch 100 → 101 taken 12 times.
✓ Branch 100 → 116 taken 2 times.
14 m_impl->object_bindings.reserve(m_impl->object_bindings.size() + 1);
2053 }
2054
1/2
✗ Branch 116 → 117 not taken.
✓ Branch 116 → 118 taken 2 times.
2 catch (const std::bad_alloc &)
2055 {
2056 2 return std::unexpected(Error{ErrorCode::OutOfMemory, "hook::vmt_apply"});
2057 2 }
2058 }
2059
2/2
✓ Branch 103 → 104 taken 4 times.
✓ Branch 103 → 107 taken 8 times.
12 if (!publish_vmt_object_word(object, current_vptr, m_impl->cloned_vptr_base))
2060 {
2061 4 return std::unexpected(
2062 4 Error{ErrorCode::InvalidObject, "hook::vmt_apply", reinterpret_cast<std::uintptr_t>(object)}
2063 4 );
2064 }
2065
1/2
✓ Branch 107 → 108 taken 8 times.
✗ Branch 107 → 111 not taken.
8 if (!already_tracked)
2066 {
2067 // The reserved capacity guarantees that this push cannot throw.
2068
1/2
✓ Branch 109 → 110 taken 8 times.
✗ Branch 109 → 122 not taken.
8 m_impl->object_bindings.push_back({object, current_vptr});
2069 }
2070 8 return {};
2071 38 }
2072
2073 14 Result<void> VmtHook::remove_from(void *object)
2074 {
2075
2/2
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 9 taken 13 times.
14 if (std::optional<Error> vetoed = refuse_on_loader_lock("hook::vmt_remove"))
2076 {
2077 1 return std::unexpected(*vetoed);
2078 }
2079 #if defined(DMK_ENABLE_TEST_SEAMS)
2080 13 note_loader_veto_passed(DetourModKit::detail::HookLoaderEntry::VmtRemove);
2081 #endif
2082
2/2
✓ Branch 11 → 12 taken 1 time.
✓ Branch 11 → 15 taken 12 times.
13 if (!m_impl)
2083 {
2084 1 return std::unexpected(Error{ErrorCode::InvalidHookState, "hook::vmt_remove"});
2085 }
2086
2/2
✓ Branch 15 → 16 taken 1 time.
✓ Branch 15 → 19 taken 11 times.
12 if (object == nullptr)
2087 {
2088 1 return std::unexpected(Error{ErrorCode::InvalidObject, "hook::vmt_remove"});
2089 }
2090 11 std::unique_lock<std::mutex> object_gate = acquire_vmt_object_lock();
2091
1/2
✗ Branch 21 → 22 not taken.
✓ Branch 21 → 25 taken 11 times.
11 if (!object_gate.owns_lock())
2092 {
2093 return std::unexpected(Error{ErrorCode::UnknownError, "hook::vmt_remove"});
2094 }
2095 // The exclusive write prevents a race between unapply and an original() snapshot reader during transition.
2096
1/2
✓ Branch 26 → 27 taken 11 times.
✗ Branch 26 → 77 not taken.
11 std::unique_lock<DetourModKit::detail::SrwSharedMutex> gate(m_impl->method_mutex);
2097
1/2
✓ Branch 31 → 32 taken 11 times.
✗ Branch 31 → 75 not taken.
22 const auto binding = std::find_if(
2098 11 m_impl->object_bindings.begin(),
2099 11 m_impl->object_bindings.end(),
2100 18 [object](const auto &entry) -> bool { return entry.object == object; }
2101 );
2102
1/2
✗ Branch 40 → 41 not taken.
✓ Branch 40 → 42 taken 11 times.
22 if (binding == m_impl->object_bindings.end())
2103 {
2104 return {};
2105 }
2106
2107 // Keep the full binding if a successor still outranks this clone. A later rollback can return it here.
2108 const DetourModKit::detail::ObjectWordResult word =
2109 11 DetourModKit::detail::validate_vmt_object_word(reinterpret_cast<std::uintptr_t>(object));
2110
5/6
✓ Branch 43 → 44 taken 11 times.
✗ Branch 43 → 47 not taken.
✓ Branch 45 → 46 taken 9 times.
✓ Branch 45 → 47 taken 2 times.
✓ Branch 48 → 49 taken 9 times.
✓ Branch 48 → 53 taken 2 times.
11 if (word.verdict == DetourModKit::detail::ObjectWordVerdict::Ok && word.vptr == m_impl->cloned_vptr_base)
2111 {
2112 // The re-read below is the authority: an object already at its original restores nothing yet must
2113 // still release its binding.
2114 9 (void)publish_vmt_object_word(object, m_impl->cloned_vptr_base, binding->original_vptr);
2115 }
2116
2117 const std::optional<std::uintptr_t> after =
2118 11 DetourModKit::detail::guarded_read<std::uintptr_t>(reinterpret_cast<std::uintptr_t>(object));
2119
5/6
✓ Branch 55 → 56 taken 11 times.
✗ Branch 55 → 61 not taken.
✓ Branch 59 → 60 taken 9 times.
✓ Branch 59 → 61 taken 2 times.
✓ Branch 62 → 63 taken 9 times.
✓ Branch 62 → 69 taken 2 times.
22 if (after && *after == binding->original_vptr)
2120 {
2121
1/2
✓ Branch 67 → 68 taken 9 times.
✗ Branch 67 → 74 not taken.
18 m_impl->object_bindings.erase(binding);
2122 }
2123 11 return {};
2124 11 }
2125
2126 42 Result<void> VmtHook::hook_method_raw(std::size_t index, void *detour)
2127 {
2128
2/2
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 9 taken 41 times.
42 if (std::optional<Error> vetoed = refuse_on_loader_lock("hook::vmt_hook_method"))
2129 {
2130 1 return std::unexpected(*vetoed);
2131 }
2132 #if defined(DMK_ENABLE_TEST_SEAMS)
2133 41 note_loader_veto_passed(DetourModKit::detail::HookLoaderEntry::VmtHookMethod);
2134 #endif
2135
2/2
✓ Branch 11 → 12 taken 2 times.
✓ Branch 11 → 15 taken 39 times.
41 if (!m_impl)
2136 {
2137 2 return std::unexpected(Error{ErrorCode::InvalidHookState, "hook::vmt_hook_method"});
2138 }
2139
2/2
✓ Branch 15 → 16 taken 1 time.
✓ Branch 15 → 19 taken 38 times.
39 if (detour == nullptr)
2140 {
2141 1 return std::unexpected(Error{ErrorCode::InvalidArg, "hook::vmt_hook_method"});
2142 }
2143 {
2144 // The map insert and backend slot patch must be atomic against a concurrent original() snapshot reader.
2145 // That reader traverses this same map under the shared read.
2146
1/2
✓ Branch 20 → 21 taken 38 times.
✗ Branch 20 → 78 not taken.
38 std::unique_lock<DetourModKit::detail::SrwSharedMutex> gate(m_impl->method_mutex);
2147
2/2
✓ Branch 22 → 23 taken 4 times.
✓ Branch 22 → 26 taken 34 times.
38 if (index >= m_impl->method_count)
2148 {
2149 4 return std::unexpected(Error{ErrorCode::InvalidArg, "hook::vmt_hook_method", index});
2150 }
2151
3/4
✓ Branch 27 → 28 taken 34 times.
✗ Branch 27 → 76 not taken.
✓ Branch 28 → 29 taken 1 time.
✓ Branch 28 → 32 taken 33 times.
34 if (m_impl->method_hooks.contains(index))
2152 {
2153 // One method hook exists per slot. A second hook reads the first detour as the "original" and
2154 // creates a silent mod chain.
2155 1 return std::unexpected(Error{ErrorCode::MethodAlreadyHooked, "hook::vmt_hook_method", index});
2156 }
2157 try
2158 {
2159 // A void* detour installs the same 8 bytes as a typed pointer. hook_method<Fn> vetted the ABI.
2160
1/2
✓ Branch 33 → 34 taken 33 times.
✗ Branch 33 → 64 not taken.
33 auto created = m_impl->backend.hook_method(index, detour);
2161
1/2
✗ Branch 35 → 36 not taken.
✓ Branch 35 → 39 taken 33 times.
33 if (!created)
2162 {
2163 return std::unexpected(Error{ErrorCode::BackendFailed, "hook::vmt_hook_method", index});
2164 }
2165 // emplace is the last fallible step and the commit point. A bad_alloc unwinds the new VmHook. Its
2166 // destructor rolls the slot back, so nothing is half-registered.
2167
2/4
✓ Branch 40 → 41 taken 33 times.
✗ Branch 40 → 61 not taken.
✓ Branch 43 → 44 taken 33 times.
✗ Branch 43 → 61 not taken.
66 m_impl->method_hooks.emplace(index, std::move(created.value()));
2168
1/2
✓ Branch 46 → 47 taken 33 times.
✗ Branch 46 → 49 not taken.
33 }
2169 catch (const std::bad_alloc &)
2170 {
2171 return std::unexpected(Error{ErrorCode::OutOfMemory, "hook::vmt_hook_method", index});
2172 }
2173 catch (...)
2174 {
2175 return std::unexpected(Error{ErrorCode::BackendFailed, "hook::vmt_hook_method", index});
2176 }
2177
2/2
✓ Branch 51 → 52 taken 33 times.
✓ Branch 51 → 57 taken 5 times.
38 }
2178 // This post-commit best-effort log contains a format bad_alloc, so it cannot flip a committed install into
2179 // a failure.
2180 try
2181 {
2182
1/2
✓ Branch 56 → 58 taken 33 times.
✗ Branch 56 → 79 not taken.
66 log().info(
2183 "hook::hook_method: hooked method index {} on VMT hook '{}'.",
2184 index,
2185 66 std::string_view{m_impl->name}
2186 );
2187 }
2188 catch (...)
2189 {
2190 }
2191 33 return {};
2192 }
2193
2194 23 void *VmtHook::method_original_address(std::size_t index) const noexcept
2195 {
2196
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 22 times.
23 if (!m_impl)
2197 {
2198 1 return nullptr;
2199 }
2200 // A shared read serializes the snapshot against exclusive writers. The copied pointer is then called
2201 // lock-free by contract (see original()).
2202 22 std::shared_lock<DetourModKit::detail::SrwSharedMutex> gate(m_impl->method_mutex);
2203 22 const auto it = m_impl->method_hooks.find(index);
2204
2/2
✓ Branch 12 → 13 taken 4 times.
✓ Branch 12 → 14 taken 18 times.
22 if (it == m_impl->method_hooks.end())
2205 {
2206 4 return nullptr;
2207 }
2208 18 return it->second.original<void *>();
2209 22 }
2210
2211 6 Result<void> VmtHook::remove_method(std::size_t index)
2212 {
2213
2/2
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 9 taken 5 times.
6 if (std::optional<Error> vetoed = refuse_on_loader_lock("hook::vmt_remove_method"))
2214 {
2215 1 return std::unexpected(*vetoed);
2216 }
2217 #if defined(DMK_ENABLE_TEST_SEAMS)
2218 5 note_loader_veto_passed(DetourModKit::detail::HookLoaderEntry::VmtRemoveMethod);
2219 #endif
2220
2/2
✓ Branch 11 → 12 taken 2 times.
✓ Branch 11 → 15 taken 3 times.
5 if (!m_impl)
2221 {
2222 2 return std::unexpected(Error{ErrorCode::InvalidHookState, "hook::vmt_remove_method"});
2223 }
2224 {
2225 // An exclusive write runs the VmHook destructor when it erases the entry. The destructor restores the
2226 // cloned slot to its original pointer. That restore must not race an original() snapshot reader.
2227
1/2
✓ Branch 16 → 17 taken 3 times.
✗ Branch 16 → 42 not taken.
3 std::unique_lock<DetourModKit::detail::SrwSharedMutex> gate(m_impl->method_mutex);
2228
1/2
✓ Branch 18 → 19 taken 3 times.
✗ Branch 18 → 40 not taken.
3 const auto it = m_impl->method_hooks.find(index);
2229
2/2
✓ Branch 22 → 23 taken 1 time.
✓ Branch 22 → 26 taken 2 times.
3 if (it == m_impl->method_hooks.end())
2230 {
2231 1 return std::unexpected(Error{ErrorCode::MethodNotFound, "hook::vmt_remove_method", index});
2232 }
2233
1/2
✓ Branch 27 → 28 taken 2 times.
✗ Branch 27 → 40 not taken.
2 m_impl->method_hooks.erase(it);
2234
2/2
✓ Branch 30 → 31 taken 2 times.
✓ Branch 30 → 36 taken 1 time.
3 }
2235 // This post-commit log is best-effort. Contain a format bad_alloc.
2236 try
2237 {
2238
1/2
✓ Branch 35 → 37 taken 2 times.
✗ Branch 35 → 43 not taken.
4 log().info(
2239 "hook::remove_method: removed method index {} from VMT hook '{}'.",
2240 index,
2241 4 std::string_view{m_impl->name}
2242 );
2243 }
2244 catch (...)
2245 {
2246 }
2247 2 return {};
2248 }
2249
2250 2 void VmtHook::release() noexcept
2251 {
2252
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 2 times.
2 if (!m_impl)
2253 {
2254 return;
2255 }
2256 // Leak the cloned vtable intentionally. Applied objects keep the clone for the process lifetime. The ledger
2257 // entry stays so is_vmt_clone_base still recognizes the live clone base. HookManager records the leak.
2258 2 diagnostics::record_intentional_leak(diagnostics::LeakSubsystem::HookManager);
2259 2 (void)m_impl.release();
2260 }
2261
2262 146 Result<VmtHook> vmt_for(std::string name, void *object, VmtOptions options)
2263 {
2264
2/2
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 9 taken 145 times.
146 if (std::optional<Error> vetoed = refuse_on_loader_lock("hook::vmt_for"))
2265 {
2266 1 return std::unexpected(*vetoed);
2267 }
2268 #if defined(DMK_ENABLE_TEST_SEAMS)
2269 145 note_loader_veto_passed(DetourModKit::detail::HookLoaderEntry::VmtFor);
2270 #endif
2271
2/2
✓ Branch 11 → 12 taken 1 time.
✓ Branch 11 → 15 taken 144 times.
145 if (name.empty())
2272 {
2273 1 return std::unexpected(Error{ErrorCode::InvalidArg, "hook::vmt_for"});
2274 }
2275
2/2
✓ Branch 15 → 16 taken 2 times.
✓ Branch 15 → 19 taken 142 times.
144 if (object == nullptr)
2276 {
2277 2 return std::unexpected(Error{ErrorCode::InvalidObject, "hook::vmt_for"});
2278 }
2279 // Take the module reference before the process-wide VMT object gate. A rollback runs FreeLibrary, which
2280 // takes the loader lock and must not nest inside the gate per ~VmtHook lock order. Declare the guard first.
2281 // The gate then unlocks first, so the guard FreeLibrary call always runs outside it.
2282 142 ModuleRefGuard self_ref(acquire_hook_self_ref());
2283
2/2
✓ Branch 22 → 23 taken 1 time.
✓ Branch 22 → 27 taken 141 times.
142 if (self_ref.get() == nullptr)
2284 {
2285 // acquire_module_ref restores GetLastError() on failure (error.hpp documents the detail contract).
2286
1/2
✓ Branch 23 → 24 taken 1 time.
✗ Branch 23 → 149 not taken.
1 return std::unexpected(Error{ErrorCode::SystemCallFailed, "hook::vmt_for", ::GetLastError()});
2287 }
2288 141 std::unique_lock<std::mutex> object_gate = acquire_vmt_object_lock();
2289
1/2
✗ Branch 29 → 30 not taken.
✓ Branch 29 → 33 taken 141 times.
141 if (!object_gate.owns_lock())
2290 {
2291 return std::unexpected(Error{ErrorCode::UnknownError, "hook::vmt_for"});
2292 }
2293 // Object-word validation is not a policy (see apply_to).
2294 const DetourModKit::detail::ObjectWordResult word =
2295 141 DetourModKit::detail::validate_vmt_object_word(reinterpret_cast<std::uintptr_t>(object));
2296
2/2
✓ Branch 34 → 35 taken 17 times.
✓ Branch 34 → 38 taken 124 times.
141 if (word.verdict != DetourModKit::detail::ObjectWordVerdict::Ok)
2297 {
2298 17 return std::unexpected(Error{ErrorCode::InvalidObject, "hook::vmt_for", word.detail});
2299 }
2300 124 const std::uintptr_t current_vptr = word.vptr;
2301
4/4
✓ Branch 38 → 39 taken 121 times.
✓ Branch 38 → 40 taken 3 times.
✓ Branch 39 → 40 taken 4 times.
✓ Branch 39 → 67 taken 117 times.
124 if (options.fail_if_already_hooked || options.fail_on_non_function_pointer)
2302 {
2303
6/6
✓ Branch 40 → 41 taken 3 times.
✓ Branch 40 → 45 taken 4 times.
✓ Branch 43 → 44 taken 2 times.
✓ Branch 43 → 45 taken 1 time.
✓ Branch 46 → 47 taken 2 times.
✓ Branch 46 → 50 taken 5 times.
10 if (options.fail_if_already_hooked &&
2304 3 DetourModKit::detail::HookLedger::instance().is_vmt_clone_base(current_vptr))
2305 {
2306 2 return std::unexpected(Error{ErrorCode::HookAlreadyExists, "hook::vmt_for", current_vptr});
2307 }
2308
2/2
✓ Branch 50 → 51 taken 4 times.
✓ Branch 50 → 66 taken 1 time.
5 if (options.fail_on_non_function_pointer)
2309 {
2310 const std::optional<std::uintptr_t> slot0 =
2311 4 DetourModKit::detail::guarded_read<std::uintptr_t>(current_vptr);
2312
1/2
✗ Branch 53 → 54 not taken.
✓ Branch 53 → 57 taken 4 times.
4 if (!slot0)
2313 {
2314 return std::unexpected(Error{ErrorCode::InvalidObject, "hook::vmt_for", current_vptr});
2315 }
2316
2/2
✓ Branch 59 → 60 taken 3 times.
✓ Branch 59 → 64 taken 1 time.
4 if (!looks_like_function_vmt_slot(*slot0))
2317 {
2318 3 return std::unexpected(Error{ErrorCode::InvalidObject, "hook::vmt_for", *slot0});
2319 }
2320 }
2321 2 }
2322
2/2
✓ Branch 69 → 70 taken 19 times.
✓ Branch 69 → 74 taken 98 times.
117 else if (DetourModKit::detail::HookLedger::instance().is_vmt_clone_base(current_vptr))
2323 {
2324 // For the permissive default, see the associated warning in apply_to. vmt_for creates a fresh clone,
2325 // so it needs no own-clone-base exclusion here.
2326 38 (void)log().try_log(
2327 LogLevel::Warning,
2328 "hook::vmt_for: VMT hook '{}' targets object 0x{:0{}X} with vptr 0x{:0{}X}. Another DMK VMT hook "
2329 "owns that clone. That clone's hooked slots become this hook's "
2330 "original. Set VmtOptions::fail_if_already_hooked to refuse instead.",
2331 38 std::string_view{name},
2332 38 reinterpret_cast<std::uintptr_t>(object),
2333 38 sizeof(std::uintptr_t) * 2,
2334 current_vptr,
2335 38 sizeof(std::uintptr_t) * 2
2336 );
2337 }
2338 119 const std::optional<std::size_t> slot_budget = count_vmt_method_slots(current_vptr);
2339
2/2
✓ Branch 76 → 77 taken 1 time.
✓ Branch 76 → 80 taken 118 times.
119 if (!slot_budget)
2340 {
2341 1 return std::unexpected(
2342 1 Error{ErrorCode::InvalidObject, "hook::vmt_for", reinterpret_cast<std::uintptr_t>(object)}
2343 1 );
2344 }
2345 // An engaged zero found no callable slot. The clone is unusable by construction.
2346
2/2
✓ Branch 81 → 82 taken 1 time.
✓ Branch 81 → 85 taken 117 times.
118 if (*slot_budget == 0)
2347 {
2348 1 return std::unexpected(Error{ErrorCode::InvalidObject, "hook::vmt_for", current_vptr});
2349 }
2350 #if defined(DMK_ENABLE_TEST_SEAMS)
2351
2/2
✓ Branch 85 → 86 taken 3 times.
✓ Branch 85 → 87 taken 114 times.
117 if (auto *probe = DetourModKit::detail::g_vmt_before_capture_probe)
2352 {
2353 3 probe();
2354 }
2355 #endif
2356 try
2357 {
2358
2/2
✓ Branch 88 → 89 taken 110 times.
✓ Branch 88 → 164 taken 7 times.
117 Result<DetachedVmtBackend> cloned = clone_vmt_snapshot(current_vptr, *slot_budget);
2359
2/2
✓ Branch 90 → 91 taken 1 time.
✓ Branch 90 → 95 taken 109 times.
110 if (!cloned)
2360 {
2361 1 return std::unexpected(cloned.error());
2362 }
2363 109 const std::uintptr_t cloned_vptr_base = cloned->cloned_vptr_base;
2364 auto impl = std::make_unique<VmtHook::Impl>(
2365 109 std::move(cloned->backend),
2366 109 std::move(name),
2367 cloned_vptr_base,
2368 109 cloned->method_count,
2369 110 0
2370
2/2
✓ Branch 102 → 103 taken 108 times.
✓ Branch 102 → 151 taken 1 time.
109 );
2371
2/2
✓ Branch 104 → 105 taken 107 times.
✓ Branch 104 → 152 taken 1 time.
108 impl->object_bindings.push_back({object, current_vptr});
2372 107 const std::string_view created_name = impl->name;
2373 const std::optional<std::uint64_t> recorded =
2374 107 DetourModKit::detail::HookLedger::instance().try_record_vmt(cloned_vptr_base);
2375
1/2
✗ Branch 110 → 111 not taken.
✓ Branch 110 → 114 taken 107 times.
107 if (!recorded)
2376 {
2377 return std::unexpected(Error{ErrorCode::OutOfMemory, "hook::vmt_for"});
2378 }
2379 107 impl->ledger_id = *recorded;
2380 // Publication is last: every allocation, binding, and ledger step completes before the guarded store
2381 // can expose the clone to host dispatch.
2382
2/2
✓ Branch 117 → 118 taken 4 times.
✓ Branch 117 → 124 taken 103 times.
107 if (!publish_vmt_object_word(object, current_vptr, cloned_vptr_base))
2383 {
2384 4 DetourModKit::detail::HookLedger::instance().release_vmt(*recorded);
2385 4 return std::unexpected(
2386 4 Error{ErrorCode::InvalidObject, "hook::vmt_for", reinterpret_cast<std::uintptr_t>(object)}
2387 4 );
2388 }
2389 // Release the gate BEFORE the log and lifecycle event: subscriber code must not run under the
2390 // process-wide VMT mutex (CP.22), because a reentrant subscriber self-deadlocks.
2391
1/2
✓ Branch 124 → 125 taken 103 times.
✗ Branch 124 → 160 not taken.
103 object_gate.unlock();
2392 // This post-commit log is best-effort. Contain a format bad_alloc (see hook_method_raw).
2393 try
2394 {
2395
1/2
✓ Branch 127 → 128 taken 102 times.
✗ Branch 127 → 153 not taken.
205 log().info(
2396 "hook::vmt_for: created VMT hook '{}' on object {}.",
2397 created_name,
2398
2/2
✓ Branch 126 → 127 taken 102 times.
✓ Branch 126 → 156 taken 1 time.
205 format::format_address(reinterpret_cast<std::uintptr_t>(object))
2399 );
2400 }
2401 1 catch (...)
2402 {
2403
1/2
✓ Branch 159 → 130 taken 1 time.
✗ Branch 159 → 160 not taken.
1 }
2404 103 emit_lifecycle(
2405 created_name,
2406 103 *recorded,
2407 diagnostics::HookKind::Vmt,
2408 diagnostics::HookTransition::Created
2409 );
2410 // Hand the module reference to the Impl only after completion of every fallible setup step.
2411 103 impl->self_ref = self_ref.release();
2412 103 return VmtHook(std::move(impl));
2413 111 }
2414
1/2
✓ Branch 165 → 166 taken 9 times.
✗ Branch 165 → 170 not taken.
9 catch (const std::bad_alloc &)
2415 {
2416 9 return std::unexpected(Error{ErrorCode::OutOfMemory, "hook::vmt_for"});
2417 9 }
2418 catch (...)
2419 {
2420 return std::unexpected(Error{ErrorCode::UnknownError, "hook::vmt_for"});
2421 }
2422 142 }
2423 } // namespace hook
2424 } // namespace DetourModKit
2425