./
Coverage:
low: ≥ 0%
medium: ≥ 75.0%
high: ≥ 90.0%
Lines:
113 / 119
95.0%
Branches:
84 / 97
86.6%
Functions:
10 / 10
100.0%

src/memory_access.cpp
Line Branch Exec Source
1 /**
2 * @file memory_access.cpp
3 * @brief Public faces of the guarded access surface: read_into, write_bytes, and the pointer-chain walk.
4 *
5 * These translation units hold no Structured Exception Handling and touch no Win32 directly. They validate arguments in
6 * the v4 value vocabulary (Address / Region / Result / Error) and call the fault-guard engine in memory_guarded.cpp.
7 * They map the engine's plain bool / status results onto ErrorCode. The header-side read<T> / write<T> templates
8 * forward into read_into / write_bytes defined here, so the only typed-read machinery in the installed header is a
9 * bit_cast.
10 */
11
12 #include "DetourModKit/memory.hpp"
13
14 #include "internal/memory_guarded.hpp"
15
16 #include <array>
17 #include <cstddef>
18 #include <limits>
19 #include <span>
20
21 namespace DetourModKit
22 {
23 namespace memory
24 {
25 namespace
26 {
27 /**
28 * @brief Reports whether the half-open ranges [a, a+a_size) and [b, b+b_size) intersect.
29 * @details Wrap-safe by construction. It subtracts the smaller base from the larger and compares against
30 * the lower range's size, so no sum can overflow. The engine separately rejects a range whose
31 * own end crosses the address-space boundary.
32 */
33 [[nodiscard]] constexpr bool
34 1645 ranges_overlap(std::uintptr_t a, std::size_t a_size, std::uintptr_t b, std::size_t b_size) noexcept
35 {
36
2/4
✓ Branch 2 → 3 taken 1645 times.
✗ Branch 2 → 4 not taken.
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 1645 times.
1645 if (a_size == 0 || b_size == 0)
37 {
38 ✗ return false;
39 }
40
2/2
✓ Branch 5 → 6 taken 233 times.
✓ Branch 5 → 7 taken 1412 times.
1645 return a <= b ? (b - a) < a_size : (a - b) < b_size;
41 }
42
43 constexpr std::uintptr_t ADDRESS_MAX = std::numeric_limits<std::uintptr_t>::max();
44 static_assert(ranges_overlap(ADDRESS_MAX - 7, 8, ADDRESS_MAX - 3, 1));
45 static_assert(ranges_overlap(ADDRESS_MAX, 1, ADDRESS_MAX, 1));
46 static_assert(!ranges_overlap(ADDRESS_MAX - 7, 0, ADDRESS_MAX - 3, 1));
47
48 /// Refuses a caller span that intersects the target range, in either direction (see T-OVERLAP).
49 [[nodiscard]] bool
50 1645 span_overlaps_target(Address address, const void *span_data, std::size_t span_size) noexcept
51 {
52 1645 return ranges_overlap(address.raw(), span_size, reinterpret_cast<std::uintptr_t>(span_data), span_size);
53 }
54
55 // Maps a protection-changing patch outcome onto the public Result, invalidating the cached protection for
56 // the touched range on every exit (every slow-path exit changed protection, and a failed change still
57 // rolled back regions it had already flipped, so a snapshot a concurrent reader cached from the transient
58 // protection must not survive). Shared by write_bytes and patch_code.
59 31 [[nodiscard]] Result<void> finish_patch(
60 detail::PatchStatus status,
61 std::uint32_t os_error,
62 const char *where,
63 Address address,
64 std::size_t size,
65 detail::GuardedWriteStatus fast_status
66 ) noexcept
67 {
68 31 invalidate_range(Region{address, size});
69
5/6
✓ Branch 3 → 4 taken 14 times.
✗ Branch 3 → 5 not taken.
✓ Branch 3 → 9 taken 2 times.
✓ Branch 3 → 18 taken 2 times.
✓ Branch 3 → 22 taken 1 time.
✓ Branch 3 → 26 taken 12 times.
31 switch (status)
70 {
71 14 case detail::PatchStatus::Ok:
72 14 return {};
73 ✗ case detail::PatchStatus::WriteMayBePartial:
74 ✗ return std::unexpected(Error{ErrorCode::WriteMayBePartial, where, address.raw(), 0});
75 2 case detail::PatchStatus::WriteFaulted:
76
2/2
✓ Branch 9 → 10 taken 1 time.
✓ Branch 9 → 14 taken 1 time.
2 if (fast_status == detail::GuardedWriteStatus::MayBePartial)
77 {
78 1 return std::unexpected(Error{ErrorCode::WriteMayBePartial, where, address.raw(), 0});
79 }
80 1 return std::unexpected(Error{ErrorCode::WriteFaulted, where, address.raw(), 0});
81 2 case detail::PatchStatus::InstructionFlushFailed:
82 2 return std::unexpected(Error{ErrorCode::InstructionFlushFailed, where, address.raw(), 0});
83 1 case detail::PatchStatus::ProtectionRestoreFailed:
84 1 return std::unexpected(Error{ErrorCode::ProtectionRestoreFailed, where, address.raw(), os_error});
85 12 case detail::PatchStatus::ProtectionChangeFailed:
86 default:
87 // The escalation could not make the whole span writable (e.g. an unmapped tail). If the
88 // no-reprotect fast path already modified a writable-head prefix, the target is partially written;
89 // otherwise nothing was written and the protection change simply failed.
90
2/2
✓ Branch 26 → 27 taken 6 times.
✓ Branch 26 → 31 taken 6 times.
12 if (fast_status == detail::GuardedWriteStatus::MayBePartial)
91 {
92 6 return std::unexpected(Error{ErrorCode::WriteMayBePartial, where, address.raw(), 0});
93 }
94 6 return std::unexpected(Error{ErrorCode::ProtectionChangeFailed, where, address.raw(), os_error});
95 }
96 }
97 } // namespace
98
99 1573 Result<void> read_into(Address address, std::span<std::byte> out) noexcept
100 {
101
2/2
✓ Branch 3 → 4 taken 4 times.
✓ Branch 3 → 5 taken 1569 times.
1573 if (out.empty())
102 {
103 4 return {};
104 }
105
2/2
✓ Branch 8 → 9 taken 3 times.
✓ Branch 8 → 13 taken 1566 times.
1569 if (span_overlaps_target(address, out.data(), out.size()))
106 {
107 3 return std::unexpected(Error{ErrorCode::OverlappingRanges, "memory::read_into", address.raw(), 0});
108 }
109 // Seed with the requested address so the argument-rejection paths (below USERSPACE_PTR_MIN, wrapping or
110 // over-ceiling span, VirtualQuery fallback) still name an address: those refuse before any access, so there
111 // is no faulting address to report and the guard leaves the slot untouched.
112 1566 volatile std::uintptr_t fault_address = address.raw();
113
2/2
✓ Branch 18 → 19 taken 38 times.
✓ Branch 18 → 22 taken 1528 times.
1566 if (!detail::guarded_read_bytes(address.raw(), out.data(), out.size(), &fault_address))
114 {
115 38 return std::unexpected(Error{ErrorCode::ReadFaulted, "memory::read_into", fault_address, 0});
116 }
117 1528 return {};
118 }
119
120 5 Result<bool> read_bool(Address address) noexcept
121 {
122 5 std::byte raw{};
123
2/2
✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 10 taken 4 times.
5 if (auto outcome = read_into(address, std::span<std::byte>{&raw, 1}); !outcome)
124 {
125 1 return std::unexpected(outcome.error());
126 }
127 // Validate the byte BEFORE forming the bool: only 0 and 1 are valid bool object representations, so an
128 // arbitrary foreign byte can never be bit-cast into an invalid bool (undefined behaviour the raw read
129 // excludes at compile time; this checked route reports it as InvalidRepresentation instead).
130
3/3
✓ Branch 12 → 13 taken 1 time.
✓ Branch 12 → 15 taken 1 time.
✓ Branch 12 → 17 taken 2 times.
8 switch (std::to_integer<unsigned char>(raw))
131 {
132 1 case 0:
133 1 return false;
134 1 case 1:
135 1 return true;
136 2 default:
137 2 return std::unexpected(Error{ErrorCode::InvalidRepresentation, "memory::read_bool", address.raw(), 0});
138 }
139 }
140
141 48 Result<void> write_bytes(Address address, std::span<const std::byte> source) noexcept
142 {
143 // Validation order: a null target outranks a null source, and a zero-length write is a success no-op that
144 // never inspects the source pointer or the size cap.
145
2/2
✓ Branch 3 → 4 taken 4 times.
✓ Branch 3 → 8 taken 44 times.
48 if (!address)
146 {
147 4 return std::unexpected(Error{ErrorCode::NullTargetAddress, "memory::write_bytes", address.raw(), 0});
148 }
149
5/6
✓ Branch 9 → 10 taken 3 times.
✓ Branch 9 → 13 taken 41 times.
✓ Branch 11 → 12 taken 3 times.
✗ Branch 11 → 13 not taken.
✓ Branch 14 → 15 taken 3 times.
✓ Branch 14 → 19 taken 41 times.
44 if (source.data() == nullptr && !source.empty())
150 {
151 3 return std::unexpected(Error{ErrorCode::NullSourceBytes, "memory::write_bytes", address.raw(), 0});
152 }
153
2/2
✓ Branch 20 → 21 taken 4 times.
✓ Branch 20 → 22 taken 37 times.
41 if (source.empty())
154 {
155 4 return {};
156 }
157
2/2
✓ Branch 23 → 24 taken 1 time.
✓ Branch 23 → 28 taken 36 times.
37 if (source.size() > MAX_WRITE_SIZE)
158 {
159 1 return std::unexpected(Error{ErrorCode::SizeTooLarge, "memory::write_bytes", address.raw(), 0});
160 }
161
2/2
✓ Branch 31 → 32 taken 3 times.
✓ Branch 31 → 36 taken 33 times.
36 if (span_overlaps_target(address, source.data(), source.size()))
162 {
163 3 return std::unexpected(Error{ErrorCode::OverlappingRanges, "memory::write_bytes", address.raw(), 0});
164 }
165
166 // Fast path: a guarded write that changes no protection. It succeeds for an already-writable target (a
167 // live game field, or any page held writable by a ProtectGuard) with no VirtualProtect and no flush, so a
168 // per-frame writer stays off the syscall path.
169 const detail::GuardedWriteStatus fast_status =
170 33 detail::guarded_write_bytes(address.raw(), source.data(), source.size());
171
2/2
✓ Branch 40 → 41 taken 16 times.
✓ Branch 40 → 42 taken 17 times.
33 if (fast_status == detail::GuardedWriteStatus::Ok)
172 {
173 16 return {};
174 }
175
176
2/2
✓ Branch 42 → 43 taken 6 times.
✓ Branch 42 → 46 taken 11 times.
17 if (fast_status == detail::GuardedWriteStatus::MayBePartial)
177 {
178 // The fallback flushes the executable regions it makes writable, but a setup failure leaves no segment
179 // to flush, so an executable prefix this attempt may already have changed is covered here. A
180 // non-executable target owes nothing and stays on the flush-free data route.
181 6 detail::flush_if_executable(address.raw(), source.size());
182 }
183
184 // Slow path: the target was read-only or executable (or straddles into one), so the engine changes
185 // protection (writable derived per region from its own execute semantics), writes, flushes executable
186 // regions, and restores. If the span cannot be made fully writable and a prefix was already written,
187 // finish_patch reports WriteMayBePartial rather than a clean ProtectionChangeFailed.
188 17 std::uint32_t os_error = 0;
189 const detail::PatchStatus status =
190 17 detail::patch_bytes(address.raw(), source.data(), source.size(), os_error);
191 17 return finish_patch(status, os_error, "memory::write_bytes", address, source.size(), fast_status);
192 }
193
194 23 Result<void> patch_code(Address address, std::span<const std::byte> source) noexcept
195 {
196 // Same validation order as write_bytes.
197
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 8 taken 22 times.
23 if (!address)
198 {
199 1 return std::unexpected(Error{ErrorCode::NullTargetAddress, "memory::patch_code", address.raw(), 0});
200 }
201
2/6
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 13 taken 22 times.
✗ Branch 11 → 12 not taken.
✗ Branch 11 → 13 not taken.
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 19 taken 22 times.
22 if (source.data() == nullptr && !source.empty())
202 {
203 ✗ return std::unexpected(Error{ErrorCode::NullSourceBytes, "memory::patch_code", address.raw(), 0});
204 }
205
2/2
✓ Branch 20 → 21 taken 1 time.
✓ Branch 20 → 22 taken 21 times.
22 if (source.empty())
206 {
207 1 return {};
208 }
209
1/2
✗ Branch 23 → 24 not taken.
✓ Branch 23 → 28 taken 21 times.
21 if (source.size() > MAX_WRITE_SIZE)
210 {
211 ✗ return std::unexpected(Error{ErrorCode::SizeTooLarge, "memory::patch_code", address.raw(), 0});
212 }
213
2/2
✓ Branch 31 → 32 taken 3 times.
✓ Branch 31 → 36 taken 18 times.
21 if (span_overlaps_target(address, source.data(), source.size()))
214 {
215 3 return std::unexpected(Error{ErrorCode::OverlappingRanges, "memory::patch_code", address.raw(), 0});
216 }
217
218 // Fast path: the target is already writable, so the store changes no protection. Unlike write_bytes,
219 // patch_code then flushes the instruction cache so an already-writable code patch is visible to execution.
220 // No protection changed, so nothing is invalidated in the protection cache.
221 const detail::GuardedWriteStatus fast_status =
222 18 detail::guarded_write_bytes(address.raw(), source.data(), source.size());
223
2/2
✓ Branch 40 → 41 taken 4 times.
✓ Branch 40 → 50 taken 14 times.
18 if (fast_status == detail::GuardedWriteStatus::Ok)
224 {
225
2/2
✓ Branch 44 → 45 taken 1 time.
✓ Branch 44 → 49 taken 3 times.
4 if (!detail::flush_instruction_cache(address.raw(), source.size()))
226 {
227 1 return std::unexpected(
228 2 Error{ErrorCode::InstructionFlushFailed, "memory::patch_code", address.raw(), 0}
229 1 );
230 }
231 3 return {};
232 }
233
234
2/2
✓ Branch 50 → 51 taken 3 times.
✓ Branch 50 → 54 taken 11 times.
14 if (fast_status == detail::GuardedWriteStatus::MayBePartial)
235 {
236 // The copy order is unspecified outside the deterministic test seam, so flush the full request before
237 // fallback setup can fail. A partial-write or restoration error outranks this best-effort flush.
238 3 (void)detail::flush_instruction_cache(address.raw(), source.size());
239 }
240
241 // Slow path: unprotect (execute preserved for a code region), write, flush executable regions, restore.
242 14 std::uint32_t os_error = 0;
243 const detail::PatchStatus status =
244 14 detail::patch_bytes(address.raw(), source.data(), source.size(), os_error, true);
245 14 return finish_patch(status, os_error, "memory::patch_code", address, source.size(), fast_status);
246 }
247
248 24 Result<void> write_in_place(Address address, std::span<const std::byte> source) noexcept
249 {
250 // Same validation order as write_bytes: a null target outranks a null source, and a zero-length write is a
251 // success no-op that inspects neither the source pointer nor the target's protection.
252
2/2
✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 8 taken 22 times.
24 if (!address)
253 {
254 2 return std::unexpected(Error{ErrorCode::NullTargetAddress, "memory::write_in_place", address.raw(), 0});
255 }
256
6/6
✓ Branch 9 → 10 taken 2 times.
✓ Branch 9 → 13 taken 20 times.
✓ Branch 11 → 12 taken 1 time.
✓ Branch 11 → 13 taken 1 time.
✓ Branch 14 → 15 taken 1 time.
✓ Branch 14 → 19 taken 21 times.
22 if (source.data() == nullptr && !source.empty())
257 {
258 1 return std::unexpected(Error{ErrorCode::NullSourceBytes, "memory::write_in_place", address.raw(), 0});
259 }
260
2/2
✓ Branch 20 → 21 taken 1 time.
✓ Branch 20 → 22 taken 20 times.
21 if (source.empty())
261 {
262 1 return {};
263 }
264 // Cap oversized spans for parity with write_bytes: the guarded copy already fails closed at the first
265 // unwritable page, so this is an API-symmetry guard that rejects an obviously-wrong length up front with
266 // the same ErrorCode::SizeTooLarge rather than attempting a multi-gigabyte guarded copy.
267
2/2
✓ Branch 23 → 24 taken 1 time.
✓ Branch 23 → 28 taken 19 times.
20 if (source.size() > MAX_WRITE_SIZE)
268 {
269 1 return std::unexpected(Error{ErrorCode::SizeTooLarge, "memory::write_in_place", address.raw(), 0});
270 }
271
2/2
✓ Branch 31 → 32 taken 3 times.
✓ Branch 31 → 36 taken 16 times.
19 if (span_overlaps_target(address, source.data(), source.size()))
272 {
273 3 return std::unexpected(Error{ErrorCode::OverlappingRanges, "memory::write_in_place", address.raw(), 0});
274 }
275
276 // The strict path: a guarded write that changes NO protection. A read-only, executable, or no-access target
277 // faults the guarded copy and fails closed. This entry point exists precisely to reject a write the
278 // caller did not intend to escalate, so it never reaches the VirtualProtect dance write_bytes takes on a
279 // fault. No cache invalidation either: changing nothing leaves the cached protection state valid.
280 const detail::GuardedWriteStatus status =
281 16 detail::guarded_write_bytes(address.raw(), source.data(), source.size());
282
2/2
✓ Branch 40 → 41 taken 9 times.
✓ Branch 40 → 42 taken 7 times.
16 if (status == detail::GuardedWriteStatus::Ok)
283 {
284 9 return {};
285 }
286
287
2/2
✓ Branch 42 → 43 taken 2 times.
✓ Branch 42 → 47 taken 5 times.
7 if (status == detail::GuardedWriteStatus::MayBePartial)
288 {
289 2 return std::unexpected(Error{ErrorCode::WriteMayBePartial, "memory::write_in_place", address.raw(), 0});
290 }
291 5 return std::unexpected(Error{ErrorCode::WriteFaulted, "memory::write_in_place", address.raw(), 0});
292 }
293
294 35 Result<Address> walk(Address base, std::span<const ChainStep> steps, std::span<Address> trace) noexcept
295 {
296 // A null root cannot be dereferenced. An empty chain is the identity walk (engine returns base), so the
297 // null root is only an error when there is at least one hop to take.
298
2/6
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 7 taken 35 times.
✗ Branch 5 → 6 not taken.
✗ Branch 5 → 7 not taken.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 12 taken 35 times.
35 if (!base && !steps.empty())
299 {
300 ✗ return std::unexpected(Error{ErrorCode::NullChain, "memory::walk", 0, 0});
301 }
302
303 const detail::ChainWalkOutcome outcome =
304 35 detail::guarded_resolve_chain(base, steps.data(), steps.size(), trace.data(), trace.size());
305
2/2
✓ Branch 17 → 18 taken 10 times.
✓ Branch 17 → 21 taken 25 times.
35 if (!outcome.ok)
306 {
307 // ReadFaulted carries the failing hop index in Error::detail: the hop whose dereference faulted, or
308 // whose dereferenced link fell below that hop's plausibility floor.
309 10 return std::unexpected(Error{ErrorCode::ReadFaulted, "memory::walk", outcome.fail_index, 0});
310 }
311 25 return outcome.address;
312 }
313
314 34 Result<Address> walk(Address base, std::span<const std::ptrdiff_t> offsets, std::span<Address> trace) noexcept
315 {
316 // The bare-offset chain applies the default plausibility floor to every hop, so it is the ChainStep walk
317 // with every min_valid defaulted. This overload is documented callback-safe (allocation-free), so it must
318 // build the ChainStep view on a fixed stack buffer and never touch the heap: a chain longer than the inline
319 // bound fails closed with SizeTooLarge rather than allocating a std::vector (which would contradict the
320 // allocation-free label and, on OOM, force a bad_alloc catch on a hot path). A caller with a genuinely
321 // long chain is steered to the ChainStep-taking overload above, where the caller owns the step storage.
322 34 constexpr std::size_t inline_capacity = 32;
323
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 8 taken 33 times.
34 if (offsets.size() > inline_capacity)
324 {
325 1 return std::unexpected(Error{ErrorCode::SizeTooLarge, "memory::walk", offsets.size(), inline_capacity});
326 }
327
2/2
✓ Branch 9 → 10 taken 1023 times.
✓ Branch 9 → 11 taken 33 times.
1056 std::array<ChainStep, inline_capacity> steps{};
328
2/2
✓ Branch 17 → 12 taken 87 times.
✓ Branch 17 → 18 taken 33 times.
120 for (std::size_t i = 0; i < offsets.size(); ++i)
329 {
330 87 steps[i] = ChainStep{offsets[i]};
331 }
332 33 return walk(base, std::span<const ChainStep>{steps.data(), offsets.size()}, trace);
333 }
334 } // namespace memory
335 } // namespace DetourModKit
336