Merge vfs-cache-persistent/p0-real-disk-backing — audit-038 cache fix

Replaces the "Synthesized empty file" cache:/* stub with persistent
host-FS HostPathDevice backing. Sub_82459D18 / sub_8245D230 (cache-miss
reconstruct + resize-and-zero-fill) drop from constant fires to 0;
multi-MB of cache files persist to disk per boot. swaps=2 plateau
unmoved at -n 100M; cluster activation gate (audit-009) remains.
Tests 640 -> 645. Lockstep deterministic across 3+ reruns at
instructions=100000004 / imports=987485.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-09 14:34:34 +02:00
4 changed files with 672 additions and 18 deletions

View File

@@ -1,6 +1,6 @@
{
"instructions": 50000011,
"imports": 407247,
"instructions": 50000009,
"imports": 407215,
"unimpl": 0,
"draws": 0,
"swaps": 2,

View File

@@ -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<std::path::PathBuf> {
// 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<usize> {
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();
}
}

View File

@@ -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<usize>,
/// 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<PathBuf>,
},
Thread {
id: u32,

View File

@@ -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<Box<dyn VfsDevice>>,
/// 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<std::path::PathBuf>,
/// Bridge to the host UI. `None` when running headless. Installed by
/// `cmd_exec` when the user passes `--ui`.
pub ui: Option<UiBridge>,
@@ -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<std::path::PathBuf> {
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