diff --git a/crates/xenia-app/tests/golden/sylpheed_n50m.json b/crates/xenia-app/tests/golden/sylpheed_n50m.json index 1a3f42e..74e3350 100644 --- a/crates/xenia-app/tests/golden/sylpheed_n50m.json +++ b/crates/xenia-app/tests/golden/sylpheed_n50m.json @@ -1,6 +1,6 @@ { - "instructions": 50000011, - "imports": 407247, + "instructions": 50000009, + "imports": 407215, "unimpl": 0, "draws": 0, "swaps": 2, diff --git a/crates/xenia-kernel/src/exports.rs b/crates/xenia-kernel/src/exports.rs index e42aa2c..fe62eab 100644 --- a/crates/xenia-kernel/src/exports.rs +++ b/crates/xenia-kernel/src/exports.rs @@ -746,15 +746,191 @@ fn write_io_status_block(mem: &GuestMemory, ptr: u32, status: u32, information: mem.write_u32(ptr + 4, information); } +/// NT `CreateDisposition` values — the only ones that matter for cache: +/// opens. From canary `xboxkrnl_io.cc` / NT documentation. +const FILE_SUPERSEDE: u32 = 0; +const FILE_OPEN: u32 = 1; +const FILE_CREATE: u32 = 2; +#[allow(dead_code)] +const FILE_OPEN_IF: u32 = 3; // open-or-create; honoured implicitly (no must_exist branch) +const FILE_OVERWRITE: u32 = 4; +const FILE_OVERWRITE_IF: u32 = 5; + +/// AUDIT-038 — given a normalised guest path (post `path::normalize_path`), +/// return the host-FS path inside `state.cache_root` if and only if the +/// guest path lives on a writable cache mount. Wrapper around +/// [`KernelState::resolve_cache_path`] that also handles the +/// already-normalised `cache:/` form (forward-slashed by normalize_path). +fn cache_path_for(state: &KernelState, normalised: &str) -> Option { + // After normalize_path, backslashes have been converted to forward + // slashes — but resolve_cache_path expects either form. Pass through. + state.resolve_cache_path(normalised) +} + +/// AUDIT-038 — open a `cache:/*` path against the host FS. Honours NT +/// create-disposition semantics (open/create/overwrite/supersede) and +/// records the host_path on the returned `File` object so subsequent +/// `NtReadFile`/`NtWriteFile` go through real I/O. Mirrors the behaviour +/// of canary's `HostPathDevice::Open` (xenia-canary/src/xenia/vfs/devices/ +/// host_path_device.cc) once the symbolic link in xenia_main.cc:649 is +/// applied. +fn open_cache_file( + state: &mut KernelState, + guest_path: &str, + host_path: &std::path::Path, + create_disposition: u32, + mem: &GuestMemory, + handle_out: u32, + io_status_block: u32, +) -> u64 { + // 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(); + if is_dir_open { + 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(), + size: 0, + position: 0, + data: std::sync::Arc::new(Vec::new()), + dir_enum_pos: None, + host_path: None, + }); + if handle_out != 0 { + mem.write_u32(handle_out, handle); + } + write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, 0); + tracing::info!( + "cache open (dir) path={:?} disp={} handle={:#x}", + guest_path, + create_disposition, + handle + ); + return STATUS_SUCCESS; + } + + let exists = host_path.is_file(); + let must_exist = matches!(create_disposition, FILE_OPEN | FILE_OVERWRITE); + let must_not_exist = create_disposition == FILE_CREATE; + let truncate = matches!( + create_disposition, + FILE_SUPERSEDE | FILE_OVERWRITE | FILE_OVERWRITE_IF + ); + + if must_exist && !exists { + if handle_out != 0 { + mem.write_u32(handle_out, 0); + } + write_io_status_block(mem, io_status_block, STATUS_OBJECT_NAME_NOT_FOUND as u32, 0); + tracing::info!( + "cache open MISS path={:?} disp={} -> NOT_FOUND", + guest_path, + create_disposition + ); + return STATUS_OBJECT_NAME_NOT_FOUND; + } + if must_not_exist && exists { + if handle_out != 0 { + mem.write_u32(handle_out, 0); + } + write_io_status_block(mem, io_status_block, STATUS_OBJECT_NAME_COLLISION as u32, 0); + tracing::info!( + "cache open COLLISION path={:?} disp={} -> NAME_COLLISION", + guest_path, + create_disposition + ); + return STATUS_OBJECT_NAME_COLLISION; + } + + // Ensure parent dir exists for create dispositions. + if !exists || truncate { + if let Some(parent) = host_path.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + tracing::warn!( + "cache create_dir_all({:?}) failed: {} — falling back to STATUS_UNSUCCESSFUL", + parent, + 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; + } + } + } + + // Truncate / create empty file if needed. Read remaining bytes only + // when the disposition keeps existing content. + if truncate || !exists { + if let Err(e) = std::fs::File::create(host_path) { + tracing::warn!( + "cache File::create({:?}) 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; + } + } + let size = host_path + .metadata() + .map(|m| m.len()) + .unwrap_or(0); + let handle = state.alloc_handle_for(KernelObject::File { + path: guest_path.to_string(), + size, + position: 0, + // Empty in-memory data; reads/writes go through host_path. + data: std::sync::Arc::new(Vec::new()), + dir_enum_pos: None, + host_path: Some(host_path.to_path_buf()), + }); + if handle_out != 0 { + mem.write_u32(handle_out, handle); + } + write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, 0); + tracing::info!( + "cache open OK path={:?} host={:?} disp={} size={} handle={:#x}", + guest_path, + host_path, + create_disposition, + size, + handle + ); + STATUS_SUCCESS +} + +/// AUDIT-038 — additional NTSTATUS used by the cache-backed open path. +const STATUS_OBJECT_NAME_COLLISION: u64 = 0xC000_0035; + /// Open a VFS-backed file. Shared between NtCreateFile and NtOpenFile — the -/// create/open distinction only matters for writable volumes, which the disc -/// image isn't. +/// create/open distinction only matters for writable volumes (cache:/), +/// which we now back with a host directory (audit-038). The disc image +/// remains read-only. +/// +/// `create_disposition` is honoured for `cache:` paths only: +/// - `FILE_OPEN` (1) / default for `NtOpenFile`: must exist, else +/// STATUS_OBJECT_NAME_NOT_FOUND. +/// - `FILE_CREATE` (2): must NOT exist, else STATUS_OBJECT_NAME_COLLISION. +/// - `FILE_OPEN_IF` (3): open or create. +/// - `FILE_SUPERSEDE` (0) / `FILE_OVERWRITE_IF` (5): create or truncate. +/// - `FILE_OVERWRITE` (4): must exist, then truncate. fn open_vfs_file( mem: &GuestMemory, state: &mut KernelState, handle_out: u32, io_status_block: u32, obj_attrs_ptr: u32, + create_disposition: u32, ) -> u64 { // Accept the empty-after-prefix case (e.g. `NtCreateFile("game:\")`) as // a valid "open the partition/device root" request — Canary's @@ -787,6 +963,7 @@ fn open_vfs_file( position: 0, data: std::sync::Arc::new(Vec::new()), dir_enum_pos: None, + host_path: None, }); if handle_out != 0 { mem.write_u32(handle_out, handle); @@ -795,6 +972,19 @@ fn open_vfs_file( return STATUS_SUCCESS; } + // AUDIT-038 — cache:/* routing. `path` here is the post-normalize form + // of `cache:\foo` (forward-slashed: `cache:/foo`); `cache:` isn't in + // DEVICE_PREFIXES so the prefix survives normalisation. Resolve to a + // host-FS path under `state.cache_root` and apply NT create-disposition + // semantics. This replaces the "Synthesized empty file" stub for this + // mountpoint specifically — other mountpoints (game:/, dat:/, etc.) + // keep the legacy behaviour to avoid disturbing audit-006 / audit-018 + // 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); + } + let vfs = match state.vfs.as_ref() { Some(v) => v, None => { @@ -816,6 +1006,7 @@ fn open_vfs_file( position: 0, data: std::sync::Arc::new(bytes), dir_enum_pos: None, + host_path: None, }); if handle_out != 0 { mem.write_u32(handle_out, handle); @@ -853,6 +1044,7 @@ fn open_vfs_file( position: 0, data: std::sync::Arc::new(Vec::new()), dir_enum_pos: None, + host_path: None, }); if handle_out != 0 { mem.write_u32(handle_out, handle); @@ -875,16 +1067,32 @@ fn nt_create_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelSta 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; - ctx.gpr[3] = open_vfs_file(mem, state, handle_out, io_status_block, obj_attrs_ptr); + let create_disposition = ctx.gpr[10] as u32; + ctx.gpr[3] = open_vfs_file( + mem, + state, + handle_out, + io_status_block, + obj_attrs_ptr, + create_disposition, + ); } 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 + // r6 = io_status_block, r7 = share_access, r8 = open_options. + // `NtOpenFile` is FILE_OPEN-only (no create) — file must exist. 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; - ctx.gpr[3] = open_vfs_file(mem, state, handle_out, io_status_block, obj_attrs_ptr); + ctx.gpr[3] = open_vfs_file( + mem, + state, + handle_out, + io_status_block, + obj_attrs_ptr, + FILE_OPEN, + ); } /// Signal an NT-style completion event on synchronous I/O completion. @@ -922,7 +1130,7 @@ fn nt_read_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState let length = ctx.gpr[9] as u32; let byte_offset_ptr = ctx.gpr[10] as u32; - let Some(KernelObject::File { path, size, position, data, .. }) = state.objects.get_mut(&handle) else { + let Some(KernelObject::File { path, size, position, data, host_path, .. }) = state.objects.get_mut(&handle) else { tracing::warn!("NtReadFile: invalid handle {:#x}", handle); ctx.gpr[3] = STATUS_INVALID_HANDLE; write_io_status_block(mem, io_status_block, STATUS_INVALID_HANDLE as u32, 0); @@ -942,6 +1150,48 @@ fn nt_read_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState *position }; + // AUDIT-038 — host-backed cache read. Refresh `size` from the live FS + // entry first (writers on the same handle may have grown the file + // since open). Use std::fs::File seek/read for the actual transfer. + if let Some(hp) = host_path.clone() { + use std::io::{Read, Seek, SeekFrom}; + let live_size = std::fs::metadata(&hp).map(|m| m.len()).unwrap_or(0); + *size = live_size; + if start_pos >= live_size { + write_io_status_block(mem, io_status_block, STATUS_END_OF_FILE as u32, 0); + ctx.gpr[3] = STATUS_END_OF_FILE; + signal_io_completion_event(state, event_handle); + return; + } + let avail = (live_size - start_pos).min(length as u64) as usize; + let mut buf = vec![0u8; avail]; + let res = (|| -> std::io::Result { + let mut f = std::fs::File::open(&hp)?; + f.seek(SeekFrom::Start(start_pos))?; + f.read_exact(&mut buf)?; + Ok(buf.len()) + })(); + match res { + Ok(n) => { + mem.write_bulk(buffer, &buf[..n]); + *position = start_pos + n as u64; + write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, n as u32); + ctx.gpr[3] = STATUS_SUCCESS; + tracing::info!( + "NtReadFile cache: {} bytes from {:?} @ {} (handle={:#x})", + n, path, start_pos, handle + ); + } + Err(e) => { + tracing::warn!("NtReadFile cache I/O error path={:?}: {}", path, e); + write_io_status_block(mem, io_status_block, STATUS_UNSUCCESSFUL as u32, 0); + ctx.gpr[3] = STATUS_UNSUCCESSFUL; + } + } + signal_io_completion_event(state, event_handle); + return; + } + let total = *size; // Synthesized empty files (system partition opens like @@ -991,13 +1241,74 @@ fn nt_read_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState } fn nt_write_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { - // We don't back anything writable, so discard. Still report the full - // length as written via IO_STATUS_BLOCK so the caller doesn't retry. + // r3 = handle, r4 = event, r5 = apc_routine, r6 = apc_ctx, + // r7 = io_status_block, r8 = buffer, r9 = length, r10 = byte_offset_ptr. + // For cache:/* (host_path Some) writes go to disk; everything else + // is still discarded (matches legacy read-only behaviour for game:/). + let handle = ctx.gpr[3] as u32; let event_handle = ctx.gpr[4] as u32; let io_status_block = ctx.gpr[7] as u32; + let buffer = ctx.gpr[8] as u32; let length = ctx.gpr[9] as u32; - write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, length); - ctx.gpr[3] = STATUS_SUCCESS; + let byte_offset_ptr = ctx.gpr[10] as u32; + + let Some(KernelObject::File { path, size, position, host_path, .. }) = + state.objects.get_mut(&handle) + else { + tracing::warn!("NtWriteFile: invalid handle {:#x}", handle); + ctx.gpr[3] = STATUS_INVALID_HANDLE; + write_io_status_block(mem, io_status_block, STATUS_INVALID_HANDLE as u32, 0); + signal_io_completion_event(state, event_handle); + return; + }; + + let start_pos = if byte_offset_ptr != 0 { + let offset = mem.read_u64(byte_offset_ptr); + if offset != FILE_USE_FILE_POINTER_POSITION && offset != u64::MAX { + *position = offset; + } + *position + } else { + *position + }; + + if let Some(hp) = host_path.clone() { + use std::io::{Seek, SeekFrom, Write}; + let mut buf = vec![0u8; length as usize]; + mem.read_bulk(buffer, &mut buf); + let res = (|| -> std::io::Result<()> { + let mut f = std::fs::OpenOptions::new() + .create(true) + .write(true) + .open(&hp)?; + f.seek(SeekFrom::Start(start_pos))?; + f.write_all(&buf)?; + f.flush()?; + Ok(()) + })(); + match res { + Ok(()) => { + *position = start_pos + length as u64; + let live_size = std::fs::metadata(&hp).map(|m| m.len()).unwrap_or(0); + *size = live_size; + write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, length); + ctx.gpr[3] = STATUS_SUCCESS; + tracing::info!( + "NtWriteFile cache: {} bytes to {:?} @ {} (handle={:#x})", + length, path, start_pos, handle + ); + } + Err(e) => { + tracing::warn!("NtWriteFile cache I/O error path={:?}: {}", path, e); + write_io_status_block(mem, io_status_block, STATUS_UNSUCCESSFUL as u32, 0); + ctx.gpr[3] = STATUS_UNSUCCESSFUL; + } + } + } else { + // Legacy: discard but report full-length-written so caller proceeds. + write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, length); + ctx.gpr[3] = STATUS_SUCCESS; + } signal_io_completion_event(state, event_handle); } @@ -1070,12 +1381,20 @@ fn nt_query_information_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mu let length = ctx.gpr[6] as u32; let class = ctx.gpr[7] as u32; - let Some(KernelObject::File { size, position, path, .. }) = state.objects.get(&handle) else { + let Some(KernelObject::File { size, position, path, host_path, .. }) = state.objects.get(&handle) else { ctx.gpr[3] = STATUS_INVALID_HANDLE; write_io_status_block(mem, io_status_block, STATUS_INVALID_HANDLE as u32, 0); return; }; + // AUDIT-038 — refresh size from the live host file when this is a + // cache-backed handle so post-write queries see accurate EOF. + let live_size = if let Some(hp) = host_path.as_ref() { + std::fs::metadata(hp).map(|m| m.len()).unwrap_or(*size) + } else { + *size + }; + // Root-of-device opens (`game:\`, `cache:\`, `partition0`) strip to // an empty string post-prefix — see `open_vfs_file`'s synth path. // Games query these as directories (DirectoryObject probe), and @@ -1087,7 +1406,7 @@ fn nt_query_information_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mu let is_directory = path.is_empty() || path.ends_with('/') || path.ends_with(':'); - let size = *size; + let size = live_size; let position = *position; // `FILE_ATTRIBUTE_DIRECTORY` (NT / Xbox) — advertised in @@ -1196,7 +1515,7 @@ fn nt_set_information_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut } // Handle lookup. - let Some(KernelObject::File { size, position, .. }) = state.objects.get_mut(&handle) else { + let Some(KernelObject::File { size, position, host_path, .. }) = state.objects.get_mut(&handle) else { ctx.gpr[3] = STATUS_INVALID_HANDLE; return; }; @@ -1211,11 +1530,28 @@ fn nt_set_information_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut *position = new_offset; (STATUS_SUCCESS, 8) } - // XFileEndOfFileInformation (20): i64 new length. Read-only VFS - // → only a no-op truncate-to-same-size succeeds. + // XFileEndOfFileInformation (20): i64 new length. + // For cache:/* (host_path Some): real `set_len` against the + // backing file. Disc-VFS / synth: only a no-op truncate-to-same + // succeeds (read-only). 20 => { let new_eof = mem.read_u64(info_ptr); - if new_eof == *size { + if let Some(hp) = host_path.clone() { + match std::fs::OpenOptions::new() + .write(true) + .open(&hp) + .and_then(|f| f.set_len(new_eof).map(|()| f)) + { + Ok(_) => { + *size = new_eof; + (STATUS_SUCCESS, 8) + } + Err(e) => { + tracing::warn!("set_len({:?}, {}): {}", hp, new_eof, e); + (STATUS_UNSUCCESSFUL, 8) + } + } + } else if new_eof == *size { (STATUS_SUCCESS, 8) } else { (STATUS_UNSUCCESSFUL, 8) @@ -1263,6 +1599,39 @@ fn nt_query_full_attributes_file(ctx: &mut PpcContext, mem: &GuestMemory, state: } }; + // AUDIT-038 — cache:/* short-circuit: stat the host-FS file directly + // so existence probes (Sylpheed's pre-open `NtQueryFullAttributesFile`) + // see real attributes for files we just created and miss for files we + // haven't. + if let Some(hp) = state.resolve_cache_path(&path) { + let entry = std::fs::metadata(&hp); + match entry { + Ok(md) => { + let filetime: u64 = 132_500_000_000_000_000; + if out != 0 { + for off in (0..32).step_by(4) { + mem.write_u32(out + off, if off & 4 == 0 { + (filetime >> 32) as u32 + } else { + filetime as u32 + }); + } + mem.write_u64(out + 32, md.len()); + mem.write_u64(out + 40, md.len()); + let attrs: u32 = if md.is_dir() { 0x10 } else { 0x80 }; + mem.write_u32(out + 48, attrs); + mem.write_u32(out + 52, 0); + } + ctx.gpr[3] = STATUS_SUCCESS; + return; + } + Err(_) => { + ctx.gpr[3] = STATUS_OBJECT_NAME_NOT_FOUND; + return; + } + } + } + let Some(vfs) = state.vfs.as_ref() else { ctx.gpr[3] = STATUS_OBJECT_NAME_NOT_FOUND; return; @@ -3872,6 +4241,7 @@ mod tests { position: 0, data: Arc::new(bytes), dir_enum_pos: None, + host_path: None, }) } @@ -4178,6 +4548,7 @@ mod tests { position: 0, data: std::sync::Arc::new(Vec::new()), dir_enum_pos: None, + host_path: None, }); let info_buf = SCRATCH_BASE + 0x600; ctx.gpr[3] = h as u64; // handle @@ -4212,6 +4583,7 @@ mod tests { position: 0, data: std::sync::Arc::new(Vec::new()), dir_enum_pos: None, + host_path: None, }); let buf = SCRATCH_BASE + 0x100; ctx.gpr[3] = handle as u64; @@ -4236,6 +4608,7 @@ mod tests { position: 0, data: std::sync::Arc::new(Vec::new()), dir_enum_pos: None, + host_path: None, }); ctx.gpr[3] = handle as u64; ctx.gpr[4] = 0; @@ -4301,6 +4674,7 @@ mod tests { position: 0, data: std::sync::Arc::new(Vec::new()), dir_enum_pos: None, + host_path: None, }); let buf = SCRATCH_BASE + 0x100; ctx.gpr[3] = handle as u64; @@ -4410,6 +4784,7 @@ mod tests { position: 0, data: std::sync::Arc::new(Vec::new()), dir_enum_pos: None, + host_path: None, }); let info_buf = SCRATCH_BASE + 0x200; ctx.gpr[3] = h as u64; @@ -4435,6 +4810,7 @@ mod tests { position: 0, data: std::sync::Arc::new(vec![0; 964]), dir_enum_pos: None, + host_path: None, }); let info_buf = SCRATCH_BASE + 0x700; ctx.gpr[3] = h as u64; @@ -4455,6 +4831,7 @@ mod tests { position: 0, data: std::sync::Arc::new(Vec::new()), dir_enum_pos: None, + host_path: None, }); let iosb = SCRATCH_BASE; let info_buf = SCRATCH_BASE + 0x100; @@ -5673,4 +6050,179 @@ mod tests { let client = state.xaudio.get(i).unwrap(); assert_eq!(client.callback_pc, 0x8200_C0DE); } + + // ===== AUDIT-038: cache:/* persistent VFS ===== + + /// Lay out an OBJECT_ATTRIBUTES + ANSI_STRING + buffer at a chosen + /// guest base and return the obj_attrs pointer. Matches the layout + /// `crate::path::object_attributes_to_vfs_path` expects: u32 + /// RootDirectory @ +0, u32 NameStringPtr @ +4, u32 Attributes @ +8; + /// the ANSI_STRING is u16 Length @ +0, u16 MaximumLength @ +2, + /// u32 Buffer @ +4. + fn write_obj_attrs(mem: &GuestMemory, base: u32, path_str: &str) -> u32 { + let obj_attrs = base; + let ansi_string = base + 0x40; + let buf = base + 0x80; + // OBJECT_ATTRIBUTES. + mem.write_u32(obj_attrs, 0); // RootDirectory + mem.write_u32(obj_attrs + 4, ansi_string); // Name -> ANSI_STRING + mem.write_u32(obj_attrs + 8, 0); // Attributes + // ANSI_STRING. + mem.write_u16(ansi_string, path_str.len() as u16); // Length + mem.write_u16(ansi_string + 2, path_str.len() as u16); // MaximumLength + mem.write_u32(ansi_string + 4, buf); // Buffer + // Path bytes. + for (i, b) in path_str.bytes().enumerate() { + mem.write_u8(buf + i as u32, b); + } + obj_attrs + } + + /// Round-trip: create with FILE_CREATE, write bytes, read them back + /// from the same handle. Verifies persistence within a single run + /// (host_path drives both directions). + #[test] + fn cache_create_write_read_roundtrip() { + let (mut ctx, mem, mut state) = fresh(); + let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "cache:\\rt.tmp"); + let handle_out = SCRATCH_BASE + 0x300; + let iosb = SCRATCH_BASE + 0x310; + ctx.gpr[3] = handle_out as u64; + ctx.gpr[5] = obj_attrs as u64; + ctx.gpr[6] = iosb as u64; + ctx.gpr[10] = FILE_CREATE as u64; + nt_create_file(&mut ctx, &mem, &mut state); + assert_eq!(ctx.gpr[3], STATUS_SUCCESS); + let handle = mem.read_u32(handle_out); + assert!(handle >= 0x1000, "handle must come from kernel allocator"); + + // NtWriteFile: 4 bytes "abcd" + let write_buf = SCRATCH_BASE + 0x400; + for (i, b) in b"abcd".iter().enumerate() { + mem.write_u8(write_buf + i as u32, *b); + } + ctx.gpr[3] = handle as u64; + ctx.gpr[4] = 0; // event_handle = none + ctx.gpr[7] = iosb as u64; + ctx.gpr[8] = write_buf as u64; + ctx.gpr[9] = 4; + ctx.gpr[10] = 0; // byte_offset_ptr null = use position + nt_write_file(&mut ctx, &mem, &mut state); + assert_eq!(ctx.gpr[3], STATUS_SUCCESS); + assert_eq!(mem.read_u32(iosb + 4), 4, "wrote 4 bytes"); + + // Position should be 4. Reset to 0 with NtSetInformationFile (class 14). + let pos_buf = SCRATCH_BASE + 0x500; + mem.write_u64(pos_buf, 0); + ctx.gpr[3] = handle as u64; + ctx.gpr[4] = iosb as u64; + ctx.gpr[5] = pos_buf as u64; + ctx.gpr[6] = 8; + ctx.gpr[7] = 14; + nt_set_information_file(&mut ctx, &mem, &mut state); + assert_eq!(ctx.gpr[3], STATUS_SUCCESS); + + // NtReadFile: 4 bytes back from position 0. + let read_buf = SCRATCH_BASE + 0x600; + for i in 0..4 { + mem.write_u8(read_buf + i, 0); + } + ctx.gpr[3] = handle as u64; + ctx.gpr[4] = 0; + ctx.gpr[7] = iosb as u64; + ctx.gpr[8] = read_buf as u64; + ctx.gpr[9] = 4; + ctx.gpr[10] = 0; + nt_read_file(&mut ctx, &mem, &mut state); + assert_eq!(ctx.gpr[3], STATUS_SUCCESS); + assert_eq!(mem.read_u32(iosb + 4), 4); + let mut got = [0u8; 4]; + for i in 0..4 { + got[i] = mem.read_u8(read_buf + i as u32); + } + assert_eq!(&got, b"abcd"); + } + + /// FILE_CREATE on an already-existing path returns + /// STATUS_OBJECT_NAME_COLLISION; the existing file is not truncated. + #[test] + fn cache_file_create_collision() { + let (mut ctx, mem, mut state) = fresh(); + let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "cache:\\dup.tmp"); + let handle_out = SCRATCH_BASE + 0x300; + let iosb = SCRATCH_BASE + 0x310; + ctx.gpr[3] = handle_out as u64; + ctx.gpr[5] = obj_attrs as u64; + ctx.gpr[6] = iosb as u64; + ctx.gpr[10] = FILE_CREATE as u64; + nt_create_file(&mut ctx, &mem, &mut state); + assert_eq!(ctx.gpr[3], STATUS_SUCCESS); + + // Second FILE_CREATE on the same path: collide. + ctx.gpr[3] = handle_out as u64; + ctx.gpr[5] = obj_attrs as u64; + ctx.gpr[6] = iosb as u64; + ctx.gpr[10] = FILE_CREATE as u64; + nt_create_file(&mut ctx, &mem, &mut state); + assert_eq!(ctx.gpr[3], STATUS_OBJECT_NAME_COLLISION); + } + + /// FILE_OPEN on a path that doesn't exist returns + /// STATUS_OBJECT_NAME_NOT_FOUND (canary `HostPathDevice::Open` mirror). + #[test] + fn cache_file_open_missing() { + let (mut ctx, mem, mut state) = fresh(); + let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "cache:\\missing.tmp"); + let handle_out = SCRATCH_BASE + 0x300; + let iosb = SCRATCH_BASE + 0x310; + ctx.gpr[3] = handle_out as u64; + ctx.gpr[5] = obj_attrs as u64; + ctx.gpr[6] = iosb as u64; + ctx.gpr[10] = FILE_OPEN as u64; + nt_create_file(&mut ctx, &mem, &mut state); + assert_eq!(ctx.gpr[3], STATUS_OBJECT_NAME_NOT_FOUND); + assert_eq!(mem.read_u32(handle_out), 0, "no handle on miss"); + } + + /// `init_cache_root` clears the directory before reuse, so a fresh + /// kernel never sees stale cache from a previous run. Determinism + /// gate for the lockstep `sylpheed_n*m.json` digest. + #[test] + fn cache_root_cleared_on_init() { + let dir = std::env::temp_dir().join(format!( + "xenia-rs-cache-test-clear-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .subsec_nanos(), + )); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("stale.tmp"), b"stale").unwrap(); + let mut state = KernelState::new(); + state.init_cache_root(dir.clone()).unwrap(); + assert!(!dir.join("stale.tmp").exists(), "stale must be cleared"); + assert!(dir.exists(), "root must be re-created"); + // Cleanup. + std::fs::remove_dir_all(&dir).ok(); + } + + /// `resolve_cache_path` rejects path-traversal attempts so a guest + /// can't escape the cache directory by passing `cache:\..\..\etc\foo`. + #[test] + fn cache_resolve_strips_path_traversal() { + let dir = std::env::temp_dir().join(format!( + "xenia-rs-cache-test-trav-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + let mut state = KernelState::new(); + state.init_cache_root(dir.clone()).unwrap(); + let resolved = state + .resolve_cache_path("cache:\\..\\..\\etc\\foo") + .expect("must resolve"); + assert!(resolved.starts_with(&dir), "must stay inside cache root"); + assert!(resolved.ends_with("etc/foo")); + std::fs::remove_dir_all(&dir).ok(); + } } diff --git a/crates/xenia-kernel/src/objects.rs b/crates/xenia-kernel/src/objects.rs index 2ed52bc..2d6754a 100644 --- a/crates/xenia-kernel/src/objects.rs +++ b/crates/xenia-kernel/src/objects.rs @@ -1,6 +1,7 @@ //! Kernel object tracking for HLE. use std::collections::VecDeque; +use std::path::PathBuf; use std::sync::Arc; use xenia_cpu::ThreadRef; @@ -41,6 +42,15 @@ pub enum KernelObject { /// to emit. Reset to `Some(0)` when the guest passes /// `restart_scan=1`. Unused on non-directory files. dir_enum_pos: Option, + /// AUDIT-038 — when `Some`, this file is backed by a real host-FS + /// path (the cache: persistent VFS) rather than the in-memory + /// `data` buffer. NtReadFile / NtWriteFile / NtSetInformationFile + /// route through `std::fs` against this path. Mirrors canary's + /// `HostPathDevice` (xenia-canary/src/xenia/vfs/devices/ + /// host_path_device.cc) which symlinks `cache:` → `\CACHE`. + /// `None` for disc-VFS reads, root-of-device opens, and synth + /// stubs (those keep the in-memory zero-byte semantics). + host_path: Option, }, Thread { id: u32, diff --git a/crates/xenia-kernel/src/state.rs b/crates/xenia-kernel/src/state.rs index e1f4e22..f25db5b 100644 --- a/crates/xenia-kernel/src/state.rs +++ b/crates/xenia-kernel/src/state.rs @@ -113,6 +113,16 @@ pub struct KernelState { /// the disc image or host directory into this slot; file I/O handlers /// route all reads through it. pub vfs: Option>, + /// AUDIT-038 — host directory backing the persistent `cache:` mount + /// (mirrors canary's `cache:` → `\CACHE` symlink in xenia_main.cc:649, + /// implemented atop `HostPathDevice`). When `Some`, opens of `cache:\*` + /// paths go through real `std::fs` I/O against this directory; when + /// `None`, they fall back to the legacy "Synthesized empty file" stub + /// (which doesn't persist writes — see audit-037 for the record-layout + /// divergence that motivated this fix). Set up by [`init_cache_root`] + /// at startup; cleared at the same time so lockstep digests stay + /// reproducible across reruns. + pub cache_root: Option, /// Bridge to the host UI. `None` when running headless. Installed by /// `cmd_exec` when the user passes `--ui`. pub ui: Option, @@ -292,6 +302,7 @@ impl KernelState { has_notified_live_startup: false, next_thread_id: AtomicU32::new(1), vfs: None, + cache_root: None, ui: None, interrupts: crate::interrupts::InterruptState::default(), xaudio: crate::xaudio::XAudioState::default(), @@ -315,6 +326,27 @@ 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()) { + tracing::warn!( + "Failed to initialise cache root at {:?}: {} — cache:/* opens \ + will fall back to the synth-empty-file stub", + root, + e + ); + } state } @@ -334,6 +366,66 @@ impl KernelState { self.exports.insert((module, ordinal), (name, func)); } + /// AUDIT-038 — install a host directory as the backing store for the + /// `cache:` mount. The directory is unconditionally cleared (and then + /// re-created) on entry so two consecutive runs see byte-identical + /// initial state — required for the `sylpheed_n*m.json` lockstep + /// goldens. Mirrors canary's `xenia_main.cc:611-651` setup, which + /// `RegisterSymbolicLink("cache:", "\\CACHE")` against a per-emulator + /// host path. + /// + /// Returns `Ok(())` on success; bubbles up any I/O error from the + /// clear/create dance so the caller can surface it. + pub fn init_cache_root(&mut self, root: std::path::PathBuf) -> std::io::Result<()> { + // Clear-then-recreate. Determinism beats incremental persistence + // here: Sylpheed's cache subsystem treats a missing/empty cache + // identically to a stale one (cache-miss → reconstruct), so + // wiping is safe and gives reproducible boots. + if root.exists() { + std::fs::remove_dir_all(&root)?; + } + std::fs::create_dir_all(&root)?; + self.cache_root = Some(root); + Ok(()) + } + + /// 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 + /// synth-stub fallback). + /// + /// Path-traversal guard: leading `..\` components are stripped so a + /// malicious guest can't escape the cache directory. Backslashes are + /// normalised to host separators on Linux. + pub fn resolve_cache_path(&self, raw: &str) -> Option { + let root = self.cache_root.as_ref()?; + let lower = raw.to_ascii_lowercase(); + // Match any of the writable cache prefixes (case-insensitive). + // canary uses separate `\CACHE0`/`\CACHE1` host dirs for cache0:/ + // cache1:, but Sylpheed only references `cache:`; collapse all + // three to one backing root until a future game splits them. + let after_prefix = if let Some(rest) = lower.strip_prefix("cache:\\") { + &raw[raw.len() - rest.len()..] + } else if let Some(rest) = lower.strip_prefix("cache:/") { + &raw[raw.len() - rest.len()..] + } else if let Some(rest) = lower.strip_prefix("cache0:\\") + .or_else(|| lower.strip_prefix("cache0:/")) + .or_else(|| lower.strip_prefix("cache1:\\")) + .or_else(|| lower.strip_prefix("cache1:/")) + { + &raw[raw.len() - rest.len()..] + } else { + return None; + }; + let normalised = after_prefix.replace('\\', "/"); + // Strip leading slashes + path-traversal segments. + let clean: std::path::PathBuf = normalised + .split('/') + .filter(|s| !s.is_empty() && *s != "." && *s != "..") + .collect(); + Some(root.join(clean)) + } + /// Record an import-thunk address resolved at load time. Called once /// per `record_type==1` import in xenia-app's Phase 1. Idempotent: a /// duplicate ordinal overwrites (later wins; in practice the loader