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 |