config_file: audit and rework — zero-copy pooled parsing (−63% load, −48% write, −99.8% heap ops), injectable file I/O, streaming API, autoconfig profile index (437 opens → 2), 5 pre-existing bugs fixed, fuzzing + regression tests in CI (#19305)

This commit is contained in:
LibretroAdmin authored and GitHub committed 2026-07-30 08:47:22 +02:00
1 parent 6898587979
commit 1d1f27a025
32 files changed
+2932 -553

No files matched your search

@@ -0,0 +1,52 @@
name: CI Linux autoconfig regression
on:
push:
branches:
- master
pull_request:
branches:
- master
workflow_dispatch:
permissions:
contents: read
env:
ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: true
jobs:
autoconf-tests:
name: Build and run the autoconfig regression suite
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Install dependencies
run: |
sudo apt-get update -y
sudo apt-get install -y build-essential zlib1g-dev libfreetype6-dev
- name: Checkout
uses: actions/checkout@v3
- name: Configure (headless, test drivers)
run: |
set -eu
# The suite drives the test input/joypad drivers with the
# null video/audio/menu drivers - no X11/Vulkan/menu needed.
# HAVE_TEST_DRIVERS is on by default; assert it survived
# configure so a silent drop turns into a clear failure
# rather than a skipped test.
./configure --disable-menu --disable-ffmpeg --disable-x11 --disable-wayland
grep -q '^HAVE_TEST_DRIVERS = 1' config.mk
- name: Build
run: make -j"$(nproc)"
- name: Run autoconfig regression suite
shell: bash
run: |
set -eu
test -x ./retroarch
./tests-other/test_autoconf.sh ./retroarch
@@ -89,12 +89,11 @@ jobs:
)
# Per-binary run command (overrides ./<binary> if present).
# config_file_test has pre-existing sample leaks unrelated to
# any regression so we disable the ASan leak detector -- if a
# real leak shows up elsewhere it will be flagged during build
# of ASan-enabled configurations, not here.
# config_file_test's leak exemption is gone: the harness now
# frees everything it allocates and the parser's own include
# leak is fixed, so LeakSanitizer runs at full strictness and
# any surviving allocation is a real config_file regression.
declare -A RUN_ENV=(
[config_file_test]="ASAN_OPTIONS=detect_leaks=0"
)
# Samples that are build-only (either they require command-line
@@ -407,6 +406,62 @@ jobs:
# Win11/ARM64 atomic ordering bug, found only on real silicon),
# but qemu catches most categorical errors and is much cheaper than
# provisioning ARM runners.
config-file-fuzz-smoke:
name: config_file differential fuzz (smoke)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Install dependencies
run: |
sudo apt-get update -y
sudo apt-get install -y clang
- name: Checkout
uses: actions/checkout@v3
- name: Build fuzz target
shell: bash
working-directory: libretro-common/samples/file/config_file
run: |
set -eu
LC=../../..
clang -O1 -g -fsanitize=fuzzer,address,undefined \
-fno-sanitize-recover=all \
-I$LC/include -o config_file_fuzz config_file_fuzz.c \
$LC/file/config_file.c \
$LC/file/file_path.c \
$LC/compat/compat_strl.c \
$LC/compat/compat_strcasestr.c \
$LC/compat/compat_posix_string.c \
$LC/encodings/encoding_utf.c \
$LC/lists/string_list.c \
$LC/time/rtime.c
- name: Run fuzz smoke
shell: bash
working-directory: libretro-common/samples/file/config_file
run: |
set -eu
# Seed corpus: representative config shapes plus the byte
# patterns the target's invariants care about (embedded NUL,
# comment-in-literal, directives, duplicate keys, CRLF).
# Generated here rather than committed so no binary files
# live in the tree.
mkdir -p corpus
printf 'video_driver = "vulkan"\nvideo_fullscreen = "true"\naudio_rate = "48000"\n' > corpus/basic
printf '# comment\nkey = "has # inside"\ndup = "a"\ndup = "b"\n spaced = bare\r\nempty = ""\n' > corpus/edge
printf '#include "sub.cfg"\n#reference "other.cfg"\nafter = "1"\n' > corpus/directives
python3 -c "import sys; sys.stdout.buffer.write(b'a = \"1\"\nb\x00c = \"2\"\nd = \"3\"\n')" > corpus/embedded_nul
# 60 seconds of coverage-guided differential fuzzing:
# every input is parsed whole and again through the
# streaming push API in input-derived packet splits, then
# entry-list equality, hash-map coherence and tail
# validity are enforced (see config_file_fuzz.c). Any
# divergence or sanitizer report fails the job.
./config_file_fuzz -max_len=512 -max_total_time=60 \
-print_final_stats=1 corpus/
retro-atomic-cross:
name: Cross-arch retro_atomic_test (${{ matrix.arch }})
runs-on: ubuntu-latest
+1
View File
@@ -359,6 +359,7 @@ OBJ += \
core_backup.o \
core_option_manager.o \
$(LIBRETRO_COMM_DIR)/file/config_file.o \
$(LIBRETRO_COMM_DIR)/file/config_file_io.o \
$(LIBRETRO_COMM_DIR)/file/config_file_userdata.o \
runtime_file.o \
disk_index_file.o
+1
View File
@@ -38,6 +38,7 @@ OBJ := ctr/ctr_system.o \
libretro-common/compat/compat_strl.o \
libretro-common/compat/compat_strldup.o \
libretro-common/file/config_file.o \
libretro-common/file/config_file_io.o \
libretro-common/streams/file_stream.o \
libretro-common/vfs/vfs_implementation.o \
libretro-common/hash/lrc_hash.o \
+1
View File
@@ -88,6 +88,7 @@ OBJ := frontend/frontend_salamander.o \
libretro-common/compat/compat_strldup.o \
libretro-common/compat/compat_strcasestr.o \
libretro-common/file/config_file.o \
libretro-common/file/config_file_io.o \
libretro-common/streams/file_stream.o \
libretro-common/vfs/vfs_implementation.o \
libretro-common/hash/lrc_hash.o \
+1
View File
@@ -86,6 +86,7 @@ OBJS = frontend/frontend_salamander.o \
libretro-common/compat/compat_strcasestr.o \
libretro-common/compat/fopen_utf8.o \
libretro-common/file/config_file.o \
libretro-common/file/config_file_io.o \
libretro-common/streams/file_stream.o \
libretro-common/vfs/vfs_implementation.o \
libretro-common/hash/lrc_hash.o \
+1
View File
@@ -56,6 +56,7 @@ EE_OBJS = frontend/frontend_salamander.o \
libretro-common/compat/compat_strldup.o \
libretro-common/compat/compat_strcasestr.o \
libretro-common/file/config_file.o \
libretro-common/file/config_file_io.o \
libretro-common/streams/file_stream.o \
libretro-common/vfs/vfs_implementation.o \
libretro-common/hash/lrc_hash.o \
+1
View File
@@ -72,6 +72,7 @@ OBJ = frontend/frontend_salamander.o \
libretro-common/streams/file_stream.o \
libretro-common/vfs/vfs_implementation.o \
libretro-common/file/config_file.o \
libretro-common/file/config_file_io.o \
libretro-common/time/rtime.o \
verbosity.o
+1
View File
@@ -43,6 +43,7 @@ OBJS = frontend/frontend_salamander.o \
libretro-common/compat/compat_strldup.o \
libretro-common/compat/compat_strcasestr.o \
libretro-common/file/config_file.o \
libretro-common/file/config_file_io.o \
libretro-common/streams/file_stream.o \
libretro-common/vfs/vfs_implementation.o \
libretro-common/hash/lrc_hash.o \
+1
View File
@@ -47,6 +47,7 @@ OBJS = frontend/frontend_salamander.o \
libretro-common/compat/compat_strcasestr.o \
libretro-common/compat/fopen_utf8.o \
libretro-common/file/config_file.o \
libretro-common/file/config_file_io.o \
libretro-common/streams/file_stream.o \
libretro-common/vfs/vfs_implementation.o \
libretro-common/hash/lrc_hash.o \
+1
View File
@@ -82,6 +82,7 @@ OBJ = frontend/frontend_salamander.o \
libretro-common/compat/compat_strcasestr.o \
libretro-common/compat/fopen_utf8.o \
libretro-common/file/config_file.o \
libretro-common/file/config_file_io.o \
libretro-common/time/rtime.o \
verbosity.o \
$(APP_BOOTER_DIR)/app_booter.binobj
+1
View File
@@ -68,6 +68,7 @@ ifeq ($(SALAMANDER_BUILD),1)
OBJ += libretro-common/compat/compat_strl.o
OBJ += libretro-common/compat/compat_strldup.o
OBJ += libretro-common/file/config_file.o
OBJ += libretro-common/file/config_file_io.o
OBJ += libretro-common/streams/file_stream.o
OBJ += libretro-common/vfs/vfs_implementation.o
OBJ += libretro-common/hash/lrc_hash.o
+3 -4
View File
@@ -26,7 +26,6 @@
#include <compat/posix_string.h>
#include <string/stdstring.h>
#include <streams/file_stream.h>
#include <array/rhmap.h>
#ifdef HAVE_CONFIG_H
#include "config.h"
@@ -7017,7 +7016,7 @@ static bool config_load_file(global_t *global,
frontend_driver_set_sustained_performance_mode(settings->bools.sustained_performance_mode);
recording_driver_update_streaming_url();
if (!(bool)RHMAP_HAS_STR(conf->entries_map, "user_language"))
if (!config_get_entry(conf, "user_language"))
msg_hash_set_uint(MSG_HASH_USER_LANGUAGE, frontend_driver_get_user_language());
/* If GameMode is enabled in the config but libgamemode is not
@@ -7037,8 +7036,8 @@ static bool config_load_file(global_t *global,
* history playlist size limit. (Have to do this, otherwise
* users with large custom history size limits may lose
* favourites entries when updating RetroArch...) */
if ( (bool)RHMAP_HAS_STR(conf->entries_map, "content_history_size")
&& !(bool)RHMAP_HAS_STR(conf->entries_map, "content_favorites_size"))
if ( config_get_entry(conf, "content_history_size")
&& !config_get_entry(conf, "content_favorites_size"))
{
if (settings->uints.content_history_size > 999)
settings->ints.content_favorites_size = -1;
+36 -124
View File
@@ -1651,23 +1651,11 @@ static void core_info_resolve_firmware(
strlcpy(key + _len2, "path", sizeof(key) - _len2);
entry = config_get_entry(conf, key);
if (entry && (entry->value && *entry->value))
{
firmware[i].path = entry->value;
entry->value = NULL;
}
firmware[i].path = config_take_string(conf, key);
strlcpy(key + _len2, "desc", sizeof(key) - _len2);
entry = config_get_entry(conf, key);
if (entry && (entry->value && *entry->value))
{
firmware[i].desc = entry->value;
entry->value = NULL;
}
firmware[i].desc = config_take_string(conf, key);
}
info->firmware_count = firmware_count;
@@ -1692,149 +1680,93 @@ static void core_info_parse_config_file(
config_file_t *conf)
{
bool tmp_bool = false;
struct config_entry_list *entry = config_get_entry(conf, "display_name");
struct config_entry_list *entry = NULL;
if (entry && (entry->value && *entry->value))
info->display_name = config_take_string(conf, "display_name");
info->display_version = config_take_string(conf, "display_version");
info->core_name = config_take_string(conf, "corename");
info->systemname = config_take_string(conf, "systemname");
info->system_id = config_take_string(conf, "systemid");
info->system_manufacturer = config_take_string(conf, "manufacturer");
info->supported_extensions = config_take_string(conf, "supported_extensions");
if (info->supported_extensions)
{
info->display_name = entry->value;
entry->value = NULL;
}
entry = config_get_entry(conf, "display_version");
if (entry && (entry->value && *entry->value))
{
info->display_version = entry->value;
entry->value = NULL;
}
entry = config_get_entry(conf, "corename");
if (entry && (entry->value && *entry->value))
{
info->core_name = entry->value;
entry->value = NULL;
}
entry = config_get_entry(conf, "systemname");
if (entry && (entry->value && *entry->value))
{
info->systemname = entry->value;
entry->value = NULL;
}
entry = config_get_entry(conf, "systemid");
if (entry && (entry->value && *entry->value))
{
info->system_id = entry->value;
entry->value = NULL;
}
entry = config_get_entry(conf, "manufacturer");
if (entry && (entry->value && *entry->value))
{
info->system_manufacturer = entry->value;
entry->value = NULL;
}
entry = config_get_entry(conf, "supported_extensions");
if (entry && (entry->value && *entry->value))
{
info->supported_extensions = entry->value;
entry->value = NULL;
info->supported_extensions_list =
string_split(info->supported_extensions, "|");
}
entry = config_get_entry(conf, "authors");
info->authors = config_take_string(conf, "authors");
if (entry && (entry->value && *entry->value))
if (info->authors)
{
info->authors = entry->value;
entry->value = NULL;
info->authors_list =
string_split(info->authors, "|");
}
entry = config_get_entry(conf, "permissions");
info->permissions = config_take_string(conf, "permissions");
if (entry && (entry->value && *entry->value))
if (info->permissions)
{
info->permissions = entry->value;
entry->value = NULL;
info->permissions_list =
string_split(info->permissions, "|");
}
entry = config_get_entry(conf, "license");
info->licenses = config_take_string(conf, "license");
if (entry && (entry->value && *entry->value))
if (info->licenses)
{
info->licenses = entry->value;
entry->value = NULL;
info->licenses_list =
string_split(info->licenses, "|");
}
entry = config_get_entry(conf, "categories");
info->categories = config_take_string(conf, "categories");
if (entry && (entry->value && *entry->value))
if (info->categories)
{
info->categories = entry->value;
entry->value = NULL;
info->categories_list =
string_split(info->categories, "|");
}
entry = config_get_entry(conf, "database");
info->databases = config_take_string(conf, "database");
if (entry && (entry->value && *entry->value))
if (info->databases)
{
info->databases = entry->value;
entry->value = NULL;
info->databases_list =
string_split(info->databases, "|");
}
entry = config_get_entry(conf, "notes");
info->notes = config_take_string(conf, "notes");
if (entry && (entry->value && *entry->value))
if (info->notes)
{
info->notes = entry->value;
entry->value = NULL;
info->note_list =
string_split(info->notes, "|");
}
entry = config_get_entry(conf, "required_hw_api");
info->required_hw_api = config_take_string(conf, "required_hw_api");
if (entry && (entry->value && *entry->value))
if (info->required_hw_api)
{
info->required_hw_api = entry->value;
entry->value = NULL;
info->required_hw_api_list =
string_split(info->required_hw_api, "|");
}
entry = config_get_entry(conf, "description");
if (entry && (entry->value && *entry->value))
{
info->description = entry->value;
entry->value = NULL;
}
info->description = config_take_string(conf, "description");
if (config_get_bool(conf, "supports_no_game",
&tmp_bool))
@@ -2829,8 +2761,6 @@ size_t core_info_list_get_display_name(
core_updater_info_t *core_info_get_core_updater_info(
const char *info_path)
{
struct config_entry_list
*entry = NULL;
bool tmp_bool = false;
core_updater_info_t *info = NULL;
config_file_t *conf = NULL;
@@ -2858,31 +2788,13 @@ core_updater_info_t *core_info_get_core_updater_info(
info->is_experimental = tmp_bool;
/* > display_name */
entry = config_get_entry(conf, "display_name");
if (entry && entry->value && *entry->value)
{
info->display_name = entry->value;
entry->value = NULL;
}
info->display_name = config_take_string(conf, "display_name");
/* > description */
entry = config_get_entry(conf, "description");
if (entry && entry->value && *entry->value)
{
info->description = entry->value;
entry->value = NULL;
}
info->description = config_take_string(conf, "description");
/* > licenses */
entry = config_get_entry(conf, "license");
if (entry && entry->value && *entry->value)
{
info->licenses = entry->value;
entry->value = NULL;
}
info->licenses = config_take_string(conf, "license");
/* Clean up */
config_file_free(conf);
+3
View File
@@ -196,6 +196,9 @@ int salamander_main(int argc, char *argv[])
int main(int argc, char *argv[])
#endif
{
/* File access for the config parser (see config_file.h). */
config_file_set_io_default(config_file_io_filestream());
char libretro_path[PATH_MAX_LENGTH] = {0};
void *args = NULL;
struct rarch_main_wrap *wrap_args = NULL;
+1
View File
@@ -194,6 +194,7 @@ CONFIG FILE
#ifdef HAVE_CONFIGFILE
#include "../libretro-common/file/config_file.c"
#include "../libretro-common/file/config_file_io.c"
#include "../libretro-common/file/config_file_userdata.c"
#endif
+3
View File
@@ -4,3 +4,6 @@ glsm/
*.so
*.dylib
*.exe
# built sample binaries
samples/**/config_file_test
File diff suppressed because it is too large. Load diff
+185
View File
@@ -0,0 +1,185 @@
/* Copyright (C) 2010-2026 The RetroArch team
*
* ---------------------------------------------------------------------------------------
* The following license statement only applies to this file (config_file_io.c).
* ---------------------------------------------------------------------------------------
*
* Permission is hereby granted, free of charge,
* to any person obtaining a copy of this software and associated documentation files (the "Software"),
* to deal in the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
* and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
* INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
/* File access for config_file.
*
* The parser core (config_file.c) performs no file I/O: it consumes
* buffers, the way the codecs under formats/ do, and reaches files
* only through the config_file_io interface declared in
* config_file.h. This translation unit is where the file system
* actually enters the picture: the filestream/VFS-backed
* implementation of that interface, the path-based constructors
* built on it, and config_file_write - everything that opens a file
* by name lives here, so a build that links only config_file.c gets
* a pure in-memory parser with no streams/ dependency.
*
* Every constructor here self-registers the filestream
* implementation as the io default (idempotently, never displacing
* a host-installed interface), so any binary that loads at least
* one config by path resolves '#include' directives exactly as
* before this split. */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <compat/fopen_utf8.h>
#include <file/config_file.h>
#include <streams/file_stream.h>
static char *config_file_io_fs_read_file(const char *path,
int64_t *len, void *ud)
{
void *buf = NULL;
/* No path_is_valid() first: filestream_read_file() opens the file
* itself and returns 0 when it cannot, so a preceding stat only
* repeats the lookup the open already does - and no caller can see
* its result, since a missing file and an unreadable one both land
* on the same NULL return. Dropping it also closes the window
* between the stat and the open in which the file could change. */
if (!filestream_read_file(path, &buf, len))
return NULL;
/* filestream_read_file NUL-terminates past *len, which is the
* buffer contract the parser relies on. */
return (char*)buf;
}
static void config_file_io_fs_free_file(char *buf, void *ud)
{
free(buf);
}
const config_file_io_t *config_file_io_filestream(void)
{
static const config_file_io_t config_file_io_fs = {
config_file_io_fs_read_file,
config_file_io_fs_free_file,
NULL
};
return &config_file_io_fs;
}
/* Idempotent: never displaces an interface a host installed. */
static void config_file_io_ensure_default(void)
{
if (!config_file_get_io_default())
config_file_set_io_default(config_file_io_filestream());
}
/**
* config_file_new_with_callback:
*
* Loads a config file.
* If @path is NULL, will create an empty config file.
* Includes cb callbacks to run custom code during config file processing.
*
* @return Returns NULL if file doesn't exist.
**/
config_file_t *config_file_new_with_callback(
const char *path, config_file_cb_t *cb)
{
int ret = 0;
struct config_file *conf = config_file_new_alloc();
if (!path || !*path)
return conf;
config_file_io_ensure_default();
if ((ret = config_file_load_file(conf, path, cb)) == -1)
{
config_file_free(conf);
return NULL;
}
else if (ret == 1)
{
free(conf);
return NULL;
}
return conf;
}
/**
* config_file_new:
*
* Loads a config file.
* If @path is NULL, will create an empty config file.
*
* @return Returns NULL if file doesn't exist.
**/
config_file_t *config_file_new(const char *path)
{
return config_file_new_with_callback(path, NULL);
}
config_file_t *config_file_new_from_path_to_string(const char *path)
{
/* Historically this read the file and re-parsed it through
* config_file_new_from_string; since the line loops were
* unified the two path constructors have identical behaviour
* (same parse, same include handling, path set before parsing,
* no callback), so route through config_file_new - which parses
* in borrow mode: the conf adopts the file buffer and entries
* point straight into it, skipping the per-entry key/value
* allocations and copies the from_string detour forced. */
return config_file_new(path);
}
/**
* config_append_file:
*
* Loads a new config, and appends its data to @conf.
* The key-value pairs of the new config file takes priority over the old.
**/
bool config_append_file(config_file_t *conf, const char *path)
{
config_file_t *new_conf = config_file_new_from_path_to_string(path);
if (!new_conf)
return false;
return config_file_append_conf(conf, new_conf);
}
/**
* config_file_write:
*
* Write the current config to a file.
**/
bool config_file_write(config_file_t *conf, const char *path, bool sort)
{
if (!conf)
return false;
if (conf->flags & CONF_FILE_FLG_MODIFIED)
{
if (!path || !*path)
config_file_dump(conf, stdout, sort);
else
{
char buf[0x4000];
FILE *file = (FILE*)fopen_utf8(path, "wb");
if (!file)
return false;
setvbuf(file, buf, _IOFBF, sizeof(buf));
config_file_dump(conf, file, sort);
if (file != stdout)
fclose(file);
conf->flags &= ~CONF_FILE_FLG_MODIFIED;
}
}
return true;
}
+19
View File
@@ -253,6 +253,11 @@ static char *rhmap_strdup(const char *s)
++count;
++count;
out = (char*)malloc(sizeof(char) * count);
/* On allocation failure store no key string rather than crash:
* the probe comparison already treats a NULL key_str as a
* hash-only match, so lookups degrade instead of faulting. */
if (!out)
return NULL;
out[--count] = 0;
while (--count >= 0)
out[count] = s[count];
@@ -295,6 +300,20 @@ RHMAP__UNUSED static ptrdiff_t rhmap__idx(struct rhmap__hdr* hdr, uint32_t key,
{
if (add)
{
/* Never claim the table's last empty slot. Deletion
* uses backward-shift (no tombstones), so a completely
* full table has no terminator for this probe loop, for
* absent-key lookups, or for the deletion fixup walk -
* they all spin forever. Growth keeps the load factor
* at or below 50%, so this bound is unreachable unless
* RHMAP__GROW failed under OOM and the caller kept
* inserting; in that case the insert is dropped (the
* slot is returned unclaimed: its key stays 0, len is
* unchanged, and a value written through it by the SET
* macros is simply never found) rather than corrupting
* or hanging. */
if (hdr->len >= hdr->maxlen)
return (ptrdiff_t)i;
hdr->len++;
hdr->keys[i] = key;
if (str)
+194
View File
@@ -57,10 +57,42 @@ enum config_file_flags
CONF_FILE_FLG_GUARANTEED_NO_DUPLICATES = (1 << 1)
};
/* Per-entry ownership bits (struct config_entry_list::flags).
* Entries parsed from a buffer the conf owns borrow their key and
* value strings from it - the strings live exactly as long as the
* conf, no per-entry allocation or copy is made, and the free paths
* skip them. Entries created or overwritten through config_set_*
* own heap strings as before. Treat key/value of entries with a
* BORROWED bit as read-only storage. */
#define CONF_ENTRY_FLG_KEY_BORROWED (1 << 0)
#define CONF_ENTRY_FLG_VAL_BORROWED (1 << 1)
/* Entry struct lives in a conf-owned pool block, not an individual
* malloc: teardown releases the block, never the entry. */
#define CONF_ENTRY_FLG_POOLED (1 << 2)
/* A text buffer owned by a config_file that entries borrow from.
* 'io' is the interface the buffer came from (NULL: plain free). */
struct config_file_owned_buf
{
struct config_file_owned_buf *next;
char *data;
const struct config_file_io *io;
};
struct config_file_entry_pool;
struct config_file
{
char *path;
struct config_entry_list **entries_map;
/* Chain of adopted text buffers entries borrow from (see
* CONF_ENTRY_FLG_*); released at deinitialize, spliced to the
* parent on include/append pilfering. */
struct config_file_owned_buf *owned_bufs;
/* Chain of entry-pool blocks (see CONF_ENTRY_FLG_POOLED);
* lifetime and pilfer semantics match owned_bufs. */
struct config_file_entry_pool *entry_pool;
struct config_entry_list *entries;
struct config_entry_list *tail;
struct config_entry_list *last;
@@ -132,8 +164,116 @@ config_file_t *config_file_new_with_callback(
config_file_t *config_file_new_from_string(char *from_string,
const char *path);
/**
* config_file_new_take_string:
*
* Like config_file_new_from_string, but takes ownership of
* @from_string instead of copying out of it: the conf adopts the
* buffer and entries borrow their key/value strings straight from
* it, the same zero-copy parse the path loaders use. The buffer
* must come from the malloc family (it is released with free() at
* conf teardown) and must not be touched or freed by the caller
* afterwards - ownership transfers unconditionally, including on
* failure. @s_len is the string length if the caller knows it, 0
* otherwise (sizing hint only).
**/
config_file_t *config_file_new_take_string(char *from_string,
size_t s_len, const char *path);
/* Streaming (push) parser: the codec-style ingest path.
*
* For consumers whose config bytes arrive in pieces - a network
* fetch, an archive entry inflating, a data_transfer fill budgeted
* across frames - instead of as one resident buffer. Open a
* stream, push packets of any size in any split (line boundaries
* need not align with packet boundaries), then finish:
*
* config_file_stream_t *st = config_file_stream_new(path);
* while ((n = source_read(chunk, sizeof(chunk))) > 0)
* config_file_stream_push(st, chunk, n);
* conf = config_file_stream_finish(st);
*
* Memory held is one window of the unconsumed tail: complete lines
* are parsed and discarded as each push arrives, so the window's
* size is bounded by the longest line plus the largest packet, not
* by the file. Parsed output is identical to handing the same
* bytes to config_file_new_from_string in one piece.
*
* @path is recorded as the config's path (include resolution,
* reference abbreviation); it may be NULL. '#include' directives
* resolve through the registered io interface as usual.
*
* push returns false only on allocation failure; the stream then
* swallows further pushes and finish returns NULL. finish frees
* the stream and returns the parsed config (caller owns it).
* config_file_stream_free abandons a stream part-way, freeing
* everything including the partial config. */
typedef struct config_file_stream config_file_stream_t;
config_file_stream_t *config_file_stream_new(const char *path);
bool config_file_stream_push(config_file_stream_t *stream,
const void *data, size_t len);
config_file_t *config_file_stream_finish(config_file_stream_t *stream);
void config_file_stream_free(config_file_stream_t *stream);
config_file_t *config_file_new_from_path_to_string(const char *path);
/* File access used by the parser core.
*
* config_file.c itself performs no file I/O: every byte it parses is
* either handed to it directly (config_file_new_from_string) or
* fetched through this interface - the path-based constructors and
* the '#include' directive both route through it. The interface is
* how a host decides where config bytes come from: the plain-VFS
* implementation in config_file_io.c, an archive, a network fetch,
* or a test harness.
*
* read_file returns the entire file as a heap buffer with a NUL
* appended after *len content bytes (the parser consumes the buffer
* in place and relies on the terminator), or NULL on any failure.
* free_file releases a buffer read_file returned.
*
* The default is registered once at startup, before any threads
* that touch config files exist, and is only read afterwards.
* The path-based constructors in config_file_io.c self-register the
* filestream implementation, so any binary that links that file and
* loads at least one config by path is covered; a host whose first
* config comes from a string and may contain '#include' directives
* must register explicitly first. With no interface registered,
* path loads fail as file-not-found and include directives are
* recorded in the include list but not loaded. */
struct config_file_io
{
char *(*read_file)(const char *path, int64_t *len, void *ud);
void (*free_file)(char *buf, void *ud);
void *ud;
};
typedef struct config_file_io config_file_io_t;
void config_file_set_io_default(const config_file_io_t *io);
const config_file_io_t *config_file_get_io_default(void);
/* The filestream/VFS-backed implementation (config_file_io.c). */
const config_file_io_t *config_file_io_filestream(void);
/**
* config_file_load_file:
*
* Loads the file at @path (through the registered io interface)
* into an already-initialized @conf. Building block for the
* path-based constructors in config_file_io.c.
*
* Returns 0 on success, 1 if the file could not be read (@conf is
* untouched), -1 on allocation failure (@conf must be freed).
**/
int config_file_load_file(config_file_t *conf, const char *path,
config_file_cb_t *cb);
/**
* config_file_free:
*
@@ -153,6 +293,18 @@ bool config_file_deinitialize(config_file_t *conf);
**/
bool config_append_file(config_file_t *conf, const char *path);
/**
* config_file_append_conf:
*
* Appends the entries of @new_conf to @conf - the merge half of
* config_append_file, usable directly when the second config was
* obtained some other way (parsed from a string, streamed in).
* The key-value pairs of @new_conf take priority over @conf's.
* Consumes @new_conf: its entries are pilfered and the struct is
* freed regardless of outcome.
**/
bool config_file_append_conf(config_file_t *conf, config_file_t *new_conf);
/* All extract functions return true when value is valid and exists.
* Returns false otherwise. */
@@ -161,9 +313,32 @@ struct config_entry_list
char *key;
char *value;
struct config_entry_list *next;
/* Cached strlen of key and value, so the write path does not
* re-measure every string it just assembled. Zero means
* "not known" - readers must fall back to strlen - which covers
* entries whose strings were replaced without updating these,
* and the pathological case of a string longer than 65535 bytes.
* Both fit in what was structure padding, so caching them costs
* no memory per entry. */
uint16_t key_len;
uint16_t value_len;
/* If we got this from an #include,
* do not allow overwrite. */
bool readonly;
/* CONF_ENTRY_FLG_* ownership bits */
uint8_t flags;
};
/* A block of entry structs owned by a config_file (see
* CONF_ENTRY_FLG_POOLED). Entries are bump-allocated from 'slab';
* the block is released whole at deinitialize and spliced to the
* parent on include/append pilfering, exactly like owned_bufs. */
struct config_file_entry_pool
{
struct config_file_entry_pool *next;
size_t used;
size_t cap;
struct config_entry_list slab[1]; /* over-allocated to cap */
};
struct config_file_entry
@@ -262,6 +437,25 @@ bool config_get_char(config_file_t *conf, const char *entry, char *in);
**/
bool config_get_string(config_file_t *conf, const char *entry, char **in);
/**
* config_take_string:
*
* Removes @key's value from the config and returns it as a heap
* string the caller owns (release with free()). Returns NULL if the
* key is absent or its value empty, leaving the entry untouched.
*
* This is the safe form of the historical idiom that lifted
* entry->value out of the entry and NULLed it: entries parsed from a
* path or taken string BORROW their strings from a buffer the conf
* owns (see CONF_ENTRY_FLG_*), so that idiom now hands out a pointer
* into the middle of the conf's buffer - reading it after
* config_file_free is use-after-free, and free()ing it corrupts the
* heap. This function copies when the value is borrowed and steals
* it when the entry owns it, so the caller holds a real allocation
* either way.
**/
char *config_take_string(config_file_t *conf, const char *key);
/* Extracts a string to a preallocated buffer. Avoid memory allocation. */
bool config_get_array(config_file_t *conf, const char *entry, char *s, size_t len);
@@ -25,6 +25,7 @@ COMMON_SOURCES := \
$(LIBRETRO_COMM_DIR)/encodings/encoding_utf.c \
$(LIBRETRO_COMM_DIR)/features/features_cpu.c \
$(LIBRETRO_COMM_DIR)/file/config_file.c \
$(LIBRETRO_COMM_DIR)/file/config_file_io.c \
$(LIBRETRO_COMM_DIR)/file/config_file_userdata.c \
$(LIBRETRO_COMM_DIR)/file/file_path.c \
$(LIBRETRO_COMM_DIR)/file/file_path_io.c \
@@ -12,6 +12,7 @@ SOURCES := \
$(LIBRETRO_COMM_DIR)/file/file_path.c \
$(LIBRETRO_COMM_DIR)/file/file_path_io.c \
$(LIBRETRO_COMM_DIR)/file/config_file.c \
$(LIBRETRO_COMM_DIR)/file/config_file_io.c \
$(LIBRETRO_COMM_DIR)/lists/string_list.c \
$(LIBRETRO_COMM_DIR)/streams/file_stream.c \
$(LIBRETRO_COMM_DIR)/vfs/vfs_implementation.c \
@@ -21,6 +22,11 @@ OBJS := $(SOURCES:.c=.o)
CFLAGS += -Wall -pedantic -std=gnu99 -g -I$(LIBRETRO_COMM_DIR)/include
ifneq ($(SANITIZER),)
CFLAGS := -fsanitize=$(SANITIZER) -fno-omit-frame-pointer $(CFLAGS)
LDFLAGS := -fsanitize=$(SANITIZER) $(LDFLAGS)
endif
all: $(TARGET)
%.o: %.c
@@ -0,0 +1,177 @@
/* Differential fuzz target for config_file structural integrity.
*
* For every input, the same bytes are parsed two ways:
* (a) in one piece via config_file_new_from_string
* (b) through the streaming push API in pseudo-random packet
* sizes derived from the input itself
* and the results must agree on three structural invariants:
*
* 1. ENTRY-LIST EQUALITY: same entries, same order, same
* key/value bytes, same count.
* 2. MAP COHERENCE: for every key in the list, config_get_entry
* returns exactly the FIRST list entry holding that key (the
* duplicate policy), in both configs. This cross-checks the
* fused parse-time hash against the real rhmap_hash_string
* used at lookup time on arbitrary byte sequences, not just
* the curated alphabet in the unit test.
* 3. TAIL VALIDITY: conf->tail is the actual last list node.
*
* No io interface is registered, so '#include' directives are
* recorded but never touch the file system - both paths behave
* identically and the target stays hermetic.
*
* Build (clang):
* clang -O1 -g -fsanitize=fuzzer,address,undefined \
* -fno-sanitize-recover=all -I../../../include \
* -o config_file_fuzz config_file_fuzz.c \
* ../../../file/config_file.c <usual libretro-common deps>
* Run:
* ./config_file_fuzz -max_len=1024 corpus/
*/
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <file/config_file.h>
static uint32_t xs32(uint32_t *s)
{
uint32_t x = *s;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
return (*s = x ? x : 0x9e3779b9u);
}
static void die(const char *what, const uint8_t *data, size_t size)
{
size_t i;
fprintf(stderr, "STRUCTURAL DIVERGENCE: %s\ninput (%u bytes):", what,
(unsigned)size);
for (i = 0; i < size; i++)
fprintf(stderr, " %02x", data[i]);
fprintf(stderr, "\n");
abort();
}
static int str_eq(const char *a, const char *b)
{
if (!a || !b)
return a == b;
return !strcmp(a, b);
}
/* Invariant 2+3 for one conf. The exact first-entry check walks
* the list per key (quadratic), so it runs only on small inputs
* where it is cheap; larger inputs get the linear form - every
* listed key must resolve through the map to SOME entry carrying
* that exact key. Small inputs dominate coverage of the map
* logic anyway; large ones mostly exercise the window mechanics,
* which invariant 1 covers at every size. */
static void check_conf(config_file_t *c, const uint8_t *data, size_t size)
{
const struct config_entry_list *e;
const struct config_entry_list *last = NULL;
int exact = (size <= 2048);
for (e = c->entries; e; e = e->next)
{
last = e;
if (e->key)
{
const struct config_entry_list *hit = config_get_entry(c, e->key);
if (!hit || !hit->key || strcmp(hit->key, e->key))
die("map lookup missed or mismatched a listed key",
data, size);
if (exact)
{
const struct config_entry_list *first = NULL;
const struct config_entry_list *w;
for (w = c->entries; w; w = w->next)
if (w->key && !strcmp(w->key, e->key))
{
first = w;
break;
}
if (hit != first)
die("map does not resolve key to first list entry",
data, size);
}
}
}
if (c->tail != last)
die("conf->tail is not the last list node", data, size);
}
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size)
{
config_file_t *slurped;
config_file_t *streamed;
config_file_stream_t *st;
const struct config_entry_list *a;
const struct config_entry_list *b;
uint32_t rng = 0x811c9dc5u ^ (uint32_t)size;
size_t off = 0;
char *copy;
if (size > 65536)
return 0;
/* (a) slurp */
if (!(copy = (char*)malloc(size + 1)))
return 0;
memcpy(copy, data, size);
copy[size] = '\0';
slurped = config_file_new_from_string(copy, NULL);
free(copy);
if (!slurped)
return 0;
/* (b) stream, packet sizes 1..37 driven by the input */
if (!(st = config_file_stream_new(NULL)))
{
config_file_free(slurped);
return 0;
}
while (off < size)
{
size_t n = 1 + (xs32(&rng) % 37);
if (n > size - off)
n = size - off;
if (!config_file_stream_push(st, data + off, n))
{
config_file_stream_free(st);
config_file_free(slurped);
return 0;
}
off += n;
}
if (!(streamed = config_file_stream_finish(st)))
{
config_file_free(slurped);
return 0;
}
/* Invariant 1: entry-list equality */
a = slurped->entries;
b = streamed->entries;
while (a && b)
{
if ( !str_eq(a->key, b->key)
|| !str_eq(a->value, b->value)
|| a->readonly != b->readonly)
die("entry mismatch", data, size);
a = a->next;
b = b->next;
}
if (a || b)
die("entry count mismatch", data, size);
/* Invariants 2+3 on both */
check_conf(slurped, data, size);
check_conf(streamed, data, size);
config_file_free(slurped);
config_file_free(streamed);
return 0;
}
@@ -27,6 +27,7 @@
#include <errno.h>
#include <file/config_file.h>
#include <file/file_path.h>
static void test_config_file_parse_contains(
const char *cfgtext,
@@ -46,7 +47,15 @@ static void test_config_file_parse_contains(
if (ok != (bool)val)
abort();
if (!val)
{
/* The harness frees everything it allocates so that under
* strict LeakSanitizer any surviving allocation belongs to
* config_file itself - this early return previously leaked
* the conf (and masked real parser leaks behind harness
* noise). */
config_file_free(cfg);
return;
}
if (!out)
out = strdup("");
@@ -57,6 +66,7 @@ static void test_config_file_parse_contains(
}
printf("[SUCCESS] Key [%s] contains val [%s]\n", key, val);
free(out);
config_file_free(cfg);
}
/* Regression for commit 87f2d0b (memcmp OOB on short '#' comment lines).
@@ -109,6 +119,7 @@ static void test_config_file_short_comments(void)
}
printf("[SUCCESS] short '#' comment lines parsed without OOB\n");
free(out);
config_file_free(cfg);
}
/* Regression for commit <round2-TBD> (config_get_int family silent zero).
@@ -373,6 +384,487 @@ static void test_config_file_high_bit_bytes_smoke(void)
printf("[SUCCESS] high-bit byte in config parsed without crash\n");
}
static void test_config_file_hash_map_agreement(void)
{
/* The parser computes each entry's map hash inline during its key
* scan instead of re-walking the key with rhmap_hash_string; every
* *lookup* still hashes with the real rhmap_hash_string. So if the
* inline fold ever diverges from rhmap_hash_string for any key
* byte, the entry becomes unfindable through the map while still
* sitting in the entry list. Parse a corpus that covers the whole
* accepted key alphabet (0x21..0x7e), then require that every
* listed key resolves through config_get_entry to the entry
* holding it. */
char cfgtext[8192];
size_t _len = 0;
int i;
int checked = 0;
int misses = 0;
config_file_t *cfg = NULL;
struct config_file_entry ent;
/* One key per accepted byte: k<byte>x = "v" */
for (i = 0x21; i <= 0x7e; i++)
{
if (i == '=' || i == '#' || i == '"')
continue; /* structural characters cannot appear bare */
_len += snprintf(cfgtext + _len, sizeof(cfgtext) - _len,
"k%cx = \"v%d\"\n", (char)i, i);
}
/* Plus long keys and a duplicate */
_len += snprintf(cfgtext + _len, sizeof(cfgtext) - _len,
"%s = \"long\"\n", "a_rather_long_configuration_key_name_to_cross_hash_word_sizes");
_len += snprintf(cfgtext + _len, sizeof(cfgtext) - _len,
"dup = \"first\"\ndup = \"second\"\n");
{
char *copy = strdup(cfgtext);
cfg = config_file_new_from_string(copy, NULL);
free(copy);
}
if (!cfg)
{
printf("[FAILED] hash-map agreement: parse failed\n");
abort();
}
if (config_get_entry_list_head(cfg, &ent))
{
do
{
if (ent.key)
{
const struct config_entry_list *hit =
config_get_entry(cfg, ent.key);
checked++;
if (!hit)
misses++;
/* The map keeps the *first* entry for a duplicated
* key; for unique keys the value must match. */
else if (strcmp(ent.key, "dup") && strcmp(hit->value, ent.value))
misses++;
}
} while (config_get_entry_list_next(&ent));
}
if (misses == 0 && checked > 80)
printf("[SUCCESS] all %d parsed keys resolve through the hash map\n",
checked);
else
{
printf("[FAILED] hash-map agreement: %d misses of %d keys\n",
misses, checked);
abort();
}
config_file_free(cfg);
}
static void test_config_file_stream_matches_from_string(void)
{
/* Push the same text through the streaming parser in packet
* sizes that cross every line boundary (1 and 7 bytes) plus one
* larger than the whole text, and require the entry list to
* match from_string exactly. The window logic (tail retention,
* NUL displacement, slide-back) has its off-by-ones exercised
* hardest by the 1-byte case. */
static const char *cfgtext =
"alpha = \"1\"\n"
"# comment line\n"
"beta = \"two words\"\n"
" gamma = bare\r\n"
"delta = \"has # inside\"\n"
"epsilon = \"\"\n"
"zeta = last_line_without_newline";
static const size_t packets[] = { 1, 7, 4096 };
size_t pi;
for (pi = 0; pi < sizeof(packets) / sizeof(packets[0]); pi++)
{
size_t off;
size_t text_len = strlen(cfgtext);
char *copy = strdup(cfgtext);
config_file_t *slurped = config_file_new_from_string(copy, NULL);
config_file_stream_t *st = config_file_stream_new(NULL);
config_file_t *streamed = NULL;
struct config_file_entry a;
struct config_file_entry b;
bool more_a, more_b;
free(copy);
if (!slurped || !st)
abort();
for (off = 0; off < text_len; off += packets[pi])
{
size_t n = (text_len - off < packets[pi])
? (text_len - off) : packets[pi];
if (!config_file_stream_push(st, cfgtext + off, n))
abort();
}
if (!(streamed = config_file_stream_finish(st)))
abort();
more_a = config_get_entry_list_head(slurped, &a);
more_b = config_get_entry_list_head(streamed, &b);
while (more_a && more_b)
{
if ( !!a.key != !!b.key
|| !!a.value != !!b.value
|| (a.key && strcmp(a.key, b.key))
|| (a.value && strcmp(a.value, b.value)))
{
printf("[FAILED] stream/packet=%u entry mismatch: [%s]=[%s] vs [%s]=[%s]\n",
(unsigned)packets[pi],
a.key ? a.key : "(null)", a.value ? a.value : "(null)",
b.key ? b.key : "(null)", b.value ? b.value : "(null)");
abort();
}
more_a = config_get_entry_list_next(&a);
more_b = config_get_entry_list_next(&b);
}
if (more_a != more_b)
{
printf("[FAILED] stream/packet=%u entry count mismatch\n",
(unsigned)packets[pi]);
abort();
}
config_file_free(slurped);
config_file_free(streamed);
}
printf("[SUCCESS] streamed parse matches from_string at packet sizes 1/7/4096\n");
}
static void test_config_file_stream_nul_ends_stream(void)
{
/* An embedded NUL must end the stream the way it ends the slurp
* path's line walk: the truncated line parses as final, and
* everything after - even in later packets - is dropped. Push
* byte-at-a-time so a packet boundary is guaranteed to fall
* between the NUL and the later lines (the divergent case the
* clamp exists for). */
static const char raw[] = "a = \"1\"\nb\0c = \"2\"\nd = \"3\"\n";
size_t raw_len = sizeof(raw) - 1; /* keep embedded NUL */
config_file_stream_t *st = config_file_stream_new(NULL);
config_file_t *conf;
char *out = NULL;
size_t i;
if (!st)
abort();
for (i = 0; i < raw_len; i++)
if (!config_file_stream_push(st, raw + i, 1))
abort();
if (!(conf = config_file_stream_finish(st)))
abort();
if (!config_get_string(conf, "a", &out))
{
printf("[FAILED] NUL-ended stream lost the entry before the NUL\n");
abort();
}
free(out);
out = NULL;
if (config_get_string(conf, "d", &out) || config_get_string(conf, "c", &out))
{
printf("[FAILED] NUL-ended stream parsed entries past the NUL\n");
abort();
}
printf("[SUCCESS] embedded NUL ends the stream with slurp semantics\n");
config_file_free(conf);
}
static void test_config_file_pathless_reference_no_crash(void)
{
/* '#reference' in a pathless string config previously handed a
* NULL base path to fill_pathname_abbreviated_or_relative,
* whose strlcpy runs strlen on it - UB found by the
* differential fuzzer. The reference must now be recorded
* verbatim. */
char *copy = strdup("#reference \"some/other.cfg\"\nfoo = \"bar\"\n");
config_file_t *cfg = config_file_new_from_string(copy, NULL);
free(copy);
if (!cfg)
abort();
if ( !cfg->references
|| !cfg->references->path
|| strcmp(cfg->references->path, "some/other.cfg"))
{
printf("[FAILED] pathless #reference not recorded verbatim (got %s)\n",
(cfg->references && cfg->references->path)
? cfg->references->path : "(none)");
abort();
}
printf("[SUCCESS] pathless '#reference' recorded verbatim without UB\n");
config_file_free(cfg);
}
static void test_config_file_borrowed_entry_lifecycle(void)
{
/* Path-loaded entries borrow their strings from the adopted file
* buffer. Exercise every mutation path against borrowed
* entries - overwrite (must not free borrowed storage), unset
* (same), append pilfering (buffers must travel), then teardown
* (borrowed skipped, owned freed, buffers released) - under the
* suite's sanitizers this catches any wrong free or dangling
* borrow. */
const char *tmp_a = "/tmp/cfg_borrow_a.cfg";
const char *tmp_b = "/tmp/cfg_borrow_b.cfg";
FILE *f;
config_file_t *cfg;
char *out = NULL;
f = fopen(tmp_a, "w");
fprintf(f, "alpha = \"one\"\nbeta = \"two\"\ngamma = \"three\"\n");
fclose(f);
f = fopen(tmp_b, "w");
fprintf(f, "beta = \"TWO\"\ndelta = \"four\"\n");
fclose(f);
if (!(cfg = config_file_new(tmp_a)))
abort();
/* Overwrite a borrowed value with an owned one, twice */
config_set_string(cfg, "alpha", "replaced");
config_set_string(cfg, "alpha", "replaced-again");
if (!config_get_string(cfg, "alpha", &out) || strcmp(out, "replaced-again"))
abort();
free(out);
out = NULL;
/* Unset a borrowed entry */
config_unset(cfg, "gamma");
if (config_get_entry(cfg, "gamma"))
abort();
/* Append a second borrowed config: its buffers must move over */
if (!config_append_file(cfg, tmp_b))
abort();
if (!config_get_string(cfg, "delta", &out) || strcmp(out, "four"))
abort();
free(out);
out = NULL;
if (!config_get_string(cfg, "beta", &out) || strcmp(out, "TWO"))
abort();
free(out);
out = NULL;
/* New owned entry alongside borrowed ones */
config_set_string(cfg, "epsilon", "five");
config_file_free(cfg);
remove(tmp_a);
remove(tmp_b);
printf("[SUCCESS] borrowed-entry lifecycle (set/unset/append/free) clean\n");
}
static void test_config_file_take_string(void)
{
/* take_string must parse identically to from_string on the same
* bytes while owning the buffer (entries borrow from it), and
* mutation after the take must behave like any borrowed conf. */
static const char *cfgtext =
"one = \"1\"\ntwo = \"double # inside\"\n three = bare\nfour = \"\"\n";
char *copy_a = strdup(cfgtext);
char *copy_b = strdup(cfgtext);
config_file_t *ref = config_file_new_from_string(copy_a, NULL);
config_file_t *took = config_file_new_take_string(copy_b,
strlen(cfgtext), NULL);
struct config_file_entry ea;
struct config_file_entry eb;
bool ma, mb;
char *out = NULL;
free(copy_a); /* from_string copied; take_string owns copy_b now */
if (!ref || !took)
abort();
ma = config_get_entry_list_head(ref, &ea);
mb = config_get_entry_list_head(took, &eb);
while (ma && mb)
{
if ( strcmp(ea.key, eb.key)
|| strcmp(ea.value, eb.value))
{
printf("[FAILED] take_string mismatch: [%s]=[%s] vs [%s]=[%s]\n",
ea.key, ea.value, eb.key, eb.value);
abort();
}
ma = config_get_entry_list_next(&ea);
mb = config_get_entry_list_next(&eb);
}
if (ma != mb)
abort();
/* Mutate through the API against the borrowed storage */
config_set_string(took, "two", "owned-now");
config_unset(took, "three");
if (!config_get_string(took, "two", &out) || strcmp(out, "owned-now"))
abort();
free(out);
config_file_free(ref);
config_file_free(took);
printf("[SUCCESS] take_string parses identically and owns its buffer\n");
}
static void test_config_take_string(void)
{
/* config_take_string exists because core_info's historical idiom
* - lift entry->value out of the entry, NULL it, free it later -
* corrupts the heap against borrowed entries: the pointer lands
* in the middle of the conf's adopted file buffer (found as a
* STATUS_HEAP_CORRUPTION crash in core_info_free on Windows).
* The take must hand out a real allocation in both ownership
* modes, outliving the conf. */
const char *tmp_path = "/tmp/cfg_take.cfg";
FILE *f;
config_file_t *cfg;
char *taken_borrowed = NULL;
char *taken_owned = NULL;
struct config_entry_list *entry;
f = fopen(tmp_path, "w");
fprintf(f, "borrowed_key = \"from file\"\nempty_key = \"\"\n");
fclose(f);
if (!(cfg = config_file_new(tmp_path))) /* borrowed entries */
abort();
config_set_string(cfg, "owned_key", "from set");
/* Borrowed: must be copied out */
if ( !(taken_borrowed = config_take_string(cfg, "borrowed_key"))
|| strcmp(taken_borrowed, "from file"))
abort();
/* Owned: stolen; either way the entry is emptied */
if ( !(taken_owned = config_take_string(cfg, "owned_key"))
|| strcmp(taken_owned, "from set"))
abort();
if ( !(entry = config_get_entry(cfg, "borrowed_key"))
|| entry->value)
abort();
/* Missing and empty: NULL, entry untouched */
if (config_take_string(cfg, "no_such_key"))
abort();
if (config_take_string(cfg, "empty_key"))
abort();
if ( !(entry = config_get_entry(cfg, "empty_key"))
|| !entry->value)
abort();
/* The taken strings must outlive the conf and be free()-able:
* under this suite's sanitizers, a borrowed pointer leaking
* through here is a bad-free. */
config_file_free(cfg);
if (strcmp(taken_borrowed, "from file") || strcmp(taken_owned, "from set"))
abort();
free(taken_borrowed);
free(taken_owned);
remove(tmp_path);
printf("[SUCCESS] config_take_string owns its result in both ownership modes\n");
}
static void test_config_entry_cached_lengths(void)
{
/* key_len/value_len are consumed by the write path, so a stale
* one silently writes a truncated or over-long line. Walk every
* entry after a parse and after each kind of mutation and assert
* the cache either matches strlen exactly or is 0 ("unknown",
* which readers must handle by measuring). */
const char *tmp_path = "/tmp/cfg_lens.cfg";
FILE *f;
config_file_t *cfg;
struct config_file_entry it;
bool more;
int checked = 0;
f = fopen(tmp_path, "w");
fprintf(f, "short = \"1\"\n");
fprintf(f, "longer_key_name = \"a rather longer value with spaces\"\n");
fprintf(f, "bare = unquoted\n");
fprintf(f, "empty = \"\"\n");
fclose(f);
if (!(cfg = config_file_new(tmp_path)))
abort();
/* Mutations: overwrite a parsed (borrowed) value, add a fresh
* owned entry, overwrite that again, and unset one. */
config_set_string(cfg, "short", "replaced with something longer");
config_set_string(cfg, "added", "brand new");
config_set_string(cfg, "added", "shorter");
config_unset(cfg, "bare");
{
const struct config_entry_list *e;
for (e = cfg->entries; e; e = e->next)
{
if (e->key)
{
if (e->key_len && e->key_len != (uint16_t)strlen(e->key))
{
printf("[FAILED] key_len %u != strlen %u for [%s]\n",
(unsigned)e->key_len, (unsigned)strlen(e->key), e->key);
abort();
}
checked++;
}
else if (e->key_len)
abort(); /* no key, but a cached key length */
if (e->value)
{
if (e->value_len && e->value_len != (uint16_t)strlen(e->value))
{
printf("[FAILED] value_len %u != strlen %u for [%s]\n",
(unsigned)e->value_len, (unsigned)strlen(e->value),
e->key ? e->key : "?");
abort();
}
}
else if (e->value_len)
abort(); /* no value, but a cached value length */
}
}
if (checked < 4)
abort();
/* And the write path must still round-trip: read the dump back
* and confirm the values survive intact. */
if (!config_file_write(cfg, "/tmp/cfg_lens_out.cfg", false))
abort();
config_file_free(cfg);
if (!(cfg = config_file_new("/tmp/cfg_lens_out.cfg")))
abort();
more = config_get_entry_list_head(cfg, &it);
while (more)
{
if (!it.key || !it.value)
abort();
more = config_get_entry_list_next(&it);
}
{
char *v = NULL;
if ( !config_get_string(cfg, "short", &v)
|| strcmp(v, "replaced with something longer"))
abort();
free(v);
v = NULL;
if ( !config_get_string(cfg, "longer_key_name", &v)
|| strcmp(v, "a rather longer value with spaces"))
abort();
free(v);
}
config_file_free(cfg);
remove(tmp_path);
remove("/tmp/cfg_lens_out.cfg");
printf("[SUCCESS] cached entry lengths agree with strlen and round-trip\n");
}
int main(void)
{
test_config_file_parse_contains("foo = \"bar\"\n", "foo", "bar");
@@ -416,4 +908,12 @@ int main(void)
test_config_file_deinitialize_clears_fields();
test_config_file_high_bit_bytes_smoke();
test_config_file_hash_map_agreement();
test_config_file_stream_matches_from_string();
test_config_file_stream_nul_ends_stream();
test_config_file_pathless_reference_no_crash();
test_config_file_borrowed_entry_lifecycle();
test_config_file_take_string();
test_config_take_string();
test_config_entry_cached_lengths();
}
+7
View File
@@ -6379,6 +6379,13 @@ int rarch_main(int argc, char *argv[], void *data)
settings_t *settings;
struct rarch_state *p_rarch = &rarch_st;
runloop_state_t *runloop_st = runloop_state_get_ptr();
/* Register file access for the config parser before anything can
* load a config - including config-from-string paths that carry
* '#include' directives, which resolve through this interface.
* The constructors in config_file_io.c self-register too; this
* explicit call just removes any ordering dependency on which
* config API gets used first. */
config_file_set_io_default(config_file_io_filestream());
#if defined(HAVE_CG) || defined(HAVE_GLSL) || defined(HAVE_SLANG) || defined(HAVE_HLSL)
video_driver_modify_disp_flags(VIDEO_FLAG_SHADER_PRESETS_NEED_RELOAD, 0);
#endif
+1
View File
@@ -107,6 +107,7 @@ SOURCES_C := \
$(CORE_DIR)/libretro-db/rmsgpack_dom.c \
$(LIBRETRO_COMM_DIR)/file/archive_file.c \
$(LIBRETRO_COMM_DIR)/file/config_file.c \
$(LIBRETRO_COMM_DIR)/file/config_file_io.c \
$(LIBRETRO_COMM_DIR)/file/file_path.c \
$(LIBRETRO_COMM_DIR)/file/file_path_io.c \
$(LIBRETRO_COMM_DIR)/file/retro_dirent.c \
+539 -147
View File
@@ -25,7 +25,6 @@
#include <retro_dirent.h>
#include <string/stdstring.h>
#include <file/config_file.h>
#include <compat/strcasestr.h>
#include <streams/file_stream.h>
#include "../configuration.h"
@@ -59,12 +58,31 @@ typedef struct
* has, and this task runs on the main loop. */
struct RDIR *scan_rdir;
config_file_t *scan_best;
/* Verified-hint index (see input_autoconfigure_index_try):
* index_build accumulates identity tuples for the directory
* currently being scanned so the index can be (re)written when
* the walk completes; index_build_count numbers its entries. */
config_file_t *index_build;
unsigned index_build_count;
/* Affinity high-water mark when the current directory's walk
* began, to tell whether THIS directory produced a match. */
unsigned scan_dir_start_affinity;
unsigned scan_max_affinity;
unsigned scan_dir_idx;
unsigned port;
input_device_info_t device_info; /* unsigned alignment */
uint8_t flags;
uint8_t scan_done;
/* A tuple did not fit its index value buffer, so this
* directory's index would be incomplete: abandon the build (see
* input_autoconfigure_index_collect). */
uint8_t index_build_abandoned;
/* The current directory's index was fresh (valid header, file
* count matches) but ranked no candidate at or above the match
* bar. If the full walk then agrees that nothing matches, the
* index was simply right, and rewriting an identical one on
* every connect of an unrecognised device would be waste. */
uint8_t index_fresh_no_candidate;
} autoconfig_handle_t;
/*********************/
@@ -76,6 +94,12 @@ static void free_autoconfig_handle(autoconfig_handle_t *autoconfig_handle)
if (!autoconfig_handle)
return;
if (autoconfig_handle->index_build)
{
config_file_free(autoconfig_handle->index_build);
autoconfig_handle->index_build = NULL;
}
/* A scan may be part way through a directory. */
if (autoconfig_handle->scan_rdir)
{
@@ -130,6 +154,68 @@ static void input_autoconfigure_free(retro_task_t *task)
* > 30-39: VID+PID match
* > 50-59: Both device name and VID+PID match
* > A physical port match adds 10, a physical port mismatch subtracts 10. */
/* Affinity of one identity tuple (one of a profile's main entry or
* its up-to-9 alternatives) against the connected device. Single
* source of truth: both the per-file computation below and the
* in-memory index matcher rank with this exact function, so the
* index can never order candidates differently than a real scan
* would. */
static unsigned input_autoconfigure_tuple_affinity(
autoconfig_handle_t *autoconfig_handle,
uint16_t config_vid, uint16_t config_pid,
const char *config_device, const char *config_phys,
int alt)
{
unsigned affinity = 0;
/* Check for matching VID+PID */
#ifdef HAVE_BLISSBOX
/* > Bliss-Box shenanigans: its adapters report the wrapped
* pad's ids at a fixed pid, so profiles are matched on vid
* alone with the pid treated as the Bliss-Box constant. */
if (autoconfig_handle->device_info.vid == BLISSBOX_VID)
config_pid = BLISSBOX_PID;
if ( (autoconfig_handle->device_info.vid == config_vid)
&& (autoconfig_handle->device_info.pid == config_pid)
&& (config_vid != 0)
&& (config_pid != 0)
&& (autoconfig_handle->device_info.vid != BLISSBOX_VID)
&& (autoconfig_handle->device_info.pid != BLISSBOX_PID))
affinity += 30;
#else
if ( (autoconfig_handle->device_info.vid == config_vid)
&& (autoconfig_handle->device_info.pid == config_pid)
&& (config_vid != 0)
&& (config_pid != 0))
affinity += 30;
#endif
/* Check for matching device name */
if ( config_device
&& *config_device
&& string_is_equal(config_device,
autoconfig_handle->device_info.name))
affinity += 20;
/* Check for matching physical location */
if ( affinity >= 20
&& config_phys
&& *config_phys)
{
if (strstr(autoconfig_handle->device_info.phys, config_phys))
affinity += 10;
else
affinity -= 10;
}
/* Store the selected alternative as last digit of affinity. */
if (affinity > 0)
affinity += alt;
return affinity;
}
static unsigned input_autoconfigure_get_config_file_affinity(
autoconfig_handle_t *autoconfig_handle,
config_file_t *config)
@@ -148,6 +234,8 @@ static unsigned input_autoconfigure_get_config_file_affinity(
uint16_t config_pid = 0;
int tmp_int = 0;
unsigned affinity = 0;
const char *config_device = NULL;
const char *config_phys = NULL;
if (i == 0)
config_key_postfix[0] = '\0';
@@ -170,57 +258,25 @@ static unsigned input_autoconfigure_get_config_file_affinity(
if (config_get_int(config, config_key, &tmp_int))
config_pid = (uint16_t)tmp_int;
/* Check for matching VID+PID */
#ifdef HAVE_BLISSBOX
/* > Bliss-Box shenanigans... */
if (autoconfig_handle->device_info.vid == BLISSBOX_VID)
config_pid = BLISSBOX_PID;
if ( (autoconfig_handle->device_info.vid == config_vid)
&& (autoconfig_handle->device_info.pid == config_pid)
&& (config_vid != 0)
&& (config_pid != 0)
&& (autoconfig_handle->device_info.vid != BLISSBOX_VID)
&& (autoconfig_handle->device_info.pid != BLISSBOX_PID))
affinity += 30;
#else
if ( (autoconfig_handle->device_info.vid == config_vid)
&& (autoconfig_handle->device_info.pid == config_pid)
&& (config_vid != 0)
&& (config_pid != 0))
affinity += 30;
#endif
/* Check for matching device name */
_len = strlcpy(config_key, "input_device",
sizeof(config_key));
strlcpy(config_key + _len, config_key_postfix,
sizeof(config_key) - _len);
if ( (entry = config_get_entry(config, config_key))
&& (entry->value && *entry->value)
&& string_is_equal(entry->value,
autoconfig_handle->device_info.name))
affinity += 20;
&& (entry->value))
config_device = entry->value;
/* Check for matching physical location */
_len = strlcpy(config_key, "input_phys",
sizeof(config_key));
_len += strlcpy(config_key + _len, config_key_postfix,
sizeof(config_key) - _len);
if ( affinity >= 20
&& (entry = config_get_entry(config, config_key))
&& (entry->value && *entry->value))
{
if (strstr(autoconfig_handle->device_info.phys,
entry->value))
affinity += 10;
else
affinity -= 10;
}
if ( (entry = config_get_entry(config, config_key))
&& (entry->value))
config_phys = entry->value;
/* Store the selected alternative as last digit of affinity. */
if (affinity > 0)
affinity += i;
affinity = input_autoconfigure_tuple_affinity(
autoconfig_handle, config_vid, config_pid,
config_device, config_phys, i);
if (max_affinity < affinity)
max_affinity = affinity;
@@ -274,85 +330,380 @@ static void input_autoconfigure_set_config_file(
* (in the autoconfig directory) matching the connected
* input device
* > Returns 'true' if successful */
/* Could this file possibly score above zero for the device we are
* looking for?
/*******************************/
/* Verified-hint profile index */
/*******************************/
/* A per-directory index of profile identity tuples, so that a device
* connect can rank every profile with two file operations (read the
* index, open the winner) instead of one open per profile - the
* historical slowness of this task is exactly that per-file open
* latency on cold or slow storage.
*
* A profile is only ever selected on a positive affinity, and affinity
* only becomes positive through a matching vendor+product id or a
* matching device name - input_phys merely adjusts a score that is
* already at least 20. So a file containing neither the name nor the
* ids cannot win, and does not need parsing.
* The index is a hint, never an authority:
* - the ranking uses input_autoconfigure_tuple_affinity, the same
* function the real scan uses, over tuples recorded verbatim;
* - the winning candidate is opened and re-scored against its real
* file contents, and accepted only if the real affinity is at
* least what the index claimed;
* - anything else - no index, unparseable index, stale file count,
* winner unreadable, size drift, verify shortfall, or no
* candidate reaching the same >= 20 bar a scan match needs -
* falls back to the full directory scan, which also rebuilds the
* index from data it necessarily reads anyway.
* A stale index can therefore cost one wasted open, but can never
* select a profile the full scan would not have selected.
*
* That matters because parsing is the expensive part by a wide margin:
* building a config_file_t reads and hashes every binding in the file,
* some fifty entries, to answer questions about four of them. Reading
* the bytes is cheap - one open, one read - and the file is read here
* either way, so a candidate is parsed from the buffer already in hand
* rather than opened a second time.
*
* The test is deliberately generous. A false positive costs one parse
* that would have happened anyway; a false negative would silently
* pick the wrong profile, so anything uncertain must pass. Hence:
*
* - the name is matched as a substring, which is sound because the
* affinity check compares it with string_is_equal - the exact text
* has to be present for that to succeed;
* - the ids are read by config_get_int, which uses strtol with base
* 0, so decimal, hexadecimal and octal are all legal spellings of
* the same number and all three are looked for.
*/
static bool input_autoconfigure_file_may_match(
const char *buf,
const char *device_name,
unsigned vid, unsigned pid)
* The file lives beside the profiles it describes (no .cfg extension,
* so the scan's extension filter never sees it). If the directory is
* not writable the write fails silently and every connect simply
* performs the full scan, exactly as before this mechanism existed.
* Freshness is keyed on the directory's *.cfg count (the VFS layer
* exposes no mtime): additions and removals are caught by the count,
* a renamed or deleted winner by the failed open, an edited winner by
* the re-score, and a profile hand-edited to match a previously
* unmatched device by the no-candidate fallback. The one blind spot
* is an in-place edit that would promote a profile that was neither
* the winner nor previously acceptable; deleting the index file (or
* any add/remove in the directory) clears it. */
#define AUTOCONFIG_INDEX_NAME ".autoconfig_index"
#define AUTOCONFIG_INDEX_VERSION 1
/* Count the *.cfg entries in a directory: two getdents syscalls,
* no per-file opens. Must apply the same filter as the scan walk
* so the count is comparable. */
static int input_autoconfigure_index_dir_count(const char *dir)
{
char num[24];
struct RDIR *rdir;
int count = 0;
if (!buf)
return false;
if (!(rdir = retro_opendir(dir)))
return -1;
if (device_name && *device_name && strstr(buf, device_name))
return true;
if (vid && pid)
while (retro_readdir(rdir))
{
bool have_vid = false;
bool have_pid = false;
snprintf(num, sizeof(num), "%u", vid);
have_vid = (strstr(buf, num) != NULL);
if (!have_vid)
{
snprintf(num, sizeof(num), "0x%x", vid);
have_vid = (compat_strcasestr(buf, num) != NULL);
}
if (!have_vid)
{
snprintf(num, sizeof(num), "0%o", vid);
have_vid = (strstr(buf, num) != NULL);
}
if (have_vid)
{
snprintf(num, sizeof(num), "%u", pid);
have_pid = (strstr(buf, num) != NULL);
if (!have_pid)
{
snprintf(num, sizeof(num), "0x%x", pid);
have_pid = (compat_strcasestr(buf, num) != NULL);
}
if (!have_pid)
{
snprintf(num, sizeof(num), "0%o", pid);
have_pid = (strstr(buf, num) != NULL);
}
}
if (have_vid && have_pid)
return true;
const char *entry_name = retro_dirent_get_name(rdir);
if ( entry_name
&& *entry_name
&& string_is_equal_noncase(
path_get_extension(entry_name), "cfg"))
count++;
}
return false;
retro_closedir(rdir);
return count;
}
/* Record one profile's identity tuples into the index being built.
* Reads exactly the keys input_autoconfigure_get_config_file_affinity
* reads, and stores them verbatim (tab-separated inside the quoted
* value; a device name containing a tab or quote would corrupt only
* its own entry, which the verify step then rejects). */
static void input_autoconfigure_index_collect(
autoconfig_handle_t *autoconfig_handle,
const char *entry_name, int64_t f_size, config_file_t *config)
{
int i;
char index_key[32];
char index_val[NAME_MAX_LENGTH + 128];
unsigned file_idx = autoconfig_handle->index_build_count;
if (autoconfig_handle->index_build_abandoned)
return;
if (!autoconfig_handle->index_build)
{
if (!(autoconfig_handle->index_build = config_file_new_alloc()))
return;
autoconfig_handle->index_build->flags |=
CONF_FILE_FLG_GUARANTEED_NO_DUPLICATES;
}
snprintf(index_key, sizeof(index_key), "f%u", file_idx);
config_set_string(autoconfig_handle->index_build, index_key,
entry_name);
snprintf(index_key, sizeof(index_key), "s%u", file_idx);
/* Written as int to stay symmetric with the config_get_int the
* verify step reads it back with; a profile anywhere near that
* bound is not a profile. Oversized files record 0, which the
* size-drift check treats as 'unknown' and skips. */
snprintf(index_val, sizeof(index_val), "%d",
(f_size > 0 && f_size <= 0x7fffffff) ? (int)f_size : 0);
config_set_string(autoconfig_handle->index_build, index_key,
index_val);
for (i = 0; i < 10; i++)
{
size_t _len;
char config_key[30];
char config_key_postfix[7];
struct config_entry_list *entry = NULL;
uint16_t config_vid = 0;
uint16_t config_pid = 0;
int tmp_int = 0;
const char *config_device = "";
const char *config_phys = "";
if (i == 0)
config_key_postfix[0] = '\0';
else
snprintf(config_key_postfix, sizeof(config_key_postfix),
"_alt%d", i);
_len = strlcpy(config_key, "input_vendor_id",
sizeof(config_key));
strlcpy(config_key + _len, config_key_postfix,
sizeof(config_key) - _len);
if (config_get_int(config, config_key, &tmp_int))
config_vid = (uint16_t)tmp_int;
_len = strlcpy(config_key, "input_product_id",
sizeof(config_key));
strlcpy(config_key + _len, config_key_postfix,
sizeof(config_key) - _len);
if (config_get_int(config, config_key, &tmp_int))
config_pid = (uint16_t)tmp_int;
_len = strlcpy(config_key, "input_device",
sizeof(config_key));
strlcpy(config_key + _len, config_key_postfix,
sizeof(config_key) - _len);
if ( (entry = config_get_entry(config, config_key))
&& (entry->value))
config_device = entry->value;
_len = strlcpy(config_key, "input_phys",
sizeof(config_key));
strlcpy(config_key + _len, config_key_postfix,
sizeof(config_key) - _len);
if ( (entry = config_get_entry(config, config_key))
&& (entry->value))
config_phys = entry->value;
/* Absent alternatives are not stored */
if ( (i > 0)
&& (config_vid == 0)
&& (config_pid == 0)
&& (*config_device == '\0'))
continue;
snprintf(index_key, sizeof(index_key), "i%u_%d", file_idx, i);
/* Device names and physical locations come from the file and
* are not length-bounded, so the composed tuple can overflow
* this buffer. A truncated tuple must never be stored: the
* verify step only catches an index that *overstates* a
* candidate (real < claimed), and truncation can just as
* easily understate one - a clipped physical location can
* drop the +10 phys bonus, and a clipped separator makes the
* parser skip the tuple entirely, hiding the profile from
* ranking. Either way the index could rank the true winner
* below a rival whose own claim is honest, so the rival would
* verify successfully and be selected where a full scan would
* not have chosen it. Abandon the whole index for this
* directory instead; connects then scan in full, exactly as
* they did before this mechanism existed. */
if ((size_t)snprintf(index_val, sizeof(index_val),
"%u\t%u\t%s\t%s",
(unsigned)config_vid, (unsigned)config_pid,
config_device, config_phys) >= sizeof(index_val))
{
autoconfig_handle->index_build_abandoned = 1;
config_file_free(autoconfig_handle->index_build);
autoconfig_handle->index_build = NULL;
autoconfig_handle->index_build_count = 0;
return;
}
config_set_string(autoconfig_handle->index_build, index_key,
index_val);
}
autoconfig_handle->index_build_count++;
}
/* Write the accumulated index beside the profiles. Failure (read
* only directory, out of space) is ignored: the index is an
* optimisation, and without one every connect scans as before. */
static void input_autoconfigure_index_write(
autoconfig_handle_t *autoconfig_handle, const char *dir)
{
char index_path[PATH_MAX_LENGTH];
char index_val[32];
config_file_t *index_build = autoconfig_handle->index_build;
if (!index_build)
return;
snprintf(index_val, sizeof(index_val), "%d",
AUTOCONFIG_INDEX_VERSION);
config_set_string(index_build, "__version", index_val);
snprintf(index_val, sizeof(index_val), "%u",
autoconfig_handle->index_build_count);
config_set_string(index_build, "__file_count", index_val);
fill_pathname_join_special(index_path, dir,
AUTOCONFIG_INDEX_NAME, sizeof(index_path));
index_build->flags |= CONF_FILE_FLG_MODIFIED;
config_file_write(index_build, index_path, false);
config_file_free(index_build);
autoconfig_handle->index_build = NULL;
autoconfig_handle->index_build_count = 0;
}
/* Try to resolve the connect from the directory's index. On a
* verified hit, returns the winning profile's parsed config with
* *affinity set to its (re-verified) affinity. Returns NULL on any
* miss or doubt, and the caller performs the full scan. */
static config_file_t *input_autoconfigure_index_try(
autoconfig_handle_t *autoconfig_handle, const char *dir,
unsigned *affinity)
{
char index_path[PATH_MAX_LENGTH];
config_file_t *index_conf = NULL;
config_file_t *winner = NULL;
int file_count = 0;
int version = 0;
int best_file = -1;
unsigned best_affinity = 0;
unsigned fi;
fill_pathname_join_special(index_path, dir,
AUTOCONFIG_INDEX_NAME, sizeof(index_path));
if (!(index_conf = config_file_new(index_path)))
return NULL;
/* Header and freshness */
if ( !config_get_int(index_conf, "__version", &version)
|| (version != AUTOCONFIG_INDEX_VERSION)
|| !config_get_int(index_conf, "__file_count", &file_count)
|| (file_count <= 0)
|| (file_count !=
input_autoconfigure_index_dir_count(dir)))
{
config_file_free(index_conf);
return NULL;
}
/* Rank every recorded tuple with the real affinity function */
for (fi = 0; fi < (unsigned)file_count; fi++)
{
int i;
char index_key[32];
for (i = 0; i < 10; i++)
{
struct config_entry_list *entry;
const char *p;
char *endp;
char config_device[NAME_MAX_LENGTH];
unsigned config_vid, config_pid, a;
const char *config_phys;
size_t device_len;
snprintf(index_key, sizeof(index_key), "i%u_%d", fi, i);
if ( !(entry = config_get_entry(index_conf, index_key))
|| !entry->value)
continue;
/* vid \t pid \t device \t phys */
p = entry->value;
config_vid = (unsigned)strtoul(p, &endp, 10);
if (*endp != '\t')
continue;
p = endp + 1;
config_pid = (unsigned)strtoul(p, &endp, 10);
if (*endp != '\t')
continue;
p = endp + 1;
if (!(endp = strchr(p, '\t')))
continue;
device_len = (size_t)(endp - p);
if (device_len >= sizeof(config_device))
device_len = sizeof(config_device) - 1;
memcpy(config_device, p, device_len);
config_device[device_len] = '\0';
config_phys = endp + 1;
a = input_autoconfigure_tuple_affinity(autoconfig_handle,
(uint16_t)config_vid, (uint16_t)config_pid,
config_device, config_phys, i);
if (a > best_affinity)
{
best_affinity = a;
best_file = (int)fi;
}
}
}
/* The same bar a scan match needs; below it, scan in full (this
* is also what finds a profile hand-edited to newly match).
* The header was valid and current, so flag it: if the scan
* agrees nothing matches, the index needs no rewrite. */
if ((best_affinity < 20) || (best_file < 0))
{
autoconfig_handle->index_fresh_no_candidate = 1;
config_file_free(index_conf);
return NULL;
}
/* Open only the winner and verify the claim against the file */
{
char index_key[32];
char profile_path[PATH_MAX_LENGTH];
struct config_entry_list *entry;
int64_t f_size = 0;
int64_t f_len = 0;
char *f_buf = NULL;
unsigned real_affinity;
snprintf(index_key, sizeof(index_key), "f%d", best_file);
if ( !(entry = config_get_entry(index_conf, index_key))
|| !entry->value
|| !*entry->value)
{
config_file_free(index_conf);
return NULL;
}
fill_pathname_join_special(profile_path, dir, entry->value,
sizeof(profile_path));
snprintf(index_key, sizeof(index_key), "s%d", best_file);
{
int tmp_int = 0;
if (config_get_int(index_conf, index_key, &tmp_int))
f_size = (int64_t)tmp_int;
}
config_file_free(index_conf);
if ( !filestream_read_file(profile_path,
(void**)&f_buf, &f_len)
|| !f_buf)
return NULL;
/* Size drift: the file changed since indexing */
if ((f_size > 0) && (f_len != f_size))
{
free(f_buf);
return NULL;
}
if (!(winner = config_file_new_take_string(f_buf, 0,
profile_path)))
return NULL;
real_affinity = input_autoconfigure_get_config_file_affinity(
autoconfig_handle, winner);
if (real_affinity < best_affinity)
{
/* The index lied (in-place edit): scan in full instead */
config_file_free(winner);
return NULL;
}
*affinity = real_affinity;
return winner;
}
}
/* How many directory entries one tick will look at.
@@ -398,8 +749,32 @@ static bool input_autoconfigure_scan_config_files_external(
if (!autoconfig_handle->scan_rdir)
{
if (!(autoconfig_handle->scan_rdir = retro_opendir(
dirs[autoconfig_handle->scan_dir_idx])))
const char *dir = dirs[autoconfig_handle->scan_dir_idx];
/* Entering a new directory: try its index first. A
* verified hit answers the connect with two file
* operations instead of one open per profile. Any miss
* or doubt falls through to the full walk below, which
* rebuilds the index as it goes. */
budget--;
autoconfig_handle->index_fresh_no_candidate = 0;
autoconfig_handle->index_build_abandoned = 0;
autoconfig_handle->scan_dir_start_affinity =
autoconfig_handle->scan_max_affinity;
if ((config = input_autoconfigure_index_try(
autoconfig_handle, dir, &affinity)))
{
if (autoconfig_handle->scan_best)
config_file_free(autoconfig_handle->scan_best);
autoconfig_handle->scan_best = config;
autoconfig_handle->scan_max_affinity = affinity;
/* Same policy as a scan match: a directory that
* produced one ends the search. */
autoconfig_handle->scan_dir_idx = num_dirs;
break;
}
if (!(autoconfig_handle->scan_rdir = retro_opendir(dir)))
{
autoconfig_handle->scan_dir_idx++;
continue;
@@ -410,6 +785,27 @@ static bool input_autoconfigure_scan_config_files_external(
{
retro_closedir(autoconfig_handle->scan_rdir);
autoconfig_handle->scan_rdir = NULL;
/* The walk read and parsed every profile: write the
* rebuilt index beside them (silently a no-op on a read
* only directory) - unless a fresh index already said,
* correctly, that nothing here matches this device, in
* which case the rebuilt one is identical and the write
* is skipped. A scan that *contradicts* a fresh index
* (a profile was hand-edited in place to match) does
* write, healing the staleness the header cannot see. */
if ( !autoconfig_handle->index_fresh_no_candidate
|| ( (autoconfig_handle->scan_max_affinity >=
20)
&& (autoconfig_handle->scan_max_affinity >
autoconfig_handle->scan_dir_start_affinity)))
input_autoconfigure_index_write(autoconfig_handle,
dirs[autoconfig_handle->scan_dir_idx]);
else if (autoconfig_handle->index_build)
{
config_file_free(autoconfig_handle->index_build);
autoconfig_handle->index_build = NULL;
autoconfig_handle->index_build_count = 0;
}
autoconfig_handle->scan_dir_idx++;
/* A directory that produced a match ends the search; the
* later directory is only a fallback. */
@@ -430,43 +826,30 @@ static bool input_autoconfigure_scan_config_files_external(
dirs[autoconfig_handle->scan_dir_idx], entry_name,
sizeof(config_file_path));
/* Read once, and only parse what could win. The Bliss-Box path
* rewrites the product id before comparing, so its devices skip
* the filter and are parsed as before. */
/* Read and parse every profile. An earlier revision skipped
* parsing files a raw-buffer prefilter rejected; the index
* rebuild needs every file's identity tuples regardless, the
* bytes are already in memory either way, and a parse costs
* microseconds against the milliseconds the read itself costs
* cold. Ranking parsed values also retires the prefilter's
* substring heuristics, which were a standing source of
* false-negative encodings. */
{
int64_t buf_len = 0;
char *buf = NULL;
bool candidate;
if (!filestream_read_file(config_file_path,
(void**)&buf, &buf_len) || !buf)
continue;
#ifdef HAVE_BLISSBOX
candidate = (autoconfig_handle->device_info.vid == BLISSBOX_VID)
|| input_autoconfigure_file_may_match(buf,
autoconfig_handle->device_info.name,
autoconfig_handle->device_info.vid,
autoconfig_handle->device_info.pid);
#else
candidate = input_autoconfigure_file_may_match(buf,
autoconfig_handle->device_info.name,
autoconfig_handle->device_info.vid,
autoconfig_handle->device_info.pid);
#endif
if (!candidate)
{
free(buf);
if (!(config = config_file_new_take_string(buf, 0,
config_file_path)))
continue;
}
config = config_file_new_from_string(buf, config_file_path);
free(buf);
input_autoconfigure_index_collect(autoconfig_handle,
entry_name, buf_len, config);
}
if (!config)
continue;
affinity = input_autoconfigure_get_config_file_affinity(
autoconfig_handle, config);
@@ -479,12 +862,22 @@ static bool input_autoconfigure_scan_config_files_external(
config = NULL;
autoconfig_handle->scan_max_affinity = affinity;
/* A vendor, product and physical location match is as good as
* it gets; nothing later can beat it. */
/* A vendor, product and physical location match is as good
* as it gets; nothing later can beat it. The partial
* tuple collection is discarded rather than written - a
* device this profile fits will hit the >= 60 early exit
* with or without an index, so rebuilding is left to a
* connect that walks the whole directory. */
if (affinity >= 60)
{
retro_closedir(autoconfig_handle->scan_rdir);
autoconfig_handle->scan_rdir = NULL;
if (autoconfig_handle->index_build)
{
config_file_free(autoconfig_handle->index_build);
autoconfig_handle->index_build = NULL;
autoconfig_handle->index_build_count = 0;
}
autoconfig_handle->scan_dir_idx = num_dirs;
break;
}
@@ -539,13 +932,12 @@ static bool input_autoconfigure_scan_config_files_internal(
if (!input_builtin_autoconfs[i] || !*input_builtin_autoconfs[i])
continue;
/* Load autoconfig string */
/* Load autoconfig string. The strdup stays (the builtin is
* const and the parse mutates), but the copy is handed over:
* the conf adopts it and entries borrow from it. */
autoconfig_str = strdup(input_builtin_autoconfs[i]);
config = config_file_new_from_string(
autoconfig_str, NULL);
/* > String no longer required - clean up */
free(autoconfig_str);
config = config_file_new_take_string(
autoconfig_str, 0, NULL);
autoconfig_str = NULL;
/* Check for a match */
+4 -2
View File
@@ -1223,8 +1223,10 @@ bool task_push_overlay_load_default(
return false;
}
str[buf_len] = '\0';
conf = config_file_new_from_string(str, overlay_path);
free(str);
/* Hand the buffer over: the conf adopts it and entries
* borrow from it - no per-entry copies. */
conf = config_file_new_take_string(str, (size_t)buf_len,
overlay_path);
}
if (conf)
RARCH_DBG("[Overlay] Config parsed successfully from archive.\n");
+182
View File
@@ -0,0 +1,182 @@
#!/bin/bash
# Regression tests for gamepad autoconfiguration (task_autodetect),
# driven end to end through the real binary with the test input
# driver and the .ratst step scripts.
#
# Usage: tests-other/test_autoconf.sh [path-to-retroarch]
#
# Requires a build with HAVE_TEST_DRIVERS (the default). Runs
# headless under timeout(1).
#
# Assertions match on device+port *fragments* rather than whole log
# lines on purpose: autoconfig runs as asynchronous tasks and the
# log is written from more than one thread, so an occasional line
# arrives with another thread's fragment spliced into it. That
# interleaving is cosmetic - the message content is intact - so a
# fragment grep is both correct and robust, where a whole-line
# grep -c is flaky. A run is also repeated a few times where a
# cold-vs-warm (index absent vs present) difference is being
# checked, so a one-off splice cannot pass or fail a phase alone.
#
# Any sanitizer report in any phase fails the suite, so running
# this against an ASan/UBSan build extends the coverage for free.
set -u
RETROARCH="${1:-./retroarch}"
RATST_DIR="$(cd "$(dirname "$0")" && pwd)"
WORK="$(mktemp -d "${TMPDIR:-/tmp}/ratest.XXXXXX")"
HOME_DIR="$WORK/home"
AUTOCONF="$WORK/autoconf"
LOG="$WORK/run.log"
INDEX="$AUTOCONF/.autoconfig_index"
FAILED=0
say() { printf '%s\n' "$*"; }
pass() { say "[PASS] $*"; }
fail() { say "[FAIL] $*"; FAILED=1; }
# assert_seen <pattern> <description>: pattern present at least once
assert_seen() {
if grep -qE "$1" "$LOG"; then pass "$2"
else fail "$2 (missing /$1/)"; fi
}
assert_absent() {
if grep -qE "$1" "$LOG"; then fail "$2 (found /$1/)"
else pass "$2"; fi
}
assert_clean() {
if grep -qE "ERROR: (Address|Leak|Undefined|Thread)Sanitizer|runtime error:|Segmentation fault" "$LOG"; then
fail "sanitizer/crash output in log"
grep -E "ERROR: (Address|Leak|Undefined|Thread)Sanitizer|runtime error:" "$LOG" | head -3
fi
}
setup() {
rm -rf "$HOME_DIR" "$AUTOCONF"
mkdir -p "$HOME_DIR/.config/retroarch" "$AUTOCONF"
cp "$RATST_DIR"/autoconf/*.cfg "$AUTOCONF/"
cat > "$HOME_DIR/.config/retroarch/retroarch.cfg" <<CFG
input_driver = "test"
input_joypad_driver = "test"
joypad_autoconfig_dir = "$AUTOCONF"
test_input_file_joypad = "$RATST_DIR/$1"
video_driver = "null"
audio_driver = "null"
menu_driver = "null"
network_cmd_enable = "false"
CFG
}
run() {
# Pass the config explicitly rather than relying on HOME discovery:
# the default search path varies with the environment (XDG base,
# portable-mode marker, an existing user config), and CI showed a
# run that found no config produces no [Autoconf] lines and fails
# every assertion identically. --config is unambiguous. HOME is
# still pinned so any writes land in the sandbox.
HOME="$HOME_DIR" timeout 25 "$RETROARCH" \
--config "$HOME_DIR/.config/retroarch/retroarch.cfg" \
--verbose > "$LOG" 2>&1
assert_clean
# Guard against a silent no-op: if the run produced no autoconfig
# activity at all, every content assertion below would fail with
# the same misleading 'missing pattern' - surface the real cause.
if ! grep -qE '\[Autoconf\]' "$LOG"; then
fail "run produced no [Autoconf] output (binary ran? config loaded? test drivers built?)"
tail -5 "$LOG" | sed 's/^/ /'
fi
}
index_hash() { md5sum "$INDEX" 2>/dev/null | cut -d' ' -f1; }
say "== autoconf regression suite: $RETROARCH"
[ -x "$RETROARCH" ] || { say "binary not found/executable"; exit 1; }
# ---------------------------------------------------------------
say "-- P1: fresh directory - full scan, index build, full cycle"
setup test_input_autoconf_cycle.ratst
[ ! -e "$INDEX" ] || fail "index present before first run"
run
# Device A (0001:0002) is won by TestpadD's vid/pid tuple (aff 30)
# over TestpadA's name-only 20, and displays as 'device name D' -
# asserting this pins vid/pid-tuple ranking above name ranking.
assert_seen 'device name D configured in port 1' "P1 connect: vid/pid winner on port 1"
assert_seen 'device B configured in port 2' "P1 connect: name match on port 2"
# Disconnect assertions check the port, not the device name. The
# notice carries the port's stored display name when the connect task
# had already applied it, the raw driver-reported name when it had
# not, and a "not available" placeholder when neither is set - see the
# fallbacks in input_autoconfigure_disconnect. Which one appears
# depends on connect/disconnect task ordering, so pinning the name
# pins a race. Device identity is asserted on the connect side above,
# where it is stable and where it actually exercises profile matching.
assert_seen 'disconnected from port 1' "P1 disconnect clears port 1"
assert_seen 'device C configured in port 1' "P1 reconnect: different device reconfigures port 1"
assert_seen 'disconnected from port 2' "P1 disconnect clears port 2"
[ -s "$INDEX" ] && pass "P1 index written" || fail "P1 index missing/empty"
# ---------------------------------------------------------------
say "-- P2: warm rerun - index present, same outcome, index stable"
H_before="$(index_hash)"
run
assert_seen 'device name D configured in port 1' "P2 vid/pid winner via index"
assert_seen 'device B configured in port 2' "P2 name match via index"
[ "$H_before" = "$(index_hash)" ] && pass "P2 index untouched by index-hit connects" \
|| fail "P2 index rewritten on pure hits"
# ---------------------------------------------------------------
say "-- P3: in-place edit of the winner's vid/pid - verify catch"
# Break the actual winning signal: TestpadD wins device A only on its
# vid/pid tuple. Zeroing the product id in place (file count
# unchanged, so the freshness header cannot notice) must be caught by
# the winner re-score, and the scan must then fall back to TestpadA's
# name match - which displays as the plain device name 'A'.
setup test_input_autoconf_cycle.ratst
run # build a fresh index
H2="$(index_hash)"
sed -i 's/input_product_id = 2/input_product_id = 0/' "$AUTOCONF/TestpadD_alternative.cfg"
run
assert_seen 'device A configured in port 1' "P3 fallback: name match after winner's vid/pid broken"
assert_absent 'device name D configured in port 1' "P3 stale vid/pid winner not selected"
[ "$H2" != "$(index_hash)" ] && pass "P3 index healed after verify caught the edit" \
|| fail "P3 index not rewritten after verify failure"
# ---------------------------------------------------------------
say "-- P4: profile added - file-count freshness invalidates"
setup test_input_autoconf_cycle.ratst
run # fresh index
cp "$AUTOCONF/TestpadA.cfg" "$AUTOCONF/zz_added.cfg"
H3="$(index_hash)"
run
assert_seen 'device name D configured in port 1' "P4 count-stale index falls back; scan finds winner"
[ "$H3" != "$(index_hash)" ] && pass "P4 index rebuilt at new file count" \
|| fail "P4 index not rebuilt after count change"
# ---------------------------------------------------------------
say "-- P5: winning profile deleted - freshness + fallback recover"
setup test_input_autoconf_cycle.ratst
run # fresh index
rm "$AUTOCONF/TestpadD_alternative.cfg"
run
assert_seen 'device A configured in port 1' "P5 deletion caught; name match selected"
assert_absent 'device name D configured in port 1' "P5 deleted winner not selected"
# ---------------------------------------------------------------
say "-- P6: unrecognised device - full scan, no index churn"
setup test_input_autoconf_unknown.ratst
run # first run builds index
assert_seen 'not configured' "P6 unknown device reported unconfigured"
H6="$(index_hash)"
run
assert_seen 'not configured' "P6 unknown device unconfigured on rerun"
[ -n "$H6" ] && [ "$H6" = "$(index_hash)" ] \
&& pass "P6 identical index not rewritten for a no-match device" \
|| fail "P6 index churned on an unrecognised device"
# ---------------------------------------------------------------
rm -rf "$WORK"
if [ "$FAILED" -eq 0 ]; then
say "== autoconf regression suite: ALL PASS"; exit 0
fi
say "== autoconf regression suite: FAILURES"; exit 1
@@ -0,0 +1,31 @@
[
{
"comment": "Autoconf regression cycle. Device A (0001:0002) is won by TestpadD_alternative on its vid/pid tuple (affinity 30) over TestpadA name-only 20, displaying as 'device name D'; the disconnect notice uses the raw name 'device A'. Device B (0003:0004) matches TestpadB by name. Sequence: connect A and B, disconnect A from port 1, connect device C to port 1 (a DIFFERENT device, so no same-device reconnect-notification suppression - the reconfigure line is deterministic), then disconnect B. Frames stay small: the null video step clock advances slowly, so steps must sit low enough to be reached in the time budget.",
"action": 1,
"param_num": 0,
"param_str": "(0001:0002) Test joypad device A",
"frame": 0
},
{
"action": 1,
"param_num": 1,
"param_str": "(0003:0004) Test joypad device B",
"frame": 30
},
{
"action": 2,
"param_num": 0,
"frame": 90
},
{
"action": 1,
"param_num": 0,
"param_str": "(0005:0006) Test joypad device C",
"frame": 150
},
{
"action": 2,
"param_num": 1,
"frame": 210
}
]
@@ -0,0 +1,9 @@
[
{
"comment": "A device no profile matches: the connect must fall back to a full scan and report unconfigured - and a fresh index that already (correctly) said so must not be rewritten with an identical copy on every such connect.",
"action": 1,
"param_num": 0,
"param_str": "(dead:beef) Completely Unknown Pad",
"frame": 0
}
]