GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 95.7% 90 / 0 / 94
Functions: 100.0% 12 / 0 / 12
Branches: 78.6% 33 / 0 / 42

src/memory_protect.cpp
Line Branch Exec Source
1 /**
2 * @file memory_protect.cpp
3 * @brief Implementation of memory::ProtectGuard, the move-only RAII page-protection change.
4 *
5 * The guard captures a region's prior protection at construction and restores it on destruction, factoring the
6 * VirtualProtect dance out of any caller that patches or writes a region repeatedly. The captured base / size /
7 * old-protection live in the pimpl Impl defined here so the public header carries no Win32 type, and the special
8 * members are defined here (not defaulted in the header) because the unique_ptr<Impl> needs Impl complete to destroy
9 * and move.
10 */
11
12 #include "DetourModKit/logger.hpp"
13 #include "DetourModKit/memory.hpp"
14 #include "internal/memory_guarded.hpp"
15
16 #include <windows.h>
17
18 #include <cstddef>
19 #include <cstdint>
20 #include <memory>
21 #include <new>
22 #include <utility>
23
24 namespace DetourModKit
25 {
26 #if defined(DMK_ENABLE_TEST_SEAMS)
27 namespace
28 {
29 thread_local std::size_t s_restore_diagnostic_count = 0;
30 } // namespace
31 #endif
32
33 namespace memory
34 {
35 // The per-region protection helpers live in DetourModKit::detail; pull them in with using-declarations so the
36 // ProtectGuard::Impl definition and make()/restore paths can name them unqualified.
37 using DetourModKit::detail::MAX_PROTECTION_SEGMENTS;
38 using DetourModKit::detail::protect_across_regions;
39 using DetourModKit::detail::ProtectionSegment;
40 using DetourModKit::detail::restore_across_regions;
41
42 namespace
43 {
44 /**
45 * @brief Maps a backend-neutral Prot flag set onto the Win32 PAGE_* constant with the matching rights.
46 * @details Windows has no write-without-read protection, so a Prot carrying W (with or without R) maps to a
47 * read/write page: a write right always implies a read right on this platform. Execute rights pick
48 * the PAGE_EXECUTE_* family so a code page keeps execution across the guard.
49 */
50 1223 [[nodiscard]] DWORD prot_to_win32(Prot protection) noexcept
51 {
52 1223 const bool readable = (protection & Prot::R) != Prot::None;
53 1223 const bool writable = (protection & Prot::W) != Prot::None;
54 1226 const bool executable = (protection & Prot::X) != Prot::None;
55
56
2/2
✓ Branch 5 → 6 taken 2 times.
✓ Branch 5 → 11 taken 1223 times.
1225 if (executable)
57 {
58
2/2
✓ Branch 6 → 7 taken 1 time.
✓ Branch 6 → 8 taken 1 time.
2 if (writable)
59 1 return PAGE_EXECUTE_READWRITE;
60
1/2
✓ Branch 8 → 9 taken 1 time.
✗ Branch 8 → 10 not taken.
1 if (readable)
61 1 return PAGE_EXECUTE_READ;
62 return PAGE_EXECUTE;
63 }
64
1/2
✓ Branch 11 → 12 taken 1223 times.
✗ Branch 11 → 13 not taken.
1223 if (writable)
65 1223 return PAGE_READWRITE;
66 if (readable)
67 return PAGE_READONLY;
68 return PAGE_NOACCESS;
69 }
70
71 /**
72 * @brief Surfaces a best-effort restore failure that cannot be returned to the caller as a warning.
73 * @details The destructor and move-assignment restore protection best-effort, so a VirtualProtect failure
74 * there has no return channel. Rather than discard it silently, it is logged. Guarded so a logger
75 * fault (or a logger torn down before a late static ProtectGuard) can never propagate out of the
76 * noexcept teardown; a caller that must observe the outcome calls ProtectGuard::restore() instead.
77 */
78 3 void diagnose_restore_failure(std::uintptr_t base, std::uint32_t restore_error) noexcept
79 {
80 #if defined(DMK_ENABLE_TEST_SEAMS)
81 3 ++s_restore_diagnostic_count;
82 #endif
83 3 (void)log().try_log(
84 LogLevel::Warning,
85 "ProtectGuard: restoring page protection at {:#x} failed (os_error={})",
86 base,
87 restore_error
88 );
89 3 }
90 } // namespace
91
92 // The captured protection state. Kept in the .cpp so the installed header never names a Win32 type. The guarded
93 // span may cross a protection seam, so the original protection of each VirtualQuery region it covers is
94 // captured separately and restored per region. Restoring the whole span to one value flattens an executable
95 // region adjacent to a read-only seam. The segment array is embedded (not heap-grown) so make() can keep its
96 // allocate-before-protect discipline: the storage exists before any VirtualProtect runs.
97 struct ProtectGuard::Impl
98 {
99 ProtectionSegment segments[MAX_PROTECTION_SEGMENTS];
100 std::size_t segment_count = 0;
101 // The whole guarded span, retained for the cache invalidation that follows every change / restore.
102 std::uintptr_t base = 0;
103 std::size_t size = 0;
104 };
105
106 1225 ProtectGuard::ProtectGuard() noexcept = default;
107
108 2456 ProtectGuard::ProtectGuard(ProtectGuard &&other) noexcept : m_impl(std::move(other.m_impl)) {}
109
110 2 ProtectGuard &ProtectGuard::operator=(ProtectGuard &&other) noexcept
111 {
112
1/2
✓ Branch 2 → 3 taken 2 times.
✗ Branch 2 → 19 not taken.
2 if (this != &other)
113 {
114 // Restore this guard's own region before adopting the other's, so reassigning never silently abandons a
115 // protection change this guard still owned.
116
1/2
✓ Branch 4 → 5 taken 2 times.
✗ Branch 4 → 16 not taken.
2 if (m_impl)
117 {
118 2 std::uint32_t restore_error = 0;
119 2 const bool ok = restore_across_regions(m_impl->segments, m_impl->segment_count, restore_error);
120 // The restore changed protection, so drop any cached snapshot of the range taken while the guard
121 // held it writable, matching the invalidation write_bytes and make() perform on a protection
122 // change.
123 2 invalidate_range(Region{Address{m_impl->base}, m_impl->size});
124
2/2
✓ Branch 12 → 13 taken 1 time.
✓ Branch 12 → 15 taken 1 time.
2 if (!ok)
125 {
126 1 diagnose_restore_failure(m_impl->base, restore_error);
127 }
128 }
129 4 m_impl = std::move(other.m_impl);
130 }
131 2 return *this;
132 }
133
134 2453 ProtectGuard::~ProtectGuard() noexcept
135 {
136
2/2
✓ Branch 3 → 4 taken 2445 times.
✓ Branch 3 → 5 taken 6 times.
2447 if (!m_impl)
137 {
138 2445 return;
139 }
140 // Best-effort restore: a destructor cannot report failure, so a restore that fails is diagnosed rather than
141 // discarded; a caller that must observe the outcome calls restore() before the guard leaves scope.
142 6 std::uint32_t restore_error = 0;
143 6 const bool ok = restore_across_regions(m_impl->segments, m_impl->segment_count, restore_error);
144 // The protection just changed back, so a cached snapshot taken while the page was writable is now stale;
145 // drop the range so a later is_readable / is_writable re-queries the restored protection.
146 6 invalidate_range(Region{Address{m_impl->base}, m_impl->size});
147
2/2
✓ Branch 12 → 13 taken 2 times.
✓ Branch 12 → 15 taken 4 times.
6 if (!ok)
148 {
149 2 diagnose_restore_failure(m_impl->base, restore_error);
150 }
151
2/2
✓ Branch 17 → 18 taken 6 times.
✓ Branch 17 → 19 taken 2442 times.
2451 }
152
153 6 ProtectGuard::operator bool() const noexcept
154 {
155 6 return static_cast<bool>(m_impl);
156 }
157
158 2 void ProtectGuard::release() noexcept
159 {
160 // Leave the changed protection in place permanently, but stop tracking the pages in the protection ledger:
161 // a phantom transaction left behind would block a later overlapping guard from ever restoring, or make a
162 // reused page address resolve to a stale original.
163
1/2
✓ Branch 3 → 4 taken 2 times.
✗ Branch 3 → 7 not taken.
2 if (m_impl)
164 {
165 2 detail::abandon_protection_tracking(m_impl->segments, m_impl->segment_count);
166 }
167 2 m_impl.reset();
168 2 }
169
170 1213 Result<void> ProtectGuard::restore() noexcept
171 {
172
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 1213 times.
1213 if (!m_impl)
173 {
174 // Moved-from, released, or already restored: nothing to put back.
175 1 return {};
176 }
177 // Capture before disarming so the failure Error can still name the range.
178 1213 const std::uintptr_t base = m_impl->base;
179 1212 const std::size_t size = m_impl->size;
180
181 1213 std::uint32_t restore_error = 0;
182 1213 const bool ok = restore_across_regions(m_impl->segments, m_impl->segment_count, restore_error);
183 1215 invalidate_range(Region{Address{base}, size});
184
185 // Disarm either way: on success there is nothing left to restore; on failure retrying the same call cannot
186 // recover the OS state, and the destructor must not attempt it again.
187 1214 m_impl.reset();
188
189
2/2
✓ Branch 13 → 14 taken 2 times.
✓ Branch 13 → 17 taken 1212 times.
1214 if (!ok)
190 {
191 2 return std::unexpected(
192 2 Error{ErrorCode::ProtectionRestoreFailed, "memory::ProtectGuard::restore", base, restore_error}
193 2 );
194 }
195 1212 return {};
196 }
197
198 1224 Result<ProtectGuard> ProtectGuard::make(Region region, Prot protection) noexcept
199 {
200 // An empty region (null base or zero size) has no pages to protect; fail closed rather than issue a
201 // VirtualProtect on a degenerate range.
202
5/6
✓ Branch 3 → 4 taken 1222 times.
✓ Branch 3 → 5 taken 1 time.
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 1222 times.
✓ Branch 7 → 8 taken 1 time.
✓ Branch 7 → 12 taken 1222 times.
1224 if (!region.base || region.size == 0)
203 {
204 1 return std::unexpected(
205 2 Error{ErrorCode::ProtectionChangeFailed, "memory::ProtectGuard::make", region.base.raw(), 0}
206 1 );
207 }
208
209 // Allocate the capture state before changing protection. If this throws (OOM), the guard fails with no
210 // protection change to leak. The reverse order (VirtualProtect then allocate) strands the region
211 // in the changed protection with no guard to restore it if the allocation threw. make() is noexcept, so the
212 // bad_alloc is caught and reported as an error rather than propagating out of the factory. The embedded
213 // segment array means the per-region walk below writes into already-allocated storage, so no allocation
214 // happens between the first VirtualProtect and the guard being armed.
215 1222 std::unique_ptr<Impl> impl;
216 try
217 {
218
2/2
✓ Branch 12 → 13 taken 1225 times.
✓ Branch 12 → 42 taken 1 time.
1222 impl = std::make_unique<Impl>();
219 }
220
1/2
✗ Branch 43 → 44 not taken.
✓ Branch 43 → 45 taken 1 time.
1 catch (const std::bad_alloc &)
221 {
222 1 return std::unexpected(
223 1 Error{ErrorCode::OutOfMemory, "memory::ProtectGuard::make", region.base.raw(), 0}
224 1 );
225 1 }
226
227 // Change every protection region the span covers, capturing each region's own prior protection so the
228 // restore is exact. A span crossing more than MAX_PROTECTION_SEGMENTS regions, or a VirtualQuery /
229 // VirtualProtect failure, fails closed here with everything already changed rolled back.
230 1225 const detail::ProtectionChangeOutcome outcome = protect_across_regions(
231 region.base.raw(),
232 region.size,
233 1223 prot_to_win32(protection),
234 1225 impl->segments,
235 MAX_PROTECTION_SEGMENTS
236 );
237
2/2
✓ Branch 19 → 20 taken 2 times.
✓ Branch 19 → 28 taken 1225 times.
1227 if (outcome.status != detail::ProtectionChangeStatus::Ok)
238 {
239 // The walk changed and then rolled back one or more regions, so it TOUCHED protection even though the
240 // net result is the original protection. Invalidate the range so a snapshot a concurrent reader cached
241 // from the transient changed protection during the walk cannot survive, matching the success path.
242 2 invalidate_range(region);
243 2 const ErrorCode code = outcome.status == detail::ProtectionChangeStatus::RestoreFailed
244
2/2
✓ Branch 21 → 22 taken 1 time.
✓ Branch 21 → 23 taken 1 time.
2 ? ErrorCode::ProtectionRestoreFailed
245 : ErrorCode::ProtectionChangeFailed;
246 2 return std::unexpected(Error{code, "memory::ProtectGuard::make", region.base.raw(), outcome.os_error});
247 }
248
249 1225 impl->segment_count = outcome.segment_count;
250 1225 impl->base = region.base.raw();
251 1225 impl->size = region.size;
252
253 // The page protection just changed, so any cached snapshot for this range is stale; drop it so a later
254 // is_readable / is_writable re-queries, mirroring write_bytes' invalidate on its protection-changing path.
255 1225 invalidate_range(region);
256
257 1225 ProtectGuard guard;
258 1224 guard.m_impl = std::move(impl);
259 1224 return guard;
260 1227 }
261 } // namespace memory
262
263 #if defined(DMK_ENABLE_TEST_SEAMS)
264 2 void detail::reset_restore_diagnostic_count() noexcept
265 {
266 2 s_restore_diagnostic_count = 0;
267 2 }
268
269 2 std::size_t detail::restore_diagnostic_count() noexcept
270 {
271 2 return s_restore_diagnostic_count;
272 }
273 #endif
274 } // namespace DetourModKit
275