diff --git a/crates/xenia-kernel/src/exports.rs b/crates/xenia-kernel/src/exports.rs index 8ad46db..a4dfa7d 100644 --- a/crates/xenia-kernel/src/exports.rs +++ b/crates/xenia-kernel/src/exports.rs @@ -779,22 +779,57 @@ fn open_cache_file( guest_path: &str, host_path: &std::path::Path, create_disposition: u32, + create_options: u32, mem: &GuestMemory, handle_out: u32, io_status_block: u32, ) -> u64 { + // FILE_DIRECTORY_FILE / FILE_NON_DIRECTORY_FILE per + // xboxkrnl_io.cc:29-34. The guest may set these to discriminate + // a "create directory" call from a "create file" call when the + // host filesystem can't infer it from the path shape (e.g. the + // hash-only paths Sylpheed builds in `cache:\` — without + // the bit, AUDIT-053 found we were creating a 0-byte file at + // `cache:\d4ea4615` which then blocked subsequent hierarchical + // creates of `cache:\d4ea4615\e\46ee8ca` with NAME_COLLISION). + const FILE_DIRECTORY_FILE: u32 = 0x0000_0001; + let want_dir = (create_options & FILE_DIRECTORY_FILE) != 0; + // Root-of-mount case: `cache:\`, `cache:/`, `cache:` resolve to the // cache root directory itself. Mirror canary's HostPathDevice.Open // which returns a directory handle (success, attributes = DIR). // Empty `path.file_name()` after our resolve_cache_path strip means // the guest asked for the mount root. let is_dir_open = host_path == state.cache_root.as_deref().unwrap_or(host_path) - || host_path.is_dir(); + || host_path.is_dir() + || want_dir; if is_dir_open { + // For non-existent paths the guest wants us to create as a + // directory, mkdir-p; canary's HostPathDevice does the same + // when FILE_DIRECTORY_FILE is set on a kCreate disposition. + if want_dir && !host_path.exists() { + if let Err(e) = std::fs::create_dir_all(host_path) { + tracing::warn!( + "cache create_dir_all({:?}) failed: {} — STATUS_UNSUCCESSFUL", + host_path, + e + ); + if handle_out != 0 { + mem.write_u32(handle_out, 0); + } + write_io_status_block(mem, io_status_block, STATUS_UNSUCCESSFUL as u32, 0); + return STATUS_UNSUCCESSFUL; + } + } + // Stored path ends with '/' so nt_query_information_file's + // path-shape probe reports Directory=1. + let dir_path = if guest_path.ends_with('/') || guest_path.ends_with(':') { + guest_path.to_string() + } else { + format!("{}/", guest_path) + }; let handle = state.alloc_handle_for(KernelObject::File { - // Mark as directory via `path.ends_with('/')` - // so nt_query_information_file reports Directory=1. - path: "cache:/".to_string(), + path: dir_path, size: 0, position: 0, data: std::sync::Arc::new(Vec::new()), @@ -806,9 +841,11 @@ fn open_cache_file( } write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, 0); tracing::info!( - "cache open (dir) path={:?} disp={} handle={:#x}", + "cache open (dir) path={:?} host={:?} disp={} opts={:#x} handle={:#x}", guest_path, + host_path, create_disposition, + create_options, handle ); return STATUS_SUCCESS; @@ -899,10 +936,11 @@ fn open_cache_file( } write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, 0); tracing::info!( - "cache open OK path={:?} host={:?} disp={} size={} handle={:#x}", + "cache open OK path={:?} host={:?} disp={} opts={:#x} size={} handle={:#x}", guest_path, host_path, create_disposition, + create_options, size, handle ); @@ -931,6 +969,7 @@ fn open_vfs_file( io_status_block: u32, obj_attrs_ptr: u32, create_disposition: u32, + create_options: u32, ) -> u64 { // Accept the empty-after-prefix case (e.g. `NtCreateFile("game:\")`) as // a valid "open the partition/device root" request — Canary's @@ -982,7 +1021,7 @@ fn open_vfs_file( // disc-validation probes. if let Some(host_path) = cache_path_for(state, &path) { return open_cache_file(state, &path, &host_path, create_disposition, - mem, handle_out, io_status_block); + create_options, mem, handle_out, io_status_block); } let vfs = match state.vfs.as_ref() { @@ -1063,11 +1102,14 @@ fn open_vfs_file( fn nt_create_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { // r3 = handle_out, r4 = desired_access, r5 = obj_attrs, r6 = io_status_block, - // r7 = allocation_size, r8 = file_attributes, r9 = share_access, r10 = create_disposition + // r7 = allocation_size, r8 = file_attributes, r9 = share_access, r10 = create_disposition, + // [sp+0x54] = create_options (9th arg, spilled per shim_utils.h:49-50). let handle_out = ctx.gpr[3] as u32; let obj_attrs_ptr = ctx.gpr[5] as u32; let io_status_block = ctx.gpr[6] as u32; let create_disposition = ctx.gpr[10] as u32; + let sp = ctx.gpr[1] as u32; + let create_options = mem.read_u32(sp + 0x54); ctx.gpr[3] = open_vfs_file( mem, state, @@ -1075,6 +1117,7 @@ fn nt_create_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelSta io_status_block, obj_attrs_ptr, create_disposition, + create_options, ); } @@ -1082,9 +1125,13 @@ fn nt_open_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState // r3 = handle_out, r4 = desired_access, r5 = obj_attrs, // r6 = io_status_block, r7 = share_access, r8 = open_options. // `NtOpenFile` is FILE_OPEN-only (no create) — file must exist. + // Per xboxkrnl_io.cc:99-122, NtOpenFile forwards `open_options` + // straight into NtCreateFile's `create_options` slot, so the + // FILE_DIRECTORY_FILE bit applies the same way. let handle_out = ctx.gpr[3] as u32; let obj_attrs_ptr = ctx.gpr[5] as u32; let io_status_block = ctx.gpr[6] as u32; + let open_options = ctx.gpr[8] as u32; ctx.gpr[3] = open_vfs_file( mem, state, @@ -1092,6 +1139,7 @@ fn nt_open_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState io_status_block, obj_attrs_ptr, FILE_OPEN, + open_options, ); } diff --git a/crates/xenia-kernel/src/state.rs b/crates/xenia-kernel/src/state.rs index 6e4bdcf..b256fe7 100644 --- a/crates/xenia-kernel/src/state.rs +++ b/crates/xenia-kernel/src/state.rs @@ -334,20 +334,23 @@ impl KernelState { }; crate::exports::register_exports(&mut state); crate::xam::register_exports(&mut state); - // AUDIT-038 — set up a deterministic per-process cache root by - // default. Each new `KernelState` lives in its own tmpdir, named - // with the host pid + a monotonic counter so concurrent tests - // don't collide. Errors here are non-fatal (cache I/O degrades - // to the legacy synth-stub fallback) but logged. - static NEXT_CACHE_ID: std::sync::atomic::AtomicU64 = - std::sync::atomic::AtomicU64::new(0); - let id = NEXT_CACHE_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let root = std::env::temp_dir().join(format!( - "xenia-rs-cache-{}-{}", - std::process::id(), - id - )); - if let Err(e) = state.init_cache_root(root.clone()) { + // AUDIT-054 — cache root selection. Defaults to AUDIT-038's + // per-process tmpdir + wipe (lockstep determinism + avoids + // the journal-append-on-reboot self-inconsistency Sylpheed's + // `.tmp` writes produce). Opt-in to persistence via + // `XENIA_CACHE_ROOT=` (explicit) or + // `XENIA_CACHE_PERSIST=1` (`$XDG_DATA_HOME/xenia-rs/cache`). + // Errors are non-fatal (cache I/O degrades to the synth-stub + // fallback) but logged. + let (root, wipe) = resolve_default_cache_root(); + let init_result = if wipe { + state.init_cache_root(root.clone()) + } else { + std::fs::create_dir_all(&root).map(|()| { + state.cache_root = Some(root.clone()); + }) + }; + if let Err(e) = init_result { tracing::warn!( "Failed to initialise cache root at {:?}: {} — cache:/* opens \ will fall back to the synth-empty-file stub", @@ -397,6 +400,13 @@ impl KernelState { Ok(()) } + /// AUDIT-054 — direct (non-wiping) cache-root install for tests + /// that want byte-for-byte control over what's already on disk + /// when the kernel boots. Skips the `init_cache_root` clear pass. + pub fn set_cache_root(&mut self, root: std::path::PathBuf) { + self.cache_root = Some(root); + } + /// Resolve a guest VFS path (e.g. `cache:\d4ea4615e46ee8ca.tmp`) to /// the host-FS path that backs it. Returns `None` if the path doesn't /// have a `cache:` prefix or if no cache root is mounted (legacy @@ -1016,6 +1026,63 @@ impl Default for KernelState { } } +/// AUDIT-054 — pick the cache root path + wipe-on-init mode for a +/// fresh `KernelState`. +/// +/// Default behaviour matches AUDIT-038: per-process tmpdir + full +/// wipe so two consecutive runs see byte-identical initial state +/// (lockstep / oracle determinism). AUDIT-054 found that Sylpheed's +/// `cache:\.tmp` journal-style writes append on each boot, so +/// a naive persistent root makes the on-disk state self-inconsistent +/// after the second boot (`runtime_error` throws from version-check +/// on reload). Opt-in to persistence via env: +/// * `XENIA_CACHE_ROOT=` — explicit persistent path. Caller +/// is responsible for wiping when needed. +/// * `XENIA_CACHE_PERSIST=1` — use `$XDG_DATA_HOME/xenia-rs/cache` +/// (or `$HOME/.local/share/xenia-rs/cache`) without wiping. +/// +/// Returns `(root, wipe)` where `wipe = true` triggers the +/// `init_cache_root` clear-then-recreate dance. +fn resolve_default_cache_root() -> (std::path::PathBuf, bool) { + if let Ok(p) = std::env::var("XENIA_CACHE_ROOT") { + if !p.is_empty() { + return (std::path::PathBuf::from(p), false); + } + } + let persist = std::env::var("XENIA_CACHE_PERSIST") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false); + if persist { + if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { + if !xdg.is_empty() { + return ( + std::path::PathBuf::from(xdg).join("xenia-rs/cache"), + false, + ); + } + } + if let Ok(home) = std::env::var("HOME") { + if !home.is_empty() { + return ( + std::path::PathBuf::from(home).join(".local/share/xenia-rs/cache"), + false, + ); + } + } + } + static NEXT_CACHE_ID: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(0); + let id = NEXT_CACHE_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + ( + std::env::temp_dir().join(format!( + "xenia-rs-cache-{}-{}", + std::process::id(), + id + )), + true, + ) +} + /// KRNBUG-AUDIT-003. Outcome of probing a guest pointer as the `this` /// of a C++ object: read `[this]` as the vtable, then attempt MSVC /// RTTI to recover the decorated class name. Pure read; lockstep-safe.