mirror of
https://github.com/fluffos/fluffos
synced 2026-08-12 18:26:06 -04:00
Add AFL++ fuzzing harnesses; fix 5 pre-existing memory-safety/DoS bugs found by them, plus a Coverity-flagged ownership-transfer hardening (#1317)
* Add opt-in AFL++ harness for restore_variable()/save_variable()
Investigating a Discord report of printf("%O") returning corrupted data
after a driver update. Manual round-trip testing (lpcshell, ASan+UBSan,
the LPC testsuite) found no reproduction, so add a fuzzing harness to dig
further: src/main_fuzz_restore.cc, gated behind a new BUILD_FUZZERS CMake
option (default OFF, no effect on normal builds).
The harness boots the VM once and uses AFL's deferred fork server so each
test case forks cheaply post-boot. It feeds a SEQUENCE of restore_variable()
calls per exec (input split on a delimiter), not just one -- the known bug
class in this code path (AGENTS.md section 13 point 4) is a restore's
error() path leaving file-scope scratch state (save_svalue_depth, sizes[])
dirty for the NEXT restore in the same process, which a single-call-per-exec
harness structurally cannot find.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* Fix two memory-safety bugs in restore_variable() found by AFL fuzzing
Both are pre-existing (confirmed present at ec9b6a4, predating this
branch's whole history) and unrelated to the printf("%O") corruption
report that started this investigation, but real and reachable from
untrusted save-data (restore_variable()/restore_object(), including
raw network save-data per this file's own comments).
1. restore_array()/restore_mapping()/restore_class() (object.cc) recurse
in lockstep with a one-shot sizing pre-pass that populates the
file-scope sizes[] scratch buffer. The pre-pass and the real parse are
two independent hand-written parsers over the same untrusted bytes and
can disagree: parse_numeric() silently treats whatever byte follows a
numeral as an already-consumed delimiter without validating it, so a
numeral directly followed by a stray "({" makes the real parse believe
it has entered a nested container the sizing pass never saw. That read
sizes[save_svalue_depth - 1] off the end of (or into a null) sizes[] --
SIGSEGV / UBSan null-pointer-load, minimized by afl-tmin to 10 bytes.
Fixed with a bounds-checked accessor that fails the restore cleanly
instead.
2. restore_hash_string() (mapping.cc, mapping keys) and
restore_interior_string() (object.cc, array/class elements and mapping
values) each have an escaped-string continuation loop that didn't stop
on '\0', only '"' -- an unterminated escape sequence (a '\' followed by
one byte, then no closing quote before the buffer ends) read past the
end of the allocation (AddressSanitizer: stack-buffer-overflow,
minimized to 9-10 bytes each). restore_string() (the sibling used by
restore_object() for a whole save-file) already had the correct
`!= '"' && c` guard; the two efun-facing parsers were just missing it.
Verified via AFL++ fuzzing restore_variable() (harness added in a prior
commit), with reproducers minimized down and confirmed to crash the
pre-fix binary and pass cleanly post-fix. Full LPC testsuite green on
both Debug+ASan/UBSan (2x) and RelWithDebInfo.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* Add opt-in AFL++ harness for the LPC compiler front-end
Sibling of the main_fuzz_restore.cc harness added earlier: an AFL++
deferred-fork-server harness targeting the compiler (lexer + preprocessor
+ parser + codegen) via load_object_from_source(), the same in-memory
"restart pattern" lpcshell uses. Gated behind the existing BUILD_FUZZERS
CMake option (default OFF, no effect on normal builds).
Like the restore harness, it feeds a SEQUENCE of source texts per exec
(input split on a delimiter), not just one -- AGENTS.md section 11 is
explicit that the compiler keeps per-compile scratch state at module scope
(the scratchpad arena, macro/predefine tables, expansion_frames/
live_expansion_stack/live_guard_counts, the diagnostics stream) and every
one of it must be re-initialized per compile. A single-compile-per-exec
harness could only find a bug that crashes during one compile; it could
never find one compile's error path leaving that state dirty for the next
compile in the same process.
Deliberately does not run create()/__INIT (callcreate=0): the target is
the compiler surface specifically, matching lpcc (which also only
compiles and dumps, never executes), not the VM/interpreter surface
main_fuzz_restore.cc and the LPC testsuite already cover.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* Fix compiler hang on source with an embedded NUL byte
Found by the new AFL++ compiler harness (fuzz_compile), minimized to 2
bytes: any non-NUL byte followed by a NUL. Pre-existing (confirmed present
at ec9b6a4, predating this branch's whole history) and reachable through
completely ordinary compilation -- any .lpc file on disk containing a raw
NUL (a stray upload, a bad editor, a crafted mudlib file) hangs the driver
on an ordinary load_object(), and the same in-memory source API lpcshell/
lpcc -e/load_object_from_source() use is exposed just as directly.
Root cause: the Flex-generated scanner's YY_END_OF_BUFFER handling
special-cases a NUL byte that isn't at the two-sentinel end-of-buffer
position (yy_try_NUL_trans / yy_get_previous_state, lexer.autogen.cc, both
untouched, unmodified Flex-generated boilerplate). For certain surrounding
byte patterns this leaves yy_get_previous_state()'s bounding pointer
(yy_c_buf_p) effectively unbounded, so its scan loop never returns --
confirmed with gdb: repeated stack samples sat inside that one loop at
increasing depth, not cycling through yylex() being re-entered.
LPC source is text; a real program never legitimately contains a raw NUL.
Rather than trying to make the generated scanner itself robust to one (a
change to sensitive, regenerated Flex machinery with no quick way to gain
confidence in a fix), reject it at the two places source bytes actually
enter the compiler: scratch_slurp_fd_prepared() (on-disk -- the main file
and every #include'd file share this one reader) and start_new_file()
(in-memory source). Both reuse the read-error contract every caller
already handles cleanly, turning the hang into an ordinary catchable
compile error.
Verified: the minimized reproducer, and all 85 hangs the fuzzing campaign
saved, no longer time out; the new regression test genuinely hangs the
pre-fix ec9b6a4 driver (confirmed via a ported build, 15s and counting)
and passes cleanly post-fix. Full LPC testsuite green on Debug+ASan/UBSan
(2x) and RelWithDebInfo.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* Fix C-stack overflow in lpc_tree_form(): a fourth uncapped tree walker
Found by AFL++ fuzzing the compiler front-end (SIGSEGV, deep recursive
backtrace through lpc_tree_form()<->lpc_tree_expr(), generate.cc). Root
cause: these two mutually-recursive functions walk the parse tree with
plain recursion and no depth cap -- the same shape as optimize()
(generate.cc) and i_generate_node() (icode.cc), both already capped at
kMax*Depth=500, and ast_json() (generate.cc), capped the same way when it
was identified as a THIRD sibling walker missing the guard. lpc_tree_form()
is a fourth: it backs the DEBUG-only `tree`/`__TREE__` keyword's parse-tree
pretty-printer (grammar_rules.cc's rule_tree_block()/rule_tree_expr(),
reachable from ordinary LPC source on any Debug build -- CMAKE_BUILD_TYPE=
Debug forces DEBUG=ON per src/CMakeLists.txt, and Debug configs are half
of this project's CI matrix), and runs mid-parse, before optimize() or
constant-folding ever gets a chance to touch the tree, so a deeply
left-nested expression chain (e.g. thousands of `+` terms) reaches it at
full, unreduced depth.
Fix mirrors ast_json()'s existing guard exactly: a depth counter capped at
500, and going over renders the offending subtree as a placeholder leaf
(matching the existing `!expr` base case) instead of failing anything --
this is diagnostic-only output, so silently truncating a pathologically
deep case is the same tradeoff its three siblings already make.
Verified: the two crash inputs AFL saved no longer crash the fixed
fuzz_compile harness; both were confirmed to crash a build of ec9b6a4
(predating this whole branch) too, so this is pre-existing, not a
regression. Extended deep_expr_recursion.lpc (the existing regression file
for the sibling optimize()/i_generate_node() depth caps) with a `__TREE__`
case built from the same deep chain generator; it segfaults the unfixed
ec9b6a4 driver and passes cleanly with the fix. Full LPC testsuite green
on Debug+ASan/UBSan (2x) and RelWithDebInfo.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* Fix scope-state corruption from a rejected typed-anon-func literal
Found by AFL++ fuzzing the compiler front-end (fatal(): "pop_n_locals
called with num < 0", minimized to 22 bytes). rule_lambda_return_type()
(the "open" half of parsing `TYPE (args) { block }` anonymous-function
literals) reports a clean yyerror() when TYPE isn't `function`, but then
fell straight through into its side effects regardless: pushing a function
context and swapping current_number_of_locals/locals_ptr aside to start a
fresh nested scope for the (about to be malformed) arg list and body.
The matching "close", rule_primary_expr_anon_func(), restores that
swapped-aside state -- but only runs if the grammar production reduces
all the way through. If the malformed input also hits a genuine
Bison-level syntax error (as opposed to parsing to a clean, if
semantically-invalid, finish), the parser's `error ';' statements`
recovery abandons that production before rule_primary_expr_anon_func()
ever runs, so the "open" half's state swap is never undone.
current_number_of_locals is left corrupted mid-block; when the ENCLOSING
block later closes, rule_block()'s own local count (a diff against an
entry snapshot) goes negative, and pop_n_locals() hits its "num < 0"
invariant.
That invariant is a DEBUG_CHECK (base/internal/log.h), which compiles to
a clean fatal() only on a Debug build (CMAKE_BUILD_TYPE=Debug forces the
project's own DEBUG flag on, per src/CMakeLists.txt) -- on a
RelWithDebInfo build, DEBUG_CHECK is a no-op entirely, so pop_n_locals()
proceeds with num<0 and walks current_number_of_locals/locals_ptr in the
wrong direction: confirmed as a real SIGSEGV on this branch's actual
RelWithDebInfo build, from nothing more than an ordinary syntax error.
Fix mirrors the established remedy for a soft-skipped push needing its
paired pop to skip in step (AGENTS.md section 13 point 13): a new
func_block_t.opened flag records whether rule_lambda_return_type()
actually pushed a scope; rule_primary_expr_anon_func() checks it and
skips its restore entirely (producing a CREATE_ERROR() placeholder
instead) when false, mirroring the skip on both sides regardless of how
parsing proceeds afterward.
Verified: the fuzzer-saved crash and its 22-byte minimization no longer
crash the fixed fuzz_compile harness; both reproduce (fatal() on Debug,
SIGSEGV on RelWithDebInfo) against a build of ec9b6a4, predating this
whole branch, so this is pre-existing, not a regression. New regression
test (testsuite/single/tests/compiler/anon_func_bad_type_scope_leak.lpc)
reproduces the exact minimized input, plus confirms a normal compile
right after still succeeds and that legitimate typed anonymous-function
literals still work correctly (not just avoid crashing). Full LPC
testsuite green on Debug+ASan/UBSan (2x) and RelWithDebInfo.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* Fix the embedded-NUL compiler hang at its root, in the scanner's own DFA
Replaces the interim fix (96a67ca), which rejected an embedded NUL by
memchr()ing the ENTIRE source buffer on every compile -- main file and
every #include -- an unconditional cost paid 100% of the time to guard
against a byte legitimate LPC source never contains. The scanner itself
now handles the byte; NUL-free compiles pay nothing at all.
Root cause, pinned exactly this time (gdb against the pre-fix binary):
the hang is a flex SKELETON defect in interior-NUL handling, not
anything lexer.l did. yylex() scans an in-buffer NUL through its
end-of-buffer machinery; when yy_try_NUL_trans() finds no NUL transition
from the state preceding the NUL, the skeleton falls back to
yy_last_accepting_state -- which the forward scan has just clobbered
with the end-of-buffer PSEUDO-accepting state (accept value
YY_END_OF_BUFFER) -- and yy_get_previous_state()'s re-walk cannot repair
it for a 0-2 character token prefix, because it only records states
BEFORE each prefix character. The scanner then re-enters the same
YY_END_OF_BUFFER action with identical pointers, forever: a closed loop,
confirmed by identical state/pointer values on every sampled iteration
under gdb.
The escape hatch is the one flex itself provides: if a RULE can match
the NUL, yy_try_NUL_trans() succeeds and the loop is never entered. So:
* lexer.l gains one first-position rule, <*>(.|\n)?(.|\n)?\x00, that
reports "Illegal embedded NUL byte (0x00) in source" as an ordinary
recoverable lex error. The two optional arms give every 0-, 1- and
2-character token prefix a NUL transition -- provably the complete
dangerous set: any longer prefix contains an accepting interior state
the re-walk does record (every single character starts an accepting
state in every start condition except a backslash in the
string/template/char escape states, and after that backslash every
two-character escape prefix is accepted by an escape rule), so the
fallback makes forward progress instead of looping. The pattern must
end in the NUL, so it can never match -- and never costs anything
on -- NUL-free input.
* Every negated character class ([^...] runs in strings, templates,
block/line comments, dead #if branches, char bodies) now excludes
\x00, so a NUL is reported everywhere instead of silently absorbed
into a string constant or comment.
* The preprocessor-directive rule deliberately keeps its whole-line
capture (NUL included, for intact diagnostics) and
lpc_lex_on_directive() rejects the byte there -- a check over one
captured '#' line, not the whole file.
* The two whole-file memchr() pre-scans and the start_new_file() bool
plumbing from 96a67ca are removed.
lexer.autogen.cc is regenerated from lexer.l by the pinned toolchain
(flex 2.6.4, matching the committed pin); the diff is exactly the
expected shape -- one new rule (YY_NUM_RULES 126 -> 127), the added
NUL-transition states (225 -> 263), and the resulting table renumbering.
This is also strictly more robust than the pre-scan: the scanner is now
NUL-proof no matter how bytes reach it, instead of relying on every
entry path remembering to validate first. One legacy behavior is kept
deliberately: the raw readers (heredoc bodies, macro-argument
collection, reading through lpc_lex_getc()) still treat NUL as
end-of-input, which surfaces as their ordinary "End of file in ..."
errors.
Verified: the minimized 2-byte reproducer and all 85 hangs saved by the
AFL++ campaigns now produce a clean error instantly; a brute-force sweep
of 9,231 NUL-containing short inputs (every 2-byte pair over all 256
bytes, every 3-byte combination over a 48-character structural alphabet,
and 25 lexical-context prefixes -- string/template/char/comment/heredoc/
directive/macro-call, including the \x, \u and surrogate-pair escape
prefixes -- crossed with every NUL-containing 2-byte suffix) shows no
hang and no crash, while a sample of the same sweep still hangs the
pre-fix ec9b6a4 build; the full 3,046-file AFL queue corpus (general
inputs, not just NUL ones) is clean against the regenerated scanner, and
a fresh 800-second AFL++ campaign against it (seeded with the old queue,
the old hang set, and the NUL probes) found no crash and no hang. The
extended regression test (10 NUL placements: token start, 1- and 2-char
prefixes, string/comment/escape/directive/dead-branch/heredoc bodies)
hangs the pre-fix ec9b6a4 driver and passes in milliseconds post-fix.
Full LPC testsuite green on Debug+ASan/UBSan (2x) and RelWithDebInfo.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5SmaNsmYdYgi8VGe1rJe1
* Harden add_mapping_malloced_string() ownership transfer against Coverity CID 1664027
Coverity flagged a RESOURCE_LEAK in mudlib_error_handler() (simulate.cc):
add_mapping_malloced_string(m, "program", add_slash(...)) passes a fresh
malloc'd string by raw pointer, and add_mapping_malloced_string() only
takes ownership of it once insert_in_mapping() succeeds -- if that insert
error()s (mapping_too_large(), OOM) before storing the pointer, the
allocation is never freed on the unwind. On inspection this call site was
already guarded: it held the allocation in a std::unique_ptr and only
.release()'d it after add_mapping_malloced_string() returned, so the
report itself was a false positive on the code as written.
But the pattern behind it was the real problem. That guard was
caller-side, manually written, and had to be reproduced correctly at
every call site passing a malloc'd string into this function -- easy to
get right once and forget elsewhere. It's also backwards: the function
that can throw (insert_in_mapping(), via find_for_insert()) is
add_mapping_malloced_string() itself, so the ownership-transfer logic
belongs inside it, not duplicated by every caller (AGENTS.md section 4).
Fix: add_mapping_malloced_string() now takes ownership of `value` itself
via a local unique_ptr for the duration of the call, releasing it only
after the insert succeeds and the pointer is stored in the mapping. Every
caller already treats the pointer as consumed by this call (matching
the new_string()/FREE_MSTR malloced-string convention), so this changes
no caller contract -- it just makes the existing contract safe against
the throwing path by construction instead of by caller discipline.
mudlib_error_handler()'s two manual unique_ptr+release() wrappers are
removed as now-redundant.
As a side effect this also protects four call sites in trace.cc (lines
249, 252, 310, 313) that pass add_slash()'s allocation straight through
with no wrapper at all -- the identical latent leak-on-throw pattern,
just never reported because Coverity's model didn't trace that path.
They needed no changes; they're safe now purely because the callee is.
Verified: rebuilt Debug+ASan/UBSan and RelWithDebInfo. Full LPC testsuite
green on all of Debug+ASan/UBSan x3 (randomized file order each run) and
RelWithDebInfo x1 -- no new failures, no "Bad ref count" reports from the
post-file check_memory() sweep. testsuite/single/tests/efuns/
error_handler_mapping_size.lpc already exercises this exact code path
(shrinks __MAX_MAPPING_SIZE__ below what mudlib_error_handler()'s own
diagnostic mapping needs, then triggers an error) and passed clean on
every run.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5SmaNsmYdYgi8VGe1rJe1
---------
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
5db89572a2
commit
b1fb96f381
17 changed files with 3614 additions and 2334 deletions
|
|
@ -3,6 +3,7 @@ option(MARCH_NATIVE "use march=native for GCC" ON)
|
|||
option(STATIC "Build static version of driver" OFF)
|
||||
option(USE_JEMALLOC "Build driver with jemalloc support" ON)
|
||||
option(ENABLE_SANITIZER "Build driver with sanitizer support" OFF)
|
||||
option(BUILD_FUZZERS "Build AFL++/libFuzzer investigation harnesses (not shipped functionality)" OFF)
|
||||
# Packages
|
||||
option(PACKAGE_ASYNC "async package" ON)
|
||||
option(PACKAGE_COMPRESS "compress package" ON)
|
||||
|
|
@ -850,6 +851,14 @@ else ()
|
|||
add_executable(generate_keywords "main_generate_keywords.cc")
|
||||
target_link_libraries(generate_keywords PUBLIC ${FLUFFOS_LINK})
|
||||
|
||||
if (BUILD_FUZZERS)
|
||||
add_executable(fuzz_restore "main_fuzz_restore.cc")
|
||||
target_link_libraries(fuzz_restore PUBLIC ${FLUFFOS_LINK})
|
||||
|
||||
add_executable(fuzz_compile "main_fuzz_compile.cc")
|
||||
target_link_libraries(fuzz_compile PUBLIC ${FLUFFOS_LINK})
|
||||
endif ()
|
||||
|
||||
add_executable(o2json "main_o2json.cc")
|
||||
target_link_libraries(o2json PUBLIC ${FLUFFOS_LINK})
|
||||
|
||||
|
|
|
|||
|
|
@ -828,6 +828,20 @@ void dump_program_ast_json(const char* filename, parse_node_t* tree_main,
|
|||
envelope.dump(-1, ' ', false, nlohmann::json::error_handler_t::replace).c_str());
|
||||
}
|
||||
|
||||
namespace {
|
||||
// Mirrors ast_json()'s guard (see above): lpc_tree_form()/lpc_tree_expr()
|
||||
// are a FOURTH mutually-recursive walker over the same parse-tree shape as
|
||||
// optimize()/i_generate_node()/ast_json(), backing the DEBUG-only `tree`
|
||||
// keyword's pretty-printer (grammar_rules.cc's rule_tree_block()/
|
||||
// rule_tree_expr()) -- found missing its own cap by AFL++ fuzzing the
|
||||
// compiler (a deeply-nested expression inside `tree(...)` C-stack-overflows
|
||||
// here). Diagnostic output only, so going over just renders the subtree as
|
||||
// a placeholder leaf instead of failing anything -- same tradeoff ast_json()
|
||||
// makes, for the same reason (github.com/fluffos/fluffos/issues/1267).
|
||||
int g_lpc_tree_depth = 0;
|
||||
constexpr int kMaxLpcTreeDepth = 500;
|
||||
} // namespace
|
||||
|
||||
void lpc_tree_form(parse_node_t* expr, parse_node_t* dest) {
|
||||
if (!expr) {
|
||||
dest->kind = NODE_NUMBER;
|
||||
|
|
@ -835,6 +849,14 @@ void lpc_tree_form(parse_node_t* expr, parse_node_t* dest) {
|
|||
dest->v.number = 0;
|
||||
return;
|
||||
}
|
||||
if (++g_lpc_tree_depth > kMaxLpcTreeDepth) {
|
||||
--g_lpc_tree_depth;
|
||||
dest->kind = NODE_NUMBER;
|
||||
dest->type = TYPE_ANY;
|
||||
dest->v.number = 0;
|
||||
return;
|
||||
}
|
||||
DEFER { --g_lpc_tree_depth; };
|
||||
|
||||
switch (expr->kind) {
|
||||
case NODE_TERNARY_OP:
|
||||
|
|
|
|||
|
|
@ -21,6 +21,13 @@ typedef struct {
|
|||
int context;
|
||||
int save_current_type;
|
||||
int save_exact_types;
|
||||
// False when rule_lambda_return_type() rejected the type (a non-`function`
|
||||
// reserved type name) and skipped pushing a fresh nested local-variable
|
||||
// scope entirely -- see the .cc for why. rule_primary_expr_anon_func()
|
||||
// must check this and skip its matching restore/pop when false, or it
|
||||
// reads uninitialized fields above and desyncs current_number_of_locals
|
||||
// against the enclosing block's own bookkeeping.
|
||||
bool opened;
|
||||
} func_block_t;
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -776,12 +776,34 @@ void rule_primary_expr_index(parse_node_t** result, parse_node_t* expr, parse_no
|
|||
|
||||
void rule_lambda_return_type(func_block_t* saved_block, LPC_INT type) {
|
||||
auto max_local_variables = CFG_INT(__MAX_LOCAL_VARIABLES__);
|
||||
if (type != TYPE_FUNCTION) yyerror("Reserved type name unexpected.");
|
||||
if (type != TYPE_FUNCTION) {
|
||||
yyerror("Reserved type name unexpected.");
|
||||
// Do NOT push a fresh nested local-variable scope for a rejected type:
|
||||
// the grammar still reduces `argument ')' block` normally afterward
|
||||
// (yyerror() is a diagnostic, not a parse abort), and rule_primary_
|
||||
// expr_anon_func() is the matching "close" that restores state from
|
||||
// this saved_block -- but if the input also hits a genuine Bison-level
|
||||
// syntax error partway through the arg list or body (as opposed to
|
||||
// parsing to a clean, if semantically-invalid, finish), error recovery
|
||||
// can abandon that production before rule_primary_expr_anon_func() ever
|
||||
// runs. An unconditional push here would then never be undone, leaving
|
||||
// current_number_of_locals stuck at 0 mid-block -- the ENCLOSING
|
||||
// block's own entry_locals diff (rule_block()) goes negative on close,
|
||||
// hitting pop_n_locals()'s "num < 0" invariant: a clean fatal() in a
|
||||
// Debug build, but a silent out-of-bounds walk in a build where
|
||||
// DEBUG_CHECK is compiled out (RelWithDebInfo -- this reproduced as a
|
||||
// real SIGSEGV there). Found by AFL++ fuzzing the compiler front-end.
|
||||
// opened=false tells rule_primary_expr_anon_func() to skip its restore
|
||||
// entirely and mirror this skip, whichever way parsing goes from here.
|
||||
saved_block->opened = false;
|
||||
return;
|
||||
}
|
||||
saved_block->num_local = current_number_of_locals;
|
||||
saved_block->max_num_locals = max_num_locals;
|
||||
saved_block->context = context;
|
||||
saved_block->save_current_type = current_type;
|
||||
saved_block->save_exact_types = exact_types;
|
||||
saved_block->opened = true;
|
||||
if (type_of_locals_ptr + max_num_locals + max_local_variables >=
|
||||
&type_of_locals[type_of_locals_size])
|
||||
reallocate_locals();
|
||||
|
|
@ -797,6 +819,18 @@ void rule_lambda_return_type(func_block_t* saved_block, LPC_INT type) {
|
|||
|
||||
void rule_primary_expr_anon_func(parse_node_t** result, func_block_t* saved_block, argument_t* arg,
|
||||
decl_t* block) {
|
||||
if (!saved_block->opened) {
|
||||
// Mirrors rule_lambda_return_type()'s skip: the return type was already
|
||||
// rejected there (a compile error already reported, so this whole
|
||||
// expression is going nowhere) and no nested scope was pushed for the
|
||||
// arg list / block that just parsed -- they landed directly in the
|
||||
// ENCLOSING scope instead. Don't touch current_number_of_locals/
|
||||
// locals_ptr/the function-context stack here: there is nothing this
|
||||
// function opened to restore or pop, and saved_block's other fields
|
||||
// were never populated. Produce a harmless placeholder and stop.
|
||||
CREATE_ERROR(*result);
|
||||
return;
|
||||
}
|
||||
if (arg->flags & ARG_IS_VARARGS) {
|
||||
yyerror("Anonymous varargs functions aren't implemented");
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -229,7 +229,39 @@ WS_NO_NL [ \t\r\v\f]
|
|||
|
||||
%%
|
||||
|
||||
|
||||
/* Embedded NUL bytes: LPC source is text -- a raw 0x00 anywhere in the
|
||||
* input is corrupt or crafted, never legitimate. It also cannot be left
|
||||
* to the ordinary rules: Flex scans an in-buffer NUL through its
|
||||
* end-of-buffer machinery (yy_try_NUL_trans/yy_get_previous_state), and
|
||||
* when the DFA state preceding the NUL has no NUL transition that
|
||||
* machinery falls back to yy_last_accepting_state -- which the forward
|
||||
* scan has just clobbered with the end-of-buffer PSEUDO-accepting state
|
||||
* (accept value YY_END_OF_BUFFER); for a 0-2 character token prefix the
|
||||
* re-walk in yy_get_previous_state() records no real accepting state
|
||||
* either (it only records states BEFORE each prefix character), so the
|
||||
* scanner re-enters the same YY_END_OF_BUFFER action with identical
|
||||
* pointers forever -- the AFL-found compiler hang, minimized to any
|
||||
* byte followed by a NUL. The fix is the one flex itself provides:
|
||||
* make sure a RULE can always match the NUL so yy_try_NUL_trans()
|
||||
* succeeds. The two optional (.|\n) arms give every 0-, 1- and
|
||||
* 2-character token prefix a NUL transition -- exactly the prefixes
|
||||
* whose re-walk can record no accepting state (any longer prefix has
|
||||
* an accepting interior state: every single character starts an
|
||||
* accepting state in every start condition except a backslash in the
|
||||
* string/template/char escape states, and after that backslash every
|
||||
* "\c" pair is accepted by an escape rule). Placed FIRST so it wins
|
||||
* every same-length tie: a NUL is reported as an error in every start
|
||||
* condition, never absorbed (the negated character classes below all
|
||||
* exclude \x00 for the same reason; only the preprocessor-directive
|
||||
* line rule deliberately still absorbs NULs so its whole-line capture
|
||||
* stays intact -- lpc_lex_on_directive() rejects those). This pattern
|
||||
* can never match NUL-free input (it must end in the NUL), so it is
|
||||
* invisible to every legitimate compile; progress is guaranteed
|
||||
* because every match consumes at least the NUL itself. */
|
||||
<*>(.|\n)?(.|\n)?\x00 {
|
||||
lpc_lex_count_newlines(yytext, yyleng);
|
||||
lexerror("Illegal embedded NUL byte (0x00) in source");
|
||||
}
|
||||
|
||||
{WS_NO_NL} { /* ignore */ }
|
||||
\n { lpc_lex_newline(yyscanner); }
|
||||
|
|
@ -242,7 +274,7 @@ WS_NO_NL [ \t\r\v\f]
|
|||
* instead of every such state needing its own copy of these six
|
||||
* rules, or hand-rolling "which state do I return to" bookkeeping. */
|
||||
"/*" { yy_push_state(SC_BLOCK_COMMENT, yyscanner); }
|
||||
"//"[^\n]* { /* line comment; trailing newline handled by the \n rule */ }
|
||||
"//"[^\n\x00]* { /* line comment; trailing newline handled by the \n rule */ }
|
||||
|
||||
/* Block comment body: matched natively, no outp access. A lone slash
|
||||
* is kept out of the ordinary-run pattern below (it gets its own rule
|
||||
|
|
@ -254,7 +286,7 @@ WS_NO_NL [ \t\r\v\f]
|
|||
<SC_BLOCK_COMMENT>"*"+"/" { yy_pop_state(yyscanner); }
|
||||
<SC_BLOCK_COMMENT>"*"+ { /* run of '*' not (yet) followed by the closing '/' */ }
|
||||
<SC_BLOCK_COMMENT>\n { lpc_lex_newline(yyscanner); }
|
||||
<SC_BLOCK_COMMENT>[^*/\n]+ { /* ordinary comment text */ }
|
||||
<SC_BLOCK_COMMENT>[^*/\n\x00]+ { /* ordinary comment text */ }
|
||||
<SC_BLOCK_COMMENT>"/" { /* lone slash, not the start of a nested comment-open */ }
|
||||
<SC_BLOCK_COMMENT><<EOF>> {
|
||||
/* Every state-specific <<EOF>> rule pops a drained pushed
|
||||
|
|
@ -343,8 +375,8 @@ WS_NO_NL [ \t\r\v\f]
|
|||
* Genuinely distinct action, not just a distinct pattern, so a plain
|
||||
* <SC_STRING_BODY,SC_TEMPLATE_BODY> tag would be wrong here. */
|
||||
<SC_TEMPLATE_BODY>\r\n { lpc_lex_newline(yyscanner); }
|
||||
<SC_STRING_BODY>[^\\\"\r\n]+ |
|
||||
<SC_TEMPLATE_BODY>[^\\`$\r\n]+ {
|
||||
<SC_STRING_BODY>[^\\\"\r\n\x00]+ |
|
||||
<SC_TEMPLATE_BODY>[^\\`$\r\n\x00]+ {
|
||||
yyextra->str_accum.append(yytext, yyleng);
|
||||
STR_CHECK_OVERFLOW();
|
||||
}
|
||||
|
|
@ -489,7 +521,7 @@ WS_NO_NL [ \t\r\v\f]
|
|||
* body, then fails the following closing-quote check) or a literal
|
||||
* embedded newline (current_line is NOT incremented here -- only an
|
||||
* escaped newline counts as a line break, matching upstream). */
|
||||
<SC_CHAR_BODY>[^\\] {
|
||||
<SC_CHAR_BODY>[^\\\x00] {
|
||||
yylval_param->number = static_cast<unsigned char>(yytext[0]);
|
||||
BEGIN(SC_CHAR_CLOSE);
|
||||
}
|
||||
|
|
@ -607,7 +639,7 @@ WS_NO_NL [ \t\r\v\f]
|
|||
}
|
||||
}
|
||||
|
||||
<SC_COND_SKIP>[^\n]+ { /* dead-branch text: never tokenized */ }
|
||||
<SC_COND_SKIP>[^\n\x00]+ { /* dead-branch text: never tokenized */ }
|
||||
<SC_COND_SKIP>\n { lpc_lex_newline(yyscanner); }
|
||||
|
||||
/* Heredoc "@"/"@@" prefix and terminator identifier: fully native, no
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
#include <cctype>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <cstring> // memchr (embedded-NUL rejection in lpc_lex_on_directive)
|
||||
#include <new> // placement new (EsPendingCall lands on the arena)
|
||||
|
||||
#include "compiler/internal/compiler.h"
|
||||
|
|
@ -1416,6 +1417,17 @@ LpcDirectiveAction lpc_lex_on_directive(const char* text, int len, void* yyscann
|
|||
// here (the terminating newline is the lexer.l rule's job, not ours).
|
||||
count_directive_newlines(text, len);
|
||||
|
||||
// A raw NUL inside the captured directive line: the directive rule's
|
||||
// [^\n] classes are the ONE place that still deliberately absorbs NUL
|
||||
// bytes (so the whole-line capture stays intact for diagnostics); every
|
||||
// other path reports it via the <*> NUL rule at the top of lexer.l.
|
||||
// Reject it here -- this line only runs for '#'-directive lines, so
|
||||
// unlike a whole-file pre-scan it costs nothing on ordinary source.
|
||||
if (memchr(text, '\0', static_cast<size_t>(len)) != nullptr) {
|
||||
lexerror("Illegal embedded NUL byte (0x00) in preprocessor directive");
|
||||
return LpcDirectiveAction::kNone;
|
||||
}
|
||||
|
||||
if (!g_compile.pp_active) return LpcDirectiveAction::kNone;
|
||||
|
||||
// Fold + parse the captured line exactly once: both the skip-mode
|
||||
|
|
|
|||
131
src/main_fuzz_compile.cc
Normal file
131
src/main_fuzz_compile.cc
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
// AFL++ deferred-fork-server harness for the LPC compiler front-end (lexer
|
||||
// + preprocessor + parser + codegen).
|
||||
//
|
||||
// Boots the VM once (master + simul_efun, same as lpcc/lpcshell), then
|
||||
// calls __AFL_INIT() so AFL forks a fresh child per test case AFTER that
|
||||
// expensive one-time setup. Each child splits the input on a "\n#==#\n"
|
||||
// delimiter into a SEQUENCE of LPC source texts and compiles each in turn
|
||||
// via load_object_from_source() -- the same in-memory "restart pattern"
|
||||
// lpcshell uses -- in the SAME process, not just one compile per exec.
|
||||
//
|
||||
// A sequence (not a single compile) matters here for the same reason it
|
||||
// mattered for the restore_variable() harness: AGENTS.md section 11 is
|
||||
// explicit that the compiler keeps per-compile scratch state at module
|
||||
// scope -- the scratchpad arena, the macro/predefine tables, expansion_
|
||||
// frames/live_expansion_stack/live_guard_counts, the diagnostics stream --
|
||||
// and every one of it "must be re-initialized per compile" (see
|
||||
// lpc_lex_reset_context). A single-compile-per-exec harness can only find
|
||||
// a bug that crashes DURING one compile; it can never find a bug where one
|
||||
// compile's error path leaves that module-global state dirty for the NEXT
|
||||
// compile in the same process -- exactly the object.cc save_svalue_depth/
|
||||
// sizes[] bug class this file's sibling (main_fuzz_restore.cc) found, one
|
||||
// level up the stack.
|
||||
//
|
||||
// deliberately does NOT run create()/__INIT (load_object_from_source's
|
||||
// callcreate=0): the goal is the compiler surface specifically (matching
|
||||
// lpcc, which also only compiles and dumps, never executes), not the VM/
|
||||
// interpreter surface main_fuzz_restore.cc and the ordinary LPC testsuite
|
||||
// already cover.
|
||||
//
|
||||
// Not wired into the default build (see BUILD_FUZZERS in CMakeLists.txt);
|
||||
// throwaway investigation tooling, not shipped functionality.
|
||||
#include "base/std.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "mainlib.h"
|
||||
#include "vm/vm.h"
|
||||
#include "vm/internal/simulate.h"
|
||||
#include "vm/internal/base/interpret.h"
|
||||
#include "vm/internal/base/object.h"
|
||||
|
||||
#ifdef __AFL_HAVE_MANUAL_CONTROL
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
|
||||
std::vector<char> read_file(const char* path) {
|
||||
std::vector<char> data;
|
||||
FILE* f = fopen(path, "rb");
|
||||
if (!f) return data;
|
||||
char buf[65536];
|
||||
size_t n;
|
||||
while ((n = fread(buf, 1, sizeof(buf), f)) > 0) {
|
||||
data.insert(data.end(), buf, buf + n);
|
||||
}
|
||||
fclose(f);
|
||||
return data;
|
||||
}
|
||||
|
||||
constexpr std::string_view kDelim = "\n#==#\n";
|
||||
constexpr size_t kMaxChunks = 8; // compiles are heavier than restores
|
||||
|
||||
// One compile, in its own caught error context -- exactly how lpcshell's
|
||||
// RunAttempt() drives load_object_from_source(). A successfully-compiled
|
||||
// object is destructed immediately: this is a deferred-fork-server harness
|
||||
// (the process exits right after run_sequence() regardless), but leaving
|
||||
// compiled objects registered in the global object table until then would
|
||||
// make LeakSanitizer flag ordinary "still reachable, by design" state as a
|
||||
// leak, drowning out real findings.
|
||||
void compile_one(int index, const std::string& src) {
|
||||
std::string name = "/fuzz_compile#" + std::to_string(index);
|
||||
|
||||
error_context_t econ{};
|
||||
save_context(&econ);
|
||||
try {
|
||||
object_t* ob = load_object_from_source(src, name.c_str(), /*callcreate=*/0);
|
||||
if (ob && !(ob->flags & O_DESTRUCTED)) {
|
||||
destruct_object(ob);
|
||||
}
|
||||
} catch (const char*) {
|
||||
restore_context(&econ);
|
||||
} catch (...) {
|
||||
restore_context(&econ);
|
||||
}
|
||||
pop_context(&econ);
|
||||
}
|
||||
|
||||
// Split on the delimiter and compile each piece in sequence, in this same
|
||||
// process -- see the file header for why a sequence (not a single compile)
|
||||
// is what this harness needs to find.
|
||||
void run_sequence(const std::vector<char>& raw) {
|
||||
std::string_view all(raw.data(), raw.size());
|
||||
size_t pos = 0, chunks = 0;
|
||||
while (chunks < kMaxChunks) {
|
||||
size_t next = all.find(kDelim, pos);
|
||||
std::string_view piece =
|
||||
(next == std::string_view::npos) ? all.substr(pos) : all.substr(pos, next - pos);
|
||||
compile_one(static_cast<int>(chunks), std::string(piece));
|
||||
chunks++;
|
||||
if (next == std::string_view::npos) break;
|
||||
pos = next + kDelim.size();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc != 3) {
|
||||
fprintf(stderr, "Usage: fuzz_compile <config> <input_file>\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
auto config = get_argument(0, argc, argv);
|
||||
init_main(config);
|
||||
vm_start();
|
||||
current_object = master_ob;
|
||||
|
||||
#ifdef __AFL_HAVE_MANUAL_CONTROL
|
||||
__AFL_INIT();
|
||||
#endif
|
||||
|
||||
std::vector<char> input = read_file(argv[2]);
|
||||
run_sequence(input);
|
||||
|
||||
return 0;
|
||||
}
|
||||
127
src/main_fuzz_restore.cc
Normal file
127
src/main_fuzz_restore.cc
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
// AFL++ deferred-fork-server harness for restore_variable()/save_variable().
|
||||
//
|
||||
// Boots the VM once (master + simul_efun, same as lpcc/lpcshell), then
|
||||
// calls __AFL_INIT() so AFL forks a fresh child per test case AFTER that
|
||||
// expensive one-time setup. Each child splits the input on a "\n===\n"
|
||||
// delimiter into a SEQUENCE of save-strings and runs restore_variable() on
|
||||
// each in turn, in the SAME process -- not just one call per exec. This
|
||||
// matters: object.cc's restore_svalue()/restore_array()/restore_mapping()/
|
||||
// restore_class() share file-scope scratch state (save_svalue_depth,
|
||||
// sizes[]) across calls, and the known bug class here (see AGENTS.md
|
||||
// section 13 point 4, and restore_variable_class.lpc) is exactly "one
|
||||
// restore's error() path leaves that scratch state dirty, and the NEXT
|
||||
// restore in the same process reads the stale value." A harness that forks
|
||||
// fresh per input and calls restore_variable() only once could never find
|
||||
// that -- it needs a first call to dirty the state and a second to observe
|
||||
// it, both inside one exec. Every successfully-restored value is also
|
||||
// round-tripped through save_variable()/restore_variable() once more, the
|
||||
// same sequence used while chasing the reported printf("%O") corruption.
|
||||
// Not wired into the default build (see BUILD_FUZZERS in CMakeLists.txt);
|
||||
// throwaway investigation tooling, not shipped functionality.
|
||||
#include "base/std.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "mainlib.h"
|
||||
#include "vm/vm.h"
|
||||
#include "vm/internal/base/object.h"
|
||||
#include "vm/internal/base/machine.h"
|
||||
#include "vm/internal/base/interpret.h"
|
||||
|
||||
#ifdef __AFL_HAVE_MANUAL_CONTROL
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
char* save_variable(svalue_t* var);
|
||||
|
||||
namespace {
|
||||
|
||||
std::vector<char> read_file(const char* path) {
|
||||
std::vector<char> data;
|
||||
FILE* f = fopen(path, "rb");
|
||||
if (!f) return data;
|
||||
char buf[65536];
|
||||
size_t n;
|
||||
while ((n = fread(buf, 1, sizeof(buf), f)) > 0) {
|
||||
data.insert(data.end(), buf, buf + n);
|
||||
}
|
||||
fclose(f);
|
||||
return data;
|
||||
}
|
||||
|
||||
constexpr std::string_view kDelim = "\n===\n";
|
||||
constexpr size_t kMaxChunks = 64; // bound worst-case work per exec
|
||||
|
||||
// One restore_variable() call, in its own caught error context -- exactly
|
||||
// how f_restore_variable() runs it -- followed by a save_variable()/
|
||||
// restore_variable() round-trip on success.
|
||||
void run_chunk(std::string chunk) {
|
||||
error_context_t econ{};
|
||||
save_context(&econ);
|
||||
try {
|
||||
svalue_t v;
|
||||
v.type = T_NUMBER;
|
||||
restore_variable(&v, chunk.data());
|
||||
|
||||
char* s2 = save_variable(&v);
|
||||
svalue_t v2;
|
||||
v2.type = T_NUMBER;
|
||||
restore_variable(&v2, s2);
|
||||
FREE_MSTR(s2);
|
||||
free_svalue(&v2, "fuzz_restore: v2");
|
||||
free_svalue(&v, "fuzz_restore: v");
|
||||
} catch (const char*) {
|
||||
restore_context(&econ);
|
||||
} catch (...) {
|
||||
restore_context(&econ);
|
||||
}
|
||||
pop_context(&econ);
|
||||
}
|
||||
|
||||
// Split on the delimiter and feed each piece to restore_variable() in
|
||||
// sequence, in this same process -- see the file header for why a sequence
|
||||
// (not a single call) is what this harness needs to find.
|
||||
void run_sequence(const std::vector<char>& raw) {
|
||||
std::string_view all(raw.data(), raw.size());
|
||||
size_t pos = 0, chunks = 0;
|
||||
while (chunks < kMaxChunks) {
|
||||
size_t next = all.find(kDelim, pos);
|
||||
std::string_view piece =
|
||||
(next == std::string_view::npos) ? all.substr(pos) : all.substr(pos, next - pos);
|
||||
// restore_variable() takes a plain NUL-terminated char*; embedded NULs
|
||||
// truncate the save string exactly like a real one read from a file
|
||||
// would if it somehow contained one (LPC strings can't, a fuzzed byte
|
||||
// can).
|
||||
run_chunk(std::string(piece));
|
||||
chunks++;
|
||||
if (next == std::string_view::npos) break;
|
||||
pos = next + kDelim.size();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc != 3) {
|
||||
fprintf(stderr, "Usage: fuzz_restore <config> <input_file>\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
auto config = get_argument(0, argc, argv);
|
||||
init_main(config);
|
||||
vm_start();
|
||||
current_object = master_ob;
|
||||
|
||||
#ifdef __AFL_HAVE_MANUAL_CONTROL
|
||||
__AFL_INIT();
|
||||
#endif
|
||||
|
||||
std::vector<char> input = read_file(argv[2]);
|
||||
run_sequence(input);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
#include <deque>
|
||||
#include <map>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
|
||||
#include "thirdparty/scope_guard/scope_guard.hpp"
|
||||
#include "vm/internal/base/machine.h"
|
||||
|
|
@ -410,7 +411,14 @@ int restore_hash_string(char** val, svalue_t* sv) {
|
|||
char* news = cp - 1;
|
||||
|
||||
if ((c = *news++ = *cp++)) {
|
||||
while ((c = *cp++) != '"') {
|
||||
// The condition must stop on '\0' too, not just '"': an
|
||||
// unterminated escaped string (no closing quote before the end
|
||||
// of the buffer) otherwise falls into the plain-character branch
|
||||
// below, which copies the NUL byte and keeps looping -- an
|
||||
// unbounded read past the end of the allocation. The `if (!c)`
|
||||
// check after the loop only catches this correctly once the loop
|
||||
// itself can actually exit on NUL.
|
||||
while ((c = *cp++) != '"' && c) {
|
||||
if (c == '\\') {
|
||||
if (!(c = *news++ = *cp++)) {
|
||||
return ROB_STRING_ERROR;
|
||||
|
|
@ -1274,10 +1282,18 @@ void add_mapping_string(mapping_t* m, const char* key, const char* value) {
|
|||
void add_mapping_malloced_string(mapping_t* m, const char* key, char* value) {
|
||||
svalue_t* s;
|
||||
|
||||
// insert_in_mapping() (via find_for_insert()) can error()/throw --
|
||||
// mapping_too_large(), OOM -- before `value` is ever stored below. Own it
|
||||
// here for the duration of that call so the unwind frees it on the
|
||||
// throwing path; every caller passes a value it expects this function to
|
||||
// take unconditional ownership of (new_string()-family allocation, freed
|
||||
// with FREE_MSTR), so the ownership transfer belongs in ONE place here,
|
||||
// not duplicated as a RAII wrapper at each call site.
|
||||
std::unique_ptr<char, void (*)(char*)> owned(value, [](char* p) { FREE_MSTR(p); });
|
||||
s = insert_in_mapping(m, key);
|
||||
s->type = T_STRING;
|
||||
s->subtype = STRING_MALLOC;
|
||||
s->u.string = value;
|
||||
s->u.string = owned.release();
|
||||
}
|
||||
|
||||
void add_mapping_object(mapping_t* m, const char* key, object_t* value) {
|
||||
|
|
|
|||
|
|
@ -87,6 +87,29 @@ static void reset_restore_scratch() {
|
|||
}
|
||||
}
|
||||
|
||||
// restore_array()/restore_mapping()/restore_class() recurse in lockstep
|
||||
// with the one-shot sizing pre-pass (restore_size()/restore_internal_size(),
|
||||
// which populates sizes[0..N-1] as it walks the string) ONLY as long as
|
||||
// both parsers agree on where each nested container starts. They are two
|
||||
// independent hand-written parsers over the same untrusted bytes, and a
|
||||
// malformed byte sequence can desync them -- e.g. parse_numeric() silently
|
||||
// treats whatever byte follows a numeral as an already-consumed delimiter
|
||||
// without validating it, so a numeral directly followed by a stray "({"
|
||||
// makes the real parse believe it has entered a nested array the sizing
|
||||
// pass never saw (it read that same span as one opaque scalar token via
|
||||
// its coarser "scan to the next delimiter" fallback). When that happens,
|
||||
// save_svalue_depth can exceed what the sizing pass ever populated --
|
||||
// `sizes` may still be null, or the depth may exceed max_depth -- and
|
||||
// indexing sizes[save_svalue_depth - 1] reads off the end of (or into a
|
||||
// null) allocation. Treat that as the malformed input it is.
|
||||
static bool get_restore_size(int* out) {
|
||||
if (!sizes || save_svalue_depth > max_depth) {
|
||||
return false;
|
||||
}
|
||||
*out = sizes[save_svalue_depth - 1];
|
||||
return true;
|
||||
}
|
||||
|
||||
int svalue_save_size(svalue_t* v) {
|
||||
switch (v->type) {
|
||||
case T_STRING: {
|
||||
|
|
@ -517,7 +540,15 @@ static int restore_interior_string(char** val, svalue_t* sv) {
|
|||
char* news = cp - 1;
|
||||
|
||||
if ((*news++ = *cp++)) {
|
||||
while ((c = *cp++) != '"') {
|
||||
// The condition must stop on '\0' too, not just '"': an
|
||||
// unterminated escaped string (no closing quote before the end
|
||||
// of the buffer) otherwise falls into the plain-character branch
|
||||
// below, which copies the NUL byte and keeps looping -- an
|
||||
// unbounded read past the end of the allocation. The `if (c ==
|
||||
// '\0')` check after the loop only catches this correctly once
|
||||
// the loop itself can actually exit on NUL. (Sibling of the same
|
||||
// bug in restore_hash_string(), mapping.cc.)
|
||||
while ((c = *cp++) != '"' && c) {
|
||||
if (c == '\\') {
|
||||
if (!(*news++ = *cp++)) {
|
||||
return ROB_STRING_ERROR;
|
||||
|
|
@ -693,7 +724,9 @@ static int restore_mapping(char** str, svalue_t* sv) {
|
|||
int err;
|
||||
|
||||
if (save_svalue_depth) {
|
||||
size = sizes[save_svalue_depth - 1];
|
||||
if (!get_restore_size(&size)) {
|
||||
return ROB_MAPPING_ERROR;
|
||||
}
|
||||
} else if ((size = restore_size((const char**)str, 1)) < 0) {
|
||||
// A malformed / too-deeply-nested mapping must be reported as an error like
|
||||
// restore_array/restore_class do; returning 0 here signalled "success" yet
|
||||
|
|
@ -927,7 +960,9 @@ static int restore_class(char** str, svalue_t* ret) {
|
|||
int err;
|
||||
|
||||
if (save_svalue_depth) {
|
||||
size = sizes[save_svalue_depth - 1];
|
||||
if (!get_restore_size(&size)) {
|
||||
return ROB_CLASS_ERROR;
|
||||
}
|
||||
} else if ((size = restore_size((const char**)str, 0)) < 0) {
|
||||
return ROB_CLASS_ERROR;
|
||||
}
|
||||
|
|
@ -1035,7 +1070,9 @@ static int restore_array(char** str, svalue_t* ret) {
|
|||
int err;
|
||||
|
||||
if (save_svalue_depth) {
|
||||
size = sizes[save_svalue_depth - 1];
|
||||
if (!get_restore_size(&size)) {
|
||||
return ROB_ARRAY_ERROR;
|
||||
}
|
||||
} else if ((size = restore_size((const char**)str, 0)) < 0) {
|
||||
return ROB_ARRAY_ERROR;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2277,16 +2277,11 @@ static void mudlib_error_handler(char* err, int katch) {
|
|||
mapping_t* m = m_owned.get();
|
||||
add_mapping_string(m, "error", err);
|
||||
if (current_prog) {
|
||||
// add_mapping_malloced_string() only takes ownership of this malloc'd
|
||||
// string if the insert actually succeeds -- if the mapping is already
|
||||
// at its configured size limit it error()s before ever storing the
|
||||
// pointer, leaking it (add_slash()'s allocation already happened, as
|
||||
// the call argument, before add_mapping_malloced_string ran at all).
|
||||
// Hold it via RAII and only relinquish that once the insert succeeds.
|
||||
std::unique_ptr<char, void (*)(char*)> program_str(add_slash(current_prog->filename),
|
||||
[](char* s) { FREE_MSTR(s); });
|
||||
add_mapping_malloced_string(m, "program", program_str.get());
|
||||
program_str.release();
|
||||
// add_mapping_malloced_string() owns the throwing call (insert_in_
|
||||
// mapping(), which can mapping_too_large()/OOM) internally now, so a
|
||||
// plain call is safe: it frees add_slash()'s allocation on its own
|
||||
// unwind if the insert never happens, same as every other caller.
|
||||
add_mapping_malloced_string(m, "program", add_slash(current_prog->filename));
|
||||
}
|
||||
if (current_object) {
|
||||
add_mapping_object(m, "object", current_object);
|
||||
|
|
@ -2296,9 +2291,7 @@ static void mudlib_error_handler(char* err, int katch) {
|
|||
get_line_number_info(&file, &line);
|
||||
}
|
||||
if (file) {
|
||||
std::unique_ptr<char, void (*)(char*)> file_str(add_slash(file), [](char* s) { FREE_MSTR(s); });
|
||||
add_mapping_malloced_string(m, "file", file_str.get());
|
||||
file_str.release();
|
||||
add_mapping_malloced_string(m, "file", add_slash(file));
|
||||
}
|
||||
if (line) {
|
||||
add_mapping_pair(m, "line", line);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
// Regression: rule_lambda_return_type() (grammar_rules_exprs.cc), the
|
||||
// "open" half of parsing a typed anonymous-function literal
|
||||
// (`TYPE (args) { block }`, e.g. `function (int x) { return x; }`),
|
||||
// reports "Reserved type name unexpected." via yyerror() when TYPE isn't
|
||||
// `function` -- but used to fall straight through into its side effects
|
||||
// regardless: pushing a function context and swapping current_number_of_
|
||||
// locals/locals_ptr aside to start a FRESH nested scope for the (about to
|
||||
// be malformed) arg list and body. The matching "close",
|
||||
// rule_primary_expr_anon_func(), restores that swapped-aside state --
|
||||
// but only runs if the grammar production `lambda_return_type '(' argument
|
||||
// ')' block` reduces all the way through. If the malformed input inside
|
||||
// the parens/braces ALSO hits a genuine Bison-level syntax error (as
|
||||
// opposed to parsing to a clean, if semantically-invalid, finish), the
|
||||
// parser's error recovery (`error ';' statements`) abandons that
|
||||
// production before rule_primary_expr_anon_func() ever runs -- so the
|
||||
// "open" half's state swap is never undone. current_number_of_locals is
|
||||
// left stuck at 0 (or some other value from a still-live sibling swap)
|
||||
// mid-way through the block that contains the TYPE(...) expression.
|
||||
//
|
||||
// When that ENCLOSING block later closes, rule_block() computes its own
|
||||
// local count as `current_number_of_locals - entry_locals` (a snapshot
|
||||
// taken when the block opened) -- now negative, since the counter was
|
||||
// yanked out from under it. pop_n_locals() hits its "num < 0" invariant:
|
||||
// DEBUG_CHECK (base/internal/log.h) makes that a clean fatal() on a Debug
|
||||
// build, but DEBUG_CHECK compiles to nothing at all outside Debug (see
|
||||
// src/CMakeLists.txt: only CMAKE_BUILD_TYPE=Debug forces DEBUG on) -- so
|
||||
// on a RelWithDebInfo build (what real deployments run) there is no check
|
||||
// at all: pop_n_locals() proceeds with num=-1, walking current_number_of_
|
||||
// locals and the locals[] array pointers in the WRONG direction, an
|
||||
// out-of-bounds walk confirmed to SIGSEGV a RelWithDebInfo build for real.
|
||||
// All from an ordinary syntax error a mudlib coder (or an attacker who can
|
||||
// get any code compiled) can write by hand -- found by AFL++ fuzzing the
|
||||
// compiler front-end, minimized to 22 bytes.
|
||||
//
|
||||
// Fix mirrors the established pattern for a soft-skipped push needing its
|
||||
// paired pop to skip in step (AGENTS.md section 13 point 13): a new
|
||||
// func_block_t.opened flag records whether rule_lambda_return_type()
|
||||
// actually pushed anything; rule_primary_expr_anon_func() checks it and
|
||||
// skips its restore entirely when false, whichever way parsing goes from
|
||||
// there -- so there is nothing left for error recovery to desync.
|
||||
void do_tests() {
|
||||
string path = "/data/anon_func_bad_type_scope_leak_gen";
|
||||
rm(path + ".c");
|
||||
|
||||
// The minimized fuzzer reproducer, verbatim: a for-loop whose body block
|
||||
// contains `object(` -- a reserved (non-`function`) type name used in
|
||||
// anon-function-literal position -- immediately followed by `;`, which
|
||||
// is not valid `argument`/`)`/`block` syntax and triggers the Bison-level
|
||||
// syntax error that abandons the production before the "close" runs.
|
||||
write_file(path + ".c", "a(){for(;n;){object(;}");
|
||||
ASSERT2(catch(load_object(path)),
|
||||
"malformed typed-anon-func-literal attempt must error cleanly, not crash");
|
||||
|
||||
rm(path + ".c");
|
||||
|
||||
// A well-formed compile right after must still work, proving
|
||||
// current_number_of_locals/locals_ptr weren't left corrupted.
|
||||
write_file(path + ".c", "int ok() { int x = 1; return x + 1; }\n");
|
||||
object ob = load_object(path);
|
||||
ASSERT2(ob, "a normal compile right after the rejection must still succeed");
|
||||
ASSERT_EQ(2, ob->ok());
|
||||
destruct(ob);
|
||||
rm(path + ".c");
|
||||
|
||||
// A legitimate typed anonymous function literal, the construct this
|
||||
// whole code path exists for, must still work correctly (not just avoid
|
||||
// crashing) -- see also single/tests/compiler/firstclass_functions.lpc.
|
||||
function f = (function (int a, int b) { return a + b; });
|
||||
ASSERT_EQ(7, f(3, 4));
|
||||
}
|
||||
|
|
@ -26,4 +26,25 @@ void do_tests() {
|
|||
ASSERT(1);
|
||||
|
||||
rm("/gen_deep_expr.c");
|
||||
|
||||
#ifdef __DEBUG__
|
||||
// lpc_tree_form()/lpc_tree_expr() (generate.cc) -- the DEBUG-only `tree`
|
||||
// keyword's pretty-printer -- are a FOURTH mutually-recursive walker over
|
||||
// this exact parse-tree shape, missed by the optimize()/i_generate_node()/
|
||||
// ast_json() audit above: they run mid-parse (grammar_rules.cc's
|
||||
// rule_tree_expr(), called directly from the `tree(...)` grammar
|
||||
// reduction), so they see the SAME unoptimized deep chain before
|
||||
// optimize() or constant-folding ever gets a chance to touch it. Found by
|
||||
// AFL++ fuzzing the compiler front-end (SIGSEGV, C-stack overflow).
|
||||
// __TREE__ expands to `tree` only when __DEBUG__ is defined (a Debug
|
||||
// build), matching the C++ `#ifdef DEBUG` gate on the grammar rule itself
|
||||
// (rule_tree_block()/rule_tree_expr(), grammar_rules.cc) -- this case is
|
||||
// simply absent from a RelWithDebInfo compile, same as constant_expr.lpc's
|
||||
// existing __TREE__ cases.
|
||||
src = "int x; mixed f() { x = 1; mixed code = __TREE__ (" + expr + "); return code; }\n";
|
||||
write_file("/gen_deep_expr.c", src);
|
||||
catch(load_object("/gen_deep_expr"));
|
||||
ASSERT(1);
|
||||
rm("/gen_deep_expr.c");
|
||||
#endif
|
||||
}
|
||||
|
|
|
|||
119
testsuite/single/tests/compiler/embedded_nul_hang.lpc
Normal file
119
testsuite/single/tests/compiler/embedded_nul_hang.lpc
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
// Regression: a real embedded NUL byte in LPC source (anywhere other than
|
||||
// as the buffer's own end-of-scan sentinel) could hang the Flex-generated
|
||||
// scanner forever -- found by AFL++ fuzzing the compiler front-end
|
||||
// (load_object_from_source()), minimized to 2 bytes: any non-NUL byte
|
||||
// followed by a NUL. Reachable through completely ordinary compilation:
|
||||
// any .lpc file on disk containing a raw NUL (a stray upload, a bad editor,
|
||||
// a crafted mudlib file) hangs the driver on load_object(), and the same
|
||||
// in-memory API lpcshell/lpcc -e and load_object_from_source() use is
|
||||
// exposed just as directly.
|
||||
//
|
||||
// Root cause (a flex skeleton defect, confirmed under gdb): when the
|
||||
// scanner meets an in-buffer NUL, yylex()'s YY_END_OF_BUFFER handling
|
||||
// tries yy_try_NUL_trans(); on failure it falls back to
|
||||
// yy_last_accepting_state -- which the forward scan has just clobbered
|
||||
// with the end-of-buffer PSEUDO-accepting state -- and for a 0-2
|
||||
// character token prefix yy_get_previous_state()'s re-walk records no
|
||||
// real accepting state either (it only records states BEFORE each prefix
|
||||
// character). The scanner then re-enters the same YY_END_OF_BUFFER
|
||||
// action with identical pointers, forever.
|
||||
//
|
||||
// The fix is the escape hatch flex itself provides -- make sure a RULE
|
||||
// can always match the NUL, so yy_try_NUL_trans() succeeds: lexer.l's
|
||||
// <*>(.|\n)?(.|\n)?\x00 rule (see its comment for why 0-2 leading
|
||||
// characters is exactly the dangerous set) reports every NUL as a clean
|
||||
// "Illegal embedded NUL byte" compile error, the negated character
|
||||
// classes all exclude \x00 so no state absorbs one silently, and
|
||||
// lpc_lex_on_directive() rejects a NUL inside a captured '#' line. No
|
||||
// whole-file pre-scan: legitimate (NUL-free) compiles pay nothing.
|
||||
//
|
||||
// This can only be driven through the file-loading / in-memory-source
|
||||
// entry points (compile_file()/load_object_from_source()), not through an
|
||||
// LPC string literal -- LPC string literals cannot contain a raw NUL byte
|
||||
// at the source-text level in the first place. Exercise it the same way
|
||||
// the compiler's own audit regressions do for a compile-time bug: write a
|
||||
// generated file to disk and load it.
|
||||
|
||||
// Write `pre` + one NUL byte + `post` as a source file and assert it
|
||||
// fails to compile cleanly (error, not hang/crash). A real embedded NUL
|
||||
// can't be expressed as an LPC string literal or via sprintf("%c", 0)
|
||||
// (rejected as not-a-valid-UTF8-char) -- build the file byte-for-byte
|
||||
// with write_buffer() instead (a fresh allocate_buffer() is zero-filled),
|
||||
// exactly the way a corrupted/crafted real .lpc file could contain one
|
||||
// even though save_object()'s own format never does.
|
||||
private void assert_nul_rejected(string path, string pre, string post,
|
||||
string why) {
|
||||
buffer nul = allocate_buffer(1);
|
||||
rm(path + ".c");
|
||||
if (strlen(pre)) write_buffer(path + ".c", 0, pre);
|
||||
write_buffer(path + ".c", strlen(pre), nul);
|
||||
if (strlen(post)) write_buffer(path + ".c", strlen(pre) + 1, post);
|
||||
ASSERT2(catch(load_object(path)), why);
|
||||
rm(path + ".c");
|
||||
}
|
||||
|
||||
void do_tests() {
|
||||
string path = "/data/embedded_nul_hang_gen";
|
||||
|
||||
// The minimized 2-byte reproducer, verbatim: one ordinary byte then NUL
|
||||
// (the 1-char-prefix shape that loops on the stale accepting state).
|
||||
assert_nul_rejected(path, "0", "",
|
||||
"a source file with an embedded NUL must fail to compile cleanly, not hang");
|
||||
|
||||
// A NUL inside a longer, otherwise ordinary program -- same bug class,
|
||||
// more representative of what a corrupted real file looks like.
|
||||
assert_nul_rejected(path, "void create() { ", " int x = 1; }\n",
|
||||
"a NUL byte inside an otherwise-valid program must fail cleanly, not hang");
|
||||
|
||||
// NUL as the file's very first byte (the 0-char-prefix / token-start
|
||||
// shape, matched by the bare \x00 arm of the lexer rule).
|
||||
assert_nul_rejected(path, "", "int x;\n",
|
||||
"a NUL as the file's first byte must fail cleanly");
|
||||
|
||||
// Inside a string literal body: without the class exclusion the run
|
||||
// rule would absorb the NUL into the string constant silently.
|
||||
assert_nul_rejected(path, "string s = \"ab", "cd\";\n",
|
||||
"a NUL inside a string literal must be a compile error");
|
||||
|
||||
// Right after a string-escape backslash: the one single-character
|
||||
// prefix that is NOT accepting, i.e. the shape the rule's second
|
||||
// optional arm exists for.
|
||||
assert_nul_rejected(path, "string s = \"ab\\", "cd\";\n",
|
||||
"a NUL after a string-escape backslash must fail cleanly, not hang");
|
||||
|
||||
// Two characters into an escape sequence ("\x" + NUL): the 2-char
|
||||
// prefix whose only accepting state is at its very end -- the deepest
|
||||
// shape whose re-walk records nothing.
|
||||
assert_nul_rejected(path, "string s = \"\\x", "\";\n",
|
||||
"a NUL after an \\x escape prefix must fail cleanly, not hang");
|
||||
|
||||
// Inside a block comment (its own start condition and negated class).
|
||||
assert_nul_rejected(path, "/* ab", "cd */\nint x;\n",
|
||||
"a NUL inside a block comment must be a compile error");
|
||||
|
||||
// Inside a preprocessor directive line: the '#' rule deliberately
|
||||
// captures the whole line, NUL included -- lpc_lex_on_directive()
|
||||
// must reject it.
|
||||
assert_nul_rejected(path, "#define A B", "\nint x;\n",
|
||||
"a NUL inside a preprocessor directive must be a compile error");
|
||||
|
||||
// Inside a dead #if branch (SC_COND_SKIP scans but never tokenizes).
|
||||
assert_nul_rejected(path, "#if 0\nabc", "def\n#endif\nint x;\n",
|
||||
"a NUL inside a dead #if branch must be a compile error");
|
||||
|
||||
// Inside a heredoc body (read through lpc_lex_getc(), not the DFA:
|
||||
// the legacy raw readers treat NUL as end-of-input, so the terminator
|
||||
// is never found and the block errors out).
|
||||
assert_nul_rejected(path, "string s = @END\nline1", "line2\nEND;\n",
|
||||
"a NUL inside a heredoc body must be a compile error");
|
||||
|
||||
// A well-formed compile right after must still work, proving the
|
||||
// rejections don't leave any compiler-global scratch state dirty.
|
||||
rm(path + ".c");
|
||||
write_file(path + ".c", "int ok() { return 42; }\n");
|
||||
object ob = load_object(path);
|
||||
ASSERT2(ob, "a normal compile right after the rejections must still succeed");
|
||||
ASSERT_EQ(42, ob->ok());
|
||||
destruct(ob);
|
||||
rm(path + ".c");
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
// Regression: restore_array()/restore_mapping()/restore_class() (src/vm/
|
||||
// internal/base/object.cc) recurse in lockstep with a one-shot sizing
|
||||
// pre-pass (restore_size()/restore_internal_size()) that populates the
|
||||
// file-scope sizes[] scratch buffer. That pre-pass and the real parse are
|
||||
// two INDEPENDENT hand-written parsers over the same untrusted bytes, and
|
||||
// they can disagree: parse_numeric() silently treats whatever byte follows
|
||||
// a numeral as an already-consumed delimiter without validating it (its
|
||||
// digit-scan loop is `while ((c = *cp++) && isdigit(c))`, which has already
|
||||
// advanced cp past the first non-digit byte before returning), while the
|
||||
// sizing pre-pass's much coarser "unrecognized token -> scan to the next
|
||||
// expected delimiter" fallback reads the same span differently. A numeral
|
||||
// directly followed by a stray "({" (found by AFL++ fuzzing restore_
|
||||
// variable(), minimized to 10 bytes) makes the real parse believe it has
|
||||
// entered a nested array the sizing pass never saw and never recorded a
|
||||
// sizes[] entry for -- indexing sizes[save_svalue_depth - 1] then reads a
|
||||
// null pointer (sizes was never allocated) or a garbage entry beyond what
|
||||
// the pre-pass populated. This crashed with SIGSEGV / a clean UBSan
|
||||
// null-pointer-load report before the fix; get_restore_size() now bounds-
|
||||
// checks save_svalue_depth against what the sizing pass actually
|
||||
// established and fails the restore cleanly instead.
|
||||
//
|
||||
// Confirmed present (via a ported copy of the harness) on the driver
|
||||
// revision that predates this branch's whole file -- this is a pre-existing
|
||||
// bug, not a regression from any change in this branch.
|
||||
void do_tests() {
|
||||
// The minimized crash: an outer mapping whose key is the "0" shorthand,
|
||||
// whose value is a bare "0" numeral immediately followed by "{(" with no
|
||||
// delimiter -- parse_numeric() silently swallows the "{" as if it were
|
||||
// the value's trailing "," while the sizing pass read the whole "{({,"
|
||||
// span as one opaque token. The mismatch previously null-derefed inside
|
||||
// restore_array(); it must now be a clean, catchable parse error.
|
||||
ASSERT2(catch(restore_variable("([:0{({,])")),
|
||||
"sizing/parse desync (mapping->array) must error cleanly, not crash");
|
||||
|
||||
// A well-formed restore right after must still work, proving the sizing
|
||||
// scratch state (save_svalue_depth / sizes[]) was left clean rather than
|
||||
// dirtied by the rejected malformed input.
|
||||
ASSERT_EQ(7, restore_variable("7"));
|
||||
ASSERT_EQ(3, sizeof(restore_variable("({1,2,3,})")));
|
||||
ASSERT_EQ(1, sizeof(restore_variable("([1:1,])")));
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
// Regression: restore_hash_string() (src/vm/internal/base/mapping.cc, used
|
||||
// for mapping keys) and restore_interior_string() (object.cc, used for
|
||||
// array/class elements and mapping values) each have an OUTER while loop
|
||||
// that stops on '"' or '\0' (a NUL byte hits `case '\0': return
|
||||
// ROB_STRING_ERROR;` in the switch), but after an escape ('\') is seen they
|
||||
// switch into a separate INNER while loop for the rest of the string. That
|
||||
// inner loop's condition used to be `!= '"'` only -- it did not stop on
|
||||
// '\0'. An unterminated escaped string (a '\' followed by one ordinary
|
||||
// byte, then the buffer ends with no closing '"') hit the loop's plain-
|
||||
// character branch on the NUL terminator, which copied it and kept
|
||||
// looping: an unbounded read past the end of the string allocation
|
||||
// (AddressSanitizer: stack-buffer-overflow; found by AFL++ fuzzing
|
||||
// restore_variable(), minimized to 9-10 bytes each). restore_string()
|
||||
// (object.cc, the sibling used by restore_object() for a whole save-file)
|
||||
// already had the correct `!= '"' && c` guard -- the two efun-facing
|
||||
// parsers were simply missing it. Both must now report a clean
|
||||
// ROB_STRING_ERROR ("Illegal string format") instead of reading off the
|
||||
// end of the buffer.
|
||||
//
|
||||
// Both minimized reproducers below are the ACTUAL fuzzer-found inputs
|
||||
// (delta-debugged down from the original crashes), not hand-constructed --
|
||||
// a hand-built "obviously unterminated" string here is deceptive, because
|
||||
// restore_size()'s OWN (separate, also-approximate) string handling
|
||||
// silently accepts several shapes that never even reach the vulnerable
|
||||
// inner loop; only these exact byte sequences drive it.
|
||||
//
|
||||
// Confirmed present (via a ported copy of the harness) on the driver
|
||||
// revision that predates this branch's whole file -- this is a pre-existing
|
||||
// bug, not a regression from any change in this branch.
|
||||
void do_tests() {
|
||||
// Mapping KEY: exercises restore_hash_string()'s inner loop.
|
||||
ASSERT2(catch(restore_variable("([:0a\"\\,])")),
|
||||
"unterminated escaped mapping key must error cleanly, not overrun");
|
||||
|
||||
// Mapping VALUE: exercises restore_interior_string()'s inner loop (the
|
||||
// same function also parses array/class elements -- one confirmed call
|
||||
// site is enough to pin the shared fix).
|
||||
ASSERT2(catch(restore_variable("([0a\"\\:])")),
|
||||
"unterminated escaped mapping value must error cleanly, not overrun");
|
||||
|
||||
// A well-formed restore right after must still work.
|
||||
ASSERT_EQ("a\"b", restore_variable("\"a\\\"b\""));
|
||||
ASSERT_EQ(3, sizeof(restore_variable("({1,2,3,})")));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue