./
Coverage:
low: ≥ 0%
medium: ≥ 75.0%
high: ≥ 90.0%
Lines:
161 / 178
90.4%
Branches:
122 / 175
69.7%
Functions:
10 / 10
100.0%

src/drift_manifest.cpp
Line Branch Exec Source
1 /**
2 * @file drift_manifest.cpp
3 * @brief Durable serialization of self-heal drift reports.
4 */
5
6 #include "DetourModKit/detail/drift_manifest.hpp"
7
8 #include "internal/utf8_conversion.hpp"
9
10 #include <charconv>
11 #include <filesystem>
12 #include <fstream>
13 #include <iterator>
14 #include <string>
15
16 namespace DetourModKit
17 {
18 namespace rtti
19 {
20 namespace
21 {
22 constexpr std::string_view MANIFEST_HEADER = "# DetourModKit drift manifest v1";
23 constexpr char FIELD_SEP = '\t';
24
25 // Stable round-trip tokens for the Rtti-block heal ErrorCodes, deliberately distinct from the verbose
26 // human-readable Error::message() text (which is for logs): a manifest must parse back even if the log
27 // wording is reworded, so the token strings are frozen independently of the enumerator spellings.
28 // A drift entry's error is meaningful only when ok == false, and heal_report only ever writes Ok (the
29 // healed default) or one of the three Rtti-block heal codes. Give each a distinct token (including Ok)
30 // so the ErrorCode round-trips exactly rather than a successful entry's Ok collapsing to a failure token.
31 // The default arm still maps any unexpected code to BadDescriptor so a malformed producer never emits an
32 // untokenizable field.
33 9 [[nodiscard]] std::string_view heal_error_token(ErrorCode error) noexcept
34 {
35
2/5
✓ Branch 2 → 3 taken 8 times.
✗ Branch 2 → 4 not taken.
✓ Branch 2 → 5 taken 1 time.
✗ Branch 2 → 6 not taken.
✗ Branch 2 → 7 not taken.
9 switch (error)
36 {
37 8 case ErrorCode::Ok:
38 8 return "Ok";
39 ✗ case ErrorCode::BadDescriptor:
40 ✗ return "BadDescriptor";
41 1 case ErrorCode::HealNoMatch:
42 1 return "NoMatch";
43 ✗ case ErrorCode::HealAmbiguous:
44 ✗ return "Ambiguous";
45 ✗ default:
46 ✗ return "BadDescriptor";
47 }
48 }
49
50 10 [[nodiscard]] bool parse_heal_error(std::string_view token, ErrorCode &out) noexcept
51 {
52
2/2
✓ Branch 4 → 5 taken 8 times.
✓ Branch 4 → 6 taken 2 times.
10 if (token == "Ok")
53 {
54 8 out = ErrorCode::Ok;
55 8 return true;
56 }
57
2/2
✓ Branch 8 → 9 taken 1 time.
✓ Branch 8 → 10 taken 1 time.
2 if (token == "BadDescriptor")
58 {
59 1 out = ErrorCode::BadDescriptor;
60 1 return true;
61 }
62
1/2
✓ Branch 12 → 13 taken 1 time.
✗ Branch 12 → 14 not taken.
1 if (token == "NoMatch")
63 {
64 1 out = ErrorCode::HealNoMatch;
65 1 return true;
66 }
67
0/2
✗ Branch 16 → 17 not taken.
✗ Branch 16 → 18 not taken.
✗ if (token == "Ambiguous")
68 {
69 ✗ out = ErrorCode::HealAmbiguous;
70 ✗ return true;
71 }
72 ✗ return false;
73 }
74
75 /**
76 * @brief Appends a name without exposing record delimiters to the manifest grammar.
77 * @param out The manifest buffer to append to.
78 * @param name The unescaped name.
79 */
80 9 void append_escaped_name(std::string &out, std::string_view name)
81 {
82
2/2
✓ Branch 16 → 4 taken 101 times.
✓ Branch 16 → 17 taken 9 times.
110 for (const char c : name)
83 {
84
5/5
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 7 taken 1 time.
✓ Branch 4 → 9 taken 2 times.
✓ Branch 4 → 11 taken 2 times.
✓ Branch 4 → 13 taken 95 times.
101 switch (c)
85 {
86 1 case '\t':
87 1 out.append("\\t");
88 1 break;
89 1 case '\n':
90 1 out.append("\\n");
91 1 break;
92 2 case '\r':
93 2 out.append("\\r");
94 2 break;
95 2 case '\\':
96 2 out.append("\\\\");
97 2 break;
98 95 default:
99 95 out.push_back(c);
100 95 break;
101 }
102 }
103 9 }
104
105 /**
106 * @brief Decodes an escaped name field.
107 * @param field The encoded field.
108 * @param out Receives the decoded name.
109 * @return False for a truncated or unknown escape.
110 */
111 13 [[nodiscard]] bool unescape_name(std::string_view field, std::string &out)
112 {
113 13 out.clear();
114 13 out.reserve(field.size());
115
2/2
✓ Branch 26 → 6 taken 115 times.
✓ Branch 26 → 27 taken 11 times.
126 for (std::size_t i = 0; i < field.size(); ++i)
116 {
117 115 const char c = field[i];
118
2/2
✓ Branch 7 → 8 taken 107 times.
✓ Branch 7 → 10 taken 8 times.
115 if (c != '\\')
119 {
120 107 out.push_back(c);
121 107 continue;
122 }
123
2/2
✓ Branch 11 → 12 taken 1 time.
✓ Branch 11 → 13 taken 7 times.
8 if (++i >= field.size())
124 {
125 1 return false;
126 }
127
5/5
✓ Branch 14 → 15 taken 1 time.
✓ Branch 14 → 16 taken 1 time.
✓ Branch 14 → 18 taken 2 times.
✓ Branch 14 → 20 taken 2 times.
✓ Branch 14 → 22 taken 1 time.
7 switch (field[i])
128 {
129 1 case 't':
130 1 out.push_back('\t');
131 1 break;
132 1 case 'n':
133 1 out.push_back('\n');
134 1 break;
135 2 case 'r':
136 2 out.push_back('\r');
137 2 break;
138 2 case '\\':
139 2 out.push_back('\\');
140 2 break;
141 1 default:
142 1 return false;
143 }
144 }
145 11 return true;
146 }
147
148 // Parses a decimal (possibly negative) offset that must span the whole field.
149 31 [[nodiscard]] bool parse_offset(std::string_view field, std::ptrdiff_t &out) noexcept
150 {
151
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 31 times.
31 if (field.empty())
152 {
153 ✗ return false;
154 }
155 31 const char *const begin = field.data();
156 31 const char *const end = field.data() + field.size();
157 31 const auto result = std::from_chars(begin, end, out);
158
3/4
✓ Branch 9 → 10 taken 30 times.
✓ Branch 9 → 12 taken 1 time.
✓ Branch 10 → 11 taken 30 times.
✗ Branch 10 → 12 not taken.
31 return result.ec == std::errc{} && result.ptr == end;
159 }
160
161 // Fail-closed manifest error: a code from the unified ErrorCategory::Manifest block, tagged with the
162 // module label. Error construction never allocates, so this is safe on any path.
163 11 [[nodiscard]] std::unexpected<Error> manifest_error(ErrorCode code) noexcept
164 {
165 11 return std::unexpected(Error{code, "rtti::drift_manifest"});
166 }
167 } // anonymous namespace
168
169 7 std::string serialize_drift_report(std::span<const DriftEntry> entries)
170 {
171 7 std::string out;
172
1/2
✓ Branch 5 → 6 taken 7 times.
✗ Branch 5 → 58 not taken.
7 out.append(MANIFEST_HEADER.data(), MANIFEST_HEADER.size());
173
1/2
✓ Branch 6 → 7 taken 7 times.
✗ Branch 6 → 58 not taken.
7 out.push_back('\n');
174
2/2
✓ Branch 44 → 9 taken 9 times.
✓ Branch 44 → 45 taken 7 times.
23 for (const DriftEntry &entry : entries)
175 {
176
1/2
✓ Branch 11 → 12 taken 9 times.
✗ Branch 11 → 56 not taken.
9 append_escaped_name(out, entry.name);
177
1/2
✓ Branch 12 → 13 taken 9 times.
✗ Branch 12 → 56 not taken.
9 out.push_back(FIELD_SEP);
178
2/4
✓ Branch 13 → 14 taken 9 times.
✗ Branch 13 → 49 not taken.
✓ Branch 14 → 15 taken 9 times.
✗ Branch 14 → 47 not taken.
9 out.append(std::to_string(entry.nominal_offset));
179
1/2
✓ Branch 16 → 17 taken 9 times.
✗ Branch 16 → 56 not taken.
9 out.push_back(FIELD_SEP);
180
2/4
✓ Branch 17 → 18 taken 9 times.
✗ Branch 17 → 52 not taken.
✓ Branch 18 → 19 taken 9 times.
✗ Branch 18 → 50 not taken.
9 out.append(std::to_string(entry.healed_offset));
181
1/2
✓ Branch 20 → 21 taken 9 times.
✗ Branch 20 → 56 not taken.
9 out.push_back(FIELD_SEP);
182
2/4
✓ Branch 21 → 22 taken 9 times.
✗ Branch 21 → 55 not taken.
✓ Branch 22 → 23 taken 9 times.
✗ Branch 22 → 53 not taken.
9 out.append(std::to_string(entry.delta));
183
1/2
✓ Branch 24 → 25 taken 9 times.
✗ Branch 24 → 56 not taken.
9 out.push_back(FIELD_SEP);
184
3/4
✓ Branch 25 → 26 taken 4 times.
✓ Branch 25 → 27 taken 5 times.
✓ Branch 28 → 29 taken 9 times.
✗ Branch 28 → 56 not taken.
9 out.push_back(entry.ok ? '1' : '0');
185
1/2
✓ Branch 29 → 30 taken 9 times.
✗ Branch 29 → 56 not taken.
9 out.push_back(FIELD_SEP);
186 9 const std::string_view token = heal_error_token(entry.error);
187
1/2
✓ Branch 33 → 34 taken 9 times.
✗ Branch 33 → 56 not taken.
9 out.append(token.data(), token.size());
188
1/2
✓ Branch 34 → 35 taken 9 times.
✗ Branch 34 → 56 not taken.
9 out.push_back('\n');
189 }
190 7 return out;
191 ✗ }
192
193 15 Result<std::vector<DriftRecord>> parse_drift_report(std::string_view text)
194 {
195 15 std::vector<DriftRecord> records;
196 15 bool header_seen = false;
197 15 std::size_t pos = 0;
198
2/2
✓ Branch 90 → 3 taken 39 times.
✓ Branch 90 → 91 taken 9 times.
48 while (pos <= text.size())
199 {
200 39 const std::size_t newline = text.find('\n', pos);
201 std::string_view line =
202
4/6
✓ Branch 4 → 5 taken 9 times.
✓ Branch 4 → 6 taken 30 times.
✓ Branch 5 → 7 taken 9 times.
✗ Branch 5 → 102 not taken.
✓ Branch 6 → 7 taken 30 times.
✗ Branch 6 → 102 not taken.
39 (newline == std::string_view::npos) ? text.substr(pos) : text.substr(pos, newline - pos);
203
2/2
✓ Branch 7 → 8 taken 9 times.
✓ Branch 7 → 10 taken 30 times.
39 pos = (newline == std::string_view::npos) ? text.size() + 1 : newline + 1;
204
205 // Strip a trailing CR (CRLF input) and skip blank lines.
206
6/6
✓ Branch 12 → 13 taken 30 times.
✓ Branch 12 → 16 taken 9 times.
✓ Branch 14 → 15 taken 4 times.
✓ Branch 14 → 16 taken 26 times.
✓ Branch 17 → 18 taken 4 times.
✓ Branch 17 → 19 taken 35 times.
39 if (!line.empty() && line.back() == '\r')
207 {
208 4 line.remove_suffix(1);
209 }
210
2/2
✓ Branch 20 → 21 taken 11 times.
✓ Branch 20 → 22 taken 28 times.
39 if (line.empty())
211 {
212 23 continue;
213 }
214
215
2/2
✓ Branch 22 → 23 taken 14 times.
✓ Branch 22 → 29 taken 14 times.
28 if (!header_seen)
216 {
217
2/2
✓ Branch 24 → 25 taken 2 times.
✓ Branch 24 → 28 taken 12 times.
14 if (line != MANIFEST_HEADER)
218 {
219 2 return manifest_error(ErrorCode::MissingHeader);
220 }
221 12 header_seen = true;
222 12 continue;
223 }
224
225 // Split into exactly six tab-separated fields; any other count is malformed.
226 14 std::string_view fields[6];
227 14 std::size_t field_count = 0;
228 14 std::size_t field_pos = 0;
229 14 bool too_many = false;
230 while (true)
231 {
232 81 const std::size_t sep = line.find(FIELD_SEP, field_pos);
233 const std::string_view field = (sep == std::string_view::npos)
234
3/4
✓ Branch 31 → 32 taken 14 times.
✓ Branch 31 → 33 taken 67 times.
✓ Branch 32 → 34 taken 14 times.
✗ Branch 32 → 99 not taken.
81 ? line.substr(field_pos)
235
1/2
✓ Branch 33 → 34 taken 67 times.
✗ Branch 33 → 99 not taken.
67 : line.substr(field_pos, sep - field_pos);
236
1/2
✗ Branch 34 → 35 not taken.
✓ Branch 34 → 36 taken 81 times.
81 if (field_count >= 6)
237 {
238 ✗ too_many = true;
239 ✗ break;
240 }
241 81 fields[field_count++] = field;
242
2/2
✓ Branch 36 → 37 taken 14 times.
✓ Branch 36 → 38 taken 67 times.
81 if (sep == std::string_view::npos)
243 {
244 14 break;
245 }
246 67 field_pos = sep + 1;
247 67 }
248
3/4
✓ Branch 39 → 40 taken 14 times.
✗ Branch 39 → 41 not taken.
✓ Branch 40 → 41 taken 1 time.
✓ Branch 40 → 44 taken 13 times.
14 if (too_many || field_count != 6)
249 {
250 1 return manifest_error(ErrorCode::MalformedLine);
251 }
252
253 13 DriftRecord record;
254
3/4
✓ Branch 45 → 46 taken 13 times.
✗ Branch 45 → 100 not taken.
✓ Branch 46 → 47 taken 2 times.
✓ Branch 46 → 50 taken 11 times.
13 if (!unescape_name(fields[0], record.name))
255 {
256 2 return manifest_error(ErrorCode::MalformedLine);
257 }
258
5/6
✓ Branch 51 → 52 taken 10 times.
✓ Branch 51 → 56 taken 1 time.
✓ Branch 53 → 54 taken 10 times.
✗ Branch 53 → 56 not taken.
✓ Branch 58 → 59 taken 1 time.
✓ Branch 58 → 62 taken 10 times.
21 if (!parse_offset(fields[1], record.nominal_offset) || !parse_offset(fields[2], record.healed_offset) ||
259
1/2
✗ Branch 55 → 56 not taken.
✓ Branch 55 → 57 taken 10 times.
10 !parse_offset(fields[3], record.delta))
260 {
261 1 return manifest_error(ErrorCode::MalformedLine);
262 }
263
2/2
✓ Branch 64 → 65 taken 5 times.
✓ Branch 64 → 66 taken 5 times.
10 if (fields[4] == "1")
264 {
265 5 record.ok = true;
266 }
267
1/2
✓ Branch 68 → 69 taken 5 times.
✗ Branch 68 → 70 not taken.
5 else if (fields[4] == "0")
268 {
269 5 record.ok = false;
270 }
271 else
272 {
273 ✗ return manifest_error(ErrorCode::MalformedLine);
274 }
275
1/2
✗ Branch 74 → 75 not taken.
✓ Branch 74 → 78 taken 10 times.
10 if (!parse_heal_error(fields[5], record.error))
276 {
277 ✗ return manifest_error(ErrorCode::MalformedLine);
278 }
279
1/2
✓ Branch 80 → 81 taken 10 times.
✗ Branch 80 → 100 not taken.
10 records.push_back(std::move(record));
280
2/2
✓ Branch 83 → 84 taken 10 times.
✓ Branch 83 → 87 taken 3 times.
13 }
281
282
2/2
✓ Branch 91 → 92 taken 1 time.
✓ Branch 91 → 95 taken 8 times.
9 if (!header_seen)
283 {
284 1 return manifest_error(ErrorCode::MissingHeader);
285 }
286 8 return records;
287 15 }
288
289 4 Result<void> write_drift_report_to_file(const std::string &path, std::span<const DriftEntry> entries)
290 {
291 // A narrow stream open decodes through the ANSI code page on both toolchains, so the UTF-8 path opens in
292 // its wide form. Ill-formed input fails before the open truncates any file.
293
1/2
✓ Branch 3 → 4 taken 4 times.
✗ Branch 3 → 44 not taken.
4 const std::wstring wide_path = DetourModKit::detail::widen_utf8(path);
294
2/2
✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 9 taken 3 times.
4 if (wide_path.empty())
295 {
296 1 return manifest_error(ErrorCode::FileOpenFailed);
297 }
298 // Binary mode so the '\n' line endings written here are not translated to
299 // CRLF; the parser tolerates either, but a stable on-disk form is clearer.
300
2/4
✓ Branch 10 → 11 taken 3 times.
✗ Branch 10 → 37 not taken.
✓ Branch 11 → 12 taken 3 times.
✗ Branch 11 → 35 not taken.
3 std::ofstream file(std::filesystem::path(wide_path), std::ios::binary | std::ios::trunc);
301
3/4
✓ Branch 13 → 14 taken 3 times.
✗ Branch 13 → 40 not taken.
✓ Branch 14 → 15 taken 1 time.
✓ Branch 14 → 18 taken 2 times.
3 if (!file)
302 {
303 1 return manifest_error(ErrorCode::FileOpenFailed);
304 }
305
1/2
✓ Branch 18 → 19 taken 2 times.
✗ Branch 18 → 40 not taken.
2 const std::string text = serialize_drift_report(entries);
306
1/2
✓ Branch 21 → 22 taken 2 times.
✗ Branch 21 → 38 not taken.
2 file.write(text.data(), static_cast<std::streamsize>(text.size()));
307 // Close explicitly instead of leaning on the destructor. The final buffered write and the OS-level flush
308 // both happen inside close(), so a failure that only surfaces there (disk full on the last block, a
309 // delayed network-filesystem error) sets failbit here where it can be reported; the destructor would
310 // swallow it. flush() alone forces the buffer out but cannot observe a close-time error.
311
1/2
✓ Branch 22 → 23 taken 2 times.
✗ Branch 22 → 38 not taken.
2 file.flush();
312
1/2
✓ Branch 23 → 24 taken 2 times.
✗ Branch 23 → 38 not taken.
2 file.close();
313 // A failbit/badbit set after the write, flush, or close means the stream opened but the bytes did not all
314 // land (disk full, an I/O error). Report that distinctly from the open failure above so a caller can tell
315 // a truncated manifest from one that was never created.
316
2/4
✓ Branch 24 → 25 taken 2 times.
✗ Branch 24 → 38 not taken.
✗ Branch 25 → 26 not taken.
✓ Branch 25 → 29 taken 2 times.
2 if (!file)
317 {
318 ✗ return manifest_error(ErrorCode::FileWriteFailed);
319 }
320 2 return {};
321 4 }
322
323 6 Result<std::vector<DriftRecord>> read_drift_report_from_file(const std::string &path)
324 {
325
1/2
✓ Branch 3 → 4 taken 6 times.
✗ Branch 3 → 45 not taken.
6 const std::wstring wide_path = DetourModKit::detail::widen_utf8(path);
326
2/2
✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 9 taken 5 times.
6 if (wide_path.empty())
327 {
328 1 return manifest_error(ErrorCode::FileOpenFailed);
329 }
330
2/4
✓ Branch 9 → 10 taken 5 times.
✗ Branch 9 → 33 not taken.
✓ Branch 10 → 11 taken 5 times.
✗ Branch 10 → 31 not taken.
5 std::ifstream file(std::filesystem::path(wide_path), std::ios::binary);
331
3/4
✓ Branch 12 → 13 taken 5 times.
✗ Branch 12 → 41 not taken.
✓ Branch 13 → 14 taken 1 time.
✓ Branch 13 → 17 taken 4 times.
5 if (!file)
332 {
333 // An open failure (missing file, lock, permission, or a directory) is distinct from a
334 // present-but-corrupt manifest: the latter flows through parse_drift_report and reports MissingHeader /
335 // MalformedLine.
336 1 return manifest_error(ErrorCode::FileOpenFailed);
337 }
338
1/2
✓ Branch 21 → 22 taken 4 times.
✗ Branch 21 → 34 not taken.
4 const std::string text((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
339
1/2
✓ Branch 24 → 25 taken 4 times.
✗ Branch 24 → 39 not taken.
4 return parse_drift_report(text);
340 6 }
341 } // namespace rtti
342 } // namespace DetourModKit
343