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 | 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 | 50926 | if (tail.empty()) | |
| 58 | { | ||
| 59 | ✗ | return std::nullopt; | |
| 60 | } | ||
| 61 | 50926 | std::size_t index = 0; | |
| 62 | 271330 | for (const char c : tail) | |
| 63 | { | ||
| 64 | 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 | 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 | 21011 | if (label.empty()) | |
| 89 | { | ||
| 90 | ✗ | return false; | |
| 91 | } | ||
| 92 | 21011 | if (label.back() == ' ' || label.back() == '\t') | |
| 93 | { | ||
| 94 | 2 | return false; | |
| 95 | } | ||
| 96 | 186691 | for (const char c : label) | |
| 97 | { | ||
| 98 | 165695 | if (c == '\0' || c == '\r' || c == '\n' || c == '[' || c == ']') | |
| 99 | { | ||
| 100 | 13 | return false; | |
| 101 | } | ||
| 102 | } | ||
| 103 | 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 | 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 | 85267 | while (!lead.empty() && (lead.front() == ' ' || lead.front() == '\t')) | |
| 118 | { | ||
| 119 | 6 | lead.remove_prefix(1); | |
| 120 | } | ||
| 121 | 85261 | if (lead.starts_with("<<<")) | |
| 122 | { | ||
| 123 | 24 | return true; | |
| 124 | } | ||
| 125 | 946 | const auto is_edge_whitespace = [](char c) noexcept { return c == ' ' || c == '\t'; }; | |
| 126 | const bool takes_heredoc_path = | ||
| 127 | 170447 | value.find('\n') != std::string_view::npos || | |
| 128 | 85210 | (!value.empty() && (is_edge_whitespace(value.front()) || is_edge_whitespace(value.back()))); | |
| 129 | 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 | 77 | while (line_start <= value.size()) | |
| 136 | { | ||
| 137 | 65 | std::size_t line_end = value.find('\n', line_start); | |
| 138 | 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 | 72 | while (!line.empty() && (line.back() == ' ' || line.back() == '\t')) | |
| 144 | { | ||
| 145 | 7 | line.remove_suffix(1); | |
| 146 | } | ||
| 147 | 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 | 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 | 823 | switch (binding.kind) | |
| 174 | { | ||
| 175 | 790 | case BindingKind::Address: | |
| 176 | 790 | return offsets_inert && width_inert && register_inert && xmm_inert && vmt_inert; | |
| 177 | 13 | case BindingKind::PointerChain: | |
| 178 | 13 | return !binding.offsets.empty() && | |
| 179 | 12 | (binding.value_width == 1 || binding.value_width == 2 || binding.value_width == 4 || | |
| 180 | 12 | binding.value_width == 8) && | |
| 181 | 25 | register_inert && xmm_inert && vmt_inert; | |
| 182 | 12 | case BindingKind::MidHookRegister: | |
| 183 | 12 | return offsets_inert && width_inert && vmt_inert && | |
| 184 | 34 | static_cast<std::uint8_t>(binding.read_register) <= static_cast<std::uint8_t>(hook::Gpr::R15) && | |
| 185 | 22 | (binding.xmm_index == XMM_INDEX_UNUSED || binding.xmm_index < 16); | |
| 186 | 8 | case BindingKind::VmtMethod: | |
| 187 | 14 | return offsets_inert && width_inert && register_inert && xmm_inert && | |
| 188 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 42089 | if (!is_serializable_anchor_kind(record.kind) || !is_valid_operand_kind(record.operand_kind) || | |
| 281 | 21038 | !is_valid_encoding(record.xref_encoding) || !is_valid_xref_return(record.xref_return) || | |
| 282 | 63117 | !is_valid_pages(record.pages) || !is_valid_binding_kind(record.binding.kind) || | |
| 283 | 21028 | !DetourModKit::detail::valid_code_constant_byte_width(record.byte_width)) | |
| 284 | { | ||
| 285 | 27 | return false; | |
| 286 | } | ||
| 287 | 42399 | for (const CandidateSpec &spec : record.ladder) | |
| 288 | { | ||
| 289 | 714 | if (!is_valid_scan_mode(spec.mode) || !is_valid_encoding(spec.string_encoding) || | |
| 290 | 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 | 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 | 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 | 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 |