diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 61ddd6b..febf3f4 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -18,6 +18,13 @@ jobs: - name: Test backend JSON and HTTP with sanitizers and real sockets run: docker run --rm --network=none -v "$GITHUB_WORKSPACE:/src:ro" pldmgr-tests make test-host + shared-state: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - name: Check concurrent config, sources and history with ThreadSanitizer + run: python3 tests/run_config_tests.py --thread-sanitize + frontend: runs-on: ubuntu-latest steps: diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 1098c1e..f40efa0 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -131,6 +131,19 @@ cross-origin GET compatibility. The verification workflow also runs actual Chrom against a native stub server and Vite, checking hostile requests, real pagehide beacons, and zero rejected-request effects. Run `node tests/run_browser_mutation_tests.mjs` for these checks. +Settings now accept validated partial updates. Saves atomically replace individual +files, preserve unspecified fields/timestamps, and return errors on failed writes. +The UI sends only the fields it edits. Settings/favorites/autoload remaps and +source edits serialize read-modify-write; history reads serialize a snapshot of +the 20 most recent entries. The new `run_config_tests.py` covers actual persistence, +injected I/O failures and concurrent history/settings/source operations. + +Run `python3 tests/run_config_tests.py --thread-sanitize` on a host supporting +Clang ThreadSanitizer; local macOS verification passed and CI has a macOS job. +A multi-file settings request validates fully first, publishes lists before scalar +settings, and reports 500 if any publication fails. Earlier completed files stay +published; per-file atomicity does not provide an all-or-nothing transaction. + Collected arrays are limited to **4,096 items** and **64 MiB** of allocated array capacity, whichever is smaller for the item type. Repository parsing accepts up to **16 MiB** of input text. These bounds apply to the parser/scanner collections; @@ -141,8 +154,8 @@ Collection failures become explicit errors rather than successful partial lists. and frontend build without publishing or deploying. Frontend lint still reports existing errors. The host suite tests response ownership/status handling, request cleanup, and autoload-list bounds. Storage transactions, autoload worker concurrency, and PS5 firmware behavior remain -separate validation scopes. Backend storage/service calls in the HTTP tests are -stubbed; successful mock imports move actual files inside temporary fixtures. +separate validation scopes. The main HTTP fixture stubs backend storage/services; the separate +`--config-only` HTTP fixture uses actual config persistence. Successful mock imports move actual files inside temporary fixtures. The HTTP server accepts config/source bodies up to **1 MiB** and streamed uploads up to **512 MiB**. Larger declared or accumulated bodies return HTTP 413; empty @@ -169,7 +182,7 @@ build a candidate under `scratch/` rather than run `make clean`: ```bash mkdir -p scratch docker run --rm --network=none -v "$(pwd):/src" -w /src \ - ps5-payload-sdk-pldmgr make ELF=scratch/pldmgr-candidate.elf + ps5-payload-sdk-pldmgr make ELF=scratch/pldmgr-step5.elf ``` This command assumes the SDK image, frontend distribution, and embedded CA diff --git a/Makefile b/Makefile index d3b2640..3548c04 100644 --- a/Makefile +++ b/Makefile @@ -57,6 +57,8 @@ test-host: HOST_CC="$(HOST_CC)" $(PYTHON) tests/run_allocation_tests.py HOST_CC="$(HOST_CC)" $(PYTHON) tests/run_integrity_tests.py HOST_CC="$(HOST_CC)" $(PYTHON) tests/run_storage_tests.py + HOST_CC="$(HOST_CC)" $(PYTHON) tests/run_config_tests.py + HOST_CC="$(HOST_CC)" $(PYTHON) tests/run_http_tests.py --config-only HOST_CC="$(HOST_CC)" $(PYTHON) tests/run_http_tests.py test-frontend: diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d466bbf..6523567 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -579,11 +579,10 @@ function App() { } const handleSaveConfig = async (newConfig) => { - const merged = { ...config, ...newConfig } const success = await api('/set_config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(merged), + body: JSON.stringify(newConfig), keepalive: true, }) if (success) { diff --git a/frontend/src/components/views/AutoloadView.jsx b/frontend/src/components/views/AutoloadView.jsx index e3d2973..4ef6c79 100644 --- a/frontend/src/components/views/AutoloadView.jsx +++ b/frontend/src/components/views/AutoloadView.jsx @@ -98,7 +98,6 @@ const AutoloadView = ({ lastSyncedRef.current = syncKey try { const body = JSON.stringify({ - ...config, AUTOLOAD_ENABLED: en, AUTOLOAD_LIST: finalStr, }) diff --git a/frontend/src/components/views/SettingsView.jsx b/frontend/src/components/views/SettingsView.jsx index 7cf893b..7b6b70b 100644 --- a/frontend/src/components/views/SettingsView.jsx +++ b/frontend/src/components/views/SettingsView.jsx @@ -44,7 +44,7 @@ const SettingsView = ({ config, onSaveConfig, setShowLogs, onNavigate }) => { const { t, i18n } = useTranslation() const autoOpen = config.AUTO_BROWSER_OPEN !== false const autoInstall = config.AUTO_INSTALL_APP !== false - const autoloadDelay = config.AUTOLOAD_DELAY || 5 + const autoloadDelay = config.AUTOLOAD_DELAY ?? 5 const multiSources = config.MULTI_SOURCES_ENABLED === true const [followBrowserLanguage, setFollowBrowserLanguage] = useState(isFollowingBrowserLanguage) diff --git a/include/autoload.h b/include/autoload.h index 773e6fe..cc33093 100644 --- a/include/autoload.h +++ b/include/autoload.h @@ -10,4 +10,4 @@ void pldmgr_autoload_reset(); int pldmgr_autoload_get_remaining_seconds(); long long pldmgr_autoload_get_remaining_ms(); void pldmgr_autoload_get_status(int *total, int *done, char *current); -void pldmgr_autoload_update_config_entry(const char *old_filename, const char *new_filename); +int pldmgr_autoload_update_config_entry(const char *old_filename, const char *new_filename); diff --git a/include/config.h b/include/config.h index ff25aa8..25ccdd9 100644 --- a/include/config.h +++ b/include/config.h @@ -26,10 +26,15 @@ int config_write(const PldmgrConfig *cfg); * Returns default_val if the key or file is missing. */ int config_read_bool(const char *key, int default_val); +enum { CONFIG_INVALID = -2 }; + /* Apply a JSON config update sent from the frontend /set_config route. * Handles AUTOLOAD_ENABLED, AUTO_BROWSER_OPEN, AUTOLOAD_DELAY, * SCAN_USB_PAYLOADS, AUTO_INSTALL_APP, MULTI_SOURCES_ENABLED, and AUTOLOAD_LIST. - * Returns 0 on success, -1 on error. */ + * Booleans must be JSON booleans; delay is an integer from 0 to 3600 seconds. + * Returns 0, CONFIG_INVALID for bad input, or -1 for persistence failure. + * All input is validated first; each file publishes atomically, but a multi-file + * I/O failure may retain earlier publications. */ int config_handle_set_json(const char *json_data); /* Upsert a single key=value line in the config file. @@ -37,15 +42,18 @@ int config_handle_set_json(const char *json_data); int config_upsert_value(const char *key, const char *value); /* Read the LAST_REPOSITORY_UPDATE timestamp from the config file. - * Always succeeds; sets *out_ts to 0 if not found. */ + * Sets *out_ts to 0 if missing; returns -1 on read failure. */ int config_read_last_update(long *out_ts); /* Write the LAST_REPOSITORY_UPDATE timestamp to the config file. */ int config_write_last_update(long ts); +/* Serialized, atomic replacement/removal of matching list entries. */ +int config_update_list_entry(const char *path, const char *old_key, const char *new_key); + /* Favorites management */ int config_get_favorites_csv(char *out_buf, size_t max_size, int *out_file_exists); int config_save_favorites_csv(const char *csv); -void pldmgr_favorites_update_entry(const char *old_key, const char *new_key); +int pldmgr_favorites_update_entry(const char *old_key, const char *new_key); #endif diff --git a/include/json_helpers.h b/include/json_helpers.h index e2824d1..873554e 100644 --- a/include/json_helpers.h +++ b/include/json_helpers.h @@ -46,11 +46,24 @@ int json_extract_string_prefix(const char *obj_start, const char *obj_end, * outside [start, limit); allows following array members after the object. */ int json_find_object_end(const char *start, const char *limit, const char **end); +/* Visit direct members/elements of a complete, validated object/array. + * Spans exclude surrounding whitespace. Callback returns 0 or -1. + * Object keys are decoded into a bounded C string (maximum 255 bytes). */ +typedef int (*JsonMemberVisitor)(const char *key, const char *start, + const char *end, void *context); +typedef int (*JsonValueVisitor)(const char *start, const char *end, void *context); +int json_visit_object(const char *start, const char *end, JsonMemberVisitor visit, void *context); +int json_visit_array(const char *start, const char *end, JsonValueVisitor visit, void *context); +int json_decode_string(const char *start, const char *end, char *out, size_t capacity); + /* Read an entire file into a malloc'd buffer. Caller must free *out_buf. * Returns 0 on success, -1 on failure. */ int read_file_text(const char *path, char **out_buf, size_t *out_size); -/* Write data to a file atomically. Returns 0 on success, -1 on failure. */ +/* Atomically replace one file via a unique sibling temporary, checked write, + * flush/file sync/close and rename. Before publication failure retains the old + * file. Returns 0 once published, -1 on failure. Directory sync is best-effort; + * this does not promise a transaction across files or power-loss recovery. */ int write_file_text(const char *path, const char *data, size_t size); /* Create a directory if it doesn't exist. Returns 0 on success. */ diff --git a/src/autoload.c b/src/autoload.c index c2ca9e8..ba00898 100644 --- a/src/autoload.c +++ b/src/autoload.c @@ -18,6 +18,7 @@ long long get_current_time_ms() { #include "pldmgr.h" #include "autoload.h" +#include "config.h" #include "payload_mgr.h" #include "slot.h" #include "ps5_launcher.h" @@ -251,45 +252,8 @@ void pldmgr_autoload_reset() { strcpy(autoload_current_name, ""); } -void pldmgr_autoload_update_config_entry(const char *old_filename, const char *new_filename) { - FILE *f = fopen(AUTOLOAD_CONFIG_PATH, "r"); - if (!f) return; - - char lines[100][256]; - int line_count = 0; - int modified = 0; - - while (line_count < 100 && fgets(lines[line_count], sizeof(lines[0]), f)) { - char clean_line[256]; - strcpy(clean_line, lines[line_count]); - clean_line[strcspn(clean_line, "\r\n")] = 0; - char *start = clean_line; - while (*start && isspace((unsigned char)*start)) start++; - char *end = start + strlen(start); - while (end > start && isspace((unsigned char)*(end - 1))) end--; - *end = '\0'; - - if (strcmp(start, old_filename) == 0) { - modified = 1; - if (new_filename) { - snprintf(lines[line_count], sizeof(lines[0]), "%s\n", new_filename); - line_count++; - } - /* If new_filename is NULL, we just skip incrementing line_count, effectively deleting it */ - } else { - line_count++; - } - } - fclose(f); - - if (modified) { - f = fopen(AUTOLOAD_CONFIG_PATH, "w"); - if (f) { - for (int i = 0; i < line_count; i++) { - fputs(lines[i], f); - } - fclose(f); - pldmgr_log("[Autoload] Config updated: replaced/removed %s\n", old_filename); - } - } +int pldmgr_autoload_update_config_entry(const char *old_filename, const char *new_filename) { + int result = config_update_list_entry(AUTOLOAD_CONFIG_PATH, old_filename, new_filename); + if (result) pldmgr_log("[Autoload] Failed to update entry: %s\n", old_filename ? old_filename : ""); + return result; } diff --git a/src/config.c b/src/config.c index 69f6031..8a79e06 100644 --- a/src/config.c +++ b/src/config.c @@ -3,292 +3,228 @@ #include #include #include -#include - +#include +#include #include "config.h" #include "pldmgr.h" #include "json_helpers.h" -/* ── Read / Write full config ──────────────────────────────── */ +/* Covers read/modify/write across settings, favorites and autoload edits. + * Never call loader/autoload callbacks with this lock held. */ +static pthread_mutex_t config_mutex = PTHREAD_MUTEX_INITIALIZER; +#define LIST_CAPACITY 4096 +#define CONFIG_LIMIT (64 * 1024) -void config_read(PldmgrConfig *cfg) { - FILE *f; - char line[256]; +static const char *setting_keys[] = {"AUTOLOAD_ENABLED", "AUTO_BROWSER_OPEN", + "AUTOLOAD_DELAY", "SCAN_USB_PAYLOADS", "AUTO_INSTALL_APP", "MULTI_SOURCES_ENABLED"}; - /* Defaults */ - cfg->autoload_enabled = 0; - cfg->last_repository_update = 0; - cfg->auto_browser_open = 1; - cfg->autoload_delay = 5; - cfg->scan_usb_payloads = 0; - cfg->auto_install_app = 1; - cfg->multi_sources_enabled = 0; - - f = fopen(PLDMGR_CONFIG_PATH, "r"); - if (!f) - return; - - while (fgets(line, sizeof(line), f)) { - if (strncmp(line, "AUTOLOAD_ENABLED=", 17) == 0) - cfg->autoload_enabled = atoi(line + 17); - else if (strncmp(line, "LAST_REPOSITORY_UPDATE=", 23) == 0) - cfg->last_repository_update = atol(line + 23); - else if (strncmp(line, "AUTO_BROWSER_OPEN=", 18) == 0) - cfg->auto_browser_open = atoi(line + 18); - else if (strncmp(line, "AUTOLOAD_DELAY=", 15) == 0) - cfg->autoload_delay = atoi(line + 15); - else if (strncmp(line, "SCAN_USB_PAYLOADS=", 18) == 0) - cfg->scan_usb_payloads = atoi(line + 18); - else if (strncmp(line, "AUTO_INSTALL_APP=", 17) == 0) - cfg->auto_install_app = atoi(line + 17); - else if (strncmp(line, "MULTI_SOURCES_ENABLED=", 22) == 0) - cfg->multi_sources_enabled = atoi(line + 22); +static void defaults(PldmgrConfig *cfg) { + *cfg = (PldmgrConfig){.auto_browser_open=1, .autoload_delay=5, .auto_install_app=1}; +} +static int read_config(PldmgrConfig *cfg) { + defaults(cfg); + char *text = NULL; size_t size = 0; + if (read_file_text(PLDMGR_CONFIG_PATH, &text, &size)) return errno == ENOENT ? 0 : -1; + if (size > CONFIG_LIMIT || memchr(text, 0, size)) { free(text); return -1; } + char *save = NULL; + for (char *line = strtok_r(text, "\r\n", &save); line; line = strtok_r(NULL, "\r\n", &save)) { + char *equal = strchr(line, '='); + if (!equal) continue; + *equal++ = 0; + char *end; errno = 0; + long value = strtol(equal, &end, 10); + if (errno || *end || end == equal) continue; + if (!strcmp(line, "LAST_REPOSITORY_UPDATE")) { if (value >= 0) cfg->last_repository_update = value; continue; } + int *fields[] = {&cfg->autoload_enabled, &cfg->auto_browser_open, &cfg->autoload_delay, + &cfg->scan_usb_payloads, &cfg->auto_install_app, &cfg->multi_sources_enabled}; + for (size_t i = 0; i < 6; i++) if (!strcmp(line, setting_keys[i])) { + if (i == 2 ? value >= 0 && value <= 3600 : value == 0 || value == 1) *fields[i] = (int)value; + } } - fclose(f); + free(text); return 0; +} +void config_read(PldmgrConfig *cfg) { if (cfg) read_config(cfg); } +static int write_config(const PldmgrConfig *cfg) { + char text[512]; + int length = snprintf(text, sizeof(text), + "AUTOLOAD_ENABLED=%d\nLAST_REPOSITORY_UPDATE=%ld\nAUTO_BROWSER_OPEN=%d\n" + "AUTOLOAD_DELAY=%d\nSCAN_USB_PAYLOADS=%d\nAUTO_INSTALL_APP=%d\nMULTI_SOURCES_ENABLED=%d\n", + !!cfg->autoload_enabled, cfg->last_repository_update, !!cfg->auto_browser_open, + cfg->autoload_delay, !!cfg->scan_usb_payloads, !!cfg->auto_install_app, !!cfg->multi_sources_enabled); + if (length < 0 || (size_t)length >= sizeof(text) || ensure_dir_recursive(BASE_DATA_DIR)) return -1; + char *old = NULL; size_t size = 0; + if (read_file_text(PLDMGR_CONFIG_PATH, &old, &size) && errno != ENOENT) return -1; + if (size > CONFIG_LIMIT || (old && memchr(old, 0, size))) { free(old); return -1; } + char *complete = malloc((size_t)length + size + 2); + if (!complete) { free(old); return -1; } + memcpy(complete, text, (size_t)length); + size_t pos = (size_t)length; + const char *line = old; + while (line && line < old + size) { + const char *end = memchr(line, '\n', (size_t)(old + size - line)); + if (!end) end = old + size; + size_t n = (size_t)(end - line); + int known = n >= 23 && !memcmp(line, "LAST_REPOSITORY_UPDATE=", 23); + for (size_t i = 0; i < 6; i++) { + size_t k = strlen(setting_keys[i]); + if (n > k && !memcmp(line, setting_keys[i], k) && line[k] == '=') known = 1; + } + if (!known) { memcpy(complete + pos, line, n); pos += n; complete[pos++] = '\n'; } + line = end < old + size ? end + 1 : old + size; + } + int result = pos > CONFIG_LIMIT ? -1 : write_file_text(PLDMGR_CONFIG_PATH, complete, pos); + free(complete); free(old); return result; } - int config_write(const PldmgrConfig *cfg) { - FILE *f; - - mkdir(BASE_DATA_DIR, 0777); - f = fopen(PLDMGR_CONFIG_PATH, "w"); - if (!f) - return -1; - - fprintf(f, "AUTOLOAD_ENABLED=%d\n", cfg->autoload_enabled ? 1 : 0); - fprintf(f, "LAST_REPOSITORY_UPDATE=%ld\n", cfg->last_repository_update); - fprintf(f, "AUTO_BROWSER_OPEN=%d\n", cfg->auto_browser_open ? 1 : 0); - fprintf(f, "AUTOLOAD_DELAY=%d\n", cfg->autoload_delay); - fprintf(f, "SCAN_USB_PAYLOADS=%d\n", cfg->scan_usb_payloads ? 1 : 0); - fprintf(f, "AUTO_INSTALL_APP=%d\n", cfg->auto_install_app ? 1 : 0); - fprintf(f, "MULTI_SOURCES_ENABLED=%d\n", cfg->multi_sources_enabled ? 1 : 0); - fclose(f); - return 0; + if (!cfg || cfg->autoload_delay < 0 || cfg->autoload_delay > 3600 || cfg->last_repository_update < 0) return -1; + pthread_mutex_lock(&config_mutex); + int result = write_config(cfg); + pthread_mutex_unlock(&config_mutex); return result; } - -/* ── Single-key helpers ────────────────────────────────────── */ - int config_read_bool(const char *key, int default_val) { - FILE *f = fopen(PLDMGR_CONFIG_PATH, "r"); - if (!f) return default_val; - char line[256]; - int res = default_val; - size_t key_len = strlen(key); - while (fgets(line, sizeof(line), f)) { - if (strncmp(line, key, key_len) == 0 && line[key_len] == '=') { - res = atoi(line + key_len + 1); - break; + if (!key) return default_val; + char *text = NULL; size_t size = 0; + if (read_file_text(PLDMGR_CONFIG_PATH, &text, &size)) return default_val; + int result = default_val; + if (size <= CONFIG_LIMIT && !memchr(text, 0, size)) { + char *save = NULL; + for (char *line = strtok_r(text, "\r\n", &save); line; line = strtok_r(NULL, "\r\n", &save)) { + size_t length = strlen(key); + if (strlen(line) > length && !strncmp(line, key, length) && line[length] == '=') { + if (!strcmp(line + length + 1, "0")) result = 0; + else if (!strcmp(line + length + 1, "1")) result = 1; + break; + } } } - fclose(f); - return res; + free(text); return result; } - int config_upsert_value(const char *key, const char *value) { - FILE *f; - char line[256]; - char old_lines[64][256]; - int line_count = 0; - int replaced = 0; - size_t key_len = strlen(key); - - ensure_dir_recursive(BASE_DATA_DIR); - - f = fopen(PLDMGR_CONFIG_PATH, "r"); - if (f) { - while (line_count < 64 && fgets(line, sizeof(line), f)) { - if (strncmp(line, key, key_len) == 0 && line[key_len] == '=') { - snprintf(old_lines[line_count], sizeof(old_lines[line_count]), - "%s=%s\n", key, value); - replaced = 1; - } else { - strncpy(old_lines[line_count], line, sizeof(old_lines[line_count])); - old_lines[line_count][sizeof(old_lines[line_count]) - 1] = '\0'; - } - line_count++; + if (!key || !key[0] || !value || strlen(key) > 255 || strlen(value) > CONFIG_LIMIT || + strpbrk(key, "=\r\n") || strpbrk(value, "\r\n")) return -1; + pthread_mutex_lock(&config_mutex); + char *old = NULL; size_t size = 0; + int result = -1; + if (read_file_text(PLDMGR_CONFIG_PATH, &old, &size) && errno != ENOENT) goto done; + if (size > CONFIG_LIMIT || (old && memchr(old, 0, size))) goto done; + size_t capacity = size + strlen(key) + strlen(value) + 4; + char *text = malloc(capacity); + if (!text) goto done; + size_t pos = 0; + /* Preserve unrelated and unknown keys without fixed-line truncation. */ + const char *line = old; + while (line && line < old + size) { + const char *end = memchr(line, '\n', (size_t)(old + size - line)); + if (!end) end = old + size; + size_t n = (size_t)(end - line), key_size = strlen(key); + if (!(n > key_size && !memcmp(line, key, key_size) && line[key_size] == '=')) { + memcpy(text + pos, line, n); pos += n; text[pos++] = '\n'; } - fclose(f); + line = end < old + size ? end + 1 : old + size; } - - if (!replaced && line_count < 64) { - snprintf(old_lines[line_count], sizeof(old_lines[line_count]), - "%s=%s\n", key, value); - line_count++; - } - - f = fopen(PLDMGR_CONFIG_PATH, "w"); - if (!f) - return -1; - for (int i = 0; i < line_count; i++) { - fputs(old_lines[i], f); - } - fclose(f); - return 0; + int count = snprintf(text + pos, capacity - pos, "%s=%s\n", key, value); + if (count >= 0 && (size_t)count < capacity - pos && !ensure_dir_recursive(BASE_DATA_DIR)) + result = write_file_text(PLDMGR_CONFIG_PATH, text, pos + (size_t)count); + free(text); +done: + free(old); pthread_mutex_unlock(&config_mutex); return result; } - int config_read_last_update(long *out_ts) { - FILE *f; - char line[256]; - long ts = 0; - - *out_ts = 0; - f = fopen(PLDMGR_CONFIG_PATH, "r"); - if (!f) - return 0; - - while (fgets(line, sizeof(line), f)) { - if (strncmp(line, "LAST_REPOSITORY_UPDATE=", 23) == 0) { - ts = atol(line + 23); - break; - } - } - fclose(f); - - *out_ts = ts; - return 0; + if (!out_ts) return -1; + PldmgrConfig cfg; int result = read_config(&cfg); *out_ts = cfg.last_repository_update; return result; } - int config_write_last_update(long ts) { - char ts_buf[64]; - snprintf(ts_buf, sizeof(ts_buf), "%ld", ts); - return config_upsert_value("LAST_REPOSITORY_UPDATE", ts_buf); + if (ts < 0) return -1; + char value[64]; snprintf(value, sizeof(value), "%ld", ts); + return config_upsert_value("LAST_REPOSITORY_UPDATE", value); } -/* ── JSON config update handler (from /set_config POST body) ── */ - -/* Helper: extract a JSON boolean value for a given key. - * Returns 1 for true, 0 for false, or unchanged if key not found. */ -static int extract_json_bool(const char *json, const char *key, int current) { - char search[64]; - snprintf(search, sizeof(search), "\"%s\"", key); - char *pos = strstr(json, search); - if (!pos) return current; - char *val = strchr(pos, ':'); - if (!val) return current; - val++; - while (*val == ' ') val++; - if (strncmp(val, "true", 4) == 0) return 1; - if (strncmp(val, "false", 5) == 0) return 0; - return current; +/* CSV is a legacy wire format; commas separate entries, never occur inside one. + * Newlines/control characters cannot be represented safely in line files. */ +static int csv_lines(const char *csv, char *out, size_t capacity) { + size_t pos = 0; + if (!csv) csv = ""; + const char *start = csv; + while (*start) { + const char *end = strchr(start, ','); if (!end) end = start + strlen(start); + const char *next = *end ? end + 1 : end; + while (start < end && (*start == ' ' || *start == '\t')) start++; + while (end > start && (end[-1] == ' ' || end[-1] == '\t')) end--; + size_t length = (size_t)(end - start); + for (const char *p = start; p < end; p++) if ((unsigned char)*p < 32 || *p == 127) return -1; + if (length) { + if (length + 1 >= capacity - pos) return -1; + memcpy(out + pos, start, length); pos += length; out[pos++] = '\n'; + } + start = next; + } + out[pos] = 0; return 0; } - -static int extract_json_int(const char *json, const char *key, int current) { - char search[64]; - snprintf(search, sizeof(search), "\"%s\"", key); - char *pos = strstr(json, search); - if (!pos) return current; - char *val = strchr(pos, ':'); - if (!val) return current; - val++; - while (*val == ' ') val++; - return atoi(val); +static int save_csv(const char *path, const char *csv) { + char lines[LIST_CAPACITY]; + if (csv_lines(csv, lines, sizeof(lines)) || ensure_dir_recursive(BASE_DATA_DIR)) return -1; + return write_file_text(path, lines, strlen(lines)); } - -int config_handle_set_json(const char *json_data) { - if (!json_data) - return -1; - - /* Read existing config */ - PldmgrConfig cfg; - config_read(&cfg); - - /* Check if AUTOLOAD_ENABLED is present */ - int enabled = extract_json_bool(json_data, "AUTOLOAD_ENABLED", -1); - if (enabled != -1) { - cfg.autoload_enabled = enabled; - cfg.auto_browser_open = extract_json_bool(json_data, "AUTO_BROWSER_OPEN", cfg.auto_browser_open); - cfg.autoload_delay = extract_json_int(json_data, "AUTOLOAD_DELAY", cfg.autoload_delay); - cfg.scan_usb_payloads = extract_json_bool(json_data, "SCAN_USB_PAYLOADS", cfg.scan_usb_payloads); - cfg.auto_install_app = extract_json_bool(json_data, "AUTO_INSTALL_APP", cfg.auto_install_app); - cfg.multi_sources_enabled = extract_json_bool(json_data, "MULTI_SOURCES_ENABLED", cfg.multi_sources_enabled); - - if (config_write(&cfg) == 0) { - pldmgr_log("[PLDMGR] Saved config to %s\n", PLDMGR_CONFIG_PATH); - } - if (enabled == 0) - pldmgr_autoload_abort(); - } - - /* Handle AUTOLOAD_LIST if present */ - char *list_start = strstr(json_data, "\"AUTOLOAD_LIST\""); - if (list_start) { - char *val = strchr(list_start, ':'); - if (val) { - val++; - while (*val == ' ' || *val == '\"') - val++; - - char *list_end = strchr(val, '\"'); - size_t list_len = list_end ? (size_t)(list_end - val) : 0; - char *list_val = malloc(list_len + 1); - if (list_val) { - memcpy(list_val, val, list_len); - list_val[list_len] = '\0'; - - mkdir(BASE_DATA_DIR, 0777); - FILE *f = fopen(AUTOLOAD_CONFIG_PATH, "w"); - if (f) { - char *token = strtok(list_val, ","); - while (token) { - fprintf(f, "%s\n", token); - token = strtok(NULL, ","); - } - fclose(f); - pldmgr_log("[PLDMGR] Saved autoload list to %s\n", - AUTOLOAD_CONFIG_PATH); - } - free(list_val); - } - } - } - - /* Handle FAVORITES if present */ - char *fav_start = strstr(json_data, "\"FAVORITES\""); - if (fav_start) { - char *val = strchr(fav_start, ':'); - if (val) { - val++; - while (*val == ' ') val++; - if (*val == '\"') { - val++; - char *fav_end = strchr(val, '\"'); - size_t fav_len = fav_end ? (size_t)(fav_end - val) : 0; - char *fav_val = malloc(fav_len + 1); - if (fav_val) { - memcpy(fav_val, val, fav_len); - fav_val[fav_len] = '\0'; - config_save_favorites_csv(fav_val); - free(fav_val); - } - } else if (*val == '[') { - val++; - char fav_buf[4096] = {0}; - int first = 1; - while (*val && *val != ']') { - char *q1 = strchr(val, '\"'); - if (!q1) break; - char *q2 = strchr(q1 + 1, '\"'); - if (!q2) break; - size_t item_len = (size_t)(q2 - (q1 + 1)); - if (item_len > 0 && item_len < 256) { - char item[256]; - memcpy(item, q1 + 1, item_len); - item[item_len] = '\0'; - if (!first) strncat(fav_buf, ",", sizeof(fav_buf) - strlen(fav_buf) - 1); - strncat(fav_buf, item, sizeof(fav_buf) - strlen(fav_buf) - 1); - first = 0; - } - val = q2 + 1; - } - config_save_favorites_csv(fav_buf); - } - } - } - +typedef struct { unsigned seen; int values[6]; char autoload[LIST_CAPACITY], favorites[LIST_CAPACITY]; } Update; +static int favorite_item(const char *start, const char *end, void *context) { + Update *update = context; + char item[LIST_CAPACITY]; + if (json_decode_string(start, end, item, sizeof(item)) || strchr(item, ',') || strpbrk(item, "\r\n")) return -1; + size_t used = strlen(update->favorites), count = strlen(item); + if (used + count + (used && count ? 1 : 0) >= sizeof(update->favorites)) return -1; + if (count) { if (used) update->favorites[used++] = ','; memcpy(update->favorites + used, item, count + 1); } return 0; } - -/* ── Favorites Management ──────────────────────────────────── */ +static int update_member(const char *key, const char *start, const char *end, void *context) { + Update *update = context; + size_t index = 0; + for (; index < 6; index++) if (!strcmp(key, setting_keys[index])) break; + if (!strcmp(key, "AUTOLOAD_LIST")) index = 6; + else if (!strcmp(key, "FAVORITES")) index = 7; + if (index == 6 && strcmp(key, "AUTOLOAD_LIST")) return 0; /* read-only/unknown fields */ + unsigned bit = 1u << index; + if (update->seen & bit) return -1; + update->seen |= bit; + size_t size = (size_t)(end - start); + if (index == 2) { + if (!size || size > 4) return -1; + long value = 0; + for (const char *p = start; p < end; p++) { if (*p < '0' || *p > '9') return -1; value = value * 10 + *p - '0'; } + if (value > 3600) return -1; + update->values[index] = (int)value; + } else if (index < 6) { + if (size == 4 && !memcmp(start, "true", 4)) update->values[index] = 1; + else if (size == 5 && !memcmp(start, "false", 5)) update->values[index] = 0; + else return -1; + } else { + char *csv = index == 6 ? update->autoload : update->favorites; + if (index == 7 && *start == '[') { + if (json_visit_array(start, end, favorite_item, update)) return -1; + } else if (json_decode_string(start, end, csv, LIST_CAPACITY)) return -1; + char lines[LIST_CAPACITY]; + if (csv_lines(csv, lines, sizeof(lines))) return -1; + } + return 0; +} +int config_handle_set_json(const char *json) { + Update update = {0}; + if (!json || json_visit_object(json, json + strlen(json), update_member, &update)) return CONFIG_INVALID; + pthread_mutex_lock(&config_mutex); + PldmgrConfig cfg; + int result = read_config(&cfg); + int *fields[] = {&cfg.autoload_enabled, &cfg.auto_browser_open, &cfg.autoload_delay, + &cfg.scan_usb_payloads, &cfg.auto_install_app, &cfg.multi_sources_enabled}; + for (size_t i = 0; i < 6; i++) if (update.seen & (1u << i)) *fields[i] = update.values[i]; + /* Validate the entire request before any write. Individual files publish + * atomically; on I/O failure prior successful file publications remain. + * Lists go first, enabled/settings last: never enable an unsaved new list. */ + if (!result && (update.seen & (1u << 6))) result = save_csv(AUTOLOAD_CONFIG_PATH, update.autoload); + if (!result && (update.seen & (1u << 7))) result = save_csv(FAVORITES_CONFIG_PATH, update.favorites); + if (!result && (update.seen & 63)) result = write_config(&cfg); + pthread_mutex_unlock(&config_mutex); + if (!result && (update.seen & 1) && !update.values[0]) pldmgr_autoload_abort(); + return result; +} int config_get_favorites_csv(char *out_buf, size_t max_size, int *out_file_exists) { if (!out_buf || !max_size) return -1; @@ -340,76 +276,41 @@ failed: return -1; } + int config_save_favorites_csv(const char *csv) { - ensure_dir_recursive(BASE_DATA_DIR); - FILE *f = fopen(FAVORITES_CONFIG_PATH, "w"); - if (!f) return -1; - - if (csv && csv[0] != '\0') { - char *copy = strdup(csv); - if (copy) { - char *token = strtok(copy, ","); - while (token) { - while (*token && isspace((unsigned char)*token)) token++; - char *end = token + strlen(token); - while (end > token && isspace((unsigned char)*(end - 1))) end--; - *end = '\0'; - - if (*token) { - fprintf(f, "%s\n", token); - } - token = strtok(NULL, ","); - } - free(copy); - } - } - fclose(f); - pldmgr_log("[PLDMGR] Saved favorites to %s\n", FAVORITES_CONFIG_PATH); - return 0; + pthread_mutex_lock(&config_mutex); + int result = save_csv(FAVORITES_CONFIG_PATH, csv); + pthread_mutex_unlock(&config_mutex); return result; } - -void pldmgr_favorites_update_entry(const char *old_key, const char *new_key) { - if (!old_key || old_key[0] == '\0') return; - - FILE *f = fopen(FAVORITES_CONFIG_PATH, "r"); - if (!f) return; - - char lines[100][256]; - int line_count = 0; - int modified = 0; - - while (line_count < 100 && fgets(lines[line_count], sizeof(lines[0]), f)) { - char clean_line[256]; - strncpy(clean_line, lines[line_count], sizeof(clean_line) - 1); - clean_line[sizeof(clean_line) - 1] = '\0'; - clean_line[strcspn(clean_line, "\r\n")] = '\0'; - char *start = clean_line; - while (*start && isspace((unsigned char)*start)) start++; - char *end = start + strlen(start); - while (end > start && isspace((unsigned char)*(end - 1))) end--; - *end = '\0'; - - if (strcmp(start, old_key) == 0) { - modified = 1; - if (new_key && new_key[0] != '\0') { - snprintf(lines[line_count], sizeof(lines[0]), "%s\n", new_key); - line_count++; - } - /* If new_key is NULL or empty, skip incrementing line_count -> effectively removed */ - } else { - line_count++; - } - } - fclose(f); - - if (modified) { - f = fopen(FAVORITES_CONFIG_PATH, "w"); - if (f) { - for (int i = 0; i < line_count; i++) { - fputs(lines[i], f); - } - fclose(f); - pldmgr_log("[Favorites] Config updated: replaced/removed %s\n", old_key); - } +int config_update_list_entry(const char *path, const char *old_key, const char *new_key) { + if (!path || !old_key || !old_key[0] || strpbrk(old_key, ",\r\n") || + strlen(old_key) >= LIST_CAPACITY || + (new_key && (strlen(new_key) >= LIST_CAPACITY || strpbrk(new_key, ",\r\n")))) return -1; + pthread_mutex_lock(&config_mutex); + char *old = NULL; size_t size = 0; int result = -1; + if (read_file_text(path, &old, &size)) { result = errno == ENOENT ? 0 : -1; goto done; } + if (size > CONFIG_LIMIT || memchr(old, 0, size)) goto done; + size_t capacity = size + 1; + /* Each matching line may grow. Bound allocation even for legacy big files. */ + if (new_key) capacity += (size + 1) * (strlen(new_key) + 1); + if (capacity > 16 * 1024 * 1024) goto done; + char *text = malloc(capacity); + if (!text) goto done; + size_t pos = 0; int changed = 0; char *save = NULL; + for (char *line = strtok_r(old, "\r\n", &save); line; line = strtok_r(NULL, "\r\n", &save)) { + while (*line && isspace((unsigned char)*line)) line++; + char *end = line + strlen(line); + while (end > line && isspace((unsigned char)end[-1])) *--end = 0; + if (!strcmp(line, old_key)) { changed = 1; line = (char *)new_key; } + if (line && *line) { size_t n = strlen(line); memcpy(text + pos, line, n); pos += n; text[pos++] = '\n'; } } + result = changed ? (pos >= LIST_CAPACITY ? -1 : write_file_text(path, text, pos)) : 0; + free(text); +done: + free(old); pthread_mutex_unlock(&config_mutex); return result; +} +int pldmgr_favorites_update_entry(const char *old_key, const char *new_key) { + int result = config_update_list_entry(FAVORITES_CONFIG_PATH, old_key, new_key); + if (result) pldmgr_log("[Favorites] Failed to update entry: %s\n", old_key ? old_key : ""); + return result; } diff --git a/src/history_mgr.c b/src/history_mgr.c index 5fdd45e..98ca64e 100644 --- a/src/history_mgr.c +++ b/src/history_mgr.c @@ -3,16 +3,19 @@ #include "pldmgr.h" #include #include +#include #define MAX_HISTORY_ITEMS 20 #define MAX_PATH_LEN 512 static char history[MAX_HISTORY_ITEMS][MAX_PATH_LEN]; static int history_count = 0; +static pthread_mutex_t history_mutex = PTHREAD_MUTEX_INITIALIZER; void history_mgr_add(const char *path) { - if (!path || path[0] == '\0') return; + if (!path || path[0] == '\0' || strlen(path) >= MAX_PATH_LEN) return; + pthread_mutex_lock(&history_mutex); /* Check if already in history */ int existing_idx = -1; for (int i = 0; i < history_count; i++) { @@ -48,18 +51,24 @@ void history_mgr_add(const char *path) { history_count++; } } + pthread_mutex_unlock(&history_mutex); } size_t history_mgr_to_json(char *buf, size_t max_size) { if (!buf || !max_size) return 0; + char snapshot[MAX_HISTORY_ITEMS][MAX_PATH_LEN]; + pthread_mutex_lock(&history_mutex); + int count = history_count; + memcpy(snapshot, history, sizeof(snapshot)); + pthread_mutex_unlock(&history_mutex); JsonListBuilder jb = { buf, max_size, 0, 1, 0 }; buf[0] = '\0'; json_append(&jb, "{\"history\":[\n"); - for (int i = 0; i < history_count; i++) { + for (int i = 0; i < count; i++) { char path_escaped[6 * 512]; - json_escape_checked(&jb, history[i], path_escaped, sizeof(path_escaped)); + json_escape_checked(&jb, snapshot[i], path_escaped, sizeof(path_escaped)); json_append(&jb, "%s \"%s\"", (i > 0) ? ",\n" : "", path_escaped); } diff --git a/src/http_server.c b/src/http_server.c index 544ba95..f835c84 100644 --- a/src/http_server.c +++ b/src/http_server.c @@ -517,7 +517,7 @@ enum MHD_Result http_on_request(void *cls, struct MHD_Connection *conn, int rc = request->kind == REQUEST_CONFIG ? config_handle_set_json(post->data) : sources_save(post->data, post->size); if (rc != 0) - return finish_request_error(conn, request, MHD_HTTP_INTERNAL_SERVER_ERROR); + return finish_request_error(conn, request, rc == CONFIG_INVALID ? MHD_HTTP_BAD_REQUEST : MHD_HTTP_INTERNAL_SERVER_ERROR); request->finished = 1; struct MHD_Response *resp = create_buffer_response( strlen(MSG_OK), (void *)MSG_OK, MHD_RESPMEM_PERSISTENT); diff --git a/src/json_helpers.c b/src/json_helpers.c index 3d03d41..3905b22 100644 --- a/src/json_helpers.c +++ b/src/json_helpers.c @@ -6,6 +6,8 @@ #include #include #include +#include +#include #include "json_helpers.h" #include "utils.h" @@ -288,61 +290,103 @@ int json_extract_string_prefix(const char *start, const char *end, const char *k return json_extract(start, end, key, out, capacity, 1); } +int json_decode_string(const char *start, const char *end, char *out, size_t capacity) { + if (!out || !capacity || !start || !end || start > end) return -1; + out[0] = '\0'; + JsonReader r = {start, end}; + JsonStringSink sink = {.out = out, .capacity = capacity}; + if (json_string(&r, &sink) || r.p != end || sink.truncated) { + out[0] = '\0'; return -1; + } + return 0; +} + +static int json_visit(const char *start, const char *end, JsonMemberVisitor member, + JsonValueVisitor value, void *context, int object) { + if (!start || !end || start >= end) return -1; + JsonReader check = {start, end}; + json_space(&check); + if (check.p == end || *check.p != (object ? '{' : '[') || json_value(&check, 0)) return -1; + json_space(&check); + if (check.p != end) return -1; + JsonReader r = {start, end}; + json_space(&r); r.p++; json_space(&r); + char close = object ? '}' : ']'; + if (*r.p == close) return 0; + for (;;) { + char key[256]; + if (object) { + JsonStringSink sink = {.out = key, .capacity = sizeof(key)}; + if (json_string(&r, &sink) || sink.truncated) return -1; + json_space(&r); r.p++; json_space(&r); + } + const char *first = r.p; + if (json_value(&r, 1)) return -1; + if (object ? member(key, first, r.p, context) : value(first, r.p, context)) return -1; + json_space(&r); + if (*r.p == close) return 0; + r.p++; json_space(&r); + } +} +int json_visit_object(const char *start, const char *end, JsonMemberVisitor visit, void *context) { + return visit ? json_visit(start, end, visit, NULL, context, 1) : -1; +} +int json_visit_array(const char *start, const char *end, JsonValueVisitor visit, void *context) { + return visit ? json_visit(start, end, NULL, visit, context, 0) : -1; +} + int read_file_text(const char *path, char **out_buf, size_t *out_size) { - FILE *f; - long fsize; - char *buf; - size_t nread; - - *out_buf = NULL; - *out_size = 0; - - f = fopen(path, "rb"); - if (!f) { - return -1; + if (!out_buf || !out_size) { errno = EINVAL; return -1; } + *out_buf = NULL; *out_size = 0; + FILE *f = fopen(path, "rb"); + if (!f) return -1; + if (fseek(f, 0, SEEK_END)) { fclose(f); return -1; } + long length = ftell(f); + if (length < 0 || (uintmax_t)length >= SIZE_MAX || fseek(f, 0, SEEK_SET)) { + fclose(f); return -1; } - - if (fseek(f, 0, SEEK_END) != 0) { - fclose(f); - return -1; - } - fsize = ftell(f); - if (fsize < 0) { - fclose(f); - return -1; - } - if (fseek(f, 0, SEEK_SET) != 0) { - fclose(f); - return -1; - } - - buf = (char *)malloc((size_t)fsize + 1); - if (!buf) { - fclose(f); - errno = ENOMEM; - return -1; - } - - nread = fread(buf, 1, (size_t)fsize, f); - fclose(f); - buf[nread] = '\0'; - - *out_buf = buf; - *out_size = nread; + char *buf = malloc((size_t)length + 1); + if (!buf) { fclose(f); errno = ENOMEM; return -1; } + size_t count = fread(buf, 1, (size_t)length, f); + int failed = count != (size_t)length || ferror(f); + if (fclose(f)) failed = 1; + if (failed) { free(buf); errno = EIO; return -1; } + buf[count] = '\0'; *out_buf = buf; *out_size = count; return 0; } int write_file_text(const char *path, const char *data, size_t size) { - FILE *f = fopen(path, "wb"); - if (!f) { - return -1; + if (!path || (!data && size)) { errno = EINVAL; return -1; } + /* Sibling temporary file: concurrent writers never share a staging path, + * readers see the complete old or new inode, never a truncated file. */ + size_t length = strlen(path); + if (length > SIZE_MAX - 16) { errno = ENAMETOOLONG; return -1; } + char *temp = malloc(length + 16); + if (!temp) return -1; + snprintf(temp, length + 16, "%s.tmp-XXXXXX", path); + int fd = mkstemp(temp); + if (fd < 0) { free(temp); return -1; } + FILE *f = fdopen(fd, "wb"); + int failed = !f; + if (!f) close(fd); + else { + if ((size && fwrite(data, 1, size, f) != size) || fflush(f) || fsync(fileno(f))) failed = 1; + if (fclose(f)) failed = 1; } - if (fwrite(data, 1, size, f) != size) { - fclose(f); - return -1; + if (!failed && rename(temp, path)) failed = 1; + int saved = errno; + if (failed) unlink(temp); + else { + /* Publication succeeded. Directory fsync is best-effort on filesystems + * that do not support it; a late sync failure cannot undo publication. */ + char *slash = strrchr(temp, '/'); + const char *parent = "."; + if (slash) { if (slash == temp) slash[1] = '\0'; else *slash = '\0'; parent = temp; } + int dir = open(parent, O_RDONLY | O_DIRECTORY); + if (dir >= 0) { fsync(dir); close(dir); } } - fclose(f); - return 0; + free(temp); errno = saved; + return failed ? -1 : 0; } int mkdir_if_missing(const char *path) { diff --git a/src/slot.c b/src/slot.c index fc07304..f2345c9 100644 --- a/src/slot.c +++ b/src/slot.c @@ -813,10 +813,9 @@ static int slot_delete_unlocked(const char *slot_key) { if (closedir(d) || rmdir(slot_dir)) return -1; /* Remove from autoload.txt and favorites.txt */ - pldmgr_autoload_update_config_entry(slot_key, NULL); - pldmgr_favorites_update_entry(slot_key, NULL); - - return 0; + int autoload_result = pldmgr_autoload_update_config_entry(slot_key, NULL); + int favorites_result = pldmgr_favorites_update_entry(slot_key, NULL); + return autoload_result || favorites_result ? -1 : 0; } int slot_version_compare(const char *v1, const char *v2) { diff --git a/src/sources.c b/src/sources.c index aae7f7f..6239b5f 100644 --- a/src/sources.c +++ b/src/sources.c @@ -6,6 +6,8 @@ #include #include #include +#include +#include #include "sources.h" #include "repository.h" @@ -28,62 +30,47 @@ typedef struct { /* ── Load / Save sources from SOURCES_CONFIG_PATH ──────────── */ -static int load_sources(SourceEntry *out, int *out_count) { - char *json = NULL; - size_t size = 0; - int count = 0; - +static pthread_mutex_t sources_mutex = PTHREAD_MUTEX_INITIALIZER; +typedef struct { SourceEntry *entries; int count; int found; } SourceParse; +static void default_source(SourceEntry *out) { memset(out, 0, sizeof(SourceEntry) * MAX_SOURCES); - - /* Always start with the default source at index 0 */ - strncpy(out[0].id, "default", sizeof(out[0].id) - 1); - strncpy(out[0].name, "Official Repository", sizeof(out[0].name) - 1); - strncpy(out[0].url, REPOSITORY_SOURCE_URL, sizeof(out[0].url) - 1); - out[0].removable = 0; - count = 1; - - if (read_file_text(SOURCES_CONFIG_PATH, &json, &size) != 0 || !json || size == 0) { - if (json) free(json); - *out_count = count; - return 0; - } - - const char *arr_start = strstr(json, "\"sources\""); - if (!arr_start) { free(json); *out_count = count; return 0; } - - arr_start = strchr(arr_start, '['); - if (!arr_start) { free(json); *out_count = count; return 0; } - - const char *p = arr_start + 1; - while (count < MAX_SOURCES && p < json + size && - (p = memchr(p, '{', (size_t)(json + size - p))) != NULL) { - const char *end; - if (json_find_object_end(p, json + size, &end) != 0) break; - - char id[64] = "", name[256] = "", url[1024] = ""; - char removable_str[8] = ""; - - json_extract_string(p, end, "id", id, sizeof(id)); - json_extract_string(p, end, "name", name, sizeof(name)); - json_extract_string(p, end, "url", url, sizeof(url)); - json_extract_string(p, end, "removable", removable_str, sizeof(removable_str)); - - if (strcmp(id, "default") == 0) { p = end; continue; } - - if (name[0] && url[0] && id[0]) { - strncpy(out[count].id, id, sizeof(out[count].id) - 1); - strncpy(out[count].name, name, sizeof(out[count].name) - 1); - strncpy(out[count].url, url, sizeof(out[count].url) - 1); - out[count].removable = (strcmp(removable_str, "false") != 0); - count++; - } - p = end; - } - - free(json); - *out_count = count; + snprintf(out[0].id, sizeof(out[0].id), "default"); + snprintf(out[0].name, sizeof(out[0].name), "Official Repository"); + snprintf(out[0].url, sizeof(out[0].url), "%s", REPOSITORY_SOURCE_URL); +} +static int source_item(const char *start, const char *end, void *context) { + SourceParse *parse = context; + SourceEntry entry = {.removable = 1}; + if (json_extract_string(start, end, "id", entry.id, sizeof(entry.id)) || + json_extract_string(start, end, "name", entry.name, sizeof(entry.name)) || + json_extract_string(start, end, "url", entry.url, sizeof(entry.url)) || + !entry.id[0] || !entry.name[0] || !entry.url[0]) return -1; + if (!strcmp(entry.id, "default")) return 0; + if (parse->count >= MAX_SOURCES) return -1; + for (int i = 0; i < parse->count; i++) + if (!strcmp(parse->entries[i].id, entry.id)) return -1; + parse->entries[parse->count++] = entry; return 0; } +static int source_member(const char *key, const char *start, const char *end, void *context) { + SourceParse *parse = context; + if (strcmp(key, "sources")) return 0; + if (parse->found++) return -1; + return json_visit_array(start, end, source_item, context); +} +static int parse_sources(const char *json, size_t size, SourceEntry *out, int *out_count) { + default_source(out); + SourceParse parse = {.entries=out, .count=1}; + if (json_visit_object(json, json + size, source_member, &parse) || !parse.found) return -1; + *out_count = parse.count; return 0; +} +static int load_sources(SourceEntry *out, int *out_count) { + default_source(out); *out_count = 1; + char *json = NULL; size_t size = 0; + if (read_file_text(SOURCES_CONFIG_PATH, &json, &size)) return errno == ENOENT ? 0 : -1; + int result = parse_sources(json, size, out, out_count); + free(json); return result; +} static int save_sources(SourceEntry *sources, int count) { size_t size = MAX_SOURCES * (6 * sizeof(SourceEntry) + 128) + 64; @@ -121,7 +108,7 @@ int sources_list_json(char *buf, size_t size) { SourceEntry *sources = calloc(MAX_SOURCES, sizeof(SourceEntry)); if (!sources) return -1; int count = 0; - load_sources(sources, &count); + if (load_sources(sources, &count)) { free(sources); return -1; } JsonListBuilder jb = { buf, size, 0, 1, 0 }; buf[0] = '\0'; @@ -145,138 +132,25 @@ int sources_list_json(char *buf, size_t size) { } int sources_save(const char *json, size_t len) { + if (!json || !len) return CONFIG_INVALID; SourceEntry *sources = calloc(MAX_SOURCES, sizeof(SourceEntry)); if (!sources) return -1; int count = 0; - - if (!json || len == 0) { - free(sources); - return -1; - } - - /* Default at slot 0 */ - strncpy(sources[0].id, "default", sizeof(sources[0].id) - 1); - strncpy(sources[0].name, "Official Repository", sizeof(sources[0].name) - 1); - strncpy(sources[0].url, REPOSITORY_SOURCE_URL, sizeof(sources[0].url) - 1); - sources[0].removable = 0; - count = 1; - - const char *arr_start = strstr(json, "\"sources\""); - if (!arr_start) { free(sources); return -1; } - arr_start = strchr(arr_start, '['); - if (!arr_start) { free(sources); return -1; } - - const char *p = arr_start + 1; - while (count < MAX_SOURCES && p < json + len && - (p = memchr(p, '{', (size_t)(json + len - p))) != NULL) { - const char *end; - if (json_find_object_end(p, json + len, &end) != 0) break; - - char id[64] = "", name[256] = "", url[1024] = ""; - json_extract_string(p, end, "id", id, sizeof(id)); - json_extract_string(p, end, "name", name, sizeof(name)); - json_extract_string(p, end, "url", url, sizeof(url)); - - if (strcmp(id, "default") == 0) { p = end; continue; } - if (!name[0] || !url[0] || !id[0]) { p = end; continue; } - - strncpy(sources[count].id, id, sizeof(sources[count].id) - 1); - strncpy(sources[count].name, name, sizeof(sources[count].name) - 1); - strncpy(sources[count].url, url, sizeof(sources[count].url) - 1); - sources[count].removable = 1; - count++; - p = end; - } - - int ret = save_sources(sources, count); - free(sources); - return ret; + if (parse_sources(json, len, sources, &count)) { free(sources); return CONFIG_INVALID; } + pthread_mutex_lock(&sources_mutex); + int result = save_sources(sources, count); + pthread_mutex_unlock(&sources_mutex); + free(sources); return result; } -int sources_add(const char *url, char *msg_buf, size_t msg_size) { - char tmp_path[512]; - char *json = NULL; - size_t json_size = 0; - RepoPayload *items = NULL; - size_t count = 0; - char source_name[256] = ""; - - if (!url || !url[0]) { - snprintf(msg_buf, msg_size, "URL is empty"); - return -1; - } - - ensure_dir_recursive(BASE_DATA_DIR); - snprintf(tmp_path, sizeof(tmp_path), "%s/source_validate.tmp", BASE_DATA_DIR); - - if (download_to_file_limited(url, tmp_path, PLDMGR_MANIFEST_DOWNLOAD_LIMIT) != 0) { - snprintf(msg_buf, msg_size, "URL unreachable or download failed"); - remove(tmp_path); - return -1; - } - - if (read_file_text(tmp_path, &json, &json_size) != 0 || !json || json_size == 0) { - snprintf(msg_buf, msg_size, "Failed to read downloaded JSON"); - remove(tmp_path); - if (json) free(json); - return -1; - } - remove(tmp_path); - - /* Read top-level name before payloads key */ - { - const char *payloads_key = strstr(json, "\"payloads\""); - if (payloads_key) { - size_t search_len = (size_t)(payloads_key - json); - const char *name_key = NULL; - for (size_t i = 0; i + 6 <= search_len; i++) { - if (memcmp(json + i, "\"name\"", 6) == 0) { - name_key = json + i; - break; - } - } - if (name_key) { - const char *colon = strchr(name_key + 6, ':'); - if (colon && (size_t)(colon - json) < search_len) { - const char *q = colon + 1; - while (*q == ' ' || *q == '\t') q++; - if (*q == '"') { - q++; - size_t pos = 0; - while (*q && *q != '"' && pos < sizeof(source_name) - 1) - source_name[pos++] = *q++; - source_name[pos] = '\0'; - } - } - } - } - if (!source_name[0]) { - const char *host = strstr(url, "://"); - host = host ? host + 3 : url; - size_t pos = 0; - while (*host && *host != '/' && *host != ':' && - pos < sizeof(source_name) - 1) - source_name[pos++] = *host++; - source_name[pos] = '\0'; - } - } - - if (parse_repository_payloads(json, &items, &count) != 0 || count == 0) { - free(json); - if (items) free(items); - snprintf(msg_buf, msg_size, "No valid payloads found in source JSON"); - return -1; - } - free(items); - free(json); - +static int merge_source(const char *url, const char *source_name, char *msg_buf, size_t msg_size) { SourceEntry *sources = calloc(MAX_SOURCES, sizeof(SourceEntry)); if (!sources) { snprintf(msg_buf, msg_size, "Memory allocation failed"); return -1; } int src_count = 0; - load_sources(sources, &src_count); + if (load_sources(sources, &src_count)) { free(sources); snprintf(msg_buf, msg_size, "Failed to read sources"); return -1; } if (src_count >= MAX_SOURCES) { free(sources); @@ -293,7 +167,14 @@ int sources_add(const char *url, char *msg_buf, size_t msg_size) { } char new_id[64]; - snprintf(new_id, sizeof(new_id), "source_%d", (int)time(NULL)); + char hash[16]; slot_source_hash(url, hash, sizeof(hash)); + snprintf(new_id, sizeof(new_id), "source_%s", hash); + for (int suffix = 1;; suffix++) { + int found = 0; + for (int i = 0; i < src_count; i++) if (!strcmp(sources[i].id, new_id)) found = 1; + if (!found) break; + snprintf(new_id, sizeof(new_id), "source_%s_%d", hash, suffix); + } strncpy(sources[src_count].id, new_id, sizeof(sources[src_count].id) - 1); strncpy(sources[src_count].name, source_name, sizeof(sources[src_count].name) - 1); @@ -312,7 +193,69 @@ int sources_add(const char *url, char *msg_buf, size_t msg_size) { return 0; } -int sources_remove(int index, char *msg_buf, size_t msg_size) { +int sources_add(const char *url, char *msg_buf, size_t msg_size) { + char tmp_path[512]; + char *json = NULL; + size_t json_size = 0; + RepoPayload *items = NULL; + size_t count = 0; + char source_name[256] = ""; + + if (!url || !url[0]) { + snprintf(msg_buf, msg_size, "URL is empty"); + return -1; + } + + ensure_dir_recursive(BASE_DATA_DIR); + snprintf(tmp_path, sizeof(tmp_path), "%s/.source-XXXXXX", BASE_DATA_DIR); + int fd = mkstemp(tmp_path); + if (fd < 0) { snprintf(msg_buf, msg_size, "Failed to create validation file"); return -1; } + if (close(fd)) { remove(tmp_path); snprintf(msg_buf, msg_size, "Failed to close validation file"); return -1; } + if (strlen(url) >= sizeof(((SourceEntry *)0)->url)) { + remove(tmp_path); snprintf(msg_buf, msg_size, "URL is too long"); return -1; + } + + if (download_to_file_limited(url, tmp_path, PLDMGR_MANIFEST_DOWNLOAD_LIMIT) != 0) { + snprintf(msg_buf, msg_size, "URL unreachable or download failed"); + remove(tmp_path); + return -1; + } + + if (read_file_text(tmp_path, &json, &json_size) != 0 || !json || json_size == 0) { + snprintf(msg_buf, msg_size, "Failed to read downloaded JSON"); + remove(tmp_path); + if (json) free(json); + return -1; + } + remove(tmp_path); + + /* Decode the actual top-level name, including escaped Unicode/quotes. */ + json_extract_string_prefix(json, json + json_size, "name", source_name, sizeof(source_name)); + if (!source_name[0]) { + const char *host = strstr(url, "://"); + host = host ? host + 3 : url; + size_t pos = 0; + while (*host && *host != '/' && *host != ':' && pos < sizeof(source_name) - 1) + source_name[pos++] = *host++; + source_name[pos] = '\0'; + } + + if (parse_repository_payloads(json, &items, &count) != 0 || count == 0) { + free(json); + if (items) free(items); + snprintf(msg_buf, msg_size, "No valid payloads found in source JSON"); + return -1; + } + free(items); + free(json); + + pthread_mutex_lock(&sources_mutex); + int result = merge_source(url, source_name, msg_buf, msg_size); + pthread_mutex_unlock(&sources_mutex); + return result; +} + +static int remove_source(int index, char *msg_buf, size_t msg_size) { if (index <= 0) { snprintf(msg_buf, msg_size, "Cannot remove the default source"); return -1; @@ -324,7 +267,7 @@ int sources_remove(int index, char *msg_buf, size_t msg_size) { return -1; } int count = 0; - load_sources(sources, &count); + if (load_sources(sources, &count)) { free(sources); snprintf(msg_buf, msg_size, "Failed to read sources"); return -1; } if (index >= count) { free(sources); @@ -348,6 +291,13 @@ int sources_remove(int index, char *msg_buf, size_t msg_size) { return 0; } +int sources_remove(int index, char *msg_buf, size_t msg_size) { + pthread_mutex_lock(&sources_mutex); + int result = remove_source(index, msg_buf, msg_size); + pthread_mutex_unlock(&sources_mutex); + return result; +} + /* ── Hash-based cache paths ────────────────────────────────── */ static void get_cache_paths(const char *url, char *cache_path, size_t csize, @@ -383,9 +333,12 @@ int sources_ensure_fresh(const char *url, int force_refresh) { } char tmp_path[512]; - snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", cache_path); + snprintf(tmp_path, sizeof(tmp_path), "%s.tmp-XXXXXX", cache_path); + int fd = mkstemp(tmp_path); + if (fd < 0) return -1; + if (close(fd)) { remove(tmp_path); return -1; } if (download_to_file_limited(url, tmp_path, PLDMGR_MANIFEST_DOWNLOAD_LIMIT) != 0) { - return -1; + remove(tmp_path); return -1; } char *json = NULL; @@ -412,15 +365,11 @@ int sources_ensure_fresh(const char *url, int force_refresh) { return -1; } - tf = fopen(ts_path, "w"); - if (tf) { - fprintf(tf, "%ld", (long)now); - fclose(tf); - } - - if (strcmp(url, REPOSITORY_SOURCE_URL) == 0) { - config_write_last_update((long)now); - } + char timestamp[64]; + int timestamp_size = snprintf(timestamp, sizeof(timestamp), "%ld", (long)now); + if (timestamp_size < 0 || (size_t)timestamp_size >= sizeof(timestamp) || + write_file_text(ts_path, timestamp, (size_t)timestamp_size)) return -1; + if (!strcmp(url, REPOSITORY_SOURCE_URL) && config_write_last_update((long)now)) return -1; return 0; } @@ -433,7 +382,7 @@ size_t sources_multi_repository_list_json(char *buf, size_t size, int force_refr return 0; } int src_count = 0; - load_sources(sources, &src_count); + if (load_sources(sources, &src_count)) { free(sources); buf[0] = 0; return 0; } /* Single-source mode: if multi_sources is off, only use source 0 */ PldmgrConfig cfg; @@ -583,7 +532,7 @@ int sources_repo_install(const char *source_url, const char *id, return -1; } int src_count = 0; - load_sources(sources, &src_count); + if (load_sources(sources, &src_count)) { free(sources); json_append(&jb, "{\"ok\":false,\"message\":\"Failed to read sources\"}"); return -1; } /* Match source */ SourceEntry src; @@ -839,9 +788,9 @@ int sources_repo_install(const char *source_url, const char *id, int old_cleanup_failed = 0; /* Retire a conflicting source only after the new slot is usable. */ if (is_mode_replace && replace_slot && replace_slot[0] && strcmp(replace_slot, target_slot)) { - pldmgr_autoload_update_config_entry(replace_slot, target_slot); - pldmgr_favorites_update_entry(replace_slot, target_slot); - if (slot_delete(replace_slot)) { + int autoload_result = pldmgr_autoload_update_config_entry(replace_slot, target_slot); + int favorites_result = pldmgr_favorites_update_entry(replace_slot, target_slot); + if (autoload_result || favorites_result || slot_delete(replace_slot)) { old_cleanup_failed = 1; pldmgr_log("[PLDMGR] New repository slot committed; old slot cleanup incomplete: %s\n", replace_slot); } diff --git a/tests/config_driver.c b/tests/config_driver.c new file mode 100644 index 0000000..224f516 --- /dev/null +++ b/tests/config_driver.c @@ -0,0 +1,185 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include "config.h" +#include "json_helpers.h" +#include "history_mgr.h" +#include "sources.h" +#include "pldmgr.h" +#include "slot.h" +#include "sha256.h" + +#define CHECK(x) do { if (!(x)) { fprintf(stderr,"FAIL %d: %s\n",__LINE__,#x); exit(1); } } while (0) +static const char *fault_name; +static int fault_at, fault_hits; +static FILE *bad_read; +static _Atomic int aborts; +static int hit(const char *name) { if (fault_name && !strcmp(name,fault_name) && ++fault_hits == fault_at) { errno=ENOSPC; return 1; } return 0; } +size_t config_test_fwrite(const void *p,size_t a,size_t b,FILE *f) { if(hit("write")) return 0; return fwrite(p,a,b,f); } +size_t config_test_fread(void *p,size_t a,size_t b,FILE *f) { if(hit("read")) { bad_read=f; return 0; } return fread(p,a,b,f); } +int config_test_ferror(FILE *f) { return f==bad_read || ferror(f); } +int config_test_fflush(FILE *f) { int rc=fflush(f); return hit("flush") ? -1 : rc; } +int config_test_fclose(FILE *f) { if(f==bad_read)bad_read=NULL; int rc=fclose(f); return hit("close") ? -1 : rc; } +int config_test_fsync(int fd) { return hit("sync") ? -1 : fsync(fd); } +int config_test_rename(const char *a,const char *b) { return hit("rename") ? -1 : rename(a,b); } +int config_test_mkstemp(char *p) { return hit("create") ? -1 : mkstemp(p); } +void pldmgr_log(const char *fmt,...) { (void)fmt; } +void pldmgr_autoload_abort(void) { aborts++; } +int pldmgr_autoload_update_config_entry(const char *a,const char *b) { return config_update_list_entry(AUTOLOAD_CONFIG_PATH,a,b); } +static _Atomic int downloads; +int download_to_file_limited(const char *url,const char *path,size_t limit) { + (void)limit; CHECK(strstr(url,"fixture://")); downloads++; + const char *json="{\"name\":\"F\\u00edxture\",\"payloads\":[{\"id\":\"safe\",\"name\":\"Safe\",\"filename\":\"safe.elf\",\"url\":\"http://example.invalid/safe.elf\"}]}"; + FILE *f=fopen(path,"wb"); CHECK(f); CHECK(fwrite(json,1,strlen(json),f)==strlen(json)); CHECK(!fclose(f)); + usleep(1000); return 0; +} +int download_to_file(const char *url,const char *path) { return download_to_file_limited(url,path,0); } +static void fault(const char *name,int at) { fault_name=name; fault_at=at; fault_hits=0; } +static char *read_text(const char *path) { char *text; size_t size; CHECK(!read_file_text(path,&text,&size)); return text; } +static void expect_text(const char *path,const char *expected) { char *text=read_text(path); CHECK(!strcmp(text,expected)); free(text); } +static void scalar(void) { + CHECK(config_read_bool("AUTOLOAD_ENABLED",1)==1); + CHECK(!config_handle_set_json("{\"AUTO_BROWSER_OPEN\":false}")); + PldmgrConfig cfg; config_read(&cfg); CHECK(cfg.auto_browser_open==0); CHECK(cfg.autoload_delay==5); CHECK(cfg.auto_install_app==1); + CHECK(!config_handle_set_json("{\"SCAN_USB_PAYLOADS\":true}")); config_read(&cfg); CHECK(cfg.scan_usb_payloads==1 && cfg.auto_browser_open==0); + CHECK(!config_handle_set_json("{\"AUTO_INSTALL_APP\":false}")); config_read(&cfg); CHECK(cfg.auto_install_app==0 && cfg.scan_usb_payloads==1); + CHECK(!config_handle_set_json("{\"MULTI_SOURCES_ENABLED\":true}")); config_read(&cfg); CHECK(cfg.multi_sources_enabled==1); + CHECK(!config_handle_set_json("{\"AUTOLOAD_DELAY\":0}")); config_read(&cfg); CHECK(cfg.autoload_delay==0); + CHECK(!config_write_last_update(123456)); + CHECK(!config_handle_set_json("{\"AUTOLOAD_ENABLED\":true}")); config_read(&cfg); CHECK(cfg.autoload_enabled==1 && cfg.last_repository_update==123456 && cfg.autoload_delay==0); + CHECK(!config_handle_set_json("{\"AUTOLOAD_ENABLED\":false}")); CHECK(aborts==1); + CHECK(!config_handle_set_json("{\"nested\":{\"AUTOLOAD_DELAY\":20},\"FAVORITES_INITIALIZED\":true}")); config_read(&cfg); CHECK(cfg.autoload_delay==0); + CHECK(!config_handle_set_json("{\"AUTOLOAD_LIST\":\"local/żółw,!1000,local/next\",\"FAVORITES\":[\"local/żółw\",\"local/a\\\"b\"]}")); + expect_text(AUTOLOAD_CONFIG_PATH,"local/żółw\n!1000\nlocal/next\n"); + expect_text(FAVORITES_CONFIG_PATH,"local/żółw\nlocal/a\"b\n"); + CHECK(!config_handle_set_json("{\"FAVORITES\":\"\",\"AUTOLOAD_LIST\":\"\"}")); + expect_text(AUTOLOAD_CONFIG_PATH,""); expect_text(FAVORITES_CONFIG_PATH,""); + CHECK(!config_handle_set_json("{\"AUTOLOAD_DELAY\":3600}")); + CHECK(!config_upsert_value("CUSTOM", "keep")); CHECK(!config_write_last_update(999)); CHECK(!config_handle_set_json("{\"AUTOLOAD_DELAY\":5}")); + char *saved=read_text(PLDMGR_CONFIG_PATH); CHECK(strstr(saved,"CUSTOM=keep\n")); free(saved); + puts("Config: individual settings, false/zero, timestamps, Unicode, empty and array lists passed"); +} +static void validation(void) { + CHECK(!config_handle_set_json("{\"AUTOLOAD_ENABLED\":true,\"FAVORITES\":\"local/keep\",\"AUTOLOAD_LIST\":\"local/keep\"}")); + char *old=read_text(PLDMGR_CONFIG_PATH); + const char *invalid[]={"{", "{} garbage", "[]", "{\"AUTOLOAD_ENABLED\":false,\"AUTOLOAD_DELAY\":-1}", + "{\"FAVORITES\":\"local/change\",\"AUTO_BROWSER_OPEN\":0}", "{\"AUTOLOAD_ENABLED\":null}", "{\"AUTOLOAD_ENABLED\":\"true\"}", + "{\"AUTOLOAD_DELAY\":1.5}", "{\"AUTOLOAD_DELAY\":1e2}", "{\"AUTOLOAD_DELAY\":3601}", "{\"AUTOLOAD_DELAY\":999999999999999999999999}", + "{\"AUTOLOAD_ENABLED\":true,\"AUTOLOAD_ENABLED\":false}", "{\"AUTOLOAD_DELAY\":1,\"AUTOLOAD_D\\u0045LAY\":2}", + "{\"FAVORITES\":[1]}", "{\"FAVORITES\":[\"local/a,b\"]}", "{\"FAVORITES\":null}", "{\"AUTOLOAD_LIST\":\"a\\nb\"}", + "{\"FAVORITES\":\"a\\u0000b\"}", "{\"FAVORITES\":\"a\\u0001b\"}", "{\"FAVORITES\":\"a\",\"FAVORITES\":\"b\"}"}; + for(size_t i=0;id_name,".tmp-")); closedir(dir); + puts("Atomic persistence: create/write/flush/sync/close/rename/read failures preserve old files; multi-file policy and remaps passed"); +} +static pthread_mutex_t gate_mutex = PTHREAD_MUTEX_INITIALIZER; +static pthread_cond_t gate_condition = PTHREAD_COND_INITIALIZER; +static int gate_count; +static void start_together(void) { + pthread_mutex_lock(&gate_mutex); + if (++gate_count == 8) pthread_cond_broadcast(&gate_condition); + else while (gate_count < 8) pthread_cond_wait(&gate_condition,&gate_mutex); + pthread_mutex_unlock(&gate_mutex); +} +static void *settings_worker(void *argument) { + long id=(long)argument; start_together(); + for(int n=0;n<200;n++) { + switch(id) { + case 0: CHECK(!config_handle_set_json("{\"AUTO_BROWSER_OPEN\":false}")); break; + case 1: CHECK(!config_handle_set_json("{\"SCAN_USB_PAYLOADS\":true}")); break; + case 2: CHECK(!config_handle_set_json("{\"AUTO_INSTALL_APP\":false}")); break; + case 3: CHECK(!config_write_last_update(777777)); break; + case 4: CHECK(!config_handle_set_json("{\"FAVORITES\":\"local/a,local/b,local/c\"}")); break; + case 5: CHECK(!config_handle_set_json("{\"AUTOLOAD_LIST\":\"local/d,!1000,local/e\"}")); break; + case 6: CHECK(!config_update_list_entry(FAVORITES_CONFIG_PATH,"missing","unused")); break; + case 7: { PldmgrConfig cfg; config_read(&cfg); CHECK(cfg.autoload_delay==5); char out[4096]; int exists; CHECK(!config_get_favorites_csv(out,sizeof(out),&exists)); break; } + } + } return NULL; +} +static void parallel_settings(void) { + pthread_t threads[8]; + for(long i=0;i<8;i++) CHECK(!pthread_create(&threads[i],NULL,settings_worker,(void *)i)); + for(int i=0;i<8;i++) CHECK(!pthread_join(threads[i],NULL)); + PldmgrConfig cfg; config_read(&cfg); CHECK(!cfg.auto_browser_open && cfg.scan_usb_payloads && !cfg.auto_install_app && cfg.last_repository_update==777777); + expect_text(FAVORITES_CONFIG_PATH,"local/a\nlocal/b\nlocal/c\n"); expect_text(AUTOLOAD_CONFIG_PATH,"local/d\n!1000\nlocal/e\n"); + puts("Config concurrency: 1,600 updates/remaps/reads preserve independent fields, timestamps and lists"); +} +typedef struct { int count; char entries[20][512]; } HistoryCheck; +static int history_item(const char *a,const char *b,void *context) { + HistoryCheck *check=context; if(check->count==20) return -1; + char *path=check->entries[check->count]; if(json_decode_string(a,b,path,512))return -1; + for(int i=0;icount;i++)if(!strcmp(check->entries[i],path))return -1; + check->count++; return 0; +} +static int history_member(const char *key,const char *a,const char *b,void *context) { return strcmp(key,"history") ? -1 : json_visit_array(a,b,history_item,context); } +static void *history_worker(void *argument) { + long id=(long)argument; + for(int i=0;i<2000;i++) { + if(id<4) { char path[512]; snprintf(path,sizeof(path),"local/thread-%ld-item-%d",id,i%25); history_mgr_add(path); } + else { char json[65536]; size_t n=history_mgr_to_json(json,sizeof(json)); CHECK(n); HistoryCheck check={0}; CHECK(!json_visit_object(json,json+n,history_member,&check)); } + } return NULL; +} +static void parallel_history(void) { + pthread_t threads[8]; for(long i=0;i<8;i++)CHECK(!pthread_create(&threads[i],NULL,history_worker,(void *)i)); + for(int i=0;i<8;i++)CHECK(!pthread_join(threads[i],NULL)); + for(int i=0;i<25;i++) { char path[64]; snprintf(path,sizeof(path),"local/item-%d",i); history_mgr_add(path); } + history_mgr_add("local/item-10"); char json[65536]; size_t size=history_mgr_to_json(json,sizeof(json)); CHECK(size); puts(json); + HistoryCheck check={0}; CHECK(!json_visit_object(json,json+size,history_member,&check)); CHECK(check.count==20 && !strcmp(check.entries[0],"local/item-10") && !strcmp(check.entries[1],"local/item-24")); + char long_path[600]; memset(long_path,'x',599); long_path[599]=0; history_mgr_add(long_path); history_mgr_add(long_path); + size=history_mgr_to_json(json,sizeof(json)); check=(HistoryCheck){0}; CHECK(!json_visit_object(json,json+size,history_member,&check)); CHECK(check.count==20); + puts("History concurrency: 8,000 launches and 8,000 snapshots preserve bounds/uniqueness; deterministic ordering passed"); +} +static void *source_worker(void *argument) { char url[128],msg[256]; snprintf(url,sizeof(url),"fixture://source-%ld",(long)argument); CHECK(!sources_add(url,msg,sizeof(msg))); return NULL; } +static void parallel_sources(void) { + CHECK(!sources_save("{\"sources\":[]}",14)); pthread_t threads[12]; + for(long i=0;i<12;i++)CHECK(!pthread_create(&threads[i],NULL,source_worker,(void *)i)); + for(int i=0;i<12;i++)CHECK(!pthread_join(threads[i],NULL)); + char json[20000]; CHECK(!sources_list_json(json,sizeof(json))); puts(json); CHECK(downloads==12); + for(int i=0;i<12;i++) { char url[128]; snprintf(url,sizeof(url),"fixture://source-%d",i); CHECK(strstr(json,url)); } + for(int i=0;i<12;i++) { char msg[128]; CHECK(!sources_remove(1,msg,sizeof(msg))); } + CHECK(!sources_list_json(json,sizeof(json))); CHECK(!strstr(json,"fixture://")); puts("Source concurrency: 12 simultaneous validations/adds retained; removals passed"); +} +int main(int argc,char **argv) { + CHECK(argc==2); CHECK(!ensure_dir_recursive(BASE_DATA_DIR)); + if(!strcmp(argv[1],"scalar"))scalar(); else if(!strcmp(argv[1],"validation"))validation(); + else if(!strcmp(argv[1],"atomic"))atomic_files(); else if(!strcmp(argv[1],"settings"))parallel_settings(); + else if(!strcmp(argv[1],"history"))parallel_history(); else if(!strcmp(argv[1],"sources"))parallel_sources(); else return 2; + return 0; +} diff --git a/tests/config_hooks.h b/tests/config_hooks.h new file mode 100644 index 0000000..7fc1b43 --- /dev/null +++ b/tests/config_hooks.h @@ -0,0 +1,18 @@ +#include +#include +size_t config_test_fwrite(const void *, size_t, size_t, FILE *); +size_t config_test_fread(void *, size_t, size_t, FILE *); +int config_test_ferror(FILE *); +int config_test_fflush(FILE *); +int config_test_fclose(FILE *); +int config_test_fsync(int); +int config_test_rename(const char *, const char *); +int config_test_mkstemp(char *); +#define fwrite config_test_fwrite +#define fread config_test_fread +#define ferror config_test_ferror +#define fflush config_test_fflush +#define fclose config_test_fclose +#define fsync config_test_fsync +#define rename config_test_rename +#define mkstemp config_test_mkstemp diff --git a/tests/http_backend_stubs.c b/tests/http_backend_stubs.c index e15f7b8..09518a8 100644 --- a/tests/http_backend_stubs.c +++ b/tests/http_backend_stubs.c @@ -27,18 +27,20 @@ size_t test_post_size; void pldmgr_log(const char *fmt, ...) { (void)fmt; } void log_server_set_active(void) { test_active_calls++; } +#ifndef PLDMGR_TEST_REAL_CONFIG void config_read(PldmgrConfig *cfg) { memset(cfg, 0, sizeof(*cfg)); cfg->autoload_delay = 5; } int config_read_bool(const char *key, int fallback) { (void)key; return fallback; } int config_handle_set_json(const char *data) { test_config_saves++; test_mutation_calls++; test_post_size = strlen(data); - return strcmp(data, "fail_test") == 0 ? -1 : 0; + return strcmp(data, "fail_test") == 0 ? -1 : (strcmp(data, "invalid_test") == 0 ? CONFIG_INVALID : 0); } int config_get_favorites_csv(char *out, size_t size, int *exists) { if (size) out[0] = '\0'; *exists = 0; return test_json_failure ? -1 : 0; } +#endif void pldmgr_autoload_get_status(int *total, int *done, char *current) { *total = 0; *done = 0; current[0] = '\0'; } @@ -92,7 +94,7 @@ int sources_list_json(char *out, size_t size) { json(out, size, "{\"sources\":[] int sources_save(const char *data, size_t size) { test_source_saves++; test_mutation_calls++; test_post_size = size; - return strcmp(data, "fail_test") == 0 ? -1 : 0; + return strcmp(data, "fail_test") == 0 ? -1 : (strcmp(data, "invalid_test") == 0 ? CONFIG_INVALID : 0); } int sources_add(const char *url, char *msg, size_t size) { test_mutation_calls++; (void)url; snprintf(msg, size, "Test source"); return 0; diff --git a/tests/integrity_driver.c b/tests/integrity_driver.c index 4741305..cfae951 100644 --- a/tests/integrity_driver.c +++ b/tests/integrity_driver.c @@ -37,7 +37,7 @@ int integrity_openat(int parent,const char *part,int flags,...) { } static int sockets, history; void pldmgr_log(const char *fmt,...) {(void)fmt;} -void pldmgr_autoload_update_config_entry(const char *a,const char *b){(void)a;(void)b;} +int pldmgr_autoload_update_config_entry(const char *a,const char *b){(void)a;(void)b;return 0;} void history_mgr_add(const char *path){(void)path;history++;} int integrity_socket(int domain,int type,int protocol){(void)domain;(void)type;(void)protocol;sockets++;return open("/dev/null",O_WRONLY);} int integrity_connect(int fd,const struct sockaddr *addr,socklen_t length){(void)fd;(void)addr;(void)length;return 0;} diff --git a/tests/retry_driver.c b/tests/retry_driver.c index 8a086b9..02c1661 100644 --- a/tests/retry_driver.c +++ b/tests/retry_driver.c @@ -11,7 +11,7 @@ static const char *manifest; static int first_calls,retry_calls,fail_first; void pldmgr_log(const char *fmt,...){(void)fmt;} -void pldmgr_autoload_update_config_entry(const char *a,const char *b){(void)a;(void)b;} +int pldmgr_autoload_update_config_entry(const char *a,const char *b){(void)a;(void)b;return 0;} static int write_bytes(const char *path,const char *text){FILE*f=fopen(path,"wb");if(!f)return -1;int rc=fwrite(text,1,strlen(text),f)==strlen(text)?0:-1;if(fclose(f))rc=-1;return rc;} int download_to_file(const char *url,const char *path){ if(!strcmp(url,REPOSITORY_SOURCE_URL)){char *text=NULL;size_t size=0;if(read_file_text(manifest,&text,&size))return -1;int rc=write_bytes(path,text);free(text);return rc;} diff --git a/tests/run_config_tests.py b/tests/run_config_tests.py new file mode 100644 index 0000000..559c722 --- /dev/null +++ b/tests/run_config_tests.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Actual config/source persistence and history under faults and concurrent access.""" +import argparse +import json +import os +from pathlib import Path +import shlex +import subprocess +import sys +import tempfile + +ROOT = Path(__file__).resolve().parents[1] +parser = argparse.ArgumentParser() +parser.add_argument('--thread-sanitize', action='store_true') +parser.add_argument('--legacy', action='store_true') +parser.add_argument('--legacy-history', action='store_true') +args = parser.parse_args() +if args.legacy_history and not args.thread_sanitize: parser.error("--legacy-history requires --thread-sanitize") +os.environ.setdefault('ASAN_OPTIONS', ('detect_leaks=0:halt_on_error=1' if sys.platform == 'darwin' else 'detect_leaks=1:halt_on_error=1')) +os.environ.setdefault('UBSAN_OPTIONS', 'halt_on_error=1:print_stacktrace=1') +os.environ.setdefault('TSAN_OPTIONS', 'halt_on_error=1') +with tempfile.TemporaryDirectory(prefix='pldmgr-config-') as temp: + build = Path(temp) + storage = build / 'data' + storage.mkdir() + (build / 'pldmgr.h').write_text((ROOT / 'include/pldmgr.h').read_text().replace('/data/pldmgr', str(storage))) + (build / 'assets_cacert_pem.h').write_text('static const unsigned char assets_cacert_pem[]={0};\nstatic const unsigned int assets_cacert_pem_len=0;\n') + flags = shlex.split(os.environ.get('HOST_CC', 'clang')) + ['-std=c11', '-D_DEFAULT_SOURCE', '-g', '-O1', '-Wall', '-Wextra', '-pthread', '-ffunction-sections', '-fdata-sections', '-I'+str(build), '-Iinclude'] + flags += ['-fsanitize=thread' if args.thread_sanitize else '-fsanitize=address,undefined', '-fno-omit-frame-pointer'] + objects = [] + for name in ['config', 'sources', 'history_mgr', 'json_helpers', 'utils', 'sha256', 'slot', 'repository']: + obj = build / (name+'.o') + source = f'src/{name}.c' + extra = ['-include', 'tests/config_hooks.h'] if name == 'json_helpers' else [] + if args.legacy and name == 'config': + # Adapt only the old void remap symbol, unused by the scalar reproduction. + snapshot = build / 'config_legacy.c' + old = subprocess.check_output(['git', 'show', 'cc0fc90:src/config.c'], cwd=ROOT, text=True) + old = old.replace('void pldmgr_favorites_update_entry', 'void legacy_favorites_update_entry') + old += '\nint config_update_list_entry(const char *p,const char *a,const char *b) {(void)p;(void)a;(void)b;return 0;}\nint pldmgr_favorites_update_entry(const char *a,const char *b) {(void)a;(void)b;return 0;}\n' + snapshot.write_text(old) + source = str(snapshot) + if args.legacy_history and name == 'history_mgr': + snapshot = build / 'history_legacy.c' + snapshot.write_text(subprocess.check_output(['git', 'show', 'cc0fc90:src/history_mgr.c'], cwd=ROOT, text=True)) + source = str(snapshot) + if name == 'repository': extra += ['-Ddownload_to_file=config_unused_download', '-Ddownload_to_file_limited=config_unused_download_limited'] + subprocess.run(flags+extra+['-c', source, '-o', str(obj)], cwd=ROOT, check=True) + objects.append(str(obj)) + binary = build / 'config_tests' + gc = '-Wl,-dead_strip' if sys.platform == 'darwin' else '-Wl,--gc-sections' + subprocess.run(flags+objects+['tests/config_driver.c', gc, '-o', str(binary)], cwd=ROOT, check=True) + cases = ['history'] if args.legacy_history else (['scalar'] if args.legacy else (['settings', 'history', 'sources'] if args.thread_sanitize else ['scalar','validation','atomic','settings','history','sources'])) + for case in cases: + # Independent clean storage for each scenario. + for path in storage.iterdir(): + if path.is_file(): path.unlink() + result = subprocess.run([str(binary), case], cwd=ROOT, text=True, capture_output=True) + if args.legacy_history: + assert result.returncode and 'ThreadSanitizer: data race' in result.stderr, result.stderr + print('Previous history implementation reproduces a ThreadSanitizer data race') + continue + if args.legacy: + assert result.returncode and 'cfg.auto_browser_open==0' in result.stderr, result.stderr + print('Previous implementation reproduces ignored individual setting: '+result.stderr.strip().splitlines()[0]) + continue + if result.returncode: + print(result.stdout); print(result.stderr, file=sys.stderr) + raise SystemExit(result.returncode) + start = result.stdout.find('{') + if start >= 0: + document, _ = json.JSONDecoder().raw_decode(result.stdout[start:]) + if case == 'history': + expected = ['local/item-10']+[f'local/item-{i}' for i in range(24,4,-1) if i != 10] + assert document['history'] == expected, document + if case == 'sources': + sources = document['sources'] + assert len(sources)==13 and len({s['id'] for s in sources})==13 + assert all(s['name']=='Fíxture' for s in sources[1:]) + assert {s['url'] for s in sources[1:]} == {f'fixture://source-{i}' for i in range(12)} + print('\n'.join(line for line in result.stdout.splitlines() if line.startswith(('Config','Atomic','History','Source')))) + print('Config/history/source checks passed'+(' under ThreadSanitizer' if args.thread_sanitize else '')) diff --git a/tests/run_http_tests.py b/tests/run_http_tests.py index eee27fa..8296d27 100644 --- a/tests/run_http_tests.py +++ b/tests/run_http_tests.py @@ -359,6 +359,43 @@ def network_tests(binary, storage, security_only=False, legacy_security=False): server.communicate() +def config_network_tests(binary, storage): + server = subprocess.Popen([str(binary), "--serve"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True) + try: + line = server.stdout.readline().strip() + assert line.startswith("PORT "), line + port = int(line.split()[1]) + def request(method, route, body=None): + connection = http.client.HTTPConnection("127.0.0.1", port, timeout=10) + try: + connection.request(method, route, body.encode("utf-8") if isinstance(body, str) else body, {"X-PLDMGR-Request":"1"}) + response = connection.getresponse() + return response.status, response.read() + finally: connection.close() + assert request("POST", "/set_config", '{"AUTO_BROWSER_OPEN":false}')[0] == 200 + status, body = request("GET", "/get_config") + assert status == 200 and json.loads(body)["AUTO_BROWSER_OPEN"] is False + for bad in ['{', '{"AUTOLOAD_ENABLED":false,"AUTOLOAD_DELAY":-1}', '{"FAVORITES":[1]}']: + assert request("POST", "/set_config", bad)[0] == 400 + # A genuine filesystem failure must produce 500 and retain old config. + old = (storage / "pldmgr_config.txt").read_bytes() + (storage / "favorites.txt").mkdir() + assert request("POST", "/set_config", '{"AUTO_BROWSER_OPEN":true,"FAVORITES":"local/x"}')[0] == 500 + assert (storage / "pldmgr_config.txt").read_bytes() == old + (storage / "favorites.txt").rmdir() + assert request("POST", "/set_config", '{"FAVORITES":"local/żółw","AUTOLOAD_DELAY":0}')[0] == 200 + status, body = request("GET", "/get_config") + config = json.loads(body) + assert status == 200 and config["FAVORITES"] == "local/żółw" and config["AUTOLOAD_DELAY"] == 0 + server.stdin.write("\n"); server.stdin.flush() + output, _ = server.communicate(timeout=10) + assert server.returncode == 0, output + print(output.strip()) + print("HTTP real config: independent save/read, invalid input 400, I/O failure 500, retained settings and Unicode/zero passed") + finally: + if server.poll() is None: server.kill(); server.communicate() + + def main(): parser = argparse.ArgumentParser() parser.add_argument("--ownership-only", action="store_true", @@ -370,6 +407,7 @@ def main(): parser.add_argument("--security-only", action="store_true") parser.add_argument("--legacy-security", action="store_true") parser.add_argument("--serve-only", action="store_true") + parser.add_argument("--config-only", action="store_true") parser.add_argument("--source", default="src/http_server.c", help="Alternate HTTP source snapshot for regression reproduction") args = parser.parse_args() @@ -397,6 +435,8 @@ def main(): f"static const unsigned int assets_{name}_len = sizeof(assets_{name});\n") common = compiler + ["-std=c11", "-D_DEFAULT_SOURCE", "-g", "-O1", "-Wall", "-Wextra", "-I" + str(build), "-Iinclude", "-pthread"] + if args.config_only: + common += ["-DPLDMGR_TEST_REAL_CONFIG"] if not args.no_sanitize: common += ["-fsanitize=address,undefined", "-fno-omit-frame-pointer"] cflags = [flag for flag in flags if flag.startswith("-I") or flag.startswith("-D")] @@ -404,9 +444,12 @@ def main(): run(common + cflags + ["-include", "tests/http_test_hooks.h", "-c", args.source, "-o", str(object_file)]) binary = build / "http_tests" - run(common + cflags + [str(object_file), "src/utils.c", "src/json_helpers.c", "tests/http_backend_stubs.c", + extra_sources = ["src/config.c"] if args.config_only else [] + run(common + cflags + extra_sources + [str(object_file), "src/utils.c", "src/json_helpers.c", "tests/http_backend_stubs.c", "tests/http_responses_test.c", "-o", str(binary)] + flags) selected = ["--ownership-only"] if args.ownership_only else (["--oom-only"] if args.oom_only else []) + if args.config_only: + config_network_tests(binary, storage); return if args.serve_only: run([str(binary), "--serve"]); return if not args.network_only and not args.security_only: diff --git a/tests/storage_driver.c b/tests/storage_driver.c index 59183a9..50f066b 100644 --- a/tests/storage_driver.c +++ b/tests/storage_driver.c @@ -72,7 +72,7 @@ int storage_test_rename(const char *a,const char *b) { return rc; } void pldmgr_log(const char *fmt,...) { (void)fmt; } -void pldmgr_autoload_update_config_entry(const char *a,const char *b) { (void)a;(void)b;autoload_updates++; } +int pldmgr_autoload_update_config_entry(const char *a,const char *b) { (void)a;(void)b;autoload_updates++; return 0; } /* Recorded local bytes only; any network-looking request aborts. */ int download_to_file(const char *url,const char *path) { if (!strcmp(url, REPOSITORY_SOURCE_URL)) return -1;