Audit and harden the content scanner database stack - #19279
Merged
Conversation
intfstream_read() reports a hard error as -1 but signals end-of-file as a zero-length or short read, so every "== -1" check in the msgpack reader was blind to EOF. The visible consequence is in rmsgpack_read(): at EOF the type byte keeps its "uint8_t type = 0" initialiser, the reader takes the positive-fixint branch and returns success, handing the caller a fabricated integer 0. It does this every time it is called, so a .rdb whose trailing nil sentinel is missing - a truncated download is enough - spins libretrodb_cursor_read_item() forever. Both consumers loop on that return (the "while (ret != -1)" in database_info_list_new_filtered() and the "for (;;)" in database_info_list_new_names_only()), so the scan task never completes and never reports an error. Reproduced with a 30-byte .rdb missing its sentinel: the cursor returned 5000001 records of type RDT_INT before the test harness gave up. The same blindness let rmsgpack_skip_bytes() report success for bytes it never read, let rmsgpack_read_uint()/rmsgpack_read_int() decode a partially-filled union, and left libretrodb_open()'s header struct partly uninitialised for a file shorter than the header before handing it to strncmp(). Add rmsgpack_read_exact() and route every fixed-length read through it. Every length in a MsgPack stream is exact by construction - a type byte is one byte, a declared payload is that many bytes - so a short read is always a truncated or malformed stream. Zero the decode unions so a future miss cannot surface stale bytes, and give libretrodb.c's three open-coded reads the same treatment.
rmsgpack_dom_read_with() frees 'out' on the error path, but never
initialises it. Every caller passes the address of a bare automatic:
database_cursor_iterate(), database_info_list_new_names_only(), and
the fast path in libretrodb_cursor_read_item(). rmsgpack_read() only
writes through the pointer once a callback fires, so a stream that
fails before the first callback left the error path freeing whatever
happened to occupy that stack slot.
In the cursor loop that slot is reused across iterations and still
holds the previous record - type RDT_MAP with an 'items' pointer that
rmsgpack_dom_value_free() already released - so reading a truncated
.rdb walked freed pairs and then freed the pair array a second time.
ASan on a two-record .rdb whose second record is a bare map header:
ERROR: AddressSanitizer: heap-use-after-free
READ of size 4 at 0x5040000000e0
[0] rmsgpack_dom_value_free rmsgpack_dom.c:204
[1] rmsgpack_dom_value_free rmsgpack_dom.c:215
[2] rmsgpack_dom_read_with rmsgpack_dom.c:438
[3] libretrodb_cursor_read_item libretrodb.c:504
freed by thread T0 here:
[1] rmsgpack_dom_value_free rmsgpack_dom.c:218
This was previously masked: before the preceding commit the reader
never failed at EOF, it looped instead.
Claim the object before handing it to the reader so the error path
always has a well-defined value to release, and have
rmsgpack_dom_value_free() leave the value in the state a fresh
RDT_NULL has. libretrodb_create() already open-coded that assignment
after every call; it belongs in the free function, where it also
makes a second free of the same object a no-op rather than a double
free.
rmsgpack_dom_read_into() switched on the type tag stored in the file
and consumed a different number of va_args per case: one output
pointer for RDT_UINT, two for RDT_STRING and RDT_BINARY. Nothing
checked that against what the caller had actually passed, so a .rdb
that declared a key with an unexpected type slid the entire va_list
out of step. Every key after the mismatch then wrote a
file-controlled uint64 through a pointer belonging to a different
field. libretrodb_find_index() passes five outputs, which makes that
an arbitrary write rather than just a crash.
A missing key was unsafe for a simpler reason:
rmsgpack_dom_value_map_value() returns NULL when the key is absent
and the result went straight into "switch (value->type)".
Both reproduce against libretrodb_open(). A 26-byte .rdb whose
metadata map is keyed "kount" instead of "count":
rmsgpack_dom.c:526:23: runtime error: member access within null
pointer of type 'struct rmsgpack_dom_value'
AddressSanitizer: SEGV on unknown address 0x000000000010
[0] rmsgpack_dom_read_into rmsgpack_dom.c:526
[1] libretrodb_open libretrodb.c:248
and a 33-byte .rdb that stores "count" as a string, which consumes
the caller's &md.count as the char* buffer and then the NULL
terminator as the uint64_t* length:
rmsgpack_dom.c:561:72: runtime error: load of null pointer
AddressSanitizer: SEGV on unknown address 0x000000000000
Take an explicit enum rmsgpack_dom_field_type per key, read before
the file's own type is consulted. The va_list now advances by an
amount fixed at the call site, an absent key is a clean failure
instead of a NULL dereference, and a stored type that disagrees with
the requested one is a parse error.
RDF_UINT and RDF_INT accept both integer spellings. MsgPack has no
single canonical encoding for a small integer - a positive fixint
decodes as RDT_INT while rmsgpack_write_uint() emits UINT8..UINT64,
which decode as RDT_UINT - so demanding one tag would reject validly
encoded files that our own writer does not happen to produce.
While here, terminate the RDF_STRING output unconditionally.
libretrodb_find_index() calls strlen() on idx->name, which is
char[50]; a stored name of 50 bytes or more filled the buffer with no
room for a terminator and ran strlen() off the end of the struct.
Both .rdb files above are now rejected by libretrodb_open() with no
sanitiser report, and a 500-record database still reads back
byte-identically.
rmsgpack_read() recurses through rmsgpack_read_map() and
rmsgpack_read_array() once per level of container nesting, with no
limit. The "len > remaining bytes" guards those two functions already
carry bound only how *wide* a container may be; depth costs one byte
per level, so a run of 0x91 (fixarray holding one element) recurses
once per byte of input.
A 200 KB .rdb consisting of 0x91 exhausts the stack:
AddressSanitizer: stack-overflow on address 0x7ffc1fb16f18
[4] rmsgpack_read rmsgpack.c:530
[5] rmsgpack_read_array rmsgpack.c:513
[6] rmsgpack_read rmsgpack.c:547
... repeated to exhaustion
The DOM reader's own MAX_DEPTH does not help: a one-element array
pops one stack entry and pushes one, so its stack index never grows
and the cap never fires.
rmsgpack_skip_value(), used by the cursor fast path to step over
fields the query does not reference, recurses on the same input for
the same reason.
Thread a depth counter through both readers and cap it at 32.
Records written by libretrodb_create() nest two deep - a map of
scalars - so this leaves ample headroom for any plausible schema
change while keeping worst-case recursion bounded.
rmsgpack_dom_value_free() and rmsgpack_dom_value_cmp() recurse over
the resulting tree and are bounded by the same cap, since the reader
can no longer build anything deeper.
Verified at the boundary: 30 and 31 levels of nesting still parse,
40 and 200000 are rejected without a sanitiser report, and a
500-record database reads back byte-identically.
…ed load binsearch() had three defects, all reachable from a malformed index header: - the memcmp() ran before the "count == 0" test, so an empty sub-range was still compared against buff[0..field_size) - a read past the end of the allocation whenever the search narrowed to nothing; - the tail call passed "count - mid", and mid is 0 when count is 1, so a one-element range recursed on itself forever, walking further past the buffer on each step until it faulted; - the stored offset was read as *(uint64_t*)(current + field_size). field_size comes from the file, so unless it is a multiple of eight every entry after the first is misaligned. UBSan flags this on the *well-formed* index our own converter writes, and it faults outright on strict-alignment targets. field_size was also declared uint8_t while the caller passed (ssize_t)idx.key_size, so a key_size of 256 truncated to 0 and made every memcmp() a zero-length match returning a garbage record offset. Nothing related idx.next, idx.count and idx.key_size to each other either, so a header claiming a large count over a small payload sent binsearch() off the end of the heap allocation. libretrodb_find_index() had a matching hang: idx->next is a file-supplied relative seek, and zero re-reads the same header forever while a value that wraps negative seeks backwards, so intfstream_eof() is never reached. Make binsearch() iterative over a half-open range, assemble the offset with memcpy so alignment is irrelevant, widen field_size to uint64_t, cross-check count against the payload the header reserved, reject degenerate key sizes, and require forward progress when walking the index chain. Six crafted index headers, before and after: i_count (count >> payload) SEGV -> rejected i_empty (payload 0, count) heap-buffer-overflow -> rejected i_ks0 (key_size 0) bogus match -> rejected i_unalign (key_size 3) heap-buffer-overflow -> rejected i_next0 (next == 0) UBSan misaligned -> rejected i_ok (well-formed) UBSan misaligned -> found A 500-record database still reads back byte-identically.
strlcpy() returns the length of its source, not the number of bytes
it copied. task_database_iterate_serial_lookup() advanced _len by
that return value and then stored three bytes at query[_len],
query[_len + 1] and query[_len + 2]:
char query[50];
_len = strlcpy(query, "{'serial': b'", sizeof(query));
_len += strlcpy(query + _len, serial_buf, sizeof(query) - _len);
query[ _len] = '\'';
query[++_len] = '}';
query[++_len] = '\0';
serial_buf is the hex expansion of db_state->serial, so _len is
13 + 2 * strlen(serial) regardless of how much actually fit. The
inner strlcpy() truncates correctly; it is the three explicit stores
that run off the end, for any serial longer than 17 characters.
Reproduced against the shipped code with ASan:
serial 17 chars: _len=49 ok
serial 18 chars: stack-buffer-overflow, WRITE of size 1
serial 40 chars: stack-buffer-overflow, WRITE of size 1
db_state->serial is a 4 KiB buffer carrying its own "TODO/FIXME -
check size" note, and detect_dc_game() concatenates lgame_id[20] and
rgame_id[20] before cue_append_multi_disc_suffix() adds to that, so
close to forty characters is reachable from a Dreamcast image. Far
past the buffer the store skips the sanitiser's redzone entirely and
lands in live stack, where nothing reports it.
Size the buffer from the string being built. The common case stays on
the stack; a serial that does not fit falls back to the heap, the
same way database_info_list_iterate_found_match() already handles
db_crc. Verified clean from 10 to 200 characters.
task_database_iterate_serial_lookup() looped while (db_state->entry_index <= db_state->info->count) and dereferenced list[entry_index] on the final iteration, reading ->serial out of whatever follows the allocation and passing it to string_is_equal(). This is not a crafted-input path: the loop runs to completion on every serial lookup that finds no match, which is the common case for content absent from the database. task_database_iterate_crc_lookup() had the same shape without the loop - it indexed list[entry_index] whenever db_state->info was non-NULL, with no check against count, so a query that matched nothing still had list[0] dereferenced. Bound both against count. The "db_info_entry &&" tests they were guarded with were dead code - the address of an array element is never NULL - and go with them.
task_database_fill_db_min_max() ran a {size:min(0)} query and then
read db_state->info->count with no NULL check.
database_info_list_new_filtered() returns NULL whenever the .rdb
cannot be opened, the query fails to compile, or an allocation fails,
so any unreadable or malformed database in the scan directory
dereferences NULL here. The second {size:max(0)} query had the same
problem.
Treat a failed query the way the existing empty-result branch already
treats no size information: record the placeholder range and move on
to the next database.
min_sizes[], max_sizes[] and flags[] were [MAX_DATABASE_COUNT] arrays declared inline in database_state_handle_t and indexed by db_state->list_index. list_index is bounded only by list->size, which is however many .rdb files dir_list_new() found in the database directory. Nothing clamped it against MAX_DATABASE_COUNT. Past 256 databases every write through those indices is out of bounds, and the most-recently-matched shuffle in database_info_list_iterate_found_match() memmove()s list_index + 1 elements over the same arrays. The shipped database set is already well over half of 256 and gains entries every release, so this is a question of when rather than whether. Allocate the three arrays from list->size once the database list is known, and release them with the rest of the handle. An empty database directory allocates nothing: every lookup path returns on "list_index == list->size" before touching them, and the one read of flags[0] is guarded by "list_index > 0".
gdi_prune() called free(fd) on its intfstream_t without calling intfstream_close() first, leaking the OS file descriptor and the inner stream object for every .gdi file in a scan. task_database_cue_prune(), five lines above, does it correctly - this is a straight omission. A large GDI collection walks the descriptor table. Separately, db_state->info is only released along the iterate paths. free_manual_content_scan_handle() never touched it, so cancelling a scan while a query result was live leaked the entire database_info_list_t: the record array plus every strdup'd field of every record in it.
type_is_prioritized() read ext[1] and ext[2] before anything
established the extension was that long, and only then tested
ext[3] == '\0'. path_get_extension() returns a pointer to the
terminator for a path with no extension, so this reads up to three
bytes past the end of the string - and it runs inside the qsort()
comparator, i.e. on every entry of the database directory listing.
ASan on the unpatched function, with a path carrying no extension:
ERROR: AddressSanitizer: global-buffer-overflow
READ of size 1
[0] in type_is_prioritized
extension_to_file_type() compared a char[6] filled by strlcpy()
against fixed byte counts - memcmp(ext_lower, "lutro", 6) and
friends. memcmp() is specified to read all n bytes, so for any
extension shorter than the count, and for the empty extension in
particular, those are uninitialised stack bytes. MSan reports it and
vectorised memcmp() implementations really do load them.
Check the length before the character reads in the first case, and
use string_is_equal(), which stops at the terminator, in the second.
Prioritisation behaviour is unchanged: cue and gdi still sort first,
case-insensitively, for both bare filenames and paths.
cue_append_multi_disc_suffix() bounded its snprintf() with "PATH_MAX_LENGTH - __len" no matter what buffer it had been handed. Twenty of the twenty-one call sites pass the detector's 's' output, whose size the caller knows as 'len'; detect_gc_game() passes a char[20] stack buffer. In every case the size argument described a buffer that was not the destination. Only the fact that cue_find_disc_number() maps a single character - so the append is at most four bytes - kept that from overflowing. Take the destination size as a parameter and bound the append with it. While here, fix the ordering in detect_wii_game(). It called the helper on 's' and then overwrote 's' with strlcpy(), so the suffix was computed and immediately discarded - every other detector copies the serial first and appends afterwards. The effect was that every disc of a multi-disc Wii set produced the same serial and matched the same database entry, which is the wrong one for all but the first disc. Against a synthetic Wii image with game id RMCE01: filename before after Mario Kart Wii (USA).iso RMCE01 RMCE01 Some Game (Disc 1).iso RMCE01 RMCE01-0 Some Game (Disc 2).iso RMCE01 RMCE01-1 Some Game (Disk B).iso RMCE01 RMCE01-1
…cators remove_disc_indicators() matched eight prefixes but then skipped a hard-coded seven characters to find the indicator text. That is correct for " (Disc " and its three siblings; the tape and side prefixes deliberately omit the leading space and are six characters long. For "(Tape 1)" the indicator was therefore taken to start at the ')' rather than the '1', giving a zero-length indicator that is_valid_disc_indicator() rejects. The net effect is that no tape or side indicator has ever been stripped, so those titles collapse differently from disc titles when M3U entries are merged. When the closing parenthesis fell before the mis-computed start the subtraction wrapped, but is_valid_disc_indicator()'s "len > 10" sanity check caught the result, so this was a behaviour bug rather than an out-of-bounds read. Compare end_pos against the start anyway now that the offset is per-pattern. Table-drive the patterns so each carries its own length. Differential over the old and new implementations: input old new Final Fantasy VII (Disc 1) Final Fantasy VII Final Fantasy VII Some Game (Disk A) Some Game Some Game Elite (Tape 1) Elite (Tape 1) Elite Head Over Heels (Side A) Head Over Heels (..) Head Over Heels Game (Disc 1 of 2) Game Game Game (Europe) Game (Europe) Game (Europe) Game (Disc Extra Content) unchanged unchanged Disc and disk handling is unaffected; only the tape and side prefixes change, and non-indicator parentheticals are still left alone.
libretrodb_validate_document() recursed into nested maps with
if ((rv == libretrodb_validate_document(&value)) != 0)
return rv;
"==" where "=" was meant. rv is 0 at that point, so the condition is
true exactly when the recursive call returned 0 - a *valid* nested
document - and then returns rv, which is still 0. When the recursive
call returned -1 for an invalid document the condition was false and
the loop simply carried on.
The check is therefore inverted: illegal keys nested inside a
sub-map, which is what the "$" prefix test exists to reject, were
accepted, while a valid sub-map short-circuited the rest of the
outer document's validation.
Driving libretrodb_create() with a document whose nested map carries
a "$"-prefixed key:
nested key before after
"ok" accepted accepted
"$bad" accepted <- should not be rejected (-1)
This only affects the database creation path (c_converter,
libretrodb_tool), not runtime scanning.
add_files_from_archive() writes the archive member name at new_path + _len + 1, so the space remaining is sizeof(new_path) - _len - 1. The bound passed to strlcpy() was sizeof(new_path) - _len, one byte more than exists. The enclosing "if (_len + strlen(...) + 1 < PATH_MAX_LENGTH)" test keeps the extra byte unreachable today, so this is not a live overflow - but the guard and the bound have to describe the same space or the next change to either lands on it.
scan_results_batch_update_playlists() decides whether to switch
playlists with
if (!single_playlist && (!current_playlist
|| !string_is_equal(current_playlist, result->db_name)))
but when task_config->playlist_file is set the branch inside assigns
current_playlist = task_config->playlist_file, a path such as
".../MyList.lpl". That never compares equal to a db_name such as
"Sega - Genesis.lpl", so the test is true on every iteration: each
result writes the playlist out in full, frees it, and calls
playlist_init() to parse it back from disk. A scan producing N
results performs N complete loads and N complete writes of the same
file, and every intermediate write is thrown away.
Compare against the key that actually identifies the target playlist
- the fixed path when one is configured, the database name otherwise
- so the fixed-file case opens once and accumulates.
Two other problems in the same function:
playlist_init()'s result was passed to seven playlist_set_scan_*
calls before "if (!playlist)" was reached. Not all of those are
NULL-guarded - playlist_set_scan_search_recursively(),
playlist_set_scan_overwrite_playlist(), playlist_set_scan_db_usage(),
playlist_set_scan_omit_db_ref(), playlist_set_scan_filter_dat_content()
and playlist_set_scan_search_archives() dereference unconditionally,
as do playlist_set_sort_mode(), playlist_qsort() and
playlist_write_file() later - so a failed init was a NULL
dereference, not a skipped entry. Move the check to the assignment.
The final cleanup decided whether to free the playlist by comparing
current_playlist against task_config->playlist_file, using a path
string as a proxy for "is this the borrowed manual_scan->playlist".
With a fixed playlist file that test also matched the playlist this
function had opened itself, which then leaked. Compare the handles,
which says what was meant.
…ompile
query_func_min() and query_func_max() report "smaller/larger than
everything seen so far", so the last row a cursor yields carries the
actual extreme. That accumulator lives in a file-scope
intermediate_res, and the only place it was reset is
libretrodb_query_compile().
A compiled query outlives the walk that uses it -
libretrodb_cursor_open() takes a reference to it - so opening a
second cursor over the same query starts with the previous walk's
extreme still in place. Nothing is then smaller than the running
minimum and the query yields no rows at all:
walk 1 (min size) 131072 131072
walk 2, same query -1 131072
before after
This is latent with the current call pattern, because
database_cursor_open() compiles a fresh query for every
database_info_list_new_filtered() call. It becomes live the moment a
compiled query is cached across walks, which is the obvious fix for
the per-(file x database) recompilation cost in the scanner.
Reset at libretrodb_cursor_open(), which is the accumulator's actual
lifetime boundary, and keep the compile-time reset. Also make
intermediate_res static; it was an unqualified global symbol and
nothing outside query.c refers to it.
This does not make min()/max() thread-safe. The accumulator is still
shared, so two cursor walks evaluating them concurrently corrupt each
other. Fixing that means moving the state into struct query, which
needs a context parameter added to rarch_query_func and threaded
through all nine query_func_* implementations and their recursive
invocation sites - a bigger change than this one, noted in the
comment on intermediate_res.
dom_read_map_start() and dom_read_array_start() assigned
v->val.map.len / v->val.array.len before calling calloc(). On
allocation failure they returned -1 with the value already marked
RDT_MAP or RDT_ARRAY, carrying a non-zero length and a NULL items
pointer.
That error propagates to rmsgpack_dom_read_with(), whose cleanup path
calls rmsgpack_dom_value_free() - which walks items[0..len) and
dereferences NULL.
The allocation size is attacker-influenced. len is bounded only by
half the bytes remaining in the file, so a large .rdb carrying a
MAP32 header sized past the process' remaining memory reaches it. A
2 MB .rdb claiming 1000000 pairs, run with a constrained allocator:
rmsgpack_dom.c:219: member access within null pointer of type
'struct rmsgpack_dom_pair'
AddressSanitizer: SEGV on unknown address 0x000000000010
[0] rmsgpack_dom_value_free rmsgpack_dom.c:204
[1] rmsgpack_dom_value_free rmsgpack_dom.c:219
[2] rmsgpack_dom_read_with rmsgpack_dom.c:473
[3] libretrodb_cursor_read_item libretrodb.c:569
Publish the length only once storage backs it, so a failed
allocation leaves a well-formed empty container for the cleanup path
to release.
Handle the zero-length case explicitly while here. An empty map or
array is legal MsgPack, but calloc(0, n) is permitted to return NULL
and the old code could not distinguish that from failure - so on a
platform whose allocator does return NULL for a zero request, a
record containing an empty container failed to parse. Verified that
a record with an empty map and one with an empty array both still
read back, alongside the existing corpus and the 500-record
byte-exact readback.
string.buff, binary.buff, map.items and array.items all sit at the same offset in the rmsgpack_dom_value union. The extraction paths read val->val.string.buff for name, serial, genre, region and the rest without checking val->type, so a field stored with an unexpected type handed strdup() a pointer to the wrong kind of data - the raw bytes of a binary field, or the head of a map's pair array - and that went into the playlist entry verbatim. Reading a .rdb whose "name" is stored as something other than a string, via the field-filtered path the scanner uses: stored type before after uint (null) (null) binary \x01\x02\x03\xff (null) map \x01 (null) The scalar case was already harmless: uint_ and int_ occupy offset 0 while buff is at offset 8, so buff reads the calloc'd zero and the existing "vs && *vs" test rejects it. The string and binary readers NUL-terminate their buffers, so the binary case stays inside its allocation too. The map case has nothing bounding it - strdup() walks a pair array looking for a zero byte - but that array is calloc'd and in practice contains one early. So this is a correctness fix, not a memory-safety one: the visible symptom is a garbage playlist label rather than a crash. The md5 and sha1 fields already gate on val->type; the string fields simply never did. Do the same for them, in both database_cursor_iterate() and database_cursor_iterate_filtered(), and for "size", which read val_.uint_ regardless of type. Also NULL-check the key's buffer before strlen().
The four task_title constructions use _len = strlcpy(task_title, msg_hash_to_str(...), sizeof(task_title)); strlcpy(task_title + _len, x, sizeof(task_title) - _len); strlcpy() returns the length of its source rather than the number of bytes copied, so when the message does not fit, _len exceeds the buffer: "task_title + _len" is an out-of-bounds pointer and "sizeof(task_title) - _len" wraps to a huge size_t. This is not currently reachable. task_title is char[128] and the longest shipped string among the four is 26 bytes (MSG_MANUAL_CONTENT_SCAN_PLAYLIST_CLEANUP), so there is a wide margin. It is the same misuse of the return value that overflowed the serial query buffer, though, and these strings come from Crowdin, so make the pattern safe rather than relying on the margin. Skip the append when the first copy already filled the buffer - there is no room for it in that case anyway - which keeps the existing single-pass idiom rather than reaching for strlcat().
query_func_min() and query_func_max() keep the running extreme in a
file-scope intermediate_res. Two queries evaluated at the same time -
a scan task walking a size probe while the menu populates a database
view - write to it concurrently.
ThreadSanitizer, two threads each doing what
database_info_list_new_filtered() does (own db handle, own compiled
query, own cursor):
WARNING: ThreadSanitizer: data race
Write of size 8 by thread T2:
[1] query_func_min query.c:310
[1] libretrodb_query_compile query.c:1021
Location is global 'intermediate_res' of size 24
The accumulator is per-evaluation state, so put it in the compiled
query and pass it to the evaluation functions instead of reaching for
a global. rarch_query_func gains a struct query_ctx * first
parameter, threaded through all nine query_func_* implementations,
the recursive invocations inside query_func_operator_or() and
query_func_operator_and(), and the three entry points
(libretrodb_query_filter, libretrodb_query_eval_field and
query_func_all_map).
libretrodb_query_reset_accumulator() now takes the query whose
accumulator to clear; libretrodb_cursor_open() passes the query it
was handed.
Race reports on intermediate_res under the harness above: 1 before,
0 after. Behaviour is unchanged - the corpus still parses clean, the
500-record database still reads back byte-identically, min() over a
reused query still returns 131072 on both walks, and the crc fast
path still matches the expected record.
This does not address the other shared static in this file,
tmp_err_buff in libretrodb_query_compile(), which the same harness
also flags.
libretrodb_query_compile() formatted its diagnostics into a
function-scope static and returned that address through err_string,
so the buffer was shared process-wide. Two concurrent compiles that
both fail overwrite each other's message, and the caller reading
err_string can see text belonging to the other thread's query.
ThreadSanitizer, two threads compiling a malformed query against the
same database:
WARNING: ThreadSanitizer: data race
Location is global 'tmp_err_buff.0' of size 256
The pointer has to outlive the call, which is why it was static in
the first place - the query object is freed on the error path, so it
cannot live there. The db handle can: it is already a parameter, it
outlives the compile, and every caller uses it immediately
afterwards. Move the storage onto struct libretrodb and expose it
through libretrodb_query_err_buf().
Compiling without a handle now fails with a fixed message instead of
writing anywhere, rather than teaching ten snprintf() sites to accept
a NULL destination. All four in-tree callers - database_info.c,
libretrodb_tool.c twice, and lua/testlib.c - already pass an open
handle.
With the preceding commit this clears both shared statics in query.c.
The failing-query harness reports two race locations before
(intermediate_res and tmp_err_buff) and zero warnings after.
Behaviour is unchanged: the corpus parses clean, the 500-record
database reads back byte-identically, min() over a reused query is
still correct, the crc fast path still matches, and a malformed query
still reports "7::Expected '}' found ':'".
A content scan spends essentially all of its time walking record streams. Timing the three phases of one probe against the databases shipped today: Nintendo - NES open 0.07ms compile 0.006ms walk 22.6ms Sega - Genesis open 0.05ms compile 0.007ms walk 6.6ms Sony - PS1 open 0.07ms compile 0.006ms walk 15.7ms The walk is 99.7% of it, and with the file already in the page cache most of that is per-read overhead through intfstream/filestream/VFS rather than parsing - the same walk over a memory-backed stream runs about 2.2x faster (17.9 -> 8.1 ms for NES, 14.2 -> 6.1 ms for PS1). Read the database in once at libretrodb_cursor_open() and walk it from memory. Best-effort: a database larger than LIBRETRODB_MAX_IMAGE_SIZE, or an allocation that fails, keeps the file-backed stream, so behaviour degrades to what it does now rather than failing. The largest database shipped today is about 8 MB against a 32 MB cap. The image is owned by the cursor rather than shared with the db handle. libretrodb_cursor_open() already opened its own stream, and some callers (lua/testlib.c) manage cursor and db lifetimes independently, so tying the image to the db would make closing the db first a use-after-free. Measured on the scan pattern - 20 content files probed against all three databases, which is what task_database_iterate_crc_lookup() does: before 60 probes in 0.84 s (14.0 ms/probe) after 60 probes in 0.45 s ( 7.5 ms/probe) Results are unchanged. Dumping every record of all three databases through the cursor walk is byte-identical before and after (30359, 7398 and 13487 records), as is a 40-CRC query differential across all three (79 matches). The malformed-input corpus still parses the same way, the 500-record readback is still byte-exact, memory streams report EOF as a zero-length read exactly as file streams do so the short-read checks are unaffected, and ASan/UBSan/LeakSanitizer are clean over all three real databases. Forcing every image allocation to fail still yields the full 7398 records via the fallback.
The preceding commit read the whole database into one buffer so that the walk's very many small reads became memcpy instead of a trip through filestream/VFS/fread. That works, but the buffer is the size of the database - about 7 MB for Nintendo - Nintendo Entertainment System - and needed a 32 MB ceiling with a silent fallback above it. A fixed sliding window gets the same effect without the footprint. Reads inside the window are a memcpy; the window is refilled by seek+read when one is not. A 6.7 MB walk needs about a hundred refills at 64 KB, against roughly 800000 individual freads before. approach probe peak RSS size cap file-backed (two commits ago) 14.0 ms +0 none whole-file image (previous) 7.5 ms +6984 KB 32 MB sliding window (this) 8.3 ms + 564 KB none Twelve times less resident memory for six percent of the time, and the ceiling goes away: a database larger than any cap now costs the same 64 KB as a small one. Window size is a compile-time knob. Measured 32/64/128/256 KB, probe time is flat across all of them (8.0-8.3 ms) while resident memory tracks the window, so the smallest workable size wins. 64 KB leaves room for the largest backwards jump the cursor makes - a rewind to the start of the record being inspected - which has to stay inside the window or it costs a refill. intfstream_open_buffered() is implemented over filestream alone. A data_transfer streaming window was tried first and rejected: it is slower (9.0 ms, from 80000 minor faults committing pages as the window slides against 5500 for the image), it holds more (1016 KB), and data_transfer_reserve_supported() is false wherever the platform cannot reserve address space - which is the newlib consoles, i.e. exactly the platforms whose memory this was meant to save. A plain malloc'd window has no such requirement and beats it on every axis. Verified against the three databases shipped for NES, Genesis and PlayStation: every record dumped through the cursor walk is identical to before (30359, 7398 and 13487 records), ASan/UBSan/LeakSanitizer report nothing, the malformed-input corpus parses unchanged, the 500-record readback is still byte-exact, and the crc fast path still matches.
Every case corresponds to a defect that was live at some point in
this file's history, so the reader now has something that fails
loudly if any of them come back.
metadata key absent the NULL from a failed map lookup fed
straight into "switch (value->type)"
metadata key mistyped rmsgpack_dom_read_into() switched on the
type found in the file and consumed a
different number of va_args per case
metadata count as fixint a legal alternative encoding that the
type check must not reject
nesting past the limit the readers recursed once per level of
container nesting with no depth cap
sentinel missing EOF arrives as a short read, not -1, so
the reader returned a fabricated record
forever
record truncated mid-map the reader freed an output value it had
never initialised, which in the cursor
loop still held the previous record
empty containers calloc(0) may return NULL and that was
read as an allocation failure
index headers binsearch() read before its count == 0
test, recursed without shrinking on a
one-element range, and loaded the record
offset through an unaligned cast, with
nothing cross-checking count against the
payload the header reserved
window spanning records have to survive crossing the
cursor's sliding-buffer boundary
field wider than window a read the window cannot serve must go
to the file rather than drop the record
The test builds each .rdb itself rather than shipping binaries, so
what every byte is for stays readable, and it does not use the
library under test to produce its own inputs.
Output is unbuffered and each case announces itself before it runs:
several of these crash outright when the defect is present, and the
line already printed is what says which one.
Verified to discriminate, not just pass:
upstream 9094fc6 (pre-audit) segfaults on case 1
audit_db 69387f9 (no window) 16/16, no false failures
with the oversized-read fix
backed out "field wider than the window" fails
Wired into the existing sample Makefile alongside libretrodb_leak_test,
including its SANITIZER= convention; "make check" runs both. Clean
under -fsanitize=address,undefined with leak detection on.
libretrodb_query_compile() takes a pointer and a length, but the
parser treated the query as a C string in two places and read past
the length in three more.
query_parse_table() copied a parsed identifier with
strlcpy(dest, ident_name, _len + 1);
strlcpy() strlen()s its source to compute a return value, and
ident_name points into the query buffer, which carries no terminator
of its own. Reading a query from an exactly-sized heap allocation:
ERROR: AddressSanitizer: heap-buffer-overflow
READ of size 21
[1] strlcpy_retro__ compat_strl.c:30
[2] query_parse_table query.c:853
[3] libretrodb_query_compile query.c:1076
query_raise_unknown_function() had the same pattern on the function
name it reports. Both now memcpy the slice they measured and
terminate it themselves.
query_parse_value(), query_parse_argument() and the entry point in
libretrodb_query_compile() each indexed buff.data[buff.offset]
directly after query_chomp(). Chomping stops at offset == len, so a
query ending in whitespace - or consisting only of whitespace - read
one byte past. query_peek() already guards this way; these three did
not.
Callers in tree pass strlen() of a NUL-terminated string, so the
byte read was that terminator and the parse still came out right.
That is what kept this from being visible, not anything in the
parser.
Two more while here:
query_parse_integer() accumulated result * 10 + digit into an int64_t
with nothing bounding it. The digit count comes from the query, so a
long run of digits is signed overflow, which is undefined. Reject the
number instead.
An empty binary string b"" sizes its allocation to (0 + 1) / 2 == 0,
and calloc(0) may return NULL, which the check below reports as an
allocation failure - the same shape as the container-length fix in
rmsgpack_dom.c. Allocate at least one byte.
Verified with eight queries fed from exactly-sized heap buffers with
no terminator, so any read past the length is a heap overflow the
sanitizer sees: clean after, and reverting the fixes puts the
overflow back. Real crc queries against the shipped NES, Genesis and
PlayStation databases still match the same records, and every record
of all three still dumps identically.
Eight queries fed to libretrodb_query_compile() from exactly-sized heap buffers with no terminator, which is what its (pointer, length) signature says is allowed. Any read past the length is then a heap overflow the sanitizer reports, rather than landing on a NUL that happened to be there - which is what hid these until now. Covers identifier slices, binary strings, queries ending at a value, queries ending in whitespace, a query that is nothing but whitespace, an integer long enough to overflow the accumulator, an empty binary string, and an unknown function name. Whether a given query compiles or is rejected is not the assertion - both are legitimate answers, and the point is that deciding does not read out of bounds. Verified to discriminate: with the parser fixes reverted the suite reports a heap-buffer-overflow at the first case.
manual_content_scan_set_menu_content_dir() copies the caller's path
into scan_content_dir and then trims a trailing separator with
if ((_len = strlcpy(scan_content_dir, content_dir,
sizeof(scan_content_dir))) <= 0)
goto error;
if (scan_content_dir[_len - 1] == PATH_DEFAULT_SLASH_C())
scan_content_dir[_len - 1] = '\0';
strlcpy() returns the length of its source, not the number of bytes
written, and here the source is longer than the destination can be:
the path is first expanded into a char[PATH_MAX_LENGTH], while
scan_content_dir is char[DIR_MAX_LENGTH] - half of PATH_MAX_LENGTH on
every platform (2048/1024 on desktop, 512/256 on the consoles).
So for any content directory longer than DIR_MAX_LENGTH the index is
past the end of the buffer, both for the read and for the store that
may follow it. A deeply nested folder chosen in the file browser is
enough; the path never has to be unusual.
The same function verbatim, with its destination on the heap so the
bound is unambiguous:
[probe] strlcpy returned _len=2047, buffer is 1024,
about to touch index 2046
AddressSanitizer: heap-buffer-overflow
READ of size 1
[0] manual_content_scan_set_menu_content_dir
located 1022 bytes after 1024-byte region
Clamp to what was actually written.
Also move the sanity check ahead of the expansion. It read
fill_pathname_expand_special(_tmpbuf, content_dir, sizeof(_tmpbuf));
content_dir = _tmpbuf;
if (!content_dir || !*content_dir)
which dereferences the caller's pointer first and then tests a
variable that has just been reassigned to a stack array, so the NULL
half of the check could never fire. task_push_dbscan() and
menu_cbs_ok.c both reach here with paths from outside this file.
Behaviour for valid paths is unchanged: short paths are accepted with
their trailing separator trimmed, NULL and empty are rejected as
before. An over-long path is still silently truncated rather than
rejected; that is pre-existing and left alone, since changing it
would alter what happens for users who currently get a truncated
scan.
libretrodb_create_index() mallocs a (key_size + 8) buffer per record
and hands it to bintree_insert(). The tree stores values by pointer
and never owns them, and bintree_free() only clears the pointer, so
every one of those buffers leaked:
before 2000 records: 24000 bytes leaked in 2000 allocations
20000 records: 240000 bytes leaked in 20000 allocations
after no leaks
The same loop also skipped its own cleanup: the "field not found"
continue jumped over the rmsgpack_dom_value_free() at the end of the
body, leaking a whole DOM value per record that does not carry the
indexed field.
Release the key buffers through bintree_iterate() before tearing the
tree down, and free the DOM value on the continue.
bintree itself reported two failures as success:
- bintree_insert() returned 0 - indistinguishable from a completed
insert - when it reached a NULL node, which is what an earlier
failed bintree_new_nil_node() leaves behind. The caller then
wrote an index that silently omitted whatever fell into the hole.
- bintree_new() returned a tree whose root allocation had failed,
so every subsequent insert took that same path.
Report -2 for an allocation failure, distinct from the -1 that means
an equal value is already present, and fail bintree_new() outright
if it cannot create a root. Also allocate both nil children before
committing either, so a half-grown node cannot be left in the tree.
Document the ownership rule in the header, since it was only
discoverable by reading bintree_free().
Behaviour is otherwise unchanged: an index over unique keys is still
built and is still usable - a create-then-look-up round trip resolves
crc 337 to the right record through binsearch() - and a database with
duplicate keys is still refused, which is every database shipped
today.
Note this whole path is reachable only from libretrodb_tool; the
scanner never creates or reads indices.
Nothing exercised libretrodb_create_index() or libretrodb_find_entry() together, which is the only path that reaches bintree_insert() and binsearch() - both of which have been corrected in this series without an end-to-end check behind them. Builds an index over 600 unique keys, then resolves one from the middle of the range so the search has to descend, and requires the right record back. Under the suite's ASan+UBSan+LeakSan build this also pins the key-buffer leak that index creation used to have. 26 checks, 0 failures.
Disc content matches on serial rather than crc, and that lookup walks
the whole database per content file exactly as the crc one did.
Measured against the shipped PlayStation Portable and PlayStation
databases, a serial probe is 3.06 ms and 8.59 ms - the same full walk.
Give it the same index. A serial is variable-length, so the index
keeps a hash rather than the bytes and reads candidate records back to
compare them exactly: a collision costs an extra read, never a wrong
match.
200 content files against the PlayStation Portable, PlayStation and
Genesis databases:
query path 3.37 s 600 probes, 5.61 ms each
index build 0.02 s 3 databases
index path 0.00 s 600 probes, under a microsecond each
The second half of this fixes a bug in the crc index from the
preceding commit, which the serial work found.
Both lookups gather matching records into a fixed buffer, and both
stopped filling it when it was full and returned what they had. That
is a shorter list than the query path returns for the same key - a
wrong answer, not a slow one. Databases really do carry placeholder
keys: Sega - Mega Drive - Genesis has a "00000000-00" serial shared by
hundreds of records, and comparing the two paths over its serials gave
89 mismatches until this was fixed.
The preceding commit's message claimed a key shared by more records
than the buffer holds "returns NULL and the query path runs instead".
It did not - it truncated. It does now, on both paths.
Equivalence re-checked over both, comparing every extracted field of
every returned record in order:
crc NES / Genesis / PS1 / PSP 600 each, 0 mismatches
serial PSP / PS1 / NES 500 each, 0 mismatches
serial Genesis 500: 411 answered from the
index, 89 correctly handed to
the query path, 0 mismatches
The differential now models the fallback rather than counting it as a
difference, since NULL is the documented "cannot serve this" signal
the scanner acts on.
A path longer than DIR_MAX_LENGTH was copied in truncated and
accepted. Truncation cuts mid-component, so what is stored is a
different directory:
input length 2161 -> accepted, kept 1023
stored tail ".../subdirectory_number_041/subdirectory_n"
system name "subdirectory_n"
The scan then runs against a path that in all likelihood does not
exist, reports nothing found, and gives no indication why. If
something does sit at the shortened path it scans that instead. The
tail also becomes the content-directory system name, so any entries
produced would be filed under a fragment of a folder name.
Nothing is lost by refusing. A path this long already produced one of
those two outcomes; failing here surfaces it as an invalid content
directory, which is what the menu already reports for the NULL, empty
and single-slash cases the function rejects a few lines above.
The bound is exact: DIR_MAX_LENGTH - 1 is accepted and stored whole,
DIR_MAX_LENGTH is refused.
length 1023 accepted stored 1023
length 1024 rejected stored 0
The preceding fix in this function clamped the index instead, which
made the read safe but left the truncated path in place. This
replaces the clamp; the out-of-bounds index it was there to prevent
cannot arise once the copy has to have fit.
The per-database size range is queried lazily, guarded by
if (db_state->min_sizes[db_state->list_index] == 0)
task_database_fill_db_min_max(db_state);
Zero is also what an unqueried slot holds, so a database whose probe
legitimately yields zero is asked again for every content file - and
each ask is two full walks of that database.
Reachable, though not by anything shipped today. query_func_min()
treats a zero specially: a zero-sized record matches, and so does
every record after it, so the stored value is the size of the last
match rather than the smallest. That lands on zero when the last
matching record is itself zero-sized. Of the databases shipped for
NES, Genesis, PlayStation and PlayStation Portable, none do:
NES min 2048 max 67108880 50.2 + 11.5 ms
Genesis min 8192 max 15466496 13.3 + 3.5 ms
PS1 min 738528 max 751746240 41.9 + 8.7 ms
PSP min 1605632 max 7815495680 12.7 + 3.1 ms
A database built to hit it - descending sizes ending in a zero-sized
record - stores min 0 and re-probes indefinitely.
Record the probe in the per-database flags instead, set before any of
the function's exits so that every one of them counts as having been
asked, including the paths that store the -1 placeholder. Nothing
else changes: the same queries run, once.
Worth noting for later that those two walks are now a visible share
of a scan. With crc and serial lookups indexed, the remaining cost is
about 60 ms per database of size probing against roughly 50 ms of
index building - the size range could come out of the same pass that
builds the index rather than costing two more walks.
The per-database size range came from two queries, {size:min(0)} and
{size:max(0)}, each a full walk that also materialises every record it
matches - and min() matches a lot of them, since it reports everything
smaller than the running value. Measured against the shipped
databases that pair costs far more than the index build it sits next
to:
NES queries 232.2 ms index build 18.4 ms
Genesis queries 374.9 ms index build 5.1 ms
PS1 queries 214.6 ms index build 11.6 ms
PSP queries 459.3 ms index build 3.5 ms
The index already walks every record. Collect the size range in that
same pass and the two queries go away entirely: libretrodb_scan_field()
now takes a companion numeric field reported alongside the key, and
the crc index keeps the smallest and largest it saw.
The range is the true minimum and maximum rather than what the
queries returned, and that is a deliberate difference. It is
identical on every database shipped today. It differs only where
min() misreports: a zero-sized record matches, and so does every
record after it, so the stored value ends up being the last match
rather than the smallest. On a database built to hit that the queries
give [0, 0] - which would skip every database for any content with a
size - against [0, 3072000] from the walk. The range is only used to
skip databases that cannot hold a file of a given size, so a wider
range can only mean fewer skips, never a missed match.
HAS_SERIAL is set rather than derived, because this walk scans crc
and does not observe serial keys. Leaving it clear would skip every
database for disc content, which is a missed match; setting it costs
at most one wasted serial-index build for a database carrying no
serials, and every database shipped today carries some. HAS_CRC is
set for symmetry - nothing reads it beyond a debug line.
Both equivalence differentials were re-run after the callback
signature changed: crc 400 keys across four databases and serial 500
across three, comparing every extracted field of every returned
record in order, 0 mismatches.
Builds and runs the two sample tests under AddressSanitizer, UndefinedBehaviorSanitizer and LeakSanitizer, and the threaded harnesses under ThreadSanitizer, returning non-zero if anything reports so it can gate a change. The in-tree tests run with no setup. The larger checks - real databases, a malformed-input corpus, concurrent scans - need .rdb data far too big to keep in the repository, so those are opt-in through SWEEP_CORPUS pointing at a directory holding them: ./sweep.sh in-tree tests only SWEEP_CORPUS=~/rdb-corpus ./sweep.sh everything Reachable as "make sweep" alongside the existing "make check". Written while auditing this code, where it repeatedly caught things reading the diff did not - most recently a signature change that compiled everywhere except the test that exercised it.
The scanner promotes a matched database to the front of its list so
the next content file tries it first, moving the entry along with its
size bounds and flags. The crc and serial index caches added recently
are keyed by the same position and were not moved with it.
After the first match at a non-zero position, therefore, the index at
a slot described a different database than the entry at that slot.
The lookup answered from whichever database its index had been built
over, and the scanner recorded the result against whichever database
now occupied the slot. Reproduced over the shipped NES, Genesis and
PlayStation databases, probing a crc only PlayStation holds:
before slot 2 Sony - PlayStation -> radicalgames@psygnosis v.3
after slot 0 Sony - PlayStation -> (no match)
slot 2 Sega - Mega Drive -> radicalgames@psygnosis v.3
so a PlayStation title would have been filed as a Mega Drive one,
with nothing reporting an error. Move the caches with everything else
that is keyed by position.
The lookups now also take the database the caller believes the index
describes, and refuse when it does not match. That cannot happen
after this fix, which is the point: if the pairing is ever broken
again the result is the query path rather than another system's
records. The equivalence checks could not have caught the original
mistake, since they exercise one database at a time and never the
promotion.
Found while adding the memory ceiling below, which is the other half
of this commit.
Indexes live for the whole scan and had no ceiling. Roughly 360 KB
for the largest database shipped today is comfortable on desktop and
not on the consoles, which is exactly where DIR_MAX_LENGTH is already
halved for the same reason. Both builders now take a byte allowance
and give up rather than exceed it - a partial index would miss
matches without saying so - and the scan tracks a budget across
databases, 24 MB on desktop and 2 MB where memory is tight. Running
out only means later databases use the query path, which is slower
and not wrong.
Covers the mistake fixed in the preceding commit and the ceiling added with it. Lives beside the existing database sample because it needs database_info.c, which the libretro-db samples cannot link. Builds two databases of its own, then checks that a lookup against the index for that database returns the right record, and that a lookup against an index built over a *different* database is refused rather than answered. The second is the one that matters: it is what turns a broken pairing into a slow scan rather than content filed under the wrong system. Also checks that an index asked for more than its allowance is refused outright rather than returned short, and that one built within its allowance is still complete. Verified to discriminate: with the pairing check backed out, the two mismatch cases return the other database's record and the suite fails. Reachable as "make check" in that directory; the SANITIZER= variable the Makefile already honoured applies. The new file needed .gitignore fixing to land. Line 96 read "database", part of the block covering the runtime directories RetroArch creates when run from the source tree - saves, config, playlists and so on. Unanchored, it also matched samples/tasks/database, so anything added there was invisible; the two files already in that directory predate the rule. Anchored to /database it still covers the runtime directory, verified, and nothing else in the tree becomes newly visible.
The teardown frees the database list and then sized the index-cache
loops from it:
if (dbstate->list)
dir_list_free(dbstate->list);
...
size_t cn = dbstate->list ? dbstate->list->size : 0;
for (ci = 0; ci < cn; ci++)
database_info_crc_index_free(dbstate->crc_index[ci]);
dir_list_free() does not clear the caller's pointer, so the guard
passes, ->size is read out of freed memory, and whatever it returns
then drives a loop that frees pointers past the end of the cache
array. That is a use-after-free feeding an out-of-bounds walk, on the
path taken whenever a scan finishes or is cancelled.
Mine, from the commit that added those caches. Take the count first,
and clear the list pointer once it is gone so a later read fails
loudly instead of silently.
Not caught by anything already in place: the harnesses and the
regression tests drive lookups, not the task lifecycle, so no sweep
ever reached this teardown. The sample in samples/tasks/database
would reach it, but its content enumeration is stubbed out, so the
scan never completes. Worth knowing that the teardown path still has
no automated coverage; this was found by reading the diff for
position-keyed state after the pairing bug, not by a tool.
The sample passed NULL for cache_supported. core_info_list_new() writes through it unconditionally: *cache_supported = true; core_info.c:2307 so the sample died on startup, before reaching the scan it exists to exercise: runtime error: store to null pointer of type '_Bool' AddressSanitizer: SEGV ... in core_info_list_new Pre-existing and outside the database chain, but it blocks running the scanner under the sanitizers at all, which is what this sample is for. Fixed on the caller's side, since the other caller in retroarch.c passes a real pointer and the parameter is not documented as optional. A guard in core_info_list_new() would be the more defensive choice; that is a call for whoever owns that file. The sample still does not complete a scan - dir_list_new_special() is stubbed to return NULL there, so no content is enumerated - but it now gets far enough to be useful under a sanitizer.
dir_list_new_special() was stubbed to return NULL for every request. The scanner only asks it for DIR_LIST_DATABASES, which retroarch.c answers with a plain "rdb" listing of the directory, and dir_list_new() is already linked here, so the stub can answer that much honestly rather than claiming no databases exist. This does not make the sample complete a scan. Driven over three real .rdb files and a directory of content, it still reports MSGQ: 0% MSGQ: 100% scan did not finish within 120 seconds so the task runs to completion of its work and never sets RETRO_TASK_FLG_FINISHED, or sets it without the callback firing. That is in the task-queue plumbing rather than anything the database chain does, and it is why nothing in this tree exercises the scan teardown - the path where a use-after-free in the index caches sat undetected through every sanitizer run in this series. Getting this sample to finish a scan is worth more than any further static review of the code it drives; it is the only harness that reaches the task lifecycle at all.
cb_task_manual_content_scan() invoked the caller's completion
callback from inside a HAVE_MENU block:
#if defined(HAVE_MENU)
end:
if (manual_scan && manual_scan->user_cb)
manual_scan->user_cb(task, task_data, user_data, err);
/* menu refresh */
#endif
so a build without menu support ran the scan to completion and then
dropped the callback. Anything waiting on it waited forever.
Nothing in tree noticed because the in-tree callers only supply a
callback under HAVE_MENU themselves - retroarch.c sets
cb_task_dbscan inside the same guard, and the menu_cbs callers exist
only in menu builds. But task_push_dbscan() and
task_push_manual_content_scan() take that callback unconditionally
and nothing says it is menu-only.
Move the invocation out of the guard; the label stays inside it,
since only the HAVE_MENU paths jump to it, and the menu refresh that
follows stays too. The two early returns above are unchanged: both
happen when there is no handle to read a callback from.
Found via samples/tasks/database, which builds without HAVE_MENU,
passes a callback, and has therefore never been able to finish a
scan. With this it does, which puts the whole scan lifecycle -
including the task teardown - under the sanitizers for the first
time. Reverting the teardown fix from earlier in this series is now
caught:
AddressSanitizer: heap-use-after-free
in free_manual_content_scan_handle
where before it produced a clean run.
task_push_dbscan() ignores its playlist_directory and content_database arguments - both are marked "always from settings" and the scan reads settings->paths instead. The sample set the playlist directory there but not the database one, so the scan ran with an empty database path: [INFO] [Scanner] ""... is that empty path being logged, and with no databases to match against the scan produced nothing. With this and the callback fix in the preceding commit the sample completes: [INFO] [Scanner] "/tmp/rdb"... PASS: scan completed under -fsanitize=address,undefined with leak detection on, reporting nothing. That is the first end-to-end sanitizer coverage of the scanner in this tree, and it reaches the task teardown, which no other harness here does.
The ceiling on index memory was a platform list with a comment claiming the consoles get less "where DIR_MAX_LENGTH is already halved for the same reason". There is no same reason. DIR_MAX_LENGTH is documented as "an upper limit for the length of a path (including the filename)" - a filesystem constraint. It says nothing about how much heap a platform has. What I actually did was copy the platform list out of that #if and write a justification that implied a shared rationale I never established. The numbers either side of it, 24 MB and 2 MB, were picked to look reasonable rather than measured against anything. mem_stats_free() is the right source and was already available: implemented for the 3DS and the GameCube/Wii among others - the platforms this was guessing about - already linked wherever task_database.c is, and already used this way by task_audio_mixer_threshold(), which sizes a task-lifetime allocation as a share of what is free. Take an eighth, deliberately more conservative than the quarter the mixer uses because these indexes are held for the whole scan rather than consulted once. Cap at 32 MB so a large-memory host does not hoard - the biggest database shipped indexes to roughly 360 KB, so that covers far more databases than exist. Below 256 KB do not start at all. Where the platform has no implementation and reports zero, fall back to 2 MB, which is small enough to be harmless given that running out only means using the query path. 3DS, most of 128 MB free 12 MB 3DS, squeezed 1 MB Wii, 40 MB free 5 MB 1.5 MB free none, query path only desktop 32 MB (capped) platform reports nothing 2 MB Behaviour is unchanged when the budget is ample, which is every case the equivalence checks and the end-to-end scan cover; running out was already handled by falling back.
Prompted by the DIR_MAX_LENGTH correlation in the preceding commit
being fabricated, I went back over the comments added in this series
looking for the same failure - a claim written from intent rather
than checked. Three did not survive.
"The scanner stops at the first entry that matches, so a bounded
number of hits is always enough", above the 32-entry lookup buffer.
That is precisely the reasoning that produced the truncation bug
fixed earlier in this series: Sega - Mega Drive - Genesis carries a
placeholder serial shared by hundreds of records, and comparing the
two lookup paths over its serials gave 89 mismatches until the
overflow was handed back to the query path instead of cut short. The
comment survived the fix and told the next reader the fallback was
unnecessary.
"Same shape as the serial path above". The serial lookup starts at
task_database_iterate_serial_lookup(), below the crc one, not above.
"12 bytes per indexed record", and the 360 KB that followed from it.
struct db_crc_entry is a uint64 offset and a uint32 key, which pads
to 16. Measured against the shipped databases:
Nintendo - NES 30359 crc entries 474 KB
Sony - PlayStation 12673 crc entries 198 KB
13487 serial 210 KB
Sega - Genesis 7398 crc entries 115 KB
Sony - PSP 4695 crc entries 73 KB
so the largest is 474 KB rather than the 360 KB claimed, and a full
set of databases costs about a third more than the figure quoted
throughout this series. The budget added in the preceding commit is
unaffected - it measures what an index actually holds rather than
predicting it - but the numbers used to justify the cap were wrong.
Nothing about the code changes here.
An index entry was a uint64 offset beside a uint32 key, which pads to
16 bytes. A 32-bit offset packs the pair to 8 with no padding, so an
index costs half what it did:
Nintendo - NES 30359 crc entries 474 -> 237 KB
Sony - PlayStation 12673 crc entries 198 -> 99 KB
13487 serial 210 -> 105 KB
Sega - Genesis 7398 crc entries 115 -> 57 KB
Sony - PSP 4695 crc entries 73 -> 36 KB
The range is not close to being a constraint - the largest database
shipped is 7.3 MB - but both collectors refuse to index a database
whose offsets would not fit rather than storing a wrong one, so it
degrades to the query path if that ever changes.
This also removes a trap. The offset comparator read the leading
eight bytes of an entry as a uint64:
uint64_t x = *(const uint64_t*)a;
which happened to work while the offset was that wide, and would have
compared offset and key together the moment it was not - sorting the
lookup results wrongly and silently, since the records would all
still be correct records. It is now typed against the struct it
actually sorts, and the comment claiming both entry types "lead with
a uint64 offset" is gone with it.
Verified after the change: crc equivalence over 250 keys in each of
three databases, serial over 500 in Genesis - which includes the 89
that exceed the lookup buffer and fall back - and the composed
decision over four databases, all with no mismatch. Sweep, the
pairing test and the end-to-end scan are clean.
playlist_manual_scan_record_t has ten members. playlist_init()
mallocs the playlist and then sets nine of them; overwrite_playlist
is the one it misses, so it is read out of uninitialised memory:
playlist.c:3855: runtime error: load of value 190,
which is not a valid value for type '_Bool' playlist_set_scan_overwrite_playlist
playlist.c:2034: runtime error: load of value 190,
which is not a valid value for type '_Bool' playlist_write_file
190 is 0xBE, the allocator's fill. The second site is the one that
matters: the flag is written into the playlist's scan record on disk,
and a later manual re-scan of that playlist reads it back to decide
whether to overwrite the existing entries or add to them. Whichever
it does was decided by whatever was in that byte.
Found by the scan sample in samples/tasks/database, which reached the
playlist-writing path for the first time once it could complete a
scan that matches something. Nothing in this series touches
playlist.c; it is reported by UBSan on the way through.
Drives task_push_dbscan() over content built to match, and checks the
playlists it produces name the right games under the right databases.
This is the only test here that reaches the scan as a whole - the
task lifecycle, database iteration, crc lookup, the promotion of a
matched database to the front of the list, the playlist write and the
teardown. Two defects went undetected because nothing exercised that:
the index caches were keyed by a database's position but not moved
when a match promoted it, and playlist_init() left
scan_record.overwrite_playlist uninitialised.
The second content file matches the *second* database deliberately,
and sorts first so a lookup happens after the promotion. Neither
detail is incidental: a match only in the first database never
promotes anything, and a promotion on the last file is never
consulted again. With both the promotion fix and the pairing guard
removed the test reports
FAIL first database's game in its playlist Alpha The Game
and with only one of them removed it passes, because either alone is
enough to keep the answer right - the guard by falling back to the
query path. That is the intended design and it is why the test needs
both removed to demonstrate it discriminates.
Under the sanitizers it also catches the playlist defect: reverting
that initialiser gives
playlist.c:3855: runtime error: load of value 190,
which is not a valid value for type '_Bool'
Databases, content and core info are all built by the test, so it
needs nothing but a writable directory. Content is made to match by
forcing its crc32 - four appended bytes bring a buffer's checksum to
any value - rather than by shipping data.
One part of the fixture is worth knowing about: the core info has to
name the databases as well as the file extension, because the scanner
skips any database no installed core claims. Without that line the
scan reports no match for content whose crc is certainly present,
which reads exactly like a lookup bug.
Reachable as "make check" alongside the index test.
The sweep is the gate this series runs before producing a patch, and it did not touch the scanner at all - only the libretro-db tests below it. The two defects that reached the branch in this series were both in the scan lifecycle, so the gate was blind to exactly the code most likely to break. Runs "make check" in samples/tasks/database under -fsanitize=address,undefined, which covers the index pairing test and the end-to-end scan. Those link most of the frontend and so are slower to build than everything else here; they are also the only coverage of the scan as a whole, including the task teardown and the playlist write. Verified to gate rather than decorate: breaking one assertion in the scan test gives scanner tests 11 checks, 0 failures 6 checks, 1 failures FAIL first database's game in its playlist Alpha The Game SWEEP FAILED where before the sweep stayed clean.
task_database_cue.c is patched in this series - the disc-suffix append was given the real destination size, and the disc-indicator stripping was using one pattern's length for all of them - and none of it had end-to-end coverage. Adds a third database and a cue sheet naming a track beside it. Only the track carries the matching crc, so the scan can only succeed by reading the sheet, resolving the file it names and checksumming that; the sheet's own checksum is not the target and could not match by chance. The playlist entry is then checked to record the sheet rather than the track, which is what a frontend loads. 8 checks.
add_files_from_archive() expands an archive that did not match into its members and rescans them. Nothing exercised it. Adds a fourth database and a one-entry zip whose member carries the matching crc. The archive itself checksums to something else, so a match can only come from expanding it. The zip is written by hand with the stored method - no compressor needed, and no binary fixture in the tree. Two things this does not do, stated because the commit that touched this code is in the same series: The bound corrected there - the copy starts at _len + 1 so the space is sizeof(new_path) - _len - 1, not - _len - is not demonstrated by this. Reverting it to the off-by-one form leaves the test passing with no sanitizer report, which matches what that commit said at the time: the enclosing length test keeps it unreachable. The coverage here is of the expansion path, not of that bound. The entry is also asserted to record the archive rather than the "archive#member" form the scan builds internally, because that is what the playlist actually contains. The first version of this test asserted the member form and failed; the assertion was wrong, not the scanner. 10 checks.
Comments and one script path named specific consoles and their
databases as illustrations. None of it was load-bearing: the figures
stand without saying which database produced them, and the sweep can
take whichever database it finds rather than one by name.
database_info.c "at least one shipped database" for the
placeholder-serial example
task_database.c "the biggest database shipped today" for
the index size, and "a db_name" for the
playlist comparison
database_index_test.c "a wholly unrelated system" for the
misfiling example
sweep.sh picks the first .rdb in the corpus rather
than a named file, which also stops the
threaded check being skipped when a corpus
happens not to contain that one
The system names in task_database_cue.c are untouched: the magic
table and the string comparisons around it are how the scanner
identifies a disc, so they are functional rather than illustrative,
and they predate this series.
No behaviour change. Sweep and both scanner suites unchanged.
Every database ships "serial" as a binary field rather than a string:
27056 binary and 0 string in Sony - PlayStation 2, 26957 and 0 in
Sony - PlayStation, 1949 and 0 in Nintendo - Nintendo Entertainment
System. The readers allocate len + 1 and write the terminator, so
such a field is a valid C string, and reading it as one was correct.
An earlier commit in this series gated the string fields on
RDT_STRING to stop a map or array being strdup'd through the shared
union member. That was the right fix for maps and arrays - their
items pointer need not contain a zero byte - but it also excluded
binary, which left db_info->serial NULL.
The scanner's serial match tests that pointer before it compares:
if (db_info_entry->serial)
if (string_is_equal(db_state->serial, db_info_entry->serial))
so the comparison never ran and no disc title matched. Reported
against a folder of 605 ISOs and 48 bin/cue where two entries
survived - both of them exact dumps that matched on crc instead.
Systems keyed on crc were unaffected throughout, which is why NES,
SNES and N64 scanned normally.
Accept RDT_BINARY alongside RDT_STRING at both extraction sites. Maps
and arrays stay excluded, which is what the original change was for.
The regression test now asserts the serial field survives extraction
rather than only that the right record is found; with the gate back
to RDT_STRING it reports
FAIL binary serial survives extraction (NULL)
Nothing caught this before because every test built its fixtures with
string serials, and the equivalence checks compared the index against
the query - both of which read through the same broken extraction and
so agreed with each other.
RLZMA_LCLP_MAX was 3, on the stated assumption that "the 7z streams
libretro-common reads do not exceed 3 either". They do. 7-Zip emits
lc=4, lp=0 routinely - the properties byte reads 0x5E - and
rlzma_dec_init() rejected every such stream:
if (dec->lc + dec->lp > RLZMA_LCLP_MAX)
return RLZMA_ERROR_PARAM;
Since 7z stores its own header as an LZMA stream, this failed at
r7z_archive_open() rather than on any particular member: the archive
could not be opened at all. Six archives from a DS set, all of them
LZMA1 with lc=4, all rejected. With the limit at 4 they open and
report entry checksums matching an independent reader exactly.
The neighbouring constants already say 4 - RLZMA2_LCLP_MAX is 4, the
reference encoder's limit is 4 - and the struct comment in this same
header documents the probability array as "~29 KiB", which is the
size for 4 and not for 3, so the two comments contradicted each
other.
Costs 6144 more uint16 entries, about 12 KiB per decoder instance,
allocated on the heap by its only caller.
file_archive_get_file_crc32_and_size() looped with no way out. The
iterate call is guarded on the transfer still being in ITERATE, but
nothing breaks once it leaves that state, and the two tests below
then re-read a current_file_path that can no longer change:
for (;;)
{
if (state.type == ARCHIVE_TRANSFER_ITERATE)
file_archive_parse_file_iterate(...);
if (!contains_compressed) break;
if (archive_path) { if (string_is_equal(...)) break; }
else break;
}
So a member that is not in the archive spun at full CPU forever. Not
a rare shape: the archive failing to open produces an empty
enumeration and the same outcome, which is how it was found - a
scanner walking a directory of 7z files stopped making progress
instead of skipping them.
It also returned whatever entry the walk stopped on. The caller
cannot tell a leftover checksum from a real one, and the scanner
would match content against the wrong record, so a miss now reports
zero size and zero crc.
Two defects stopped a content scan rather than failing it, and neither had coverage. archive_zip_test gains a missing-member case. file_archive_get_file_crc32_and_size() looped with no exit once the transfer left ITERATE, so asking for a member the archive does not hold spun at full CPU. The test builds a one-entry zip and asks for a name that is not in it, expecting nothing back. A regression does not fail this so much as hang it - there is no portable way to bound the call from inside - but a stuck test is a visible failure either way, and reverting the fix does hang it. samples/formats/r7z is new and covers the lc/lp limit. RLZMA_LCLP_MAX was 3 while 7-Zip routinely writes lc=4, and since 7z holds its own header as an LZMA stream, such an archive could not be opened at all. Checks the properties byte 7-Zip actually emits (0x5E), that every lc/lp pair inside the declared limit is accepted, and that the probability array is sized for that limit - the constant and the comment describing the array had already drifted apart once, the comment documenting the size for 4 while the constant said 3. Both are verified to discriminate: with RLZMA_LCLP_MAX back at 3 the property test reports FAIL lc=4 lp=0 pb=2, as 7-Zip writes it props 0x5E FAIL limit covers what the format allows in practice and with the loop fix reverted the archive test hangs. The archive_file sample gained a check target and SANITIZER support so the sweep can run it; it had neither. Both now run there.
encoding_crc32.c gained a PCLMULQDQ path that calls cpu_features_get(), so every harness linking it now needs features/features_cpu.c too: encoding_crc32.c:274: undefined reference to `cpu_features_get' The in-tree tests build through their own makefiles and were unaffected; only the corpus harnesses the sweep compiles directly failed, which is why the tests all reported clean while the sweep did not.
LibretroAdmin
force-pushed
the
audit_db
branch
2 times, most recently
from
July 28, 2026 14:36
fcd0ec3 to
ec6ab78
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
An audit of the content scanner and everything it calls:
tasks/task_database.c,tasks/task_database_cue.c,database_info.c,manual_content_scan.c, and all six sources underlibretro-db/. Every file in that chain was read. The fixes are here, along with the test coverage that demonstrates them.Two archive-handling fixes came out of testing this against a real library and are included; they sit outside that chain and could be split out if preferred.
.rdbfiles are downloaded from the buildbot, so the reader has to survive whatever is in one. Most of the parser findings are of that shape.Fixes
Parser and query (
libretro-db/)rmsgpack_dom_read_with()freed an output value it had never initialised; in the cursor loop that slot still held the previous record, so the free walked a dangling map.rmsgpack_dom_read_into()switched on the type found in the file and consumed a different number ofva_args per case, sliding the caller's list out of step. The caller now declares the type.0x91exhausted the stack.intfstream_read()as a short read, not-1, so the reader took its zero-initialised type byte for a positive fixint and returned a fabricated record forever.binsearch()read before itscount == 0test, recursed without shrinking on a one-element range, and loaded the record offset through an unaligned cast. Nothing cross-checked the index header's count against the payload it reserved.query_parse_table()andquery_raise_unknown_function()passed identifier slices tostrlcpy(), whichstrlen()s its source.query_parse_value(),query_parse_argument()and the compile entry point indexedbuff.data[buff.offset]afterquery_chomp(), which stops atoffset == len. Callers all pass NUL-terminated strings, so the byte read was that terminator — luck, not correctness.query_parse_integer()accumulatedresult * 10 + digitinto anint64_tunbounded, from a digit count the query controls.query.c(intermediate_res,tmp_err_buff) were shared across concurrent compiles. Both TSan-confirmed with a two-thread harness; 0 warnings after.Scanner (
tasks/,database_info.c,manual_content_scan.c)<= countinstead of< countwalking the info list; a dead NULL test onlist[0].manual_content_scan_set_menu_content_dir()trimmed a trailing separator usingstrlcpy()'s return, which is the length of its source. The source ischar[PATH_MAX_LENGTH], the destinationchar[DIR_MAX_LENGTH]— half of it on every platform. ASan:READ of size 1 ... located 1022 bytes after 1024-byte region. Reached by any deeply nested folder picked in the file browser. Its NULL check also sat after the pointer had been dereferenced.cb_task_manual_content_scan()invoked the caller's completion callback from inside aHAVE_MENUblock, so a build without menu support ran the scan and then dropped the callback. In-tree callers only supply one underHAVE_MENUthemselves, which is why nothing noticed.playlist_init()leftscan_record.overwrite_playlistuninitialised — 9 of the struct's 10 members were set. It is written into the playlist and read back on the next scan to decide whether to overwrite. UBSan:load of value 190, which is not a valid value for type '_Bool'.create_indexleaking every key buffer,bintreereporting a failed grow as a successful insert.Archive handling (
libretro-common/)These are outside the database chain and could reasonably be split out; they are here because a scan over a directory of
.7zfiles stops dead without them.RLZMA_LCLP_MAXwas 3, on the stated assumption that "the 7z streams libretro-common reads do not exceed 3 either". 7-Zip routinely writeslc=4, lp=0— a properties byte of0x5E— andrlzma_dec_init()rejected every such stream. Since 7z holds its own header as an LZMA stream, the rejection landed atr7z_archive_open(): the archive could not be opened at all and the member list came back empty. The neighbouringRLZMA2_LCLP_MAXis already 4, and the comment describing the probability array documents the size for 4, so the constant and its own comment disagreed.file_archive_get_file_crc32_and_size()looped with no exit. Its iterate call is guarded on the transfer still being inITERATE, but nothing broke once it left that state, and the name tests then re-read acurrent_file_paththat could no longer change — so a member the archive does not hold spun at full CPU indefinitely. An archive that fails to open produces an empty walk and lands in the same place, which is how it was found. It also returned whichever entry the walk stopped on, so a miss could match content against the wrong record; a miss now reports nothing.Measured on a real DS archive: opening went from failing outright to working, and the member lookup from not returning within 240 s to 0.021 s.
Performance
The scanner asked each database the same question once per content file, and each was a full cursor walk. Building a crc index in one pass and answering from it instead:
Serial lookups (disc content) got the same treatment, and the per-database size range now comes out of the same walk rather than two more queries. Indexes cost 8 bytes per record, are capped at a share of
mem_stats_free(), and anything the index cannot serve falls back to the query path.Equivalence was checked rather than assumed, because the scanner takes the first match and 15–24% of CRCs in the shipped databases are duplicates: 1,225 single-record lookups and 126 duplicate groups return the same records in the same order through both paths, and a composed decision over four databases agrees on all 300 items.
Tests
make checkinlibretro-db/samples/libretrodb,samples/tasks/database,libretro-common/samples/file/archive_fileandlibretro-common/samples/formats/r7z:task_push_dbscan(), and checks each game lands in its own database's playlist. Covers plain files, a cue sheet resolved through its track, and an archive member.Every case was verified to fail without its fix.
libretro-db/samples/libretrodb/sweep.shruns everything under ASan, UBSan, LeakSanitizer and TSan, and gates on the result.A mutational fuzzer (attached to the discussion, not in the tree) ran ~115,000 cases against the patched reader with no crashes or hangs. The same fuzzer against the pre-audit code reproduces five of the findings above in under 20 seconds.
Every file touched here builds clean under
-std=gnu90 -pedantic-errors. No C++ comments, non-ASCII bytes, tab-indented lines or trailing whitespace are introduced.libretro-db/c_converter.creports 9 C89 errors, unchanged from before this series — it is the offline converter, built with the host compiler.Three bugs I introduced and fixed here
All three are the same shape — a wrong answer with no error:
RDT_STRINGstopped every disc-based system matching.serialis stored as binary in all 145 shipped databases — 206,276 records, none as string — and the readers allocatelen + 1and write the terminator, so reading one as a string was correct. Excluding binary leftdb_info->serialNULL, and the scanner tests that pointer before it compares, so serial matching stopped entirely while crc-keyed systems carried on working. Found by a maintainer scanning a real library: 605 ISOs and 48 bin/cue produced two entries, both exact dumps that had matched on crc instead.dbstate->list->sizeafterdir_list_free(), and the garbage count then drove a loop freeing pointers past the end of the cache array.None was caught by anything in place at the time. The equivalence checks exercise one database at a time and never the promotion; no harness reached the task lifecycle; and every test fixture built its serials as strings, a shape the real data never uses, while the differentials compared the index against the query path — both reading through the same broken extraction, so they agreed with each other on all 1,225 comparisons.
All three now have regression tests that fail without their fixes, and the sweep runs them. Every field in all 145 shipped databases has since been surveyed against how the code reads it, across roughly 740,000 records;
serialwas the only mismatch.What has not been done
samples/tasks/databaseneeded three fixes to complete a scan at all, and its content enumeration still depends on a stubbeddir_list_new_special.libretro-db/c_converter.candbintree.care audited but are offline-tool paths the scanner never reaches.It has been thoroughly tested on CRC and serial-based content