GCC Code Coverage Report


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

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