GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 96.2% 126 / 0 / 131
Functions: 100.0% 19 / 0 / 19
Branches: 88.8% 174 / 0 / 196

src/internal/manifest_record_rules.hpp
Line Branch Exec Source
1 #ifndef DETOURMODKIT_INTERNAL_MANIFEST_RECORD_RULES_HPP
2 #define DETOURMODKIT_INTERNAL_MANIFEST_RECORD_RULES_HPP
3
4 /**
5 * @file internal/manifest_record_rules.hpp
6 * @brief Shared record validation rules for the manifest sibling TUs.
7 * @details src/manifest.cpp (checked serialization) and src/manifest_overlay.cpp (Signature compile/adopt) enforce
8 * the same record, label, value, binding, and baseline rules, so each rule is stated exactly once here.
9 */
10
11 #include "DetourModKit/hook.hpp"
12 #include "DetourModKit/manifest.hpp"
13
14 #include "internal/scan_shared.hpp"
15
16 #include <cstddef>
17 #include <cstdint>
18 #include <expected>
19 #include <format>
20 #include <limits>
21 #include <optional>
22 #include <string_view>
23
24 namespace DetourModKit::manifest
25 {
26 386 [[nodiscard]] inline std::unexpected<Error> fail(ErrorCode code, const char *where) noexcept
27 {
28 386 return std::unexpected(Error{code, where});
29 }
30
31 [[nodiscard]] constexpr bool
32 10 rip_pattern_spans_displacement(const scan::Pattern &pattern, std::size_t displacement_at) noexcept
33 {
34 10 return DetourModKit::detail::min_match_suffix_length(DetourModKit::detail::pattern_buffer(pattern)) >=
35 10 displacement_at + sizeof(std::int32_t);
36 }
37
38 struct RungSectionName
39 {
40 std::string_view parent;
41 std::size_t index = 0;
42 };
43
44 // A rung section always uses `[sig.<label>.rung.<N>]`. Treat malformed tails as ordinary labels. A label with
45 // ".rung." in the middle remains legal.
46 267390 [[nodiscard]] inline std::optional<RungSectionName> parse_rung_section_name(std::string_view name) noexcept
47 {
48 267390 const std::size_t pos = name.rfind(".rung.");
49
2/2
✓ Branch 3 → 4 taken 267202 times.
✓ Branch 3 → 5 taken 188 times.
267390 if (pos == std::string_view::npos)
50 {
51 267202 return std::nullopt;
52 }
53 188 const std::string_view tail = name.substr(pos + 6);
54
1/2
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 188 times.
188 if (tail.empty())
55 {
56 return std::nullopt;
57 }
58 188 std::size_t index = 0;
59
2/2
✓ Branch 17 → 11 taken 256 times.
✓ Branch 17 → 18 taken 188 times.
444 for (const char c : tail)
60 {
61
2/4
✓ Branch 11 → 12 taken 256 times.
✗ Branch 11 → 13 not taken.
✗ Branch 12 → 13 not taken.
✓ Branch 12 → 14 taken 256 times.
256 if (c < '0' || c > '9')
62 {
63 return std::nullopt;
64 }
65 256 const std::size_t digit = static_cast<std::size_t>(c - '0');
66 256 constexpr std::size_t MAX_INDEX = std::numeric_limits<std::size_t>::max();
67
1/2
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 16 taken 256 times.
256 if (index > (MAX_INDEX - digit) / 10U)
68 {
69 return std::nullopt;
70 }
71 256 index = (index * 10U) + digit;
72 }
73 188 return RungSectionName{
74 188 .parent = name.substr(0, pos),
75 .index = index,
76 188 };
77 }
78
79 // A record label becomes its `[sig.<label>]` section name. Reject a label that cannot round-trip. Reject INI
80 // structural characters and embedded NUL. Reject a blank suffix because SimpleIni strips it and changes the
81 // key. Reject the reserved `.rung.<digits>` grammar. Check the full section name that parse() creates so a bare
82 // `rung.0` label also fails.
83 602 [[nodiscard]] inline bool label_is_serializable(std::string_view label)
84 {
85
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 602 times.
602 if (label.empty())
86 {
87 return false;
88 }
89
6/6
✓ Branch 6 → 7 taken 601 times.
✓ Branch 6 → 9 taken 1 time.
✓ Branch 8 → 9 taken 1 time.
✓ Branch 8 → 10 taken 600 times.
✓ Branch 11 → 12 taken 2 times.
✓ Branch 11 → 13 taken 600 times.
602 if (label.back() == ' ' || label.back() == '\t')
90 {
91 2 return false;
92 }
93
2/2
✓ Branch 22 → 15 taken 4156 times.
✓ Branch 22 → 23 taken 587 times.
4743 for (const char c : label)
94 {
95
10/10
✓ Branch 15 → 16 taken 4155 times.
✓ Branch 15 → 20 taken 1 time.
✓ Branch 16 → 17 taken 4152 times.
✓ Branch 16 → 20 taken 3 times.
✓ Branch 17 → 18 taken 4149 times.
✓ Branch 17 → 20 taken 3 times.
✓ Branch 18 → 19 taken 4146 times.
✓ Branch 18 → 20 taken 3 times.
✓ Branch 19 → 20 taken 3 times.
✓ Branch 19 → 21 taken 4143 times.
4156 if (c == '\0' || c == '\r' || c == '\n' || c == '[' || c == ']')
96 {
97 13 return false;
98 }
99 }
100
1/2
✓ Branch 23 → 24 taken 587 times.
✗ Branch 23 → 31 not taken.
587 return !parse_rung_section_name(std::format("sig.{}", label)).has_value();
101 }
102
103 // Validates every free-text value before compile, adopt, or checked serialization. Reject embedded NUL or '\r'
104 // because reload changes the contract. Reject a whitespace-prefixed "<<<" because raw output opens a heredoc.
105 // Reject a heredoc body line equal to "END_OF_TEXT" because the store truncates there. Apply the terminator
106 // scan only to values that use a heredoc. Raw values round-trip verbatim.
107 2736 [[nodiscard]] inline bool value_is_unserializable(std::string_view value) noexcept
108 {
109
6/6
✓ Branch 3 → 4 taken 2735 times.
✓ Branch 3 → 6 taken 1 time.
✓ Branch 5 → 6 taken 18 times.
✓ Branch 5 → 7 taken 2717 times.
✓ Branch 8 → 9 taken 19 times.
✓ Branch 8 → 10 taken 2717 times.
2736 if (value.find('\0') != std::string_view::npos || value.find('\r') != std::string_view::npos)
110 {
111 19 return true;
112 }
113 2717 std::string_view lead = value;
114
7/8
✓ Branch 13 → 14 taken 280 times.
✓ Branch 13 → 19 taken 2443 times.
✓ Branch 15 → 16 taken 274 times.
✓ Branch 15 → 18 taken 6 times.
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 19 taken 274 times.
✓ Branch 20 → 11 taken 6 times.
✓ Branch 20 → 21 taken 2717 times.
2723 while (!lead.empty() && (lead.front() == ' ' || lead.front() == '\t'))
115 {
116 6 lead.remove_prefix(1);
117 }
118
2/2
✓ Branch 22 → 23 taken 24 times.
✓ Branch 22 → 24 taken 2693 times.
2717 if (lead.starts_with("<<<"))
119 {
120 24 return true;
121 }
122
4/4
✓ Branch 2 → 3 taken 435 times.
✓ Branch 2 → 4 taken 7 times.
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 434 times.
442 const auto is_edge_whitespace = [](char c) noexcept { return c == ' ' || c == '\t'; };
123 const bool takes_heredoc_path =
124
2/2
✓ Branch 25 → 26 taken 2666 times.
✓ Branch 25 → 34 taken 27 times.
5359 value.find('\n') != std::string_view::npos ||
125
6/6
✓ Branch 27 → 28 taken 223 times.
✓ Branch 27 → 35 taken 2443 times.
✓ Branch 30 → 31 taken 219 times.
✓ Branch 30 → 34 taken 4 times.
✓ Branch 33 → 34 taken 4 times.
✓ Branch 33 → 35 taken 215 times.
2666 (!value.empty() && (is_edge_whitespace(value.front()) || is_edge_whitespace(value.back())));
126
2/2
✓ Branch 36 → 37 taken 2658 times.
✓ Branch 36 → 38 taken 35 times.
2693 if (!takes_heredoc_path)
127 {
128 2658 return false;
129 }
130 35 constexpr std::string_view heredoc_terminator = "END_OF_TEXT";
131 35 std::size_t line_start = 0;
132
2/2
✓ Branch 59 → 39 taken 65 times.
✓ Branch 59 → 60 taken 12 times.
77 while (line_start <= value.size())
133 {
134 65 std::size_t line_end = value.find('\n', line_start);
135
2/2
✓ Branch 40 → 41 taken 14 times.
✓ Branch 40 → 42 taken 51 times.
65 if (line_end == std::string_view::npos)
136 {
137 14 line_end = value.size();
138 }
139 65 std::string_view line = value.substr(line_start, line_end - line_start);
140
8/8
✓ Branch 46 → 47 taken 70 times.
✓ Branch 46 → 52 taken 2 times.
✓ Branch 48 → 49 taken 65 times.
✓ Branch 48 → 51 taken 5 times.
✓ Branch 50 → 51 taken 2 times.
✓ Branch 50 → 52 taken 63 times.
✓ Branch 53 → 44 taken 7 times.
✓ Branch 53 → 54 taken 65 times.
72 while (!line.empty() && (line.back() == ' ' || line.back() == '\t'))
141 {
142 7 line.remove_suffix(1);
143 }
144
2/2
✓ Branch 55 → 56 taken 23 times.
✓ Branch 55 → 57 taken 42 times.
65 if (line == heredoc_terminator)
145 {
146 23 return true;
147 }
148 42 line_start = line_end + 1;
149 }
150 12 return false;
151 }
152
153 // Resolution converts Utf16le evidence through detail::decode_utf8. Persistence uses that rule for all xref text.
154 // Utf8 evidence remains byte-transparent.
155 675 [[nodiscard]] inline bool xref_evidence_is_malformed(std::string_view text, scan::StringEncoding encoding) noexcept
156 {
157
4/4
✓ Branch 2 → 3 taken 48 times.
✓ Branch 2 → 6 taken 627 times.
✓ Branch 4 → 5 taken 39 times.
✓ Branch 4 → 6 taken 9 times.
675 return encoding == scan::StringEncoding::Utf16le && !DetourModKit::detail::utf8_is_well_formed(text);
158 }
159
160 // Every inert field must keep its default. An inert edit enters the drift fingerprint but is never emitted.
161 // A recaptured baseline therefore cannot survive its own save and reload. See fold_binding.
162 456 [[nodiscard]] inline bool binding_structure_is_valid(const Binding &binding) noexcept
163 {
164 456 const Binding defaults{};
165 456 const bool offsets_inert = binding.offsets.empty();
166 456 const bool width_inert = binding.value_width == defaults.value_width;
167 456 const bool register_inert = binding.read_register == defaults.read_register;
168 456 const bool xmm_inert = binding.xmm_index == XMM_INDEX_UNUSED;
169 456 const bool vmt_inert = binding.vmt_index == 0;
170
4/5
✓ Branch 3 → 4 taken 423 times.
✓ Branch 3 → 12 taken 13 times.
✓ Branch 3 → 24 taken 12 times.
✓ Branch 3 → 33 taken 8 times.
✗ Branch 3 → 41 not taken.
456 switch (binding.kind)
171 {
172 423 case BindingKind::Address:
173
7/10
✓ Branch 4 → 5 taken 423 times.
✗ Branch 4 → 10 not taken.
✓ Branch 5 → 6 taken 422 times.
✓ Branch 5 → 10 taken 1 time.
✓ Branch 6 → 7 taken 422 times.
✗ Branch 6 → 10 not taken.
✓ Branch 7 → 8 taken 422 times.
✗ Branch 7 → 10 not taken.
✓ Branch 8 → 9 taken 420 times.
✓ Branch 8 → 10 taken 2 times.
423 return offsets_inert && width_inert && register_inert && xmm_inert && vmt_inert;
174 13 case BindingKind::PointerChain:
175 13 return !binding.offsets.empty() &&
176
4/6
✓ Branch 14 → 15 taken 12 times.
✗ Branch 14 → 18 not taken.
✓ Branch 15 → 16 taken 12 times.
✗ Branch 15 → 18 not taken.
✓ Branch 16 → 17 taken 2 times.
✓ Branch 16 → 18 taken 10 times.
12 (binding.value_width == 1 || binding.value_width == 2 || binding.value_width == 4 ||
177
4/4
✓ Branch 17 → 18 taken 1 time.
✓ Branch 17 → 22 taken 1 time.
✓ Branch 18 → 19 taken 10 times.
✓ Branch 18 → 22 taken 1 time.
12 binding.value_width == 8) &&
178
4/6
✓ Branch 13 → 14 taken 12 times.
✓ Branch 13 → 22 taken 1 time.
✓ Branch 19 → 20 taken 10 times.
✗ Branch 19 → 22 not taken.
✓ Branch 20 → 21 taken 10 times.
✗ Branch 20 → 22 not taken.
25 register_inert && xmm_inert && vmt_inert;
179 12 case BindingKind::MidHookRegister:
180
2/4
✓ Branch 25 → 26 taken 12 times.
✗ Branch 25 → 31 not taken.
✓ Branch 26 → 27 taken 12 times.
✗ Branch 26 → 31 not taken.
12 return offsets_inert && width_inert && vmt_inert &&
181
3/4
✓ Branch 24 → 25 taken 12 times.
✗ Branch 24 → 31 not taken.
✓ Branch 27 → 28 taken 10 times.
✓ Branch 27 → 31 taken 2 times.
34 static_cast<std::uint8_t>(binding.read_register) <= static_cast<std::uint8_t>(hook::Gpr::R15) &&
182
4/4
✓ Branch 28 → 29 taken 3 times.
✓ Branch 28 → 30 taken 7 times.
✓ Branch 29 → 30 taken 2 times.
✓ Branch 29 → 31 taken 1 time.
22 (binding.xmm_index == XMM_INDEX_UNUSED || binding.xmm_index < 16);
183 8 case BindingKind::VmtMethod:
184 {
185 // VmtHook bounds its captured table to 4096 methods, so no valid handle can expose a larger index.
186 8 constexpr std::size_t MAX_VMT_BINDING_SLOTS = 4096;
187
6/8
✓ Branch 33 → 34 taken 7 times.
✓ Branch 33 → 39 taken 1 time.
✓ Branch 34 → 35 taken 7 times.
✗ Branch 34 → 39 not taken.
✓ Branch 35 → 36 taken 7 times.
✗ Branch 35 → 39 not taken.
✓ Branch 36 → 37 taken 6 times.
✓ Branch 36 → 39 taken 1 time.
14 return offsets_inert && width_inert && register_inert && xmm_inert &&
188
2/2
✓ Branch 37 → 38 taken 5 times.
✓ Branch 37 → 39 taken 1 time.
14 binding.vmt_index < MAX_VMT_BINDING_SLOTS;
189 }
190 }
191 return false;
192 456 }
193
194 // Persisted-enum range guards reject an out-of-range cast. Such an enum never becomes a permissive token that
195 // the author never expressed. AnchorKind's serializable set is not contiguous, so it needs an explicit
196 // membership test.
197 626 [[nodiscard]] constexpr bool is_serializable_anchor_kind(anchor::AnchorKind kind) noexcept
198 {
199
3/3
✓ Branch 2 → 3 taken 616 times.
✓ Branch 2 → 4 taken 9 times.
✓ Branch 2 → 5 taken 1 time.
626 switch (kind)
200 {
201 616 case anchor::AnchorKind::VtableIdentity:
202 case anchor::AnchorKind::RipGlobal:
203 case anchor::AnchorKind::CodeOperand:
204 case anchor::AnchorKind::StringXref:
205 case anchor::AnchorKind::ExportName:
206 case anchor::AnchorKind::Manual:
207 616 return true;
208 9 case anchor::AnchorKind::CallArgHome:
209 case anchor::AnchorKind::Quorum:
210 case anchor::AnchorKind::Unset:
211 9 return false;
212 }
213 1 return false;
214 }
215
216 134 [[nodiscard]] constexpr bool is_valid_scan_mode(scan::Mode mode) noexcept
217 {
218
2/2
✓ Branch 2 → 3 taken 133 times.
✓ Branch 2 → 4 taken 1 time.
134 switch (mode)
219 {
220 133 case scan::Mode::Direct:
221 case scan::Mode::RipRelative:
222 case scan::Mode::RttiVtable:
223 case scan::Mode::StringXref:
224 133 return true;
225 }
226 1 return false;
227 }
228
229 598 [[nodiscard]] constexpr bool is_valid_operand_kind(scan::OperandKind kind) noexcept
230 {
231
4/4
✓ Branch 2 → 3 taken 13 times.
✓ Branch 2 → 4 taken 585 times.
✓ Branch 3 → 4 taken 11 times.
✓ Branch 3 → 5 taken 2 times.
598 return kind == scan::OperandKind::Immediate || kind == scan::OperandKind::MemoryDisplacement;
232 }
233
234 729 [[nodiscard]] constexpr bool is_valid_encoding(scan::StringEncoding encoding) noexcept
235 {
236
4/4
✓ Branch 2 → 3 taken 37 times.
✓ Branch 2 → 4 taken 692 times.
✓ Branch 3 → 4 taken 34 times.
✓ Branch 3 → 5 taken 3 times.
729 return encoding == scan::StringEncoding::Utf8 || encoding == scan::StringEncoding::Utf16le;
237 }
238
239 726 [[nodiscard]] constexpr bool is_valid_xref_return(scan::XrefReturn mode) noexcept
240 {
241
2/2
✓ Branch 2 → 3 taken 723 times.
✓ Branch 2 → 4 taken 3 times.
726 switch (mode)
242 {
243 723 case scan::XrefReturn::ReferencingInstruction:
244 case scan::XrefReturn::EnclosingFunction:
245 case scan::XrefReturn::StringPointerSlot:
246 723 return true;
247 }
248 3 return false;
249 }
250
251 592 [[nodiscard]] constexpr bool is_valid_pages(scan::Pages pages) noexcept
252 {
253
4/4
✓ Branch 2 → 3 taken 12 times.
✓ Branch 2 → 4 taken 580 times.
✓ Branch 3 → 4 taken 8 times.
✓ Branch 3 → 5 taken 4 times.
592 return pages == scan::Pages::Readable || pages == scan::Pages::Executable;
254 }
255
256 588 [[nodiscard]] constexpr bool is_valid_binding_kind(BindingKind kind) noexcept
257 {
258
2/2
✓ Branch 2 → 3 taken 586 times.
✓ Branch 2 → 4 taken 2 times.
588 switch (kind)
259 {
260 586 case BindingKind::Address:
261 case BindingKind::PointerChain:
262 case BindingKind::MidHookRegister:
263 case BindingKind::VmtMethod:
264 586 return true;
265 }
266 2 return false;
267 }
268
269 // record_policy_domains_are_valid checks whether each persisted policy field belongs to its named domain.
270 // Checked serialization also rejects invalid fields that the active kind ignores, so it never normalizes
271 // garbage into valid syntax.
272 607 [[nodiscard]] inline bool record_policy_domains_are_valid(const SignatureRecord &record) noexcept
273 {
274
2/2
✓ Branch 5 → 6 taken 596 times.
✓ Branch 5 → 16 taken 2 times.
1205 if (!is_serializable_anchor_kind(record.kind) || !is_valid_operand_kind(record.operand_kind) ||
275
4/4
✓ Branch 7 → 8 taken 594 times.
✓ Branch 7 → 16 taken 2 times.
✓ Branch 9 → 10 taken 592 times.
✓ Branch 9 → 16 taken 2 times.
596 !is_valid_encoding(record.xref_encoding) || !is_valid_xref_return(record.xref_return) ||
276
8/8
✓ Branch 3 → 4 taken 598 times.
✓ Branch 3 → 16 taken 9 times.
✓ Branch 11 → 12 taken 588 times.
✓ Branch 11 → 16 taken 4 times.
✓ Branch 13 → 14 taken 586 times.
✓ Branch 13 → 16 taken 2 times.
✓ Branch 18 → 19 taken 27 times.
✓ Branch 18 → 20 taken 580 times.
1791 !is_valid_pages(record.pages) || !is_valid_binding_kind(record.binding.kind) ||
277
2/2
✓ Branch 15 → 16 taken 6 times.
✓ Branch 15 → 17 taken 580 times.
586 !DetourModKit::detail::valid_code_constant_byte_width(record.byte_width))
278 {
279 27 return false;
280 }
281
2/2
✓ Branch 43 → 22 taken 134 times.
✓ Branch 43 → 44 taken 577 times.
1291 for (const CandidateSpec &spec : record.ladder)
282 {
283
6/6
✓ Branch 25 → 26 taken 133 times.
✓ Branch 25 → 30 taken 1 time.
✓ Branch 27 → 28 taken 132 times.
✓ Branch 27 → 30 taken 1 time.
✓ Branch 32 → 33 taken 3 times.
✓ Branch 32 → 34 taken 131 times.
266 if (!is_valid_scan_mode(spec.mode) || !is_valid_encoding(spec.string_encoding) ||
284
2/2
✓ Branch 29 → 30 taken 1 time.
✓ Branch 29 → 31 taken 131 times.
132 !is_valid_xref_return(spec.string_return))
285 {
286 3 return false;
287 }
288 }
289 577 return true;
290 }
291
292 522 [[nodiscard]] constexpr bool image_identity_is_absent(const scan::ImageIdentity &identity) noexcept
293 {
294
4/6
✓ Branch 2 → 3 taken 500 times.
✓ Branch 2 → 6 taken 22 times.
✓ Branch 3 → 4 taken 500 times.
✗ Branch 3 → 6 not taken.
✓ Branch 4 → 5 taken 500 times.
✗ Branch 4 → 6 not taken.
522 return identity.timestamp == 0 && identity.size_of_image == 0 && identity.section_digest == 0;
295 }
296
297 522 [[nodiscard]] constexpr bool image_identity_is_valid(const scan::ImageIdentity &identity) noexcept
298 {
299
4/4
✓ Branch 3 → 4 taken 22 times.
✓ Branch 3 → 6 taken 500 times.
✓ Branch 5 → 6 taken 20 times.
✓ Branch 5 → 7 taken 2 times.
522 return image_identity_is_absent(identity) || identity.present();
300 }
301
302 // A persisted content baseline is only ever a complete capture, which keeps "present in the file" and
303 // "usable to authorize a mutation" the same condition.
304 520 [[nodiscard]] constexpr bool winning_bytes_are_valid(const scan::WinningEvidence &evidence) noexcept
305 {
306
4/4
✓ Branch 2 → 3 taken 516 times.
✓ Branch 2 → 5 taken 4 times.
✓ Branch 3 → 4 taken 514 times.
✓ Branch 3 → 5 taken 2 times.
520 return !evidence.truncated && evidence.length <= scan::MAX_MUTATION_WITNESS_BYTES;
307 }
308 } // namespace DetourModKit::manifest
309
310 #endif // DETOURMODKIT_INTERNAL_MANIFEST_RECORD_RULES_HPP
311