GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 100.0% 1 / 0 / 1
Functions: 100.0% 1 / 0 / 1
Branches: -% 0 / 0 / 0

src/internal/hook_ledger.hpp
Line Branch Exec Source
1 #ifndef DETOURMODKIT_INTERNAL_HOOK_LEDGER_HPP
2 #define DETOURMODKIT_INTERNAL_HOOK_LEDGER_HPP
3
4 /**
5 * @file internal/hook_ledger.hpp
6 * @brief Per-linked-instance safety ledger for the free-function hook surface; not a public registry.
7 * @details Hooks are owned by the caller's handle rather than a central registry, but two safety properties still need
8 * shared state: exact same-kit duplicate detection (does this kit already patch address X?) and layer
9 * ordering for hooks stacked on one target address. This ledger is that state and only that state. It keys on
10 * the raw target address (inline/mid) and the cloned-vptr base (VMT clones), holds no names, exposes no
11 * enumeration, and is never installed.
12 *
13 * SCOPE: one ledger per linked DMK instance, NOT per process. DetourModKit is a static archive
14 * (CMakeLists.txt: add_library(DetourModKit STATIC)), so two DLLs that each link it get two independent
15 * ledgers with two duplicate-detection and two layer-ordering domains. Cross-instance layering is therefore
16 * invisible here and is not defended against; a real process-shared registry would be required for that.
17 *
18 * Layer ordering is not enforced by reordering destructors. The RAII model deliberately hands lifetime to
19 * the caller. Instead an operation that would alter a target's bytes claims that target's serialization slot
20 * and measures how many NEWER live hooks sit on the same address. A non-zero answer means the caller is
21 * toggling or unwinding a hook that is no longer on top, so the operation is refused (toggle) or the backend
22 * is intentionally leaked (teardown) rather than writing bytes a newer trampoline still depends on.
23 */
24
25 #include <atomic>
26 #include <condition_variable>
27 #include <cstddef>
28 #include <cstdint>
29 #include <mutex>
30 #include <optional>
31 #include <unordered_map>
32 #include <vector>
33
34 namespace DetourModKit
35 {
36 namespace detail
37 {
38 #if defined(DMK_ENABLE_TEST_SEAMS)
39 /**
40 * @brief Fired immediately before every HookLedger state-lock acquisition.
41 * @details A throwing probe drives the synchronization-failure branch of each noexcept ledger boundary, which a
42 * real std::mutex on Windows does not produce on demand. Intentionally NOT noexcept: the throw is the
43 * injected failure. Null in production builds, where this seam does not compile at all.
44 */
45 extern void (*g_hook_ledger_lock_probe)();
46 #endif
47
48 /**
49 * @class HookLedger
50 * @brief Tracker of live and in-progress DMK hooks for one linked instance, by target address and VMT base.
51 * @details Never destroyed: see @ref instance.
52 */
53 class HookLedger
54 {
55 public:
56 /**
57 * @brief Returns this linked instance's ledger, constructing it once on first use.
58 * @details Constructed into function-local static storage and NEVER destroyed, mirroring
59 * StringPool::instance() and Profiler::instance(). A Meyers singleton (`static HookLedger l;`)
60 * would register a static destructor, and a Hook/VmtHook whose own destructor runs later would
61 * then lock a destroyed mutex and search a destroyed map. That is a use-after-free, reachable
62 * whenever a namespace-scope owner (e.g. an empty `static HookStack` populated after startup)
63 * registers its destructor BEFORE the first hook is created and therefore before this ledger
64 * registers its own. Leaking the state for the process lifetime is the cost of making that
65 * ordering irrelevant.
66 */
67 [[nodiscard]] static HookLedger &instance() noexcept;
68
69 /// Outcome of @ref try_reserve_hook.
70 enum class ReserveStatus : std::uint8_t
71 {
72 /// The slot is reserved and must be committed or released.
73 Reserved,
74 /// This instance already patches the target, so nothing was reserved.
75 AlreadyHooked,
76 /// Bookkeeping allocation or lock acquisition failed, so nothing was reserved.
77 OutOfMemory
78 };
79
80 /**
81 * @struct Reservation
82 * @brief The result of an atomic check-and-reserve on the target ledger.
83 * @details On @ref ReserveStatus::Reserved, @ref id MUST be committed (@ref commit_hook) or rolled back
84 * (@ref release_hook) by the caller. @ref preexisting reports whether this kit already tracked the
85 * target BEFORE this reservation, distinguishing a fresh install from a layered one.
86 */
87 struct Reservation
88 {
89 ReserveStatus status{ReserveStatus::OutOfMemory};
90 std::uint64_t id{0};
91 bool preexisting{false};
92 };
93
94 // Inline / mid hooks: keyed by the patched target address. Inline and mid hooks at one address share the
95 // same prologue bytes, so they share one key space, one pending queue, and one layering order.
96
97 /**
98 * @brief Atomically checks the target and reserves a ledger id under a single lock acquisition.
99 * @param target The patched target address.
100 * @param refuse_if_hooked Refuse (reserving nothing) if this kit already patches @p target; this is the
101 * @ref hook::Options::fail_if_already_hooked exact same-kit gate.
102 * @return A @ref Reservation. On @ref ReserveStatus::Reserved the id is appended newest-last and is next in
103 * the per-target queue.
104 * @details Folding the check and the record into one locked step closes the time-of-check/time-of-use gap a
105 * separate is_target_hooked + record left open. Reservations also wait their turn in creation
106 * order, so two permissive same-target installs cannot patch concurrently or invert the layering
107 * order. Allocation or lock failure returns OutOfMemory so the caller fails the install closed
108 * rather than installing a live-but-unledgered hook that duplicate detection and layer tracking
109 * cannot see.
110 * @warning Setup/control-plane only, and NOT reentrant for a single target on one thread: a thread MUST
111 * commit or roll back its reservation before reserving the SAME @p target again, or the second
112 * call blocks forever waiting for the first (which the same thread still holds) to leave the
113 * queue. DMK's install paths honor this and never install reentrantly from a detour, so the
114 * deadlock is unreachable in supported use; it is a caller error, not a defect.
115 */
116 [[nodiscard]] Reservation try_reserve_hook(std::uintptr_t target, bool refuse_if_hooked) noexcept;
117
118 /**
119 * @brief Completes a reservation after backend creation and wakes the next same-target installer.
120 * @return True when the reservation was committed. False on lock failure or an invalid reservation, so the
121 * installer can fail before publishing a handle.
122 * @details The id stays in the target's creation order for duplicate detection and layer tracking; only the
123 * pending reservation is removed.
124 */
125 [[nodiscard]] bool commit_hook(std::uintptr_t target, std::uint64_t id) noexcept;
126
127 /**
128 * @brief Removes hook @p id from @p target and returns how many newer hooks remain recorded on it.
129 * @return The count of ids created AFTER @p id still in @p target's order. Zero means @p id is the newest
130 * (or sole) record, so a byte restore is in the safe newest-first order. Any positive value means
131 * newer layers sit on top and the caller must not restore. Lock failure returns a positive count
132 * so the caller fails closed to a leak.
133 * @note The count is keyed on the record, not on whether a handle still owns it: an id counts until its
134 * owner calls this method, so it includes ids left behind by a pin (a teardown that took @ref
135 * release_target_slot, and the release verbs, which abandon the backend without a ledger call at
136 * all). Excluding the ownerless ones would authorize an older layer to write over a prologue a
137 * retained trampoline can still resume through.
138 */
139 [[nodiscard]] std::size_t release_hook(std::uintptr_t target, std::uint64_t id) noexcept;
140
141 /**
142 * @brief Claims @p target's serialization slot for an operation that may alter its bytes, returning the
143 * newer-live count measured at the instant the slot is held.
144 * @return The count of ids created AFTER @p id still recorded on @p target (see @ref release_hook for what
145 * that includes). Zero authorizes the caller to write target bytes (enable, disable, or restore);
146 * any positive value means @p id is not the top layer (or one raced in during the claim) and
147 * the caller MUST refuse (toggle) or leak (teardown). An absent target/id, a bookkeeping
148 * allocation failure, or a lock failure all return a positive count so the caller fails closed.
149 * @details The write-side counterpart to @ref try_reserve_hook, used by enable, disable, and teardown. A
150 * bare newer-count peek followed by a byte write is not atomic against a concurrent same-target
151 * install: an install reserved after the peek reads the caller's prologue as its resume, and the
152 * caller's write then clobbers it and the trampoline the new layer chains through - a
153 * use-after-free. This method closes that window by waiting its turn in the SAME per-target queue
154 * an install waits in, then blocking every new reserver behind this id until the slot is released.
155 * While the slot is held no install can read or write @p target's prologue, so {decide, write} is
156 * effectively atomic against installs.
157 * @warning The caller MUST release the slot exactly once, through @ref release_hook (which also drops the
158 * id from the creation order) or @ref release_target_slot (which keeps it). Otherwise later
159 * same-target installs block forever behind the unreleased queue sentinel. Only the sentinel can
160 * block. The creation-order entry never does. Release the slot before running user code or
161 * taking the loader lock: holding it across either invites a deadlock against an install that is
162 * itself under the loader lock.
163 * @note A release path that cannot retake the state lock leaves this id at the front of the pending queue,
164 * which in isolation reads like a stranded sentinel that would park every later same-target reserver
165 * forever. It cannot, and the release paths deliberately do NOT try to repair it: the queue is plain
166 * vector state that only the mutex makes safe to touch, so erasing the sentinel without the lock
167 * would be a data race, trading a stall for undefined behaviour. The stall is unreachable instead.
168 * @ref lock_state fails only when std::mutex::lock throws, which is a permanent property of that
169 * mutex rather than a transient one, so a later reserver fails its OWN acquisition and returns
170 * OutOfMemory before it can reach the wait. Only the test seam can produce the selective one-shot
171 * failure the stall would need, and it is compiled out of shipping builds.
172 * HookLedgerFaultProof.AbandonedSlotCannotParkALaterReserver pins that ordering.
173 */
174 [[nodiscard]] std::size_t acquire_target_slot(std::uintptr_t target, std::uint64_t id) noexcept;
175
176 /**
177 * @brief Releases a slot claimed by @ref acquire_target_slot WITHOUT removing @p id from the creation
178 * order.
179 * @details For callers whose hook remains live or remains physically patched: a refused toggle, or a
180 * teardown that leaked its backend. Only the queue sentinel is removed, waking the next
181 * same-target installer; the order entry stays so @ref is_target_hooked keeps reporting the
182 * target hooked. The restoring teardown path instead calls @ref release_hook, which drops the id
183 * from both.
184 * @note The order entry this method keeps goes on counting as a newer layer even once no handle owns it,
185 * which is the contract rather than an oversight: the teardown caller here kept an installed
186 * backend, so an older layer underneath must stay refused. Because ids are appended newest-last, a
187 * kept id never outranks a layer installed after it, so a layer installed over a pinned target is
188 * still the newest and still restores its own bytes on teardown. The pinned backend underneath it
189 * is never restored, and the target stays reported hooked for the process lifetime.
190 */
191 void release_target_slot(std::uintptr_t target, std::uint64_t id) noexcept;
192
193 /// True when this kit currently has at least one live or reserved hook for @p target; true on lock failure.
194 [[nodiscard]] bool is_target_hooked(std::uintptr_t target) const noexcept;
195
196 // VMT clones: keyed by the cloned-vptr base SafetyHook installs. One base per VmtHook (every object the
197 // clone is applied to shares that base), so a clone is recorded once at create.
198
199 /**
200 * @brief Records a live VMT clone (installed vptr base @p cloned_base); returns its unique ledger id.
201 * @return The new ledger id, or std::nullopt if the bookkeeping allocation or the lock failed.
202 * @details A nullopt result tells the caller to fail the clone closed (unwinding the backend to restore the
203 * object's vptr) rather than leave a live-but-untracked clone @ref is_vmt_clone_base cannot
204 * recognise. That is the VMT analogue of the inline/mid fail-closed reservation.
205 */
206 [[nodiscard]] std::optional<std::uint64_t> try_record_vmt(std::uintptr_t cloned_base) noexcept;
207
208 /// Removes the VMT clone @p id from the ledger; best-effort on lock failure.
209 void release_vmt(std::uint64_t id) noexcept;
210
211 /**
212 * @brief True when @p vptr is the installed base of any live VMT clone this kit owns.
213 * @return True on lock failure, so a caller refuses or warns rather than silently double-hooking an
214 * unrecognised clone.
215 */
216 [[nodiscard]] bool is_vmt_clone_base(std::uintptr_t vptr) const noexcept;
217
218 private:
219 378 HookLedger() noexcept = default;
220
221 /**
222 * @brief Acquires the state lock, containing a synchronization failure instead of crossing a noexcept edge.
223 * @return An owning lock, or an unowned lock when acquisition failed. Every caller is noexcept and MUST
224 * check owns_lock() and fail closed; std::mutex::lock can throw std::system_error, which would
225 * otherwise reach a noexcept boundary and terminate the host.
226 */
227 [[nodiscard]] std::unique_lock<std::mutex> lock_state() const noexcept;
228
229 struct VmtEntry
230 {
231 std::uint64_t id;
232 std::uintptr_t base;
233 };
234
235 struct TargetEntry
236 {
237 std::vector<std::uint64_t> order;
238 std::vector<std::uint64_t> pending;
239 };
240
241 mutable std::mutex m_mutex;
242 std::condition_variable m_install_cv;
243 // target address -> live/reserved hook ids in creation order (back = newest).
244 std::unordered_map<std::uintptr_t, TargetEntry> m_by_target;
245 // Live VMT clones (small; a linear scan is cheaper than a map at this size).
246 std::vector<VmtEntry> m_vmt;
247 std::atomic<std::uint64_t> m_next_id{1};
248 };
249 } // namespace detail
250 } // namespace DetourModKit
251
252 #endif // DETOURMODKIT_INTERNAL_HOOK_LEDGER_HPP
253