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:
Matthew Stanley
2026-07-19 16:19:57 -07:00
parent 76257fcf48
commit e02b7b3455
11 changed files with 529 additions and 27 deletions
+6
View File
@@ -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
+7
View File
@@ -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);
+39
View File
@@ -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 */
+1
View File
@@ -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
View File
@@ -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);
+5
View File
@@ -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
View File
@@ -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);
+71
View File
@@ -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
+29 -1
View File
@@ -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;
}
+14 -3
View File
@@ -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")
+140
View File
@@ -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;
}