src/internal/manifest_grammar.cpp
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | /** | ||
| 2 | * @file manifest_grammar.cpp | ||
| 3 | * @brief Raw-text prepass that closes manifest identity and framing collisions before the INI backend can merge them. | ||
| 4 | * @details CSimpleIniCaseA merges duplicate sections, retains the last duplicate key, and absorbs an unterminated | ||
| 5 | * heredoc through end of file. This pass uses the same whitespace, section, line-break, and heredoc rules. | ||
| 6 | * It rejects lost identities, unclosed heredocs, discarded key lines, and resource-cap excesses before store | ||
| 7 | * allocation. One shared token model prevents a collision from escape through a backend difference. | ||
| 8 | */ | ||
| 9 | |||
| 10 | #include "internal/manifest_grammar.hpp" | ||
| 11 | |||
| 12 | #include <limits> | ||
| 13 | #include <string> | ||
| 14 | #include <string_view> | ||
| 15 | #include <unordered_map> | ||
| 16 | #include <unordered_set> | ||
| 17 | |||
| 18 | namespace DetourModKit::manifest::detail | ||
| 19 | { | ||
| 20 | namespace | ||
| 21 | { | ||
| 22 | // The backend's IsSpace: a space, tab, or either newline character. Used for blank-line and trailing-name | ||
| 23 | // trimming exactly as FindEntry does. | ||
| 24 | 504341 | [[nodiscard]] bool is_space(char c) noexcept | |
| 25 | { | ||
| 26 | 504341 | return c == ' ' || c == '\t' || c == '\r' || c == '\n'; | |
| 27 | } | ||
| 28 | |||
| 29 | 11409617 | [[nodiscard]] bool is_newline(char c) noexcept | |
| 30 | { | ||
| 31 | 11409617 | return c == '\r' || c == '\n'; | |
| 32 | } | ||
| 33 | |||
| 34 | 179256 | [[nodiscard]] std::string_view rtrim(std::string_view text) noexcept | |
| 35 | { | ||
| 36 | 250968 | while (!text.empty() && is_space(text.back())) | |
| 37 | { | ||
| 38 | 71712 | text.remove_suffix(1); | |
| 39 | } | ||
| 40 | 179256 | return text; | |
| 41 | } | ||
| 42 | |||
| 43 | 107473 | [[nodiscard]] std::string to_lower(std::string_view text) | |
| 44 | { | ||
| 45 | 107473 | std::string out(text); | |
| 46 | 1170488 | for (char &c : out) | |
| 47 | { | ||
| 48 | 955544 | if (c >= 'A' && c <= 'Z') | |
| 49 | { | ||
| 50 | 401 | c = static_cast<char>(c - 'A' + 'a'); | |
| 51 | } | ||
| 52 | } | ||
| 53 | 107472 | return out; | |
| 54 | } | ||
| 55 | |||
| 56 | 55 | [[nodiscard]] std::unexpected<Error> fail(ErrorCode code, const char *context) noexcept | |
| 57 | { | ||
| 58 | 55 | return std::unexpected(Error{code, context}); | |
| 59 | } | ||
| 60 | |||
| 61 | 71737 | [[nodiscard]] bool consume_bytes(std::size_t bytes, std::size_t limit, std::size_t &total) noexcept | |
| 62 | { | ||
| 63 | 71737 | if (bytes > limit - total) | |
| 64 | { | ||
| 65 | 3 | return false; | |
| 66 | } | ||
| 67 | 71734 | total += bytes; | |
| 68 | 71734 | return true; | |
| 69 | } | ||
| 70 | |||
| 71 | 35442 | [[nodiscard]] std::string_view rung_parent(std::string_view name) noexcept | |
| 72 | { | ||
| 73 | 35442 | const std::size_t marker = name.rfind(".rung."); | |
| 74 | 35442 | if (marker == std::string_view::npos) | |
| 75 | { | ||
| 76 | 1486 | return {}; | |
| 77 | } | ||
| 78 | 33956 | const std::string_view tail = name.substr(marker + 6); | |
| 79 | 33956 | if (tail.empty()) | |
| 80 | { | ||
| 81 | ✗ | return {}; | |
| 82 | } | ||
| 83 | 33956 | std::size_t index = 0; | |
| 84 | 180898 | for (const char c : tail) | |
| 85 | { | ||
| 86 | 146942 | if (c < '0' || c > '9') | |
| 87 | { | ||
| 88 | ✗ | return {}; | |
| 89 | } | ||
| 90 | 146942 | const std::size_t digit = static_cast<std::size_t>(c - '0'); | |
| 91 | 146942 | constexpr std::size_t MAX_INDEX = std::numeric_limits<std::size_t>::max(); | |
| 92 | 146942 | if (index > (MAX_INDEX - digit) / 10U) | |
| 93 | { | ||
| 94 | ✗ | return {}; | |
| 95 | } | ||
| 96 | 146942 | index = (index * 10U) + digit; | |
| 97 | } | ||
| 98 | 33956 | return name.substr(0, marker); | |
| 99 | } | ||
| 100 | |||
| 101 | 107553 | [[nodiscard]] std::size_t line_end_from(std::string_view text, std::size_t start) noexcept | |
| 102 | { | ||
| 103 | 10238823 | while (start < text.size() && !is_newline(text[start])) | |
| 104 | { | ||
| 105 | 10131270 | ++start; | |
| 106 | } | ||
| 107 | 107553 | return start; | |
| 108 | } | ||
| 109 | |||
| 110 | /// Consumes one line terminator as the backend's SkipNewLine does: a lone `\r`, a lone `\n`, or a `\r\n` pair. | ||
| 111 | 96 | [[nodiscard]] std::size_t skip_newline(std::string_view text, std::size_t at) noexcept | |
| 112 | { | ||
| 113 | 96 | if (at < text.size() && text[at] == '\r') | |
| 114 | { | ||
| 115 | 72 | ++at; | |
| 116 | 72 | if (at < text.size() && text[at] == '\n') | |
| 117 | { | ||
| 118 | 72 | ++at; | |
| 119 | } | ||
| 120 | } | ||
| 121 | 24 | else if (at < text.size() && text[at] == '\n') | |
| 122 | { | ||
| 123 | 24 | ++at; | |
| 124 | } | ||
| 125 | 96 | return at; | |
| 126 | } | ||
| 127 | |||
| 128 | /// Rejects a section name that parse() never reads or whose folded form repeats, and charges the section cap. | ||
| 129 | 35776 | [[nodiscard]] Result<void> register_section( | |
| 130 | std::string_view name, | ||
| 131 | const GrammarLimits &limits, | ||
| 132 | const char *context, | ||
| 133 | std::unordered_set<std::string> &seen_sections, | ||
| 134 | std::size_t §ion_count | ||
| 135 | ) | ||
| 136 | { | ||
| 137 | // parse() reads only the canonical lowercase `manifest` header and `sig.` sections. Any other name, | ||
| 138 | // including a miscased one, holds state that parse() never reads. An empty name (`[]`, `[ ]`) also | ||
| 139 | // reopens the backend's implicit default section, where a key collision escapes the per-section | ||
| 140 | // namespace. This pass fails closed instead. | ||
| 141 | 35776 | if (name != "manifest" && !name.starts_with("sig.")) | |
| 142 | { | ||
| 143 | 8 | return fail(ErrorCode::MalformedLine, context); | |
| 144 | } | ||
| 145 | 35768 | std::string folded = to_lower(name); | |
| 146 | // The folded, trimmed name is the merge key. The backend merges two sections that reach it by case, | ||
| 147 | // whitespace, or exact repetition into one before the trust gate. | ||
| 148 | 35767 | if (section_count >= limits.max_sections) | |
| 149 | { | ||
| 150 | 2 | return fail(ErrorCode::SizeTooLarge, context); | |
| 151 | } | ||
| 152 | 35765 | if (!seen_sections.insert(std::move(folded)).second) | |
| 153 | { | ||
| 154 | 8 | return fail(ErrorCode::ManifestIdentityCollision, context); | |
| 155 | } | ||
| 156 | 35744 | ++section_count; | |
| 157 | 35744 | return {}; | |
| 158 | 35767 | } | |
| 159 | |||
| 160 | /** | ||
| 161 | * @brief Rejects a `sig.` rung without a parent label or a `sig.` label over the field cap, then charges the | ||
| 162 | * record or rung cap. | ||
| 163 | * @details A rung charges the rung cap of its parent record. A name without the `sig.` prefix passes with no | ||
| 164 | * check or charge. | ||
| 165 | */ | ||
| 166 | 35744 | [[nodiscard]] Result<void> charge_signature_section( | |
| 167 | std::string_view name, | ||
| 168 | const GrammarLimits &limits, | ||
| 169 | const char *context, | ||
| 170 | std::unordered_map<std::string, std::size_t> &rung_counts, | ||
| 171 | std::size_t &record_count | ||
| 172 | ) | ||
| 173 | { | ||
| 174 | // Classify a record or a rung by the raw name, not the folded name. parse() reads `.rung.` case-sensitively | ||
| 175 | // and treats a miscased marker as an ordinary label. This pass and parse() must charge the same cap, and a | ||
| 176 | // fold counts the legitimate record `sig.a.RUNG.0` (label `a.RUNG.0`) against a rung cap. | ||
| 177 | 35744 | if (name.starts_with("sig.")) | |
| 178 | { | ||
| 179 | 35442 | const std::string_view parent = rung_parent(name); | |
| 180 | 35442 | if (!parent.empty() && parent.size() <= 4U) | |
| 181 | { | ||
| 182 | 1 | return fail(ErrorCode::MalformedLine, context); | |
| 183 | } | ||
| 184 | 35441 | const std::string_view label = parent.empty() ? name.substr(4) : parent.substr(4); | |
| 185 | 35441 | if (label.size() > limits.max_field_bytes) | |
| 186 | { | ||
| 187 | 1 | return fail(ErrorCode::SizeTooLarge, context); | |
| 188 | } | ||
| 189 | 35440 | if (parent.empty()) | |
| 190 | { | ||
| 191 | 1485 | if (record_count >= limits.max_records) | |
| 192 | { | ||
| 193 | 2 | return fail(ErrorCode::SizeTooLarge, context); | |
| 194 | } | ||
| 195 | 1483 | ++record_count; | |
| 196 | } | ||
| 197 | else | ||
| 198 | { | ||
| 199 | 67912 | auto count_it = rung_counts.try_emplace(std::string(parent), 0).first; | |
| 200 | 33953 | if (count_it->second >= limits.max_rungs_per_record) | |
| 201 | { | ||
| 202 | 2 | return fail(ErrorCode::SizeTooLarge, context); | |
| 203 | } | ||
| 204 | 33951 | ++count_it->second; | |
| 205 | } | ||
| 206 | } | ||
| 207 | 35736 | return {}; | |
| 208 | } | ||
| 209 | |||
| 210 | /// Rejects a non-lowercase or repeated key and charges it against the per-section key cap. | ||
| 211 | 71705 | [[nodiscard]] Result<void> register_key( | |
| 212 | std::string_view key, | ||
| 213 | const GrammarLimits &limits, | ||
| 214 | const char *context, | ||
| 215 | std::unordered_set<std::string> &seen_keys, | ||
| 216 | std::size_t &key_count | ||
| 217 | ) | ||
| 218 | { | ||
| 219 | 71705 | std::string folded_key = to_lower(key); | |
| 220 | 71705 | if (key != folded_key) | |
| 221 | { | ||
| 222 | 1 | return fail(ErrorCode::MalformedLine, context); | |
| 223 | } | ||
| 224 | 71704 | if (key_count >= limits.max_keys_per_section) | |
| 225 | { | ||
| 226 | 2 | return fail(ErrorCode::SizeTooLarge, context); | |
| 227 | } | ||
| 228 | 71702 | if (!seen_keys.insert(std::move(folded_key)).second) | |
| 229 | { | ||
| 230 | 1 | return fail(ErrorCode::ManifestIdentityCollision, context); | |
| 231 | } | ||
| 232 | 71676 | ++key_count; | |
| 233 | 71676 | return {}; | |
| 234 | 71705 | } | |
| 235 | |||
| 236 | /** | ||
| 237 | * @brief Rejects unsafe heredoc framing and charges a key value against the field and aggregate caps. | ||
| 238 | * @details On entry, `pos` indexes the end of the value line. A heredoc value advances `pos` past its | ||
| 239 | * terminator line, and any other value leaves it unchanged. | ||
| 240 | */ | ||
| 241 | 71676 | [[nodiscard]] Result<void> consume_value( | |
| 242 | std::string_view text, | ||
| 243 | std::string_view value, | ||
| 244 | const GrammarLimits &limits, | ||
| 245 | const char *context, | ||
| 246 | std::size_t &pos, | ||
| 247 | std::size_t &total_bytes | ||
| 248 | ) noexcept | ||
| 249 | { | ||
| 250 | 71676 | if (value.starts_with("<<<")) | |
| 251 | { | ||
| 252 | // A heredoc runs until a line whose trailing-trimmed form EQUALS the tag (case-sensitive, matching the | ||
| 253 | // case-sensitive store). An unterminated block would absorb every record below it into this value. | ||
| 254 | 28 | const std::string_view tag = value.substr(3); | |
| 255 | // An empty tag terminates differently here and in the backend: the backend's terminator trim never | ||
| 256 | // removes a line's first character, so a whitespace-only line closes the block for this pass but not | ||
| 257 | // for the store, desynchronizing every section below. The checked serializer never emits a tagless | ||
| 258 | // heredoc, so rejection is round-trip safe. | ||
| 259 | 28 | if (tag.empty()) | |
| 260 | { | ||
| 261 | 2 | return fail(ErrorCode::ManifestFramingUnsafe, context); | |
| 262 | } | ||
| 263 | 26 | pos = skip_newline(text, pos); | |
| 264 | 26 | std::size_t body_bytes = 0; | |
| 265 | 26 | bool has_body_line = false; | |
| 266 | 26 | bool closed = false; | |
| 267 | 72 | while (pos < text.size()) | |
| 268 | { | ||
| 269 | 70 | const std::size_t body_start = pos; | |
| 270 | 70 | const std::size_t body_end = line_end_from(text, pos); | |
| 271 | 70 | const std::string_view body_line = text.substr(body_start, body_end - body_start); | |
| 272 | 70 | pos = skip_newline(text, body_end); | |
| 273 | 70 | if (rtrim(body_line) == tag) | |
| 274 | { | ||
| 275 | // A terminator as the first body line is not an empty value in the backend: its | ||
| 276 | // LoadMultiLineText leaves the value cursor on that line and restores the line break it | ||
| 277 | // NUL-tested, so the store loads the tag line plus every byte up to the next NUL the parser | ||
| 278 | // writes. This pass models that content as empty and never charges it against the caps, and | ||
| 279 | // the content can carry a raw `\r` or a leading `<<<` past the shared validator. An empty | ||
| 280 | // value is emitted raw, never as a heredoc, so rejection is round-trip safe. A blank body line | ||
| 281 | // before the terminator is a genuinely empty value in both tokenizers and stays accepted. | ||
| 282 | 23 | if (!has_body_line) | |
| 283 | { | ||
| 284 | 4 | return fail(ErrorCode::ManifestFramingUnsafe, context); | |
| 285 | } | ||
| 286 | 19 | closed = true; | |
| 287 | 19 | break; | |
| 288 | } | ||
| 289 | 94 | if ((has_body_line && !consume_bytes(1, limits.max_field_bytes, body_bytes)) || | |
| 290 | 47 | !consume_bytes(body_line.size(), limits.max_field_bytes, body_bytes)) | |
| 291 | { | ||
| 292 | 1 | return fail(ErrorCode::SizeTooLarge, context); | |
| 293 | } | ||
| 294 | 46 | has_body_line = true; | |
| 295 | } | ||
| 296 | 21 | if (!closed) | |
| 297 | { | ||
| 298 | 2 | return fail(ErrorCode::ManifestFramingUnsafe, context); | |
| 299 | } | ||
| 300 | 19 | if (!consume_bytes(body_bytes, limits.max_total_decoded_bytes, total_bytes)) | |
| 301 | { | ||
| 302 | ✗ | return fail(ErrorCode::SizeTooLarge, context); | |
| 303 | } | ||
| 304 | } | ||
| 305 | else | ||
| 306 | { | ||
| 307 | 71648 | if (value.size() > limits.max_field_bytes) | |
| 308 | { | ||
| 309 | 2 | return fail(ErrorCode::SizeTooLarge, context); | |
| 310 | } | ||
| 311 | 71646 | if (!consume_bytes(value.size(), limits.max_total_decoded_bytes, total_bytes)) | |
| 312 | { | ||
| 313 | 2 | return fail(ErrorCode::SizeTooLarge, context); | |
| 314 | } | ||
| 315 | } | ||
| 316 | 71663 | return {}; | |
| 317 | } | ||
| 318 | } // namespace | ||
| 319 | |||
| 320 | 316 | Result<void> validate_manifest_grammar(std::string_view text, const GrammarLimits &limits, const char *context) | |
| 321 | { | ||
| 322 | 316 | if (text.size() > limits.max_file_bytes) | |
| 323 | { | ||
| 324 | 2 | return fail(ErrorCode::SizeTooLarge, context); | |
| 325 | } | ||
| 326 | // The backend strips a leading UTF-8 BOM before it tokenizes, so the store and this pass read a BOM-prefixed | ||
| 327 | // first line differently. The checked serializer never emits a BOM, so rejection is round-trip safe. | ||
| 328 | 314 | if (text.starts_with("\xEF\xBB\xBF")) | |
| 329 | { | ||
| 330 | 1 | return fail(ErrorCode::MalformedLine, context); | |
| 331 | } | ||
| 332 | // The backend's tokenizer is NUL-terminated and stops at the first '\0'. Without this check, this pass | ||
| 333 | // validates the bytes after that NUL, which the store never loads, and a record vanishes without an error. | ||
| 334 | // The checked serializer never emits a NUL, so rejection is round-trip safe. | ||
| 335 | 313 | if (text.find('\0') != std::string_view::npos) | |
| 336 | { | ||
| 337 | 1 | return fail(ErrorCode::MalformedLine, context); | |
| 338 | } | ||
| 339 | |||
| 340 | 312 | std::unordered_set<std::string> seen_sections; | |
| 341 | 312 | std::unordered_set<std::string> seen_keys; // reset on each section | |
| 342 | 312 | std::unordered_map<std::string, std::size_t> rung_counts; | |
| 343 | 312 | std::size_t section_count = 0; | |
| 344 | 312 | std::size_t key_count = 0; | |
| 345 | 312 | std::size_t record_count = 0; | |
| 346 | 312 | std::size_t total_bytes = 0; | |
| 347 | |||
| 348 | 312 | const std::size_t size = text.size(); | |
| 349 | 312 | std::size_t pos = 0; | |
| 350 | |||
| 351 | 107713 | while (pos < size) | |
| 352 | { | ||
| 353 | // One whitespace run absorbs leading indentation, blank lines, and every line terminator. | ||
| 354 | 217819 | while (pos < size && is_space(text[pos])) | |
| 355 | { | ||
| 356 | 110109 | ++pos; | |
| 357 | } | ||
| 358 | 107710 | if (pos >= size) | |
| 359 | { | ||
| 360 | 217 | break; | |
| 361 | } | ||
| 362 | |||
| 363 | 107493 | if (text[pos] == ';' || text[pos] == '#') | |
| 364 | { | ||
| 365 | 2 | pos = line_end_from(text, pos); | |
| 366 | 35738 | continue; | |
| 367 | } | ||
| 368 | |||
| 369 | 107491 | if (text[pos] == '[') | |
| 370 | { | ||
| 371 | 35777 | ++pos; | |
| 372 | 35780 | while (pos < size && is_space(text[pos])) | |
| 373 | { | ||
| 374 | 3 | ++pos; | |
| 375 | } | ||
| 376 | 35777 | const std::size_t name_start = pos; | |
| 377 | 586904 | while (pos < size && text[pos] != ']' && !is_newline(text[pos])) | |
| 378 | { | ||
| 379 | 551127 | ++pos; | |
| 380 | } | ||
| 381 | 35777 | if (pos >= size || text[pos] != ']') | |
| 382 | { | ||
| 383 | // The backend does not discard a line with no closing bracket. FindEntry points its section cursor | ||
| 384 | // at the name before it tests for `]`. On the miss, it resumes the scan and leaves that cursor set. | ||
| 385 | // The next key line's NUL terminator then folds the unterminated name and an embedded newline into | ||
| 386 | // a section identity that this pass never validates. A `sig.`-prefixed record can then reach the | ||
| 387 | // store past every collision, prefix, and size check. A canonical manifest never opens a bracket | ||
| 388 | // that it does not close, so this pass fails closed. | ||
| 389 | 1 | return fail(ErrorCode::MalformedLine, context); | |
| 390 | } | ||
| 391 | 35776 | const std::string_view name = rtrim(text.substr(name_start, pos - name_start)); | |
| 392 | 35776 | pos = line_end_from(text, pos); | |
| 393 | |||
| 394 | 35776 | DMK_TRY_VOID(register_section(name, limits, context, seen_sections, section_count)); | |
| 395 | 35744 | DMK_TRY_VOID(charge_signature_section(name, limits, context, rung_counts, record_count)); | |
| 396 | 35736 | seen_keys.clear(); | |
| 397 | 35736 | key_count = 0; | |
| 398 | 35736 | continue; | |
| 399 | 35736 | } | |
| 400 | |||
| 401 | // Any other line is a key line, and its key runs to the first `=` or newline. A key before the first header | ||
| 402 | // lands in the backend's implicit default section, which parse() never reads, so this pass fails closed. | ||
| 403 | 71714 | if (section_count == 0) | |
| 404 | { | ||
| 405 | 1 | return fail(ErrorCode::MalformedLine, context); | |
| 406 | } | ||
| 407 | 71713 | const std::size_t key_start = pos; | |
| 408 | 547965 | while (pos < size && text[pos] != '=' && !is_newline(text[pos])) | |
| 409 | { | ||
| 410 | 476252 | ++pos; | |
| 411 | } | ||
| 412 | 71713 | if (pos >= size || text[pos] != '=') | |
| 413 | { | ||
| 414 | // The backend discards a noncomment line with no `=`. A dropped separator can restore a default value. | ||
| 415 | 4 | return fail(ErrorCode::MalformedLine, context); | |
| 416 | } | ||
| 417 | 71709 | if (pos == key_start) | |
| 418 | { | ||
| 419 | // The backend discards an empty key without entry into heredoc mode. | ||
| 420 | 4 | return fail(ErrorCode::MalformedLine, context); | |
| 421 | } | ||
| 422 | 71705 | const std::string_view key = rtrim(text.substr(key_start, pos - key_start)); | |
| 423 | 71705 | ++pos; | |
| 424 | 143410 | while (pos < size && !is_newline(text[pos]) && (text[pos] == ' ' || text[pos] == '\t')) | |
| 425 | { | ||
| 426 | 71705 | ++pos; | |
| 427 | } | ||
| 428 | 71705 | const std::size_t value_start = pos; | |
| 429 | 71705 | const std::size_t value_end = line_end_from(text, pos); | |
| 430 | 71705 | const std::string_view value = rtrim(text.substr(value_start, value_end - value_start)); | |
| 431 | 71705 | pos = value_end; | |
| 432 | |||
| 433 | 71705 | DMK_TRY_VOID(register_key(key, limits, context, seen_keys, key_count)); | |
| 434 | 71676 | DMK_TRY_VOID(consume_value(text, value, limits, context, pos, total_bytes)); | |
| 435 | } | ||
| 436 | 220 | return {}; | |
| 437 | 394 | } | |
| 438 | } // namespace DetourModKit::manifest::detail | ||
| 439 |