GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 90.1% 155 / 0 / 172
Functions: 100.0% 10 / 0 / 10
Branches: 69.9% 114 / 0 / 163

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