./
Coverage:
low: ≥ 0%
medium: ≥ 75.0%
high: ≥ 90.0%
Lines:
1 / 1
100.0%
Branches:
-
-%
Functions:
1 / 1
100.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 Tracks local duplicate and layer identities for the free-function hook surface.
7 * @details The target slot serializes local requests under `[B-16]` and `[B-79]`. The process coordinator owns
8 * order across participants. hook.hpp owns the duplicate-detection scope.
9 */
10
11 #include <atomic>
12 #include <condition_variable>
13 #include <cstddef>
14 #include <cstdint>
15 #include <memory>
16 #include <mutex>
17 #include <optional>
18 #include <unordered_map>
19 #include <vector>
20
21 namespace DetourModKit
22 {
23 namespace detail
24 {
25 #if defined(DMK_ENABLE_TEST_SEAMS)
26 /**
27 * @brief Fired immediately before every HookLedger state-lock acquisition.
28 * @details A throwing probe drives the synchronization-failure branch of each noexcept ledger boundary, which a
29 * real std::mutex on Windows does not produce on demand. Intentionally NOT noexcept: the throw is the
30 * injected failure. Null in production builds, where this seam does not compile at all.
31 */
32 extern void (*g_hook_ledger_lock_probe)();
33 #endif
34
35 /**
36 * @class HookLedger
37 * @brief Tracker of live and in-progress DMK hooks for one linked instance, by target address and VMT base.
38 * @details Never destroyed: see @ref instance.
39 */
40 class HookLedger
41 {
42 public:
43 /**
44 * @brief Returns this linked instance's ledger, constructing it once on first use.
45 * @details Never-destroyed storage (`[B-47]`): a namespace-scope Hook owner can register its destructor
46 * before the ledger exists. tests/lifecycle/test_hook_static_order.cpp proves the late teardown.
47 * A first-use allocation failure publishes the inert ledger, whose every operation fails closed
48 * for the process (`[B-91]`). Lifecycle.HookLedgerFirstUseOomFailsClosed pins the latch.
49 */
50 [[nodiscard]] static HookLedger &instance() noexcept;
51
52 /// Outcome of @ref try_reserve_hook.
53 enum class ReserveStatus : std::uint8_t
54 {
55 /// The slot is reserved and must be committed or released.
56 Reserved,
57 /// This instance already patches the target, so nothing was reserved.
58 AlreadyHooked,
59 /// Bookkeeping allocation or lock acquisition failed, so nothing was reserved.
60 OutOfMemory
61 };
62
63 /**
64 * @struct Reservation
65 * @brief The result of an atomic check-and-reserve on the target ledger.
66 * @details On @ref ReserveStatus::Reserved, @ref id MUST be committed (@ref commit_hook) or rolled back
67 * (@ref release_hook) by the caller. @ref preexisting reports whether this kit already tracked the
68 * target BEFORE this reservation, distinguishing a fresh install from a layered one.
69 */
70 struct Reservation
71 {
72 ReserveStatus status{ReserveStatus::OutOfMemory};
73 std::uint64_t id{0};
74 bool preexisting{false};
75 };
76
77 // Inline / mid hooks: keyed by the patched target address. Inline and mid hooks at one address share the
78 // same prologue bytes, so they share one key space, one pending queue, and one layering order.
79
80 /**
81 * @brief Atomically checks the target and reserves a ledger id under a single lock acquisition.
82 * @param target The patched target address.
83 * @param refuse_if_hooked Refuse (reserving nothing) if this kit already patches @p target; this is the
84 * @ref hook::Options::fail_if_already_hooked exact same-kit gate.
85 * @return A @ref Reservation. On @ref ReserveStatus::Reserved the id is appended newest-last and is next in
86 * the per-target queue.
87 * @details One locked step checks and records, so no same-target install slips between them. Reservations
88 * wait their turn in reservation order, so two permissive same-target installs cannot patch
89 * concurrently. Allocation or lock failure returns OutOfMemory, so the
90 * install fails closed instead of a live hook that the ledger cannot see.
91 * @warning Setup/control-plane only, with no wait cap. A thread must commit or roll back its reservation
92 * before it reserves the same @p target again, or the second call blocks forever. A caller must
93 * not reserve while it holds the backend process coordinator (`[B-109]`).
94 */
95 [[nodiscard]] Reservation try_reserve_hook(std::uintptr_t target, bool refuse_if_hooked) noexcept;
96
97 /**
98 * @brief Completes a reservation after backend creation and wakes the next same-target installer.
99 * @return True when the reservation was committed. False on lock failure or an invalid reservation, so the
100 * installer can fail before publishing a handle.
101 * @details The id stays in the target's layer order for duplicate detection and layer tracking. Only the
102 * pending reservation is removed.
103 */
104 [[nodiscard]] bool commit_hook(std::uintptr_t target, std::uint64_t id) noexcept;
105
106 /**
107 * @brief Completes a reservation and keeps its queue entry as the write slot of the same thread.
108 * @return True when @p id is the front of the target queue. No newer id of this kit is then committed, so
109 * @p id is the top layer of this kit. False on lock failure or an invalid reservation.
110 * @details HookStack::install arms through the kept slot under its coordinator hold. A slot from
111 * @ref acquire_target_slot counts a queued reservation of this kit as a newer layer, and its
112 * queue entry allocates under the hold. The commit settles @p id, so the arm promotes past it.
113 * HookLedgerSlot.CommitKeepsTheSlotForTheArm pins it.
114 * @warning The caller MUST release the slot through @ref release_target_slot, as @ref acquire_target_slot
115 * states. On false, roll the reservation back through @ref release_hook.
116 */
117 [[nodiscard]] bool commit_hook_keep_slot(std::uintptr_t target, std::uint64_t id) noexcept;
118
119 /**
120 * @brief The result of a slot or release call that cannot decide a count.
121 * @details It is positive, so a caller that reads only the count still fails closed.
122 */
123 static constexpr std::size_t SLOT_UNAVAILABLE = static_cast<std::size_t>(-1);
124
125 /**
126 * @brief Removes hook @p id from @p target and returns how many newer hooks remain recorded on it.
127 * @return The count of ids newer than @p id in @p target's layer order, or @ref SLOT_UNAVAILABLE on an
128 * inert ledger or a lock failure. Zero means that @p id is the newest record.
129 * @note An id counts until its owner calls this method, so retained and residue ids count too
130 * (docs/design/hooking.md, "A retained id still counts").
131 */
132 [[nodiscard]] std::size_t release_hook(std::uintptr_t target, std::uint64_t id) noexcept;
133
134 /**
135 * @brief Claims @p target's serialization slot for an operation that may alter its bytes, returning the
136 * newer-live count measured at the instant the slot is held.
137 * @return The count of ids newer than @p id in @p target's layer order (see @ref release_hook for what
138 * that includes). Zero authorizes the caller to write target bytes (enable, disable, or restore).
139 * A newer count means that @p id is not the top layer: an Active disable bypasses, and
140 * teardown retires by bypass. An absent target or id, a bookkeeping allocation failure, or a lock
141 * failure returns @ref SLOT_UNAVAILABLE. The layer order then stays undecided, so the caller
142 * refuses (toggle) or pins (teardown).
143 * @details The write-side counterpart to @ref try_reserve_hook. The slot waits its turn in the same
144 * per-target queue an install waits in, so {decide, write} is atomic against installs (`[B-79]`).
145 * @warning The caller MUST release the slot exactly once, through @ref release_hook (which also drops the
146 * id from the layer order) or @ref release_target_slot (which keeps it). Otherwise later
147 * same-target installs block forever behind the unreleased queue sentinel. Only the sentinel can
148 * block. The layer-order entry never does. Release the slot before running user code or
149 * taking the loader lock: holding it across either invites a deadlock against an install that is
150 * itself under the loader lock.
151 * @note A release path that cannot retake the state lock leaves the sentinel in place, because the queue
152 * is safe to touch only under the mutex. The stall is unreachable: @ref lock_state fails permanently,
153 * so a later reserver fails its own acquisition first.
154 * HookLedgerFaultProof.AbandonedSlotCannotParkALaterReserver pins that ordering.
155 */
156 [[nodiscard]] std::size_t acquire_target_slot(std::uintptr_t target, std::uint64_t id) noexcept;
157
158 /**
159 * @brief Releases a slot claimed by @ref acquire_target_slot WITHOUT removing @p id from the layer order.
160 * @details For callers whose hook remains live or remains physically patched: a refused toggle, a
161 * teardown that leaked its backend, or a residue. Only the queue sentinel is removed, which wakes
162 * the next same-target installer. The order entry stays, so @ref is_target_hooked keeps reporting
163 * the target hooked. The restoring teardown path instead calls @ref release_hook, which drops the
164 * id from both.
165 * @note The kept order entry stays counted as a newer layer for the process lifetime
166 * (docs/design/hooking.md, "A retained id still counts").
167 */
168 void release_target_slot(std::uintptr_t target, std::uint64_t id) noexcept;
169
170 /**
171 * @brief Moves @p id below the uncommitted installs of @p target after an arm made it the newest armed
172 * layer (`[B-110]`).
173 * @return A layer stamp from the id sequence, newer than every id and stamp before it. Zero on an inert
174 * ledger, a lock failure, or an absent target or id. Nothing allocates.
175 * @details The caller holds the write slot of @p id. An install that queued behind that slot creates over
176 * the armed patch, so it stays newer. HookLedgerSlot.PromoteMovesTheIdWithoutAllocation pins the
177 * order and the allocation, and HookLedgerTargetSlot.PromoteKeepsAQueuedInstallNewer pins the
178 * queued install.
179 */
180 [[nodiscard]] std::uint64_t promote_to_newest(std::uintptr_t target, std::uint64_t id) noexcept;
181
182 /// True when this kit currently has at least one live or reserved hook for @p target; true on lock failure.
183 [[nodiscard]] bool is_target_hooked(std::uintptr_t target) const noexcept;
184
185 // VMT clones: keyed by the cloned-vptr base SafetyHook installs. One base per VmtHook (every object the
186 // clone is applied to shares that base), so a clone is recorded once at create.
187
188 /**
189 * @brief Records a live VMT clone (installed vptr base @p cloned_base); returns its unique ledger id.
190 * @return The new ledger id, or std::nullopt if the bookkeeping allocation or the lock failed.
191 * @details A nullopt result tells the caller to fail the clone closed (unwinding the backend to restore the
192 * object's vptr) rather than leave a live-but-untracked clone @ref is_vmt_clone_base cannot
193 * recognise. That is the VMT analogue of the inline/mid fail-closed reservation.
194 */
195 [[nodiscard]] std::optional<std::uint64_t> try_record_vmt(std::uintptr_t cloned_base) noexcept;
196
197 /// Removes the VMT clone @p id from the ledger; best-effort on lock failure.
198 void release_vmt(std::uint64_t id) noexcept;
199
200 /**
201 * @brief True when @p vptr is the installed base of any live VMT clone this kit owns.
202 * @return True on lock failure, so a caller refuses or warns rather than silently double-hooking an
203 * unrecognised clone.
204 */
205 [[nodiscard]] bool is_vmt_clone_base(std::uintptr_t vptr) const noexcept;
206
207 private:
208 /// Allocates the target map. A std::bad_alloc reaches @ref instance, which publishes the inert ledger.
209 HookLedger();
210
211 struct InertTag
212 {
213 };
214
215 /// Builds the inert ledger: no target map, so every operation fails closed.
216 explicit HookLedger(InertTag) noexcept;
217
218 /// True when first-use construction failed.
219 128776 [[nodiscard]] bool inert() const noexcept { return m_by_target == nullptr; }
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 // Reserved install ids that no commit or release settled. They stay the newest end of order.
240 std::vector<std::uint64_t> uncommitted;
241 };
242
243 mutable std::mutex m_mutex;
244 std::condition_variable m_install_cv;
245 // target address -> live/reserved hook ids in layer order: creation order, and an arm moves its id below
246 // the uncommitted installs (`[B-110]`). Null in the inert ledger.
247 std::unique_ptr<std::unordered_map<std::uintptr_t, TargetEntry>> m_by_target;
248 // Live VMT clones (small; a linear scan is cheaper than a map at this size).
249 std::vector<VmtEntry> m_vmt;
250 std::atomic<std::uint64_t> m_next_id{1};
251 };
252 } // namespace detail
253 } // namespace DetourModKit
254
255 #endif // DETOURMODKIT_INTERNAL_HOOK_LEDGER_HPP
256