mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-26 02:11:36 -04:00
Close publication lifecycle: shutdown join, prepare retry, loud caps, canonical paths
Audit fixes for the async shard-publication pipeline (45a2dda):
- autocompile_shutdown(): stops the pipeline, kills the compiler tree via a
kill-on-close job object (assigned while the child is SUSPENDED so cmd.exe
cannot spawn grandchildren outside it), and JOINS the watcher + preparer —
never abandoning a thread inside the Windows loader lock at ExitProcess.
Wired first into shutdown_runtime(). Crash exits reap the tree via the job.
- Failed off-thread prepares retry (3 attempts, 100 ms) on the worker before
the artifact silently reverts to a synchronous emu-thread first-map; retry
and giveup counters exposed in autocompile_status_json.
- Capacity caps fail closed and LOUD: cand_register shouts once at CAND_CAP;
load_one_dll_prepared refuses at MAX_LOADED_DLLS before registering, so
dll_already_loaded can never lose track and double-register on rescan.
- Publication confinement is filesystem-resolved: published_path_valid
canonicalizes the cache root and the pipe path (GetFinalPathNameByHandle /
realpath), prefix-checks canonical forms, and the worker maps the canonical
string while holding a no-delete-share guard handle (TOCTOU). `..`/junction
spellings that passed the lexical check are now rejected — proven by the
new overlay_path_canon_test boundary test.
- test_autocompile_publication gains a shutdown-mid-run scenario asserting
prepared == committed + discarded with nothing queued; perf guards updated
to require validate -> guard-handle -> canonical first-map ordering.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -67,6 +67,12 @@ if(BUILD_TESTING)
|
||||
target_link_libraries(overlay_capture_retry_test PRIVATE ${SDL2_LIBRARIES})
|
||||
add_test(NAME overlay_capture_retry_test COMMAND overlay_capture_retry_test)
|
||||
|
||||
add_executable(overlay_path_canon_test
|
||||
tests/test_overlay_path_canon.c
|
||||
src/overlay_path_canon.c)
|
||||
target_include_directories(overlay_path_canon_test PRIVATE include)
|
||||
add_test(NAME overlay_path_canon_test COMMAND overlay_path_canon_test)
|
||||
|
||||
if(WIN32)
|
||||
add_executable(autocompile_publication_test
|
||||
tests/test_autocompile_publication.c
|
||||
|
||||
@@ -44,6 +44,13 @@ int autocompile_request(void);
|
||||
* Must be called from the same thread that owns the overlay loader. */
|
||||
void autocompile_poll_main(void);
|
||||
|
||||
/* Process-shutdown teardown, emu thread only: stops the publication pipeline,
|
||||
* kills the compiler process tree (job object), and JOINS the watcher and
|
||||
* preparer threads — waiting out any in-flight LoadLibrary rather than
|
||||
* abandoning a thread inside the Windows loader lock (which can deadlock
|
||||
* ExitProcess). Safe to call in any state, including mid-run; idempotent. */
|
||||
void autocompile_shutdown(void);
|
||||
|
||||
/* JSON status blob for the debug server: state, run/fail counters, last
|
||||
* exit code, and the output tail. Returns bytes written. */
|
||||
int autocompile_status_json(char *out, int cap);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/* overlay_path_canon.h — filesystem-resolved path canonicalization for the
|
||||
* overlay cache's confinement boundary. A lexical prefix check can never be
|
||||
* made correct against `..` segments or reparse points (symlinks/junctions):
|
||||
* the only sound comparison resolves BOTH sides through the filesystem, the
|
||||
* same way LoadLibrary/dlopen will resolve them, and prefix-checks the
|
||||
* results. All functions fail CLOSED (0) on any resolution error. */
|
||||
#ifndef PSXRECOMP_OVERLAY_PATH_CANON_H
|
||||
#define PSXRECOMP_OVERLAY_PATH_CANON_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef _WIN32
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#include <windows.h>
|
||||
#endif
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Resolve `path` (file or directory; must exist) to its final normalized
|
||||
* form — `..`, short names, and every reparse point resolved. Returns 1 and
|
||||
* NUL-terminates `out` on success; 0 on any failure (missing, inaccessible,
|
||||
* result longer than cap). */
|
||||
int overlay_path_canonicalize(const char *path, char *out, size_t cap);
|
||||
|
||||
#ifdef _WIN32
|
||||
/* Same resolution from an already-open handle. Lets a caller open the file
|
||||
* with restrictive sharing FIRST and validate the canonical identity of the
|
||||
* object it actually holds — closing the validate-then-swap (TOCTOU) window
|
||||
* before LoadLibrary. */
|
||||
int overlay_path_canonicalize_handle(HANDLE h, char *out, size_t cap);
|
||||
#endif
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* PSXRECOMP_OVERLAY_PATH_CANON_H */
|
||||
@@ -197,6 +197,7 @@ set(PSXRECOMP_RUNTIME_SOURCES
|
||||
${PSXRECOMP_ROOT}/runtime/src/card_data_writes.c
|
||||
${PSXRECOMP_ROOT}/runtime/src/overlay_capture.c
|
||||
${PSXRECOMP_ROOT}/runtime/src/overlay_loader.c
|
||||
${PSXRECOMP_ROOT}/runtime/src/overlay_path_canon.c
|
||||
${PSXRECOMP_ROOT}/runtime/src/overlay_posix.c
|
||||
${PSXRECOMP_ROOT}/runtime/src/overlay_backend.c
|
||||
${PSXRECOMP_ROOT}/runtime/src/autocompile.c
|
||||
|
||||
+117
-4
@@ -68,6 +68,11 @@ static CRITICAL_SECTION s_out_lock;
|
||||
static CONDITION_VARIABLE s_publish_cv;
|
||||
static int s_out_lock_init = 0;
|
||||
static HANDLE s_proc = NULL;
|
||||
/* Kill-on-close job tying the whole cmd->python->gcc compile tree to this
|
||||
* process: any exit path (including a crash) closes the handle and the kernel
|
||||
* reaps the tree, so a compiler can never keep writing into the cache after
|
||||
* the runtime is gone. Owned exclusively by the emulation thread. */
|
||||
static HANDLE s_job = NULL;
|
||||
static HANDLE s_watch_thread = NULL;
|
||||
static HANDLE s_prepare_thread = NULL;
|
||||
|
||||
@@ -90,6 +95,8 @@ static uint64_t s_publish_prepare_max_us;
|
||||
static uint64_t s_publish_prepare_last_us;
|
||||
static unsigned s_publish_prepare_count;
|
||||
static unsigned s_publish_prepare_fail;
|
||||
static unsigned s_publish_prepare_retry;
|
||||
static unsigned s_publish_prepare_giveup;
|
||||
static char s_child_line[1024];
|
||||
static int s_child_line_len = 0;
|
||||
static int s_child_line_overflow = 0;
|
||||
@@ -162,10 +169,31 @@ static DWORD WINAPI publish_prepare_thread_main(LPVOID unused) {
|
||||
s_publish_preparing++;
|
||||
LeaveCriticalSection(&s_out_lock);
|
||||
|
||||
/* A dropped publication silently reverts this artifact to a
|
||||
* synchronous emulation-thread first-map on its next dispatch miss —
|
||||
* the exact stall this pipeline exists to remove. Mapping failures
|
||||
* right after the writer finishes are usually transient (AV scan or
|
||||
* a sharing violation holding the file), so retry here where waiting
|
||||
* costs no frame time. Validation rejects also land here and burn two
|
||||
* pointless sleeps; that's an accepted cost of keeping prepare's
|
||||
* failure reasons opaque. */
|
||||
enum { AC_PREPARE_ATTEMPTS = 3, AC_PREPARE_RETRY_MS = 100 };
|
||||
LARGE_INTEGER q0, q1, qf;
|
||||
QueryPerformanceCounter(&q0);
|
||||
item->image = overlay_loader_prepare_published(item->path);
|
||||
QueryPerformanceCounter(&q1);
|
||||
int attempts = 0;
|
||||
for (;;) {
|
||||
QueryPerformanceCounter(&q0);
|
||||
item->image = overlay_loader_prepare_published(item->path);
|
||||
QueryPerformanceCounter(&q1);
|
||||
attempts++;
|
||||
if (item->image || attempts >= AC_PREPARE_ATTEMPTS) break;
|
||||
int stopping;
|
||||
EnterCriticalSection(&s_out_lock);
|
||||
s_publish_prepare_retry++;
|
||||
stopping = s_publish_stop;
|
||||
LeaveCriticalSection(&s_out_lock);
|
||||
if (stopping) break;
|
||||
Sleep(AC_PREPARE_RETRY_MS);
|
||||
}
|
||||
QueryPerformanceFrequency(&qf);
|
||||
uint64_t elapsed_us = qf.QuadPart > 0
|
||||
? (uint64_t)((q1.QuadPart - q0.QuadPart) * 1000000LL /
|
||||
@@ -175,6 +203,7 @@ static DWORD WINAPI publish_prepare_thread_main(LPVOID unused) {
|
||||
s_publish_preparing--;
|
||||
s_publish_prepare_count++;
|
||||
s_publish_prepare_fail++;
|
||||
s_publish_prepare_giveup++;
|
||||
s_publish_prepare_last_us = elapsed_us;
|
||||
s_publish_prepare_total_us += elapsed_us;
|
||||
if (elapsed_us > s_publish_prepare_max_us)
|
||||
@@ -476,6 +505,8 @@ int autocompile_request(void) {
|
||||
s_publish_prepare_last_us = 0;
|
||||
s_publish_prepare_count = 0;
|
||||
s_publish_prepare_fail = 0;
|
||||
s_publish_prepare_retry = 0;
|
||||
s_publish_prepare_giveup = 0;
|
||||
s_publish_commit_active = 0;
|
||||
s_publish_input_done = 0;
|
||||
s_publish_stop = 0;
|
||||
@@ -513,18 +544,43 @@ int autocompile_request(void) {
|
||||
si.hStdError = wr;
|
||||
PROCESS_INFORMATION pi;
|
||||
memset(&pi, 0, sizeof(pi));
|
||||
/* Kill-on-close job for the whole compile tree. Created before the child
|
||||
* and assigned while the child is still SUSPENDED, so cmd.exe cannot spawn
|
||||
* python/gcc grandchildren outside the job. If job setup fails (rare),
|
||||
* proceed without it — the shutdown path still terminates the direct
|
||||
* child; only crash-orphan protection is lost. */
|
||||
HANDLE job = CreateJobObjectA(NULL, NULL);
|
||||
if (job) {
|
||||
JOBOBJECT_EXTENDED_LIMIT_INFORMATION jeli;
|
||||
memset(&jeli, 0, sizeof(jeli));
|
||||
jeli.BasicLimitInformation.LimitFlags =
|
||||
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||
if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation,
|
||||
&jeli, sizeof(jeli))) {
|
||||
CloseHandle(job);
|
||||
job = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
/* Compilation is opportunistic: it must never outrank the interpreter that
|
||||
* keeps the current frame alive. cmd -> python -> gcc inherit below-normal
|
||||
* priority, while the live compile script also defaults to one worker. */
|
||||
BOOL ok = CreateProcessA(NULL, full, NULL, NULL, TRUE,
|
||||
CREATE_NO_WINDOW | BELOW_NORMAL_PRIORITY_CLASS,
|
||||
CREATE_NO_WINDOW | BELOW_NORMAL_PRIORITY_CLASS |
|
||||
CREATE_SUSPENDED,
|
||||
NULL, s_cwd[0] ? s_cwd : NULL, &si, &pi);
|
||||
CloseHandle(wr);
|
||||
if (!ok) {
|
||||
if (job) CloseHandle(job);
|
||||
CloseHandle(rd);
|
||||
s_fails++;
|
||||
return 0;
|
||||
}
|
||||
if (job && !AssignProcessToJobObject(job, pi.hProcess)) {
|
||||
CloseHandle(job);
|
||||
job = NULL;
|
||||
}
|
||||
ResumeThread(pi.hThread);
|
||||
CloseHandle(pi.hThread);
|
||||
|
||||
WatchCtx *ctx = (WatchCtx *)HeapAlloc(GetProcessHeap(), 0, sizeof(*ctx));
|
||||
@@ -535,12 +591,14 @@ int autocompile_request(void) {
|
||||
WaitForSingleObject(pi.hProcess, INFINITE);
|
||||
CloseHandle(rd);
|
||||
CloseHandle(pi.hProcess);
|
||||
if (job) CloseHandle(job); /* reaps any grandchild */
|
||||
s_fails++;
|
||||
return 0;
|
||||
}
|
||||
ctx->read_pipe = rd;
|
||||
ctx->proc = pi.hProcess;
|
||||
s_proc = pi.hProcess;
|
||||
s_job = job;
|
||||
ac_state_store(AC_RUNNING);
|
||||
s_runs++;
|
||||
s_prepare_thread = CreateThread(NULL, 0, publish_prepare_thread_main,
|
||||
@@ -552,6 +610,7 @@ int autocompile_request(void) {
|
||||
CloseHandle(pi.hProcess);
|
||||
HeapFree(GetProcessHeap(), 0, ctx);
|
||||
s_proc = NULL;
|
||||
if (s_job) { CloseHandle(s_job); s_job = NULL; }
|
||||
ac_state_store(AC_IDLE);
|
||||
s_fails++;
|
||||
return 0;
|
||||
@@ -573,6 +632,7 @@ int autocompile_request(void) {
|
||||
WaitForSingleObject(s_prepare_thread, INFINITE);
|
||||
CloseHandle(s_prepare_thread);
|
||||
s_prepare_thread = NULL;
|
||||
if (s_job) { CloseHandle(s_job); s_job = NULL; }
|
||||
ac_state_store(AC_IDLE);
|
||||
s_fails++;
|
||||
return 0;
|
||||
@@ -663,6 +723,12 @@ void autocompile_poll_main(void) {
|
||||
CloseHandle(s_watch_thread);
|
||||
s_watch_thread = NULL;
|
||||
}
|
||||
if (s_job) {
|
||||
/* Child already exited (watcher joined); closing the kill-on-close
|
||||
* job only reaps any grandchild the tree left behind. */
|
||||
CloseHandle(s_job);
|
||||
s_job = NULL;
|
||||
}
|
||||
#endif
|
||||
ac_state_store(AC_IDLE);
|
||||
s_shard_result_seen = parse_shard_result();
|
||||
@@ -681,6 +747,48 @@ void autocompile_poll_main(void) {
|
||||
s_fails++;
|
||||
}
|
||||
|
||||
void autocompile_shutdown(void) {
|
||||
#ifdef _WIN32
|
||||
if (!s_out_lock_init) return; /* never configured — nothing to stop */
|
||||
/* Halt the pipeline first so the preparer can never START another
|
||||
* LoadLibrary; one already in flight is waited out below (terminating a
|
||||
* thread that holds the Windows loader lock can deadlock ExitProcess). */
|
||||
HANDLE proc_dup = NULL;
|
||||
EnterCriticalSection(&s_out_lock);
|
||||
s_publish_stop = 1;
|
||||
WakeAllConditionVariable(&s_publish_cv);
|
||||
/* s_proc is cleared+closed by the watcher on child exit; duplicate under
|
||||
* the lock so the kill below can never race that close onto a reused
|
||||
* handle value. */
|
||||
if (s_proc)
|
||||
DuplicateHandle(GetCurrentProcess(), s_proc, GetCurrentProcess(),
|
||||
&proc_dup, 0, FALSE, DUPLICATE_SAME_ACCESS);
|
||||
LeaveCriticalSection(&s_out_lock);
|
||||
/* Kill the compile tree; the dying pipe write end unblocks the watcher. */
|
||||
if (s_job) TerminateJobObject(s_job, 1);
|
||||
else if (proc_dup) TerminateProcess(proc_dup, 1);
|
||||
if (proc_dup) CloseHandle(proc_dup);
|
||||
if (s_prepare_thread) {
|
||||
WaitForSingleObject(s_prepare_thread, INFINITE);
|
||||
CloseHandle(s_prepare_thread);
|
||||
s_prepare_thread = NULL;
|
||||
}
|
||||
if (s_watch_thread) {
|
||||
WaitForSingleObject(s_watch_thread, INFINITE);
|
||||
CloseHandle(s_watch_thread);
|
||||
s_watch_thread = NULL;
|
||||
}
|
||||
if (s_job) {
|
||||
CloseHandle(s_job);
|
||||
s_job = NULL;
|
||||
}
|
||||
/* Both workers are joined and this is the emulation thread, so nothing is
|
||||
* preparing or committing: the discard cannot be refused. */
|
||||
(void)publish_discard_all();
|
||||
ac_state_store(AC_IDLE);
|
||||
#endif
|
||||
}
|
||||
|
||||
/* Minimal JSON string escaper for the output tail. */
|
||||
static int json_escape_into(char *dst, int cap, const char *src, int n) {
|
||||
int o = 0;
|
||||
@@ -708,6 +816,7 @@ int autocompile_status_json(char *out, int cap) {
|
||||
unsigned publish_ready = 0, publish_ready_highwater = 0;
|
||||
unsigned publish_preparing = 0, publish_prepare_count = 0;
|
||||
unsigned publish_prepare_fail = 0;
|
||||
unsigned publish_prepare_retry = 0, publish_prepare_giveup = 0;
|
||||
uint64_t publish_prepare_total_us = 0, publish_prepare_max_us = 0;
|
||||
uint64_t publish_prepare_last_us = 0;
|
||||
tail[0] = '\0';
|
||||
@@ -722,6 +831,8 @@ int autocompile_status_json(char *out, int cap) {
|
||||
publish_preparing = s_publish_preparing;
|
||||
publish_prepare_count = s_publish_prepare_count;
|
||||
publish_prepare_fail = s_publish_prepare_fail;
|
||||
publish_prepare_retry = s_publish_prepare_retry;
|
||||
publish_prepare_giveup = s_publish_prepare_giveup;
|
||||
publish_prepare_total_us = s_publish_prepare_total_us;
|
||||
publish_prepare_max_us = s_publish_prepare_max_us;
|
||||
publish_prepare_last_us = s_publish_prepare_last_us;
|
||||
@@ -737,6 +848,7 @@ int autocompile_status_json(char *out, int cap) {
|
||||
"\"publish_ready\":%u,\"publish_ready_highwater\":%u,"
|
||||
"\"publish_preparing\":%u,\"publish_prepare_count\":%u,"
|
||||
"\"publish_prepare_fail\":%u,"
|
||||
"\"publish_prepare_retry\":%u,\"publish_prepare_giveup\":%u,"
|
||||
"\"publish_prepare_total_us\":%llu,"
|
||||
"\"publish_prepare_max_us\":%llu,"
|
||||
"\"publish_prepare_last_us\":%llu,"
|
||||
@@ -747,6 +859,7 @@ int autocompile_status_json(char *out, int cap) {
|
||||
s_shard_fail_total, s_shard_result_seen,
|
||||
publish_ready, publish_ready_highwater, publish_preparing,
|
||||
publish_prepare_count, publish_prepare_fail,
|
||||
publish_prepare_retry, publish_prepare_giveup,
|
||||
(unsigned long long)publish_prepare_total_us,
|
||||
(unsigned long long)publish_prepare_max_us,
|
||||
(unsigned long long)publish_prepare_last_us, tail);
|
||||
|
||||
@@ -978,6 +978,11 @@ static void close_controller(void);
|
||||
static void shutdown_runtime(void) {
|
||||
/* (sljit removed 2026-07-15: overlay_compile_worker_stop joined the
|
||||
* off-thread JIT worker here; the worker no longer exists.) */
|
||||
/* First: stop the autocompile pipeline (kills the compiler tree, joins the
|
||||
* pipe watcher + publication preparer). Ordered before the capture flush
|
||||
* so no child is still writing into the cache while we finalize state,
|
||||
* and before ExitProcess can strand a thread inside the loader lock. */
|
||||
autocompile_shutdown();
|
||||
memcard_flush_all();
|
||||
overlay_capture_wait_pending();
|
||||
overlay_capture_write_json();
|
||||
|
||||
+100
-19
@@ -1,5 +1,6 @@
|
||||
#include "overlay_loader.h"
|
||||
#include "overlay_api.h"
|
||||
#include "overlay_path_canon.h"
|
||||
#include "code_provider.h"
|
||||
#include "overlay_backend.h"
|
||||
#include "crc32.h"
|
||||
@@ -753,7 +754,20 @@ static int cand_delay_slots_hashed(const Candidate *c) {
|
||||
static void loader_log(const char *fmt, ...); /* defined below */
|
||||
static void cand_register(uint32_t phys, OverlayFn fn, const ManFn *m, int dll,
|
||||
int tier) {
|
||||
if (s_cand_n >= CAND_CAP || !man_structurally_valid(m)) return;
|
||||
if (s_cand_n >= CAND_CAP) {
|
||||
/* Never-again (the silent-256 truncation, see CACHE_IDX_CAP): a full
|
||||
* candidate table means real native coverage is being IGNORED. Shout
|
||||
* once — per-registration would spam a big rescan. */
|
||||
static int s_cand_overflow_logged = 0;
|
||||
if (!s_cand_overflow_logged) {
|
||||
s_cand_overflow_logged = 1;
|
||||
loader_log("*** CANDIDATE TABLE FULL (%d): func_%08X and every "
|
||||
"later candidate are being DROPPED — their code runs "
|
||||
"interpreted. Raise CAND_CAP.", CAND_CAP, phys);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!man_structurally_valid(m)) return;
|
||||
int idx = s_cand_n++;
|
||||
Candidate *c = &s_cand[idx];
|
||||
c->addr = phys;
|
||||
@@ -817,6 +831,14 @@ static char s_cache_dir[512];
|
||||
static char s_game_id[64];
|
||||
static int s_active = 0;
|
||||
|
||||
/* Canonical (filesystem-resolved) form of s_cache_dir, computed lazily on the
|
||||
* first live publication — the root is guaranteed to exist by then (the
|
||||
* compiler just wrote a DLL into it). cache_path_has_root() is sound ONLY on
|
||||
* canonical inputs: raw pipe text can smuggle `..` segments or a junction
|
||||
* past a lexical prefix compare. */
|
||||
static char s_cache_root_canon[768];
|
||||
static int s_cache_root_canon_ok = 0;
|
||||
|
||||
/* Rule 3: no stderr logging. Most recent loader event recorded here and
|
||||
* surfaced through overlay_loader_status. */
|
||||
static char s_last_msg[256] = {0};
|
||||
@@ -2096,6 +2118,7 @@ void overlay_loader_init(const char *cache_dir, const char *game_id) {
|
||||
s_range_pc_cache[i].cand = -1;
|
||||
strncpy(s_cache_dir, cache_dir, sizeof(s_cache_dir) - 1);
|
||||
strncpy(s_game_id, game_id, sizeof(s_game_id) - 1);
|
||||
s_cache_root_canon_ok = 0; /* re-resolve for the (re)assigned root */
|
||||
/* data shards persist under the same unified cache root (data_shards.c) */
|
||||
{ extern void ds_init(const char*, const char*); ds_init(cache_dir, game_id); }
|
||||
init_callbacks();
|
||||
@@ -2297,6 +2320,17 @@ static void overlay_library_close(OverlayLibraryHandle handle) {
|
||||
* retains that reference for process lifetime, matching the ordinary loader. */
|
||||
static int load_one_dll_prepared(const char *dll_path,
|
||||
OverlayLibraryHandle prepared) {
|
||||
/* Fail CLOSED at the tracking cap, BEFORE registering anything: past this
|
||||
* point dll_already_loaded() would lose track of the DLL and a later
|
||||
* rescan could double-register its candidates (stale-chain execution).
|
||||
* Refusing keeps the region interpreted — same contract as CACHE_IDX_CAP. */
|
||||
if (s_nloaded_paths >= MAX_LOADED_DLLS) {
|
||||
loader_log("*** LOADED-DLL TABLE FULL (%d): refusing %s — its region "
|
||||
"stays interpreted. Raise MAX_LOADED_DLLS.",
|
||||
MAX_LOADED_DLLS, dll_path);
|
||||
overlay_library_close(prepared);
|
||||
return 0;
|
||||
}
|
||||
#ifdef _WIN32
|
||||
LARGE_INTEGER q0, q1, qf;
|
||||
QueryPerformanceCounter(&q0);
|
||||
@@ -2334,11 +2368,11 @@ static int load_one_dll_prepared(const char *dll_path,
|
||||
/* The DLL may publish other PCs that were previously negative-cached. */
|
||||
lazy_miss_invalidate_loader();
|
||||
|
||||
if (s_nloaded_paths < MAX_LOADED_DLLS) {
|
||||
strncpy(s_loaded_paths[s_nloaded_paths], dll_path, 767);
|
||||
s_loaded_paths[s_nloaded_paths][767] = '\0';
|
||||
s_nloaded_paths++;
|
||||
}
|
||||
/* Unconditional: the fail-closed gate at entry guarantees room, so the
|
||||
* tracking table can never silently lose a loaded DLL again. */
|
||||
strncpy(s_loaded_paths[s_nloaded_paths], dll_path, 767);
|
||||
s_loaded_paths[s_nloaded_paths][767] = '\0';
|
||||
s_nloaded_paths++;
|
||||
s_ndlls++;
|
||||
return registered;
|
||||
}
|
||||
@@ -2347,15 +2381,38 @@ static int load_one_dll(const char *dll_path) {
|
||||
return load_one_dll_prepared(dll_path, NULL);
|
||||
}
|
||||
|
||||
static int published_path_valid(const char *dll_path) {
|
||||
static int published_root_canonical(void) {
|
||||
if (s_cache_root_canon_ok) return 1;
|
||||
if (!overlay_path_canonicalize(s_cache_dir, s_cache_root_canon,
|
||||
sizeof(s_cache_root_canon))) {
|
||||
/* Not cached as a failure: the root may simply not exist yet; the
|
||||
* next publication retries. Rejecting until it resolves is the
|
||||
* fail-closed direction. */
|
||||
loader_log("publication rejected: cache root %s does not "
|
||||
"canonicalize", s_cache_dir);
|
||||
return 0;
|
||||
}
|
||||
return s_cache_root_canon_ok = 1;
|
||||
}
|
||||
|
||||
/* Validates dll_path and writes its CANONICAL form into canon; every later
|
||||
* consumer (LoadLibrary, .ranges derivation, loaded-path tracking) uses the
|
||||
* canonical string, never the raw pipe text. */
|
||||
static int published_path_valid(const char *dll_path, char *canon,
|
||||
size_t canon_cap) {
|
||||
if (!s_active || !dll_path || !dll_path[0]) return 0;
|
||||
/* The compiler command is trusted local code, but keep its native-library
|
||||
* output confined to the cache root the framework injected. */
|
||||
if (!cache_path_has_root(dll_path, s_cache_dir)) return 0;
|
||||
const char *base = strrchr(dll_path, '/');
|
||||
const char *win_base = strrchr(dll_path, '\\');
|
||||
* output confined to the cache root the framework injected — resolved
|
||||
* through the filesystem on BOTH sides, exactly as the OS loader will
|
||||
* resolve it, so `..`/short-name/reparse forms cannot slip a lexical
|
||||
* prefix check. */
|
||||
if (!published_root_canonical()) return 0;
|
||||
if (!overlay_path_canonicalize(dll_path, canon, canon_cap)) return 0;
|
||||
if (!cache_path_has_root(canon, s_cache_root_canon)) return 0;
|
||||
const char *base = strrchr(canon, '/');
|
||||
const char *win_base = strrchr(canon, '\\');
|
||||
if (!base || (win_base && win_base > base)) base = win_base;
|
||||
base = base ? base + 1 : dll_path;
|
||||
base = base ? base + 1 : canon;
|
||||
uint32_t addr = 0, crc = 0;
|
||||
if (!psx_overlay_cache_name_parse(base, &addr, &crc)) return 0;
|
||||
if (!cache_name_is_immutable(base)) return 0;
|
||||
@@ -2365,16 +2422,37 @@ static int published_path_valid(const char *dll_path) {
|
||||
}
|
||||
|
||||
OverlayPreparedImage *overlay_loader_prepare_published(const char *dll_path) {
|
||||
if (!published_path_valid(dll_path)) return NULL;
|
||||
char canon[768];
|
||||
if (!published_path_valid(dll_path, canon, sizeof(canon))) return NULL;
|
||||
OverlayPreparedImage *image =
|
||||
(OverlayPreparedImage *)calloc(1, sizeof(*image));
|
||||
if (!image) return NULL;
|
||||
snprintf(image->path, sizeof(image->path), "%s", dll_path);
|
||||
#ifdef _WIN32
|
||||
image->handle = LoadLibraryA(dll_path);
|
||||
/* Guard handle: GENERIC_READ with only READ shared blocks rename/replace/
|
||||
* delete of the validated file until LoadLibrary has mapped it, and the
|
||||
* canonical identity is re-derived from the object actually held — so a
|
||||
* swap between validation and mapping cannot survive the re-check.
|
||||
* (Anything already writable inside the cache root itself is outside this
|
||||
* boundary by design; see the threat-model note above cand_register.) */
|
||||
HANDLE guard = CreateFileA(canon, GENERIC_READ, FILE_SHARE_READ, NULL,
|
||||
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
|
||||
if (guard == INVALID_HANDLE_VALUE) {
|
||||
free(image);
|
||||
return NULL;
|
||||
}
|
||||
if (!overlay_path_canonicalize_handle(guard, canon, sizeof(canon)) ||
|
||||
!cache_path_has_root(canon, s_cache_root_canon)) {
|
||||
CloseHandle(guard);
|
||||
free(image);
|
||||
return NULL;
|
||||
}
|
||||
snprintf(image->path, sizeof(image->path), "%s", canon);
|
||||
image->handle = LoadLibraryA(canon);
|
||||
CloseHandle(guard);
|
||||
#else
|
||||
snprintf(image->path, sizeof(image->path), "%s", canon);
|
||||
char error[256] = {0};
|
||||
image->handle = psx_overlay_posix_library_open(dll_path, error,
|
||||
image->handle = psx_overlay_posix_library_open(canon, error,
|
||||
sizeof(error));
|
||||
#endif
|
||||
if (!image->handle) {
|
||||
@@ -2389,9 +2467,12 @@ int overlay_loader_commit_published(OverlayPreparedImage *image) {
|
||||
OverlayLibraryHandle handle = image->handle;
|
||||
image->handle = NULL;
|
||||
int loaded = 0;
|
||||
if (published_path_valid(image->path) &&
|
||||
!dll_already_loaded(image->path))
|
||||
loaded = load_one_dll_prepared(image->path, handle);
|
||||
char canon[768];
|
||||
/* image->path is already canonical (prepare stored the handle-derived
|
||||
* form); re-validating is an idempotent freshness check. */
|
||||
if (published_path_valid(image->path, canon, sizeof(canon)) &&
|
||||
!dll_already_loaded(canon))
|
||||
loaded = load_one_dll_prepared(canon, handle);
|
||||
else
|
||||
overlay_library_close(handle);
|
||||
free(image);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/* overlay_path_canon.c — see header. Windows resolves via
|
||||
* GetFinalPathNameByHandle (the loader's own view of the object); POSIX via
|
||||
* realpath. No allocation, no logging: callers decide how loud a rejection
|
||||
* is. */
|
||||
#include "overlay_path_canon.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
int overlay_path_canonicalize_handle(HANDLE h, char *out, size_t cap) {
|
||||
if (h == NULL || h == INVALID_HANDLE_VALUE || !out || cap < 2) return 0;
|
||||
char buf[1024];
|
||||
DWORD n = GetFinalPathNameByHandleA(h, buf, (DWORD)sizeof(buf),
|
||||
FILE_NAME_NORMALIZED |
|
||||
VOLUME_NAME_DOS);
|
||||
if (n == 0 || n >= sizeof(buf)) return 0;
|
||||
/* Strip the \\?\ (or rewrite \\?\UNC\ to \\) prefix so the result
|
||||
* compares against ordinary Win32 paths. */
|
||||
const char *p = buf;
|
||||
char unc[1024];
|
||||
if (strncmp(buf, "\\\\?\\UNC\\", 8) == 0) {
|
||||
unc[0] = '\\';
|
||||
unc[1] = '\\';
|
||||
strncpy(unc + 2, buf + 8, sizeof(unc) - 3);
|
||||
unc[sizeof(unc) - 1] = '\0';
|
||||
p = unc;
|
||||
} else if (strncmp(buf, "\\\\?\\", 4) == 0) {
|
||||
p = buf + 4;
|
||||
}
|
||||
size_t len = strlen(p);
|
||||
if (len + 1 > cap) return 0;
|
||||
memcpy(out, p, len + 1);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int overlay_path_canonicalize(const char *path, char *out, size_t cap) {
|
||||
if (!path || !path[0]) return 0;
|
||||
/* dwDesiredAccess=0 opens for attribute access only (works even on
|
||||
* read-locked files); BACKUP_SEMANTICS is required to open directories. */
|
||||
HANDLE h = CreateFileA(path, 0,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE |
|
||||
FILE_SHARE_DELETE,
|
||||
NULL, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS,
|
||||
NULL);
|
||||
if (h == INVALID_HANDLE_VALUE) return 0;
|
||||
int ok = overlay_path_canonicalize_handle(h, out, cap);
|
||||
CloseHandle(h);
|
||||
return ok;
|
||||
}
|
||||
|
||||
#else /* POSIX */
|
||||
|
||||
#include <limits.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#ifndef PATH_MAX
|
||||
#define PATH_MAX 4096
|
||||
#endif
|
||||
|
||||
int overlay_path_canonicalize(const char *path, char *out, size_t cap) {
|
||||
if (!path || !path[0] || !out || cap < 2) return 0;
|
||||
char resolved[PATH_MAX];
|
||||
if (!realpath(path, resolved)) return 0;
|
||||
size_t len = strlen(resolved);
|
||||
if (len + 1 > cap) return 0;
|
||||
memcpy(out, resolved, len + 1);
|
||||
return 1;
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -135,7 +135,35 @@ int main(void) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* Shutdown mid-run: feed markers, never signal input-done, and tear down
|
||||
* while the preparer may be anywhere in its loop. autocompile_shutdown
|
||||
* must join the worker (never abandon it) and conserve every reference:
|
||||
* prepared == committed + discarded, with nothing left queued. */
|
||||
{
|
||||
static char feed[8 * 96];
|
||||
int len = 0;
|
||||
for (unsigned i = 0; i < 8; ++i)
|
||||
len += snprintf(feed + len, sizeof(feed) - (size_t)len,
|
||||
"PSX_SHARD_PUBLISHED C:\\cache\\00010000_DEADBEEF_%08X.dll\n",
|
||||
0x1000u + i);
|
||||
if (!autocompile_test_start_preparer()) return 1;
|
||||
autocompile_test_feed_output(feed, len);
|
||||
autocompile_shutdown(); /* input never finished — stop must win */
|
||||
}
|
||||
if (autocompile_test_ready_count() != 0 ||
|
||||
autocompile_test_preparing_count() != 0 ||
|
||||
s_prepared != s_committed + s_discarded) {
|
||||
fprintf(stderr,
|
||||
"FAIL: shutdown conservation prep=%ld commit=%ld discard=%ld "
|
||||
"ready=%d preparing=%d\n",
|
||||
s_prepared, s_committed, s_discarded,
|
||||
autocompile_test_ready_count(),
|
||||
autocompile_test_preparing_count());
|
||||
return 1;
|
||||
}
|
||||
|
||||
puts("PASS: publications prepare off-thread, backpressure at one mapped "
|
||||
"images, and commit on the emulation thread");
|
||||
"images, commit on the emulation thread, and shutdown conserves "
|
||||
"every prepared reference");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -166,9 +166,20 @@ def main():
|
||||
raise AssertionError("live publication queue can silently overflow a fixed path ring")
|
||||
prepare_image = body(loader, "overlay_loader_prepare_published")
|
||||
commit_image = body(loader, "overlay_loader_commit_published")
|
||||
if "LoadLibraryA(dll_path)" not in prepare_image:
|
||||
raise AssertionError("Windows published image is not first-mapped on the worker")
|
||||
if ("load_one_dll_prepared(image->path, handle)" not in commit_image or
|
||||
validated = prepare_image.find(
|
||||
"published_path_valid(dll_path, canon, sizeof(canon))")
|
||||
guard_open = prepare_image.find(
|
||||
"CreateFileA(canon, GENERIC_READ, FILE_SHARE_READ")
|
||||
first_map = prepare_image.find("LoadLibraryA(canon)")
|
||||
if min(validated, guard_open, first_map) < 0 or not (
|
||||
validated < guard_open < first_map):
|
||||
raise AssertionError(
|
||||
"Windows published image must be canonical-validated and "
|
||||
"guard-held before its worker first-map")
|
||||
if "LoadLibraryA(dll_path)" in prepare_image:
|
||||
raise AssertionError(
|
||||
"worker first-map regressed to mapping raw (non-canonical) pipe text")
|
||||
if ("load_one_dll_prepared(canon, handle)" not in commit_image or
|
||||
"overlay_library_close(handle)" not in commit_image):
|
||||
raise AssertionError("prepared image commit does not consume every speculative reference")
|
||||
watched_write = body(memory, "overlay_watch_note_write")
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/* Boundary test for overlay_path_canon: a confinement check is only sound if
|
||||
* `..` segments, mixed separators, and reparse points all resolve BEFORE the
|
||||
* prefix compare. Builds a scratch tree under %TEMP% / $TMPDIR:
|
||||
* <root>/cache/gcc/aaaaaaaa_bbbbbbbb.dll
|
||||
* <root>/outside/evil.dll
|
||||
* and proves the canonical form of every escape spelling lands outside
|
||||
* <root>/cache while every legitimate spelling lands inside. */
|
||||
#include "overlay_path_canon.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <direct.h>
|
||||
#define MKDIR(p) _mkdir(p)
|
||||
#else
|
||||
#include <sys/stat.h>
|
||||
#define MKDIR(p) mkdir(p, 0700)
|
||||
#endif
|
||||
|
||||
static int s_fails = 0;
|
||||
|
||||
static void expect(int cond, const char *what) {
|
||||
if (!cond) {
|
||||
fprintf(stderr, "FAIL: %s\n", what);
|
||||
s_fails++;
|
||||
}
|
||||
}
|
||||
|
||||
/* Same contract as the loader's cache_path_has_root: prefix match plus a
|
||||
* separator, case-insensitive on the letters (NTFS). */
|
||||
static int has_root(const char *path, const char *root) {
|
||||
size_t n = strlen(root);
|
||||
while (n && (root[n - 1] == '/' || root[n - 1] == '\\')) n--;
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
char a = path[i], b = root[i];
|
||||
if (a >= 'A' && a <= 'Z') a += 32;
|
||||
if (b >= 'A' && b <= 'Z') b += 32;
|
||||
if ((a == '/' || a == '\\') && (b == '/' || b == '\\')) continue;
|
||||
if (a != b) return 0;
|
||||
}
|
||||
return path[n] == '/' || path[n] == '\\';
|
||||
}
|
||||
|
||||
static void touch(const char *path) {
|
||||
FILE *f = fopen(path, "wb");
|
||||
if (f) {
|
||||
fputc('x', f);
|
||||
fclose(f);
|
||||
}
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
const char *tmp = getenv("TEMP");
|
||||
if (!tmp) tmp = getenv("TMPDIR");
|
||||
if (!tmp) tmp = ".";
|
||||
char root[512], cache[512], gcc_dir[512], outside[512];
|
||||
snprintf(root, sizeof(root), "%s/canon_test_%u", tmp,
|
||||
(unsigned)(size_t)&s_fails);
|
||||
snprintf(cache, sizeof(cache), "%s/cache", root);
|
||||
snprintf(gcc_dir, sizeof(gcc_dir), "%s/gcc", cache);
|
||||
snprintf(outside, sizeof(outside), "%s/outside", root);
|
||||
MKDIR(root);
|
||||
MKDIR(cache);
|
||||
MKDIR(gcc_dir);
|
||||
MKDIR(outside);
|
||||
|
||||
char good[512], evil[512];
|
||||
snprintf(good, sizeof(good), "%s/aaaaaaaa_bbbbbbbb.dll", gcc_dir);
|
||||
snprintf(evil, sizeof(evil), "%s/evil.dll", outside);
|
||||
touch(good);
|
||||
touch(evil);
|
||||
|
||||
char canon_root[768], canon[768];
|
||||
expect(overlay_path_canonicalize(cache, canon_root, sizeof(canon_root)),
|
||||
"cache root canonicalizes");
|
||||
|
||||
/* Legitimate path: canonical form stays inside the root. */
|
||||
expect(overlay_path_canonicalize(good, canon, sizeof(canon)),
|
||||
"good path canonicalizes");
|
||||
expect(has_root(canon, canon_root), "good path stays under root");
|
||||
|
||||
/* Traversal escape: lexically starts with the root, resolves outside.
|
||||
* This is the exact spelling that passed the old lexical-only check. */
|
||||
char sneaky[1024];
|
||||
snprintf(sneaky, sizeof(sneaky), "%s/gcc/../../outside/evil.dll", cache);
|
||||
if (overlay_path_canonicalize(sneaky, canon, sizeof(canon)))
|
||||
expect(!has_root(canon, canon_root),
|
||||
"traversal spelling must NOT canonicalize under root");
|
||||
|
||||
/* Mixed separators on the same escape. */
|
||||
snprintf(sneaky, sizeof(sneaky), "%s\\gcc\\..\\..\\outside\\evil.dll",
|
||||
cache);
|
||||
if (overlay_path_canonicalize(sneaky, canon, sizeof(canon)))
|
||||
expect(!has_root(canon, canon_root),
|
||||
"mixed-separator traversal must NOT land under root");
|
||||
|
||||
/* Traversal that stays inside must still be accepted (no blocklist). */
|
||||
snprintf(sneaky, sizeof(sneaky), "%s/gcc/../gcc/aaaaaaaa_bbbbbbbb.dll",
|
||||
cache);
|
||||
expect(overlay_path_canonicalize(sneaky, canon, sizeof(canon)) &&
|
||||
has_root(canon, canon_root),
|
||||
"inside-root traversal still resolves under root");
|
||||
|
||||
/* Nonexistent target fails closed. */
|
||||
snprintf(sneaky, sizeof(sneaky), "%s/gcc/missing.dll", cache);
|
||||
expect(!overlay_path_canonicalize(sneaky, canon, sizeof(canon)),
|
||||
"missing file fails closed");
|
||||
|
||||
/* Undersized output buffer fails closed. */
|
||||
char tiny[4];
|
||||
expect(!overlay_path_canonicalize(good, tiny, sizeof(tiny)),
|
||||
"tiny buffer fails closed");
|
||||
|
||||
#ifdef _WIN32
|
||||
/* Junction inside the cache pointing outside: the canonical form of a
|
||||
* file reached THROUGH it must resolve outside the root. Junction
|
||||
* creation can fail without privileges; skip silently then. */
|
||||
char link_dir[512], via_link[1024], mkcmd[1600];
|
||||
snprintf(link_dir, sizeof(link_dir), "%s/jump", cache);
|
||||
snprintf(mkcmd, sizeof(mkcmd),
|
||||
"cmd /c mklink /J \"%s\" \"%s\" >nul 2>nul", link_dir, outside);
|
||||
if (system(mkcmd) == 0) {
|
||||
snprintf(via_link, sizeof(via_link), "%s/evil.dll", link_dir);
|
||||
if (overlay_path_canonicalize(via_link, canon, sizeof(canon)))
|
||||
expect(!has_root(canon, canon_root),
|
||||
"junction-routed path must NOT land under root");
|
||||
RemoveDirectoryA(link_dir); /* removes the LINK, never the target */
|
||||
}
|
||||
#endif
|
||||
|
||||
if (s_fails) {
|
||||
fprintf(stderr, "FAIL: %d canon boundary checks failed\n", s_fails);
|
||||
return 1;
|
||||
}
|
||||
puts("PASS: canonicalization resolves traversal/separators/reparse "
|
||||
"before the confinement compare and fails closed");
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user