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:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"instructions": 50000011,
|
"instructions": 50000009,
|
||||||
"imports": 407247,
|
"imports": 407215,
|
||||||
"unimpl": 0,
|
"unimpl": 0,
|
||||||
"draws": 0,
|
"draws": 0,
|
||||||
"swaps": 2,
|
"swaps": 2,
|
||||||
|
|||||||
@@ -746,15 +746,191 @@ fn write_io_status_block(mem: &GuestMemory, ptr: u32, status: u32, information:
|
|||||||
mem.write_u32(ptr + 4, 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
|
/// Open a VFS-backed file. Shared between NtCreateFile and NtOpenFile — the
|
||||||
/// create/open distinction only matters for writable volumes, which the disc
|
/// create/open distinction only matters for writable volumes (cache:/),
|
||||||
/// image isn't.
|
/// 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(
|
fn open_vfs_file(
|
||||||
mem: &GuestMemory,
|
mem: &GuestMemory,
|
||||||
state: &mut KernelState,
|
state: &mut KernelState,
|
||||||
handle_out: u32,
|
handle_out: u32,
|
||||||
io_status_block: u32,
|
io_status_block: u32,
|
||||||
obj_attrs_ptr: u32,
|
obj_attrs_ptr: u32,
|
||||||
|
create_disposition: u32,
|
||||||
) -> u64 {
|
) -> u64 {
|
||||||
// Accept the empty-after-prefix case (e.g. `NtCreateFile("game:\")`) as
|
// Accept the empty-after-prefix case (e.g. `NtCreateFile("game:\")`) as
|
||||||
// a valid "open the partition/device root" request — Canary's
|
// a valid "open the partition/device root" request — Canary's
|
||||||
@@ -787,6 +963,7 @@ fn open_vfs_file(
|
|||||||
position: 0,
|
position: 0,
|
||||||
data: std::sync::Arc::new(Vec::new()),
|
data: std::sync::Arc::new(Vec::new()),
|
||||||
dir_enum_pos: None,
|
dir_enum_pos: None,
|
||||||
|
host_path: None,
|
||||||
});
|
});
|
||||||
if handle_out != 0 {
|
if handle_out != 0 {
|
||||||
mem.write_u32(handle_out, handle);
|
mem.write_u32(handle_out, handle);
|
||||||
@@ -795,6 +972,19 @@ fn open_vfs_file(
|
|||||||
return STATUS_SUCCESS;
|
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() {
|
let vfs = match state.vfs.as_ref() {
|
||||||
Some(v) => v,
|
Some(v) => v,
|
||||||
None => {
|
None => {
|
||||||
@@ -816,6 +1006,7 @@ fn open_vfs_file(
|
|||||||
position: 0,
|
position: 0,
|
||||||
data: std::sync::Arc::new(bytes),
|
data: std::sync::Arc::new(bytes),
|
||||||
dir_enum_pos: None,
|
dir_enum_pos: None,
|
||||||
|
host_path: None,
|
||||||
});
|
});
|
||||||
if handle_out != 0 {
|
if handle_out != 0 {
|
||||||
mem.write_u32(handle_out, handle);
|
mem.write_u32(handle_out, handle);
|
||||||
@@ -853,6 +1044,7 @@ fn open_vfs_file(
|
|||||||
position: 0,
|
position: 0,
|
||||||
data: std::sync::Arc::new(Vec::new()),
|
data: std::sync::Arc::new(Vec::new()),
|
||||||
dir_enum_pos: None,
|
dir_enum_pos: None,
|
||||||
|
host_path: None,
|
||||||
});
|
});
|
||||||
if handle_out != 0 {
|
if handle_out != 0 {
|
||||||
mem.write_u32(handle_out, handle);
|
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 handle_out = ctx.gpr[3] as u32;
|
||||||
let obj_attrs_ptr = ctx.gpr[5] as u32;
|
let obj_attrs_ptr = ctx.gpr[5] as u32;
|
||||||
let io_status_block = ctx.gpr[6] 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) {
|
fn nt_open_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
|
||||||
// r3 = handle_out, r4 = desired_access, r5 = obj_attrs,
|
// 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 handle_out = ctx.gpr[3] as u32;
|
||||||
let obj_attrs_ptr = ctx.gpr[5] as u32;
|
let obj_attrs_ptr = ctx.gpr[5] as u32;
|
||||||
let io_status_block = ctx.gpr[6] 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.
|
/// 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 length = ctx.gpr[9] as u32;
|
||||||
let byte_offset_ptr = ctx.gpr[10] 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);
|
tracing::warn!("NtReadFile: invalid handle {:#x}", handle);
|
||||||
ctx.gpr[3] = STATUS_INVALID_HANDLE;
|
ctx.gpr[3] = STATUS_INVALID_HANDLE;
|
||||||
write_io_status_block(mem, io_status_block, STATUS_INVALID_HANDLE as u32, 0);
|
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
|
*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;
|
let total = *size;
|
||||||
|
|
||||||
// Synthesized empty files (system partition opens like
|
// 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) {
|
fn nt_write_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
|
||||||
// We don't back anything writable, so discard. Still report the full
|
// r3 = handle, r4 = event, r5 = apc_routine, r6 = apc_ctx,
|
||||||
// length as written via IO_STATUS_BLOCK so the caller doesn't retry.
|
// 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 event_handle = ctx.gpr[4] as u32;
|
||||||
let io_status_block = ctx.gpr[7] 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;
|
let length = ctx.gpr[9] as u32;
|
||||||
write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, length);
|
let byte_offset_ptr = ctx.gpr[10] as u32;
|
||||||
ctx.gpr[3] = STATUS_SUCCESS;
|
|
||||||
|
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);
|
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 length = ctx.gpr[6] as u32;
|
||||||
let class = ctx.gpr[7] 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;
|
ctx.gpr[3] = STATUS_INVALID_HANDLE;
|
||||||
write_io_status_block(mem, io_status_block, STATUS_INVALID_HANDLE as u32, 0);
|
write_io_status_block(mem, io_status_block, STATUS_INVALID_HANDLE as u32, 0);
|
||||||
return;
|
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
|
// Root-of-device opens (`game:\`, `cache:\`, `partition0`) strip to
|
||||||
// an empty string post-prefix — see `open_vfs_file`'s synth path.
|
// an empty string post-prefix — see `open_vfs_file`'s synth path.
|
||||||
// Games query these as directories (DirectoryObject probe), and
|
// 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()
|
let is_directory = path.is_empty()
|
||||||
|| path.ends_with('/')
|
|| path.ends_with('/')
|
||||||
|| path.ends_with(':');
|
|| path.ends_with(':');
|
||||||
let size = *size;
|
let size = live_size;
|
||||||
let position = *position;
|
let position = *position;
|
||||||
|
|
||||||
// `FILE_ATTRIBUTE_DIRECTORY` (NT / Xbox) — advertised in
|
// `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.
|
// 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;
|
ctx.gpr[3] = STATUS_INVALID_HANDLE;
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -1211,11 +1530,28 @@ fn nt_set_information_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut
|
|||||||
*position = new_offset;
|
*position = new_offset;
|
||||||
(STATUS_SUCCESS, 8)
|
(STATUS_SUCCESS, 8)
|
||||||
}
|
}
|
||||||
// XFileEndOfFileInformation (20): i64 new length. Read-only VFS
|
// XFileEndOfFileInformation (20): i64 new length.
|
||||||
// → only a no-op truncate-to-same-size succeeds.
|
// 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 => {
|
20 => {
|
||||||
let new_eof = mem.read_u64(info_ptr);
|
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)
|
(STATUS_SUCCESS, 8)
|
||||||
} else {
|
} else {
|
||||||
(STATUS_UNSUCCESSFUL, 8)
|
(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 {
|
let Some(vfs) = state.vfs.as_ref() else {
|
||||||
ctx.gpr[3] = STATUS_OBJECT_NAME_NOT_FOUND;
|
ctx.gpr[3] = STATUS_OBJECT_NAME_NOT_FOUND;
|
||||||
return;
|
return;
|
||||||
@@ -3872,6 +4241,7 @@ mod tests {
|
|||||||
position: 0,
|
position: 0,
|
||||||
data: Arc::new(bytes),
|
data: Arc::new(bytes),
|
||||||
dir_enum_pos: None,
|
dir_enum_pos: None,
|
||||||
|
host_path: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4178,6 +4548,7 @@ mod tests {
|
|||||||
position: 0,
|
position: 0,
|
||||||
data: std::sync::Arc::new(Vec::new()),
|
data: std::sync::Arc::new(Vec::new()),
|
||||||
dir_enum_pos: None,
|
dir_enum_pos: None,
|
||||||
|
host_path: None,
|
||||||
});
|
});
|
||||||
let info_buf = SCRATCH_BASE + 0x600;
|
let info_buf = SCRATCH_BASE + 0x600;
|
||||||
ctx.gpr[3] = h as u64; // handle
|
ctx.gpr[3] = h as u64; // handle
|
||||||
@@ -4212,6 +4583,7 @@ mod tests {
|
|||||||
position: 0,
|
position: 0,
|
||||||
data: std::sync::Arc::new(Vec::new()),
|
data: std::sync::Arc::new(Vec::new()),
|
||||||
dir_enum_pos: None,
|
dir_enum_pos: None,
|
||||||
|
host_path: None,
|
||||||
});
|
});
|
||||||
let buf = SCRATCH_BASE + 0x100;
|
let buf = SCRATCH_BASE + 0x100;
|
||||||
ctx.gpr[3] = handle as u64;
|
ctx.gpr[3] = handle as u64;
|
||||||
@@ -4236,6 +4608,7 @@ mod tests {
|
|||||||
position: 0,
|
position: 0,
|
||||||
data: std::sync::Arc::new(Vec::new()),
|
data: std::sync::Arc::new(Vec::new()),
|
||||||
dir_enum_pos: None,
|
dir_enum_pos: None,
|
||||||
|
host_path: None,
|
||||||
});
|
});
|
||||||
ctx.gpr[3] = handle as u64;
|
ctx.gpr[3] = handle as u64;
|
||||||
ctx.gpr[4] = 0;
|
ctx.gpr[4] = 0;
|
||||||
@@ -4301,6 +4674,7 @@ mod tests {
|
|||||||
position: 0,
|
position: 0,
|
||||||
data: std::sync::Arc::new(Vec::new()),
|
data: std::sync::Arc::new(Vec::new()),
|
||||||
dir_enum_pos: None,
|
dir_enum_pos: None,
|
||||||
|
host_path: None,
|
||||||
});
|
});
|
||||||
let buf = SCRATCH_BASE + 0x100;
|
let buf = SCRATCH_BASE + 0x100;
|
||||||
ctx.gpr[3] = handle as u64;
|
ctx.gpr[3] = handle as u64;
|
||||||
@@ -4410,6 +4784,7 @@ mod tests {
|
|||||||
position: 0,
|
position: 0,
|
||||||
data: std::sync::Arc::new(Vec::new()),
|
data: std::sync::Arc::new(Vec::new()),
|
||||||
dir_enum_pos: None,
|
dir_enum_pos: None,
|
||||||
|
host_path: None,
|
||||||
});
|
});
|
||||||
let info_buf = SCRATCH_BASE + 0x200;
|
let info_buf = SCRATCH_BASE + 0x200;
|
||||||
ctx.gpr[3] = h as u64;
|
ctx.gpr[3] = h as u64;
|
||||||
@@ -4435,6 +4810,7 @@ mod tests {
|
|||||||
position: 0,
|
position: 0,
|
||||||
data: std::sync::Arc::new(vec![0; 964]),
|
data: std::sync::Arc::new(vec![0; 964]),
|
||||||
dir_enum_pos: None,
|
dir_enum_pos: None,
|
||||||
|
host_path: None,
|
||||||
});
|
});
|
||||||
let info_buf = SCRATCH_BASE + 0x700;
|
let info_buf = SCRATCH_BASE + 0x700;
|
||||||
ctx.gpr[3] = h as u64;
|
ctx.gpr[3] = h as u64;
|
||||||
@@ -4455,6 +4831,7 @@ mod tests {
|
|||||||
position: 0,
|
position: 0,
|
||||||
data: std::sync::Arc::new(Vec::new()),
|
data: std::sync::Arc::new(Vec::new()),
|
||||||
dir_enum_pos: None,
|
dir_enum_pos: None,
|
||||||
|
host_path: None,
|
||||||
});
|
});
|
||||||
let iosb = SCRATCH_BASE;
|
let iosb = SCRATCH_BASE;
|
||||||
let info_buf = SCRATCH_BASE + 0x100;
|
let info_buf = SCRATCH_BASE + 0x100;
|
||||||
@@ -5673,4 +6050,179 @@ mod tests {
|
|||||||
let client = state.xaudio.get(i).unwrap();
|
let client = state.xaudio.get(i).unwrap();
|
||||||
assert_eq!(client.callback_pc, 0x8200_C0DE);
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! Kernel object tracking for HLE.
|
//! Kernel object tracking for HLE.
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use xenia_cpu::ThreadRef;
|
use xenia_cpu::ThreadRef;
|
||||||
@@ -41,6 +42,15 @@ pub enum KernelObject {
|
|||||||
/// to emit. Reset to `Some(0)` when the guest passes
|
/// to emit. Reset to `Some(0)` when the guest passes
|
||||||
/// `restart_scan=1`. Unused on non-directory files.
|
/// `restart_scan=1`. Unused on non-directory files.
|
||||||
dir_enum_pos: Option<usize>,
|
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 {
|
Thread {
|
||||||
id: u32,
|
id: u32,
|
||||||
|
|||||||
@@ -113,6 +113,16 @@ pub struct KernelState {
|
|||||||
/// the disc image or host directory into this slot; file I/O handlers
|
/// the disc image or host directory into this slot; file I/O handlers
|
||||||
/// route all reads through it.
|
/// route all reads through it.
|
||||||
pub vfs: Option<Box<dyn VfsDevice>>,
|
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
|
/// Bridge to the host UI. `None` when running headless. Installed by
|
||||||
/// `cmd_exec` when the user passes `--ui`.
|
/// `cmd_exec` when the user passes `--ui`.
|
||||||
pub ui: Option<UiBridge>,
|
pub ui: Option<UiBridge>,
|
||||||
@@ -292,6 +302,7 @@ impl KernelState {
|
|||||||
has_notified_live_startup: false,
|
has_notified_live_startup: false,
|
||||||
next_thread_id: AtomicU32::new(1),
|
next_thread_id: AtomicU32::new(1),
|
||||||
vfs: None,
|
vfs: None,
|
||||||
|
cache_root: None,
|
||||||
ui: None,
|
ui: None,
|
||||||
interrupts: crate::interrupts::InterruptState::default(),
|
interrupts: crate::interrupts::InterruptState::default(),
|
||||||
xaudio: crate::xaudio::XAudioState::default(),
|
xaudio: crate::xaudio::XAudioState::default(),
|
||||||
@@ -315,6 +326,27 @@ impl KernelState {
|
|||||||
};
|
};
|
||||||
crate::exports::register_exports(&mut state);
|
crate::exports::register_exports(&mut state);
|
||||||
crate::xam::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
|
state
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,6 +366,66 @@ impl KernelState {
|
|||||||
self.exports.insert((module, ordinal), (name, func));
|
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
|
/// Record an import-thunk address resolved at load time. Called once
|
||||||
/// per `record_type==1` import in xenia-app's Phase 1. Idempotent: a
|
/// per `record_type==1` import in xenia-app's Phase 1. Idempotent: a
|
||||||
/// duplicate ordinal overwrites (later wins; in practice the loader
|
/// duplicate ordinal overwrites (later wins; in practice the loader
|
||||||
|
|||||||
Reference in New Issue
Block a user