diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index bc194e99..09295918 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -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 diff --git a/runtime/include/autocompile.h b/runtime/include/autocompile.h index 36e60e40..14490284 100644 --- a/runtime/include/autocompile.h +++ b/runtime/include/autocompile.h @@ -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); diff --git a/runtime/include/overlay_path_canon.h b/runtime/include/overlay_path_canon.h new file mode 100644 index 00000000..bfebc7c0 --- /dev/null +++ b/runtime/include/overlay_path_canon.h @@ -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 + +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#include +#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 */ diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index a4c2dce4..b918c4f5 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -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 diff --git a/runtime/src/autocompile.c b/runtime/src/autocompile.c index 39055027..998bb77d 100644 --- a/runtime/src/autocompile.c +++ b/runtime/src/autocompile.c @@ -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); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index e2f68e02..07ae2749 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -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(); diff --git a/runtime/src/overlay_loader.c b/runtime/src/overlay_loader.c index 2ff6e89b..8baab74d 100644 --- a/runtime/src/overlay_loader.c +++ b/runtime/src/overlay_loader.c @@ -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); diff --git a/runtime/src/overlay_path_canon.c b/runtime/src/overlay_path_canon.c new file mode 100644 index 00000000..94853837 --- /dev/null +++ b/runtime/src/overlay_path_canon.c @@ -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 + +#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 +#include + +#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 diff --git a/runtime/tests/test_autocompile_publication.c b/runtime/tests/test_autocompile_publication.c index af416172..cf2697f1 100644 --- a/runtime/tests/test_autocompile_publication.c +++ b/runtime/tests/test_autocompile_publication.c @@ -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; } diff --git a/runtime/tests/test_interpreter_perf_guards.py b/runtime/tests/test_interpreter_perf_guards.py index f3ed271d..2682d17b 100644 --- a/runtime/tests/test_interpreter_perf_guards.py +++ b/runtime/tests/test_interpreter_perf_guards.py @@ -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") diff --git a/runtime/tests/test_overlay_path_canon.c b/runtime/tests/test_overlay_path_canon.c new file mode 100644 index 00000000..f62f7dae --- /dev/null +++ b/runtime/tests/test_overlay_path_canon.c @@ -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: + * /cache/gcc/aaaaaaaa_bbbbbbbb.dll + * /outside/evil.dll + * and proves the canonical form of every escape spelling lands outside + * /cache while every legitimate spelling lands inside. */ +#include "overlay_path_canon.h" + +#include +#include +#include + +#ifdef _WIN32 +#include +#define MKDIR(p) _mkdir(p) +#else +#include +#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; +}