./
Coverage:
low: ≥ 0%
medium: ≥ 75.0%
high: ≥ 90.0%
Lines:
127 / 132
96.2%
Branches:
178 / 200
89.0%
Functions:
20 / 20
100.0%

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_overlay.cpp (Signature compile/adopt) enforces these record, label, value, binding, and
8 * baseline rules, so each rule is stated exactly once here. Checked serialization in src/manifest.cpp applies
9 * them through Signature::compile. parse shares only the rung-section grammar, the Utf16le rule, the
10 * RipRelative span rule, and kind_uses_ladder.
11 */
12
13 #include "DetourModKit/hook.hpp"
14 #include "DetourModKit/manifest.hpp"
15
16 #include "internal/hook_fault_boundary.hpp"
17 #include "internal/scan_shared.hpp"
18
19 #include <cstddef>
20 #include <cstdint>
21 #include <expected>
22 #include <format>
23 #include <limits>
24 #include <optional>
25 #include <string_view>
26
27 namespace DetourModKit::manifest
28 {
29 600 [[nodiscard]] inline std::unexpected<Error> fail(ErrorCode code, const char *where) noexcept
30 {
31 600 return std::unexpected(Error{code, where});
32 }
33
34 [[nodiscard]] constexpr bool
35 16 rip_pattern_spans_displacement(const scan::Pattern &pattern, std::size_t displacement_at) noexcept
36 {
37 16 return DetourModKit::detail::min_match_suffix_length(DetourModKit::detail::pattern_buffer(pattern)) >=
38 16 displacement_at + sizeof(std::int32_t);
39 }
40
41 struct RungSectionName
42 {
43 std::string_view parent;
44 std::size_t index = 0;
45 };
46
47 // A rung section always uses `[sig.<label>.rung.<N>]`. Treat malformed tails as ordinary labels. A label with
48 // ".rung." in the middle remains legal.
49 355535 [[nodiscard]] inline std::optional<RungSectionName> parse_rung_section_name(std::string_view name) noexcept
50 {
51 355535 const std::size_t pos = name.rfind(".rung.");
52
2/2
✓ Branch 3 → 4 taken 304609 times.
✓ Branch 3 → 5 taken 50926 times.
355535 if (pos == std::string_view::npos)
53 {
54 304609 return std::nullopt;
55 }
56 50926 const std::string_view tail = name.substr(pos + 6);
57
1/2
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 50926 times.
50926 if (tail.empty())
58 {
59 ✗ return std::nullopt;
60 }
61 50926 std::size_t index = 0;
62
2/2
✓ Branch 17 → 11 taken 220404 times.
✓ Branch 17 → 18 taken 50926 times.
271330 for (const char c : tail)
63 {
64
2/4
✓ Branch 11 → 12 taken 220404 times.
✗ Branch 11 → 13 not taken.
✗ Branch 12 → 13 not taken.
✓ Branch 12 → 14 taken 220404 times.
220404 if (c < '0' || c > '9')
65 {
66 ✗ return std::nullopt;
67 }
68 220404 const std::size_t digit = static_cast<std::size_t>(c - '0');
69 220404 constexpr std::size_t MAX_INDEX = std::numeric_limits<std::size_t>::max();
70
1/2
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 16 taken 220404 times.
220404 if (index > (MAX_INDEX - digit) / 10U)
71 {
72 ✗ return std::nullopt;
73 }
74 220404 index = (index * 10U) + digit;
75 }
76 50926 return RungSectionName{
77 50926 .parent = name.substr(0, pos),
78 .index = index,
79 50926 };
80 }
81
82 // A record label becomes its `[sig.<label>]` section name. Reject a label that cannot round-trip. Reject INI
83 // structural characters and embedded NUL. Reject a blank suffix because SimpleIni strips it and changes the
84 // key. Reject the reserved `.rung.<digits>` grammar. Check the full section name that parse() creates so a bare
85 // `rung.0` label also fails.
86 21011 [[nodiscard]] inline bool label_is_serializable(std::string_view label)
87 {
88
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 21011 times.
21011 if (label.empty())
89 {
90 ✗ return false;
91 }
92
6/6
✓ Branch 6 → 7 taken 21010 times.
✓ Branch 6 → 9 taken 1 time.
✓ Branch 8 → 9 taken 1 time.
✓ Branch 8 → 10 taken 21009 times.
✓ Branch 11 → 12 taken 2 times.
✓ Branch 11 → 13 taken 21009 times.
21011 if (label.back() == ' ' || label.back() == '\t')
93 {
94 2 return false;
95 }
96
2/2
✓ Branch 22 → 15 taken 165695 times.
✓ Branch 22 → 23 taken 20996 times.
186691 for (const char c : label)
97 {
98
10/10
✓ Branch 15 → 16 taken 165694 times.
✓ Branch 15 → 20 taken 1 time.
✓ Branch 16 → 17 taken 165691 times.
✓ Branch 16 → 20 taken 3 times.
✓ Branch 17 → 18 taken 165688 times.
✓ Branch 17 → 20 taken 3 times.
✓ Branch 18 → 19 taken 165685 times.
✓ Branch 18 → 20 taken 3 times.
✓ Branch 19 → 20 taken 3 times.
✓ Branch 19 → 21 taken 165682 times.
165695 if (c == '\0' || c == '\r' || c == '\n' || c == '[' || c == ']')
99 {
100 13 return false;
101 }
102 }
103
1/2
✓ Branch 23 → 24 taken 20996 times.
✗ Branch 23 → 31 not taken.
20996 return !parse_rung_section_name(std::format("sig.{}", label)).has_value();
104 }
105
106 // Validates every free-text value before compile or checked serialization, and each record string before adopt.
107 // Reject embedded NUL or '\r' because reload changes the contract. Reject a whitespace-prefixed "<<<" because raw
108 // output opens a heredoc. Reject a heredoc body line equal to "END_OF_TEXT" because the store truncates there.
109 // Apply the terminator scan only to values that use a heredoc. Raw values round-trip verbatim.
110 85280 [[nodiscard]] inline bool value_is_unserializable(std::string_view value) noexcept
111 {
112
6/6
✓ Branch 3 → 4 taken 85279 times.
✓ Branch 3 → 6 taken 1 time.
✓ Branch 5 → 6 taken 18 times.
✓ Branch 5 → 7 taken 85261 times.
✓ Branch 8 → 9 taken 19 times.
✓ Branch 8 → 10 taken 85261 times.
85280 if (value.find('\0') != std::string_view::npos || value.find('\r') != std::string_view::npos)
113 {
114 19 return true;
115 }
116 85261 std::string_view lead = value;
117
7/8
✓ Branch 13 → 14 taken 532 times.
✓ Branch 13 → 19 taken 84735 times.
✓ Branch 15 → 16 taken 526 times.
✓ Branch 15 → 18 taken 6 times.
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 19 taken 526 times.
✓ Branch 20 → 11 taken 6 times.
✓ Branch 20 → 21 taken 85261 times.
85267 while (!lead.empty() && (lead.front() == ' ' || lead.front() == '\t'))
118 {
119 6 lead.remove_prefix(1);
120 }
121
2/2
✓ Branch 22 → 23 taken 24 times.
✓ Branch 22 → 24 taken 85237 times.
85261 if (lead.starts_with("<<<"))
122 {
123 24 return true;
124 }
125
4/4
✓ Branch 2 → 3 taken 939 times.
✓ Branch 2 → 4 taken 7 times.
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 938 times.
946 const auto is_edge_whitespace = [](char c) noexcept { return c == ' ' || c == '\t'; };
126 const bool takes_heredoc_path =
127
2/2
✓ Branch 25 → 26 taken 85210 times.
✓ Branch 25 → 34 taken 27 times.
170447 value.find('\n') != std::string_view::npos ||
128
6/6
✓ Branch 27 → 28 taken 475 times.
✓ Branch 27 → 35 taken 84735 times.
✓ Branch 30 → 31 taken 471 times.
✓ Branch 30 → 34 taken 4 times.
✓ Branch 33 → 34 taken 4 times.
✓ Branch 33 → 35 taken 467 times.
85210 (!value.empty() && (is_edge_whitespace(value.front()) || is_edge_whitespace(value.back())));
129
2/2
✓ Branch 36 → 37 taken 85202 times.
✓ Branch 36 → 38 taken 35 times.
85237 if (!takes_heredoc_path)
130 {
131 85202 return false;
132 }
133 35 constexpr std::string_view heredoc_terminator = "END_OF_TEXT";
134 35 std::size_t line_start = 0;
135
2/2
✓ Branch 59 → 39 taken 65 times.
✓ Branch 59 → 60 taken 12 times.
77 while (line_start <= value.size())
136 {
137 65 std::size_t line_end = value.find('\n', line_start);
138
2/2
✓ Branch 40 → 41 taken 14 times.
✓ Branch 40 → 42 taken 51 times.
65 if (line_end == std::string_view::npos)
139 {
140 14 line_end = value.size();
141 }
142 65 std::string_view line = value.substr(line_start, line_end - line_start);
143
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'))
144 {
145 7 line.remove_suffix(1);
146 }
147
2/2
✓ Branch 55 → 56 taken 23 times.
✓ Branch 55 → 57 taken 42 times.
65 if (line == heredoc_terminator)
148 {
149 23 return true;
150 }
151 42 line_start = line_end + 1;
152 }
153 12 return false;
154 }
155
156 // Resolution converts Utf16le evidence through detail::decode_utf8. Persistence uses that rule for all xref text.
157 // Utf8 evidence remains byte-transparent.
158 21321 [[nodiscard]] inline bool xref_evidence_is_malformed(std::string_view text, scan::StringEncoding encoding) noexcept
159 {
160
4/4
✓ Branch 2 → 3 taken 57 times.
✓ Branch 2 → 6 taken 21264 times.
✓ Branch 4 → 5 taken 39 times.
✓ Branch 4 → 6 taken 18 times.
21321 return encoding == scan::StringEncoding::Utf16le && !DetourModKit::detail::utf8_is_well_formed(text);
161 }
162
163 // Every inert field must keep its default. An inert edit enters the drift fingerprint but is never emitted.
164 // A recaptured baseline therefore cannot survive its own save and reload. See fold_binding.
165 823 [[nodiscard]] inline bool binding_structure_is_valid(const Binding &binding) noexcept
166 {
167 823 const Binding defaults{};
168 823 const bool offsets_inert = binding.offsets.empty();
169 823 const bool width_inert = binding.value_width == defaults.value_width;
170 823 const bool register_inert = binding.read_register == defaults.read_register;
171 823 const bool xmm_inert = binding.xmm_index == XMM_INDEX_UNUSED;
172 823 const bool vmt_inert = binding.vmt_index == 0;
173
4/5
✓ Branch 3 → 4 taken 790 times.
✓ Branch 3 → 12 taken 13 times.
✓ Branch 3 → 24 taken 12 times.
✓ Branch 3 → 33 taken 8 times.
✗ Branch 3 → 41 not taken.
823 switch (binding.kind)
174 {
175 790 case BindingKind::Address:
176
7/10
✓ Branch 4 → 5 taken 790 times.
✗ Branch 4 → 10 not taken.
✓ Branch 5 → 6 taken 789 times.
✓ Branch 5 → 10 taken 1 time.
✓ Branch 6 → 7 taken 789 times.
✗ Branch 6 → 10 not taken.
✓ Branch 7 → 8 taken 789 times.
✗ Branch 7 → 10 not taken.
✓ Branch 8 → 9 taken 787 times.
✓ Branch 8 → 10 taken 2 times.
790 return offsets_inert && width_inert && register_inert && xmm_inert && vmt_inert;
177 13 case BindingKind::PointerChain:
178 13 return !binding.offsets.empty() &&
179
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 ||
180
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) &&
181
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;
182 12 case BindingKind::MidHookRegister:
183
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 &&
184
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) &&
185
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);
186 8 case BindingKind::VmtMethod:
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 < DetourModKit::detail::MAX_VMT_SLOTS;
189 }
190 ✗ return false;
191 823 }
192
193 // Persisted-enum range guards reject an out-of-range cast. Such an enum never becomes a permissive token that
194 // the author never expressed. AnchorKind's serializable set is not contiguous, so it needs an explicit
195 // membership test.
196 21071 [[nodiscard]] constexpr bool is_serializable_anchor_kind(anchor::AnchorKind kind) noexcept
197 {
198
3/3
✓ Branch 2 → 3 taken 21061 times.
✓ Branch 2 → 4 taken 9 times.
✓ Branch 2 → 5 taken 1 time.
21071 switch (kind)
199 {
200 21061 case anchor::AnchorKind::VtableIdentity:
201 case anchor::AnchorKind::RipGlobal:
202 case anchor::AnchorKind::CodeOperand:
203 case anchor::AnchorKind::StringXref:
204 case anchor::AnchorKind::ExportName:
205 case anchor::AnchorKind::Manual:
206 21061 return true;
207 9 case anchor::AnchorKind::CallArgHome:
208 case anchor::AnchorKind::Quorum:
209 case anchor::AnchorKind::Unset:
210 9 return false;
211 }
212 1 return false;
213 }
214
215 // Only RipGlobal and CodeOperand resolve, fingerprint, and corroborate through a candidate ladder. A rung on any
216 // other kind is state that nothing reads.
217 41599 [[nodiscard]] constexpr bool kind_uses_ladder(anchor::AnchorKind kind) noexcept
218 {
219
4/4
✓ Branch 2 → 3 taken 1399 times.
✓ Branch 2 → 4 taken 40200 times.
✓ Branch 3 → 4 taken 193 times.
✓ Branch 3 → 5 taken 1206 times.
41599 return kind == anchor::AnchorKind::RipGlobal || kind == anchor::AnchorKind::CodeOperand;
220 }
221
222 358 [[nodiscard]] constexpr bool is_valid_scan_mode(scan::Mode mode) noexcept
223 {
224
2/2
✓ Branch 2 → 3 taken 357 times.
✓ Branch 2 → 4 taken 1 time.
358 switch (mode)
225 {
226 357 case scan::Mode::Direct:
227 case scan::Mode::RipRelative:
228 case scan::Mode::RttiVtable:
229 case scan::Mode::StringXref:
230 357 return true;
231 }
232 1 return false;
233 }
234
235 21040 [[nodiscard]] constexpr bool is_valid_operand_kind(scan::OperandKind kind) noexcept
236 {
237
4/4
✓ Branch 2 → 3 taken 178 times.
✓ Branch 2 → 4 taken 20862 times.
✓ Branch 3 → 4 taken 176 times.
✓ Branch 3 → 5 taken 2 times.
21040 return kind == scan::OperandKind::Immediate || kind == scan::OperandKind::MemoryDisplacement;
238 }
239
240 21395 [[nodiscard]] constexpr bool is_valid_encoding(scan::StringEncoding encoding) noexcept
241 {
242
4/4
✓ Branch 2 → 3 taken 42 times.
✓ Branch 2 → 4 taken 21353 times.
✓ Branch 3 → 4 taken 39 times.
✓ Branch 3 → 5 taken 3 times.
21395 return encoding == scan::StringEncoding::Utf8 || encoding == scan::StringEncoding::Utf16le;
243 }
244
245 21392 [[nodiscard]] constexpr bool is_valid_xref_return(scan::XrefReturn mode) noexcept
246 {
247
2/2
✓ Branch 2 → 3 taken 21389 times.
✓ Branch 2 → 4 taken 3 times.
21392 switch (mode)
248 {
249 21389 case scan::XrefReturn::ReferencingInstruction:
250 case scan::XrefReturn::EnclosingFunction:
251 case scan::XrefReturn::StringPointerSlot:
252 21389 return true;
253 }
254 3 return false;
255 }
256
257 21034 [[nodiscard]] constexpr bool is_valid_pages(scan::Pages pages) noexcept
258 {
259
4/4
✓ Branch 2 → 3 taken 12 times.
✓ Branch 2 → 4 taken 21022 times.
✓ Branch 3 → 4 taken 8 times.
✓ Branch 3 → 5 taken 4 times.
21034 return pages == scan::Pages::Readable || pages == scan::Pages::Executable;
260 }
261
262 21030 [[nodiscard]] constexpr bool is_valid_binding_kind(BindingKind kind) noexcept
263 {
264
2/2
✓ Branch 2 → 3 taken 21028 times.
✓ Branch 2 → 4 taken 2 times.
21030 switch (kind)
265 {
266 21028 case BindingKind::Address:
267 case BindingKind::PointerChain:
268 case BindingKind::MidHookRegister:
269 case BindingKind::VmtMethod:
270 21028 return true;
271 }
272 2 return false;
273 }
274
275 // record_policy_domains_are_valid checks whether each persisted policy field belongs to its named domain.
276 // Checked serialization also rejects invalid fields that the active kind ignores, so it never normalizes
277 // garbage into valid syntax.
278 21049 [[nodiscard]] inline bool record_policy_domains_are_valid(const SignatureRecord &record) noexcept
279 {
280
2/2
✓ Branch 5 → 6 taken 21038 times.
✓ Branch 5 → 16 taken 2 times.
42089 if (!is_serializable_anchor_kind(record.kind) || !is_valid_operand_kind(record.operand_kind) ||
281
4/4
✓ Branch 7 → 8 taken 21036 times.
✓ Branch 7 → 16 taken 2 times.
✓ Branch 9 → 10 taken 21034 times.
✓ Branch 9 → 16 taken 2 times.
21038 !is_valid_encoding(record.xref_encoding) || !is_valid_xref_return(record.xref_return) ||
282
8/8
✓ Branch 3 → 4 taken 21040 times.
✓ Branch 3 → 16 taken 9 times.
✓ Branch 11 → 12 taken 21030 times.
✓ Branch 11 → 16 taken 4 times.
✓ Branch 13 → 14 taken 21028 times.
✓ Branch 13 → 16 taken 2 times.
✓ Branch 18 → 19 taken 27 times.
✓ Branch 18 → 20 taken 21022 times.
63117 !is_valid_pages(record.pages) || !is_valid_binding_kind(record.binding.kind) ||
283
2/2
✓ Branch 15 → 16 taken 6 times.
✓ Branch 15 → 17 taken 21022 times.
21028 !DetourModKit::detail::valid_code_constant_byte_width(record.byte_width))
284 {
285 27 return false;
286 }
287
2/2
✓ Branch 43 → 22 taken 358 times.
✓ Branch 43 → 44 taken 21019 times.
42399 for (const CandidateSpec &spec : record.ladder)
288 {
289
6/6
✓ Branch 25 → 26 taken 357 times.
✓ Branch 25 → 30 taken 1 time.
✓ Branch 27 → 28 taken 356 times.
✓ Branch 27 → 30 taken 1 time.
✓ Branch 32 → 33 taken 3 times.
✓ Branch 32 → 34 taken 355 times.
714 if (!is_valid_scan_mode(spec.mode) || !is_valid_encoding(spec.string_encoding) ||
290
2/2
✓ Branch 29 → 30 taken 1 time.
✓ Branch 29 → 31 taken 355 times.
356 !is_valid_xref_return(spec.string_return))
291 {
292 3 return false;
293 }
294 }
295 21019 return true;
296 }
297
298 934 [[nodiscard]] constexpr bool image_identity_is_absent(const scan::ImageIdentity &identity) noexcept
299 {
300
4/6
✓ Branch 2 → 3 taken 910 times.
✓ Branch 2 → 6 taken 24 times.
✓ Branch 3 → 4 taken 910 times.
✗ Branch 3 → 6 not taken.
✓ Branch 4 → 5 taken 910 times.
✗ Branch 4 → 6 not taken.
934 return identity.timestamp == 0 && identity.size_of_image == 0 && identity.section_digest == 0;
301 }
302
303 934 [[nodiscard]] constexpr bool image_identity_is_valid(const scan::ImageIdentity &identity) noexcept
304 {
305
4/4
✓ Branch 3 → 4 taken 24 times.
✓ Branch 3 → 6 taken 910 times.
✓ Branch 5 → 6 taken 22 times.
✓ Branch 5 → 7 taken 2 times.
934 return image_identity_is_absent(identity) || identity.present();
306 }
307
308 // A persisted content baseline is only ever a complete capture, which keeps "present in the file" and
309 // "usable to authorize a mutation" the same condition.
310 932 [[nodiscard]] constexpr bool winning_bytes_are_valid(const scan::WinningEvidence &evidence) noexcept
311 {
312
4/4
✓ Branch 2 → 3 taken 928 times.
✓ Branch 2 → 5 taken 4 times.
✓ Branch 3 → 4 taken 926 times.
✓ Branch 3 → 5 taken 2 times.
932 return !evidence.truncated && evidence.length <= scan::MAX_MUTATION_WITNESS_BYTES;
313 }
314 } // namespace DetourModKit::manifest
315
316 #endif // DETOURMODKIT_INTERNAL_MANIFEST_RECORD_RULES_HPP
317