AUDIT-054: thread CreateOptions through NtCreateFile + opt-in cache persistence
Track A — FILE_DIRECTORY_FILE handling. NtCreateFile's 9th parameter
`create_options` (sp+0x54 per shim_utils.h:49-50) is now read and
forwarded to open_vfs_file/open_cache_file. When the
FILE_DIRECTORY_FILE bit (0x1) is set on a `cache:\<hash>` path,
the host-side handler `mkdir -p`s instead of `File::create`'ing a
0-byte sentinel that blocked subsequent hierarchical creates of
`cache:\<hash>\<sub>\<leaf>` with NAME_COLLISION. Confirmed by
`opts=0x4021` (incl. FILE_DIRECTORY_FILE) on `cache:\d4ea4615`
and `opts=0x4020` (no DIR bit) on the leaf `.tmp` files. NtOpenFile
forwards `open_options` (r8) into the same slot per
xboxkrnl_io.cc:118-122. Closes the AUDIT-053 ζ-class VFS layout
aliasing wedge.
Track B — opt-in persistent cache root. AUDIT-038's per-process
tmpdir + wipe stays the default (preserves lockstep/oracle
determinism + dodges Sylpheed's `<hash>.tmp` journal-append-on-
reboot self-inconsistency). Persistence is now opt-in via
* `XENIA_CACHE_ROOT=<path>` — explicit path (caller manages
wiping); hands a stable place to drop a canary-built cache
for cascade A/B oracle work.
* `XENIA_CACHE_PERSIST=1` — `$XDG_DATA_HOME/xenia-rs/cache`
(or `$HOME/.local/share/xenia-rs/cache`).
Cold-start (-n 500M, default tmpfs) with FILE_DIRECTORY_FILE fix:
swaps=1 draws=0 imports=40454 cxx_throw=0 — matches master baseline,
no regression. Cache hierarchy now mkdir-p'd correctly: `cache:/`
contains 9 hash dirs (e.g. `d4ea4615/e/`, `aab216c3/5/`) instead
of the 0-byte sentinel files AUDIT-053 found masquerading as
directories.
LOC: +88 / -14 = +74 net (≤80 budget). All 127 xenia-kernel unit
tests pass.
Trace: audit-runs/audit-054-vfs-layout-fix/
cold-start-digest.json + warm-start-digest.json (defaults)
persist-cold-digest.json + persist-warm-digest.json (opt-in)
baseline-master-digest.json (master 25704c5 reference)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -779,22 +779,57 @@ fn open_cache_file(
|
|||||||
guest_path: &str,
|
guest_path: &str,
|
||||||
host_path: &std::path::Path,
|
host_path: &std::path::Path,
|
||||||
create_disposition: u32,
|
create_disposition: u32,
|
||||||
|
create_options: u32,
|
||||||
mem: &GuestMemory,
|
mem: &GuestMemory,
|
||||||
handle_out: u32,
|
handle_out: u32,
|
||||||
io_status_block: u32,
|
io_status_block: u32,
|
||||||
) -> u64 {
|
) -> u64 {
|
||||||
|
// FILE_DIRECTORY_FILE / FILE_NON_DIRECTORY_FILE per
|
||||||
|
// xboxkrnl_io.cc:29-34. The guest may set these to discriminate
|
||||||
|
// a "create directory" call from a "create file" call when the
|
||||||
|
// host filesystem can't infer it from the path shape (e.g. the
|
||||||
|
// hash-only paths Sylpheed builds in `cache:\<hash>` — without
|
||||||
|
// the bit, AUDIT-053 found we were creating a 0-byte file at
|
||||||
|
// `cache:\d4ea4615` which then blocked subsequent hierarchical
|
||||||
|
// creates of `cache:\d4ea4615\e\46ee8ca` with NAME_COLLISION).
|
||||||
|
const FILE_DIRECTORY_FILE: u32 = 0x0000_0001;
|
||||||
|
let want_dir = (create_options & FILE_DIRECTORY_FILE) != 0;
|
||||||
|
|
||||||
// Root-of-mount case: `cache:\`, `cache:/`, `cache:` resolve to the
|
// Root-of-mount case: `cache:\`, `cache:/`, `cache:` resolve to the
|
||||||
// cache root directory itself. Mirror canary's HostPathDevice.Open
|
// cache root directory itself. Mirror canary's HostPathDevice.Open
|
||||||
// which returns a directory handle (success, attributes = DIR).
|
// which returns a directory handle (success, attributes = DIR).
|
||||||
// Empty `path.file_name()` after our resolve_cache_path strip means
|
// Empty `path.file_name()` after our resolve_cache_path strip means
|
||||||
// the guest asked for the mount root.
|
// the guest asked for the mount root.
|
||||||
let is_dir_open = host_path == state.cache_root.as_deref().unwrap_or(host_path)
|
let is_dir_open = host_path == state.cache_root.as_deref().unwrap_or(host_path)
|
||||||
|| host_path.is_dir();
|
|| host_path.is_dir()
|
||||||
|
|| want_dir;
|
||||||
if is_dir_open {
|
if is_dir_open {
|
||||||
|
// For non-existent paths the guest wants us to create as a
|
||||||
|
// directory, mkdir-p; canary's HostPathDevice does the same
|
||||||
|
// when FILE_DIRECTORY_FILE is set on a kCreate disposition.
|
||||||
|
if want_dir && !host_path.exists() {
|
||||||
|
if let Err(e) = std::fs::create_dir_all(host_path) {
|
||||||
|
tracing::warn!(
|
||||||
|
"cache create_dir_all({:?}) failed: {} — STATUS_UNSUCCESSFUL",
|
||||||
|
host_path,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
if handle_out != 0 {
|
||||||
|
mem.write_u32(handle_out, 0);
|
||||||
|
}
|
||||||
|
write_io_status_block(mem, io_status_block, STATUS_UNSUCCESSFUL as u32, 0);
|
||||||
|
return STATUS_UNSUCCESSFUL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Stored path ends with '/' so nt_query_information_file's
|
||||||
|
// path-shape probe reports Directory=1.
|
||||||
|
let dir_path = if guest_path.ends_with('/') || guest_path.ends_with(':') {
|
||||||
|
guest_path.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}/", guest_path)
|
||||||
|
};
|
||||||
let handle = state.alloc_handle_for(KernelObject::File {
|
let handle = state.alloc_handle_for(KernelObject::File {
|
||||||
// Mark as directory via `path.ends_with('/')`
|
path: dir_path,
|
||||||
// so nt_query_information_file reports Directory=1.
|
|
||||||
path: "cache:/".to_string(),
|
|
||||||
size: 0,
|
size: 0,
|
||||||
position: 0,
|
position: 0,
|
||||||
data: std::sync::Arc::new(Vec::new()),
|
data: std::sync::Arc::new(Vec::new()),
|
||||||
@@ -806,9 +841,11 @@ fn open_cache_file(
|
|||||||
}
|
}
|
||||||
write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, 0);
|
write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, 0);
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"cache open (dir) path={:?} disp={} handle={:#x}",
|
"cache open (dir) path={:?} host={:?} disp={} opts={:#x} handle={:#x}",
|
||||||
guest_path,
|
guest_path,
|
||||||
|
host_path,
|
||||||
create_disposition,
|
create_disposition,
|
||||||
|
create_options,
|
||||||
handle
|
handle
|
||||||
);
|
);
|
||||||
return STATUS_SUCCESS;
|
return STATUS_SUCCESS;
|
||||||
@@ -899,10 +936,11 @@ fn open_cache_file(
|
|||||||
}
|
}
|
||||||
write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, 0);
|
write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, 0);
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"cache open OK path={:?} host={:?} disp={} size={} handle={:#x}",
|
"cache open OK path={:?} host={:?} disp={} opts={:#x} size={} handle={:#x}",
|
||||||
guest_path,
|
guest_path,
|
||||||
host_path,
|
host_path,
|
||||||
create_disposition,
|
create_disposition,
|
||||||
|
create_options,
|
||||||
size,
|
size,
|
||||||
handle
|
handle
|
||||||
);
|
);
|
||||||
@@ -931,6 +969,7 @@ fn open_vfs_file(
|
|||||||
io_status_block: u32,
|
io_status_block: u32,
|
||||||
obj_attrs_ptr: u32,
|
obj_attrs_ptr: u32,
|
||||||
create_disposition: u32,
|
create_disposition: u32,
|
||||||
|
create_options: 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
|
||||||
@@ -982,7 +1021,7 @@ fn open_vfs_file(
|
|||||||
// disc-validation probes.
|
// disc-validation probes.
|
||||||
if let Some(host_path) = cache_path_for(state, &path) {
|
if let Some(host_path) = cache_path_for(state, &path) {
|
||||||
return open_cache_file(state, &path, &host_path, create_disposition,
|
return open_cache_file(state, &path, &host_path, create_disposition,
|
||||||
mem, handle_out, io_status_block);
|
create_options, mem, handle_out, io_status_block);
|
||||||
}
|
}
|
||||||
|
|
||||||
let vfs = match state.vfs.as_ref() {
|
let vfs = match state.vfs.as_ref() {
|
||||||
@@ -1063,11 +1102,14 @@ fn open_vfs_file(
|
|||||||
|
|
||||||
fn nt_create_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
|
fn nt_create_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
|
||||||
// r3 = handle_out, r4 = desired_access, r5 = obj_attrs, r6 = io_status_block,
|
// r3 = handle_out, r4 = desired_access, r5 = obj_attrs, r6 = io_status_block,
|
||||||
// r7 = allocation_size, r8 = file_attributes, r9 = share_access, r10 = create_disposition
|
// r7 = allocation_size, r8 = file_attributes, r9 = share_access, r10 = create_disposition,
|
||||||
|
// [sp+0x54] = create_options (9th arg, spilled per shim_utils.h:49-50).
|
||||||
let handle_out = ctx.gpr[3] as u32;
|
let 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;
|
||||||
let create_disposition = ctx.gpr[10] as u32;
|
let create_disposition = ctx.gpr[10] as u32;
|
||||||
|
let sp = ctx.gpr[1] as u32;
|
||||||
|
let create_options = mem.read_u32(sp + 0x54);
|
||||||
ctx.gpr[3] = open_vfs_file(
|
ctx.gpr[3] = open_vfs_file(
|
||||||
mem,
|
mem,
|
||||||
state,
|
state,
|
||||||
@@ -1075,6 +1117,7 @@ fn nt_create_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelSta
|
|||||||
io_status_block,
|
io_status_block,
|
||||||
obj_attrs_ptr,
|
obj_attrs_ptr,
|
||||||
create_disposition,
|
create_disposition,
|
||||||
|
create_options,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1082,9 +1125,13 @@ fn nt_open_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState
|
|||||||
// r3 = handle_out, r4 = desired_access, r5 = obj_attrs,
|
// 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.
|
// `NtOpenFile` is FILE_OPEN-only (no create) — file must exist.
|
||||||
|
// Per xboxkrnl_io.cc:99-122, NtOpenFile forwards `open_options`
|
||||||
|
// straight into NtCreateFile's `create_options` slot, so the
|
||||||
|
// FILE_DIRECTORY_FILE bit applies the same way.
|
||||||
let handle_out = ctx.gpr[3] as u32;
|
let 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;
|
||||||
|
let open_options = ctx.gpr[8] as u32;
|
||||||
ctx.gpr[3] = open_vfs_file(
|
ctx.gpr[3] = open_vfs_file(
|
||||||
mem,
|
mem,
|
||||||
state,
|
state,
|
||||||
@@ -1092,6 +1139,7 @@ fn nt_open_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState
|
|||||||
io_status_block,
|
io_status_block,
|
||||||
obj_attrs_ptr,
|
obj_attrs_ptr,
|
||||||
FILE_OPEN,
|
FILE_OPEN,
|
||||||
|
open_options,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -334,20 +334,23 @@ 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
|
// AUDIT-054 — cache root selection. Defaults to AUDIT-038's
|
||||||
// default. Each new `KernelState` lives in its own tmpdir, named
|
// per-process tmpdir + wipe (lockstep determinism + avoids
|
||||||
// with the host pid + a monotonic counter so concurrent tests
|
// the journal-append-on-reboot self-inconsistency Sylpheed's
|
||||||
// don't collide. Errors here are non-fatal (cache I/O degrades
|
// `<hash>.tmp` writes produce). Opt-in to persistence via
|
||||||
// to the legacy synth-stub fallback) but logged.
|
// `XENIA_CACHE_ROOT=<path>` (explicit) or
|
||||||
static NEXT_CACHE_ID: std::sync::atomic::AtomicU64 =
|
// `XENIA_CACHE_PERSIST=1` (`$XDG_DATA_HOME/xenia-rs/cache`).
|
||||||
std::sync::atomic::AtomicU64::new(0);
|
// Errors are non-fatal (cache I/O degrades to the synth-stub
|
||||||
let id = NEXT_CACHE_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
// fallback) but logged.
|
||||||
let root = std::env::temp_dir().join(format!(
|
let (root, wipe) = resolve_default_cache_root();
|
||||||
"xenia-rs-cache-{}-{}",
|
let init_result = if wipe {
|
||||||
std::process::id(),
|
state.init_cache_root(root.clone())
|
||||||
id
|
} else {
|
||||||
));
|
std::fs::create_dir_all(&root).map(|()| {
|
||||||
if let Err(e) = state.init_cache_root(root.clone()) {
|
state.cache_root = Some(root.clone());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
if let Err(e) = init_result {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"Failed to initialise cache root at {:?}: {} — cache:/* opens \
|
"Failed to initialise cache root at {:?}: {} — cache:/* opens \
|
||||||
will fall back to the synth-empty-file stub",
|
will fall back to the synth-empty-file stub",
|
||||||
@@ -397,6 +400,13 @@ impl KernelState {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// AUDIT-054 — direct (non-wiping) cache-root install for tests
|
||||||
|
/// that want byte-for-byte control over what's already on disk
|
||||||
|
/// when the kernel boots. Skips the `init_cache_root` clear pass.
|
||||||
|
pub fn set_cache_root(&mut self, root: std::path::PathBuf) {
|
||||||
|
self.cache_root = Some(root);
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve a guest VFS path (e.g. `cache:\d4ea4615e46ee8ca.tmp`) to
|
/// 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
|
/// 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
|
/// have a `cache:` prefix or if no cache root is mounted (legacy
|
||||||
@@ -1016,6 +1026,63 @@ impl Default for KernelState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// AUDIT-054 — pick the cache root path + wipe-on-init mode for a
|
||||||
|
/// fresh `KernelState`.
|
||||||
|
///
|
||||||
|
/// Default behaviour matches AUDIT-038: per-process tmpdir + full
|
||||||
|
/// wipe so two consecutive runs see byte-identical initial state
|
||||||
|
/// (lockstep / oracle determinism). AUDIT-054 found that Sylpheed's
|
||||||
|
/// `cache:\<hash>.tmp` journal-style writes append on each boot, so
|
||||||
|
/// a naive persistent root makes the on-disk state self-inconsistent
|
||||||
|
/// after the second boot (`runtime_error` throws from version-check
|
||||||
|
/// on reload). Opt-in to persistence via env:
|
||||||
|
/// * `XENIA_CACHE_ROOT=<path>` — explicit persistent path. Caller
|
||||||
|
/// is responsible for wiping when needed.
|
||||||
|
/// * `XENIA_CACHE_PERSIST=1` — use `$XDG_DATA_HOME/xenia-rs/cache`
|
||||||
|
/// (or `$HOME/.local/share/xenia-rs/cache`) without wiping.
|
||||||
|
///
|
||||||
|
/// Returns `(root, wipe)` where `wipe = true` triggers the
|
||||||
|
/// `init_cache_root` clear-then-recreate dance.
|
||||||
|
fn resolve_default_cache_root() -> (std::path::PathBuf, bool) {
|
||||||
|
if let Ok(p) = std::env::var("XENIA_CACHE_ROOT") {
|
||||||
|
if !p.is_empty() {
|
||||||
|
return (std::path::PathBuf::from(p), false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let persist = std::env::var("XENIA_CACHE_PERSIST")
|
||||||
|
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
if persist {
|
||||||
|
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
|
||||||
|
if !xdg.is_empty() {
|
||||||
|
return (
|
||||||
|
std::path::PathBuf::from(xdg).join("xenia-rs/cache"),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(home) = std::env::var("HOME") {
|
||||||
|
if !home.is_empty() {
|
||||||
|
return (
|
||||||
|
std::path::PathBuf::from(home).join(".local/share/xenia-rs/cache"),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
static NEXT_CACHE_ID: std::sync::atomic::AtomicU64 =
|
||||||
|
std::sync::atomic::AtomicU64::new(0);
|
||||||
|
let id = NEXT_CACHE_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
(
|
||||||
|
std::env::temp_dir().join(format!(
|
||||||
|
"xenia-rs-cache-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
id
|
||||||
|
)),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// KRNBUG-AUDIT-003. Outcome of probing a guest pointer as the `this`
|
/// KRNBUG-AUDIT-003. Outcome of probing a guest pointer as the `this`
|
||||||
/// of a C++ object: read `[this]` as the vtable, then attempt MSVC
|
/// of a C++ object: read `[this]` as the vtable, then attempt MSVC
|
||||||
/// RTTI to recover the decorated class name. Pure read; lockstep-safe.
|
/// RTTI to recover the decorated class name. Pure read; lockstep-safe.
|
||||||
|
|||||||
Reference in New Issue
Block a user