From 543b4ce6e6fa45e02fef99887bdaba35d14fcac4 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Mon, 6 Jul 2026 20:03:11 -0700 Subject: [PATCH] fix(native-sidecar): propagate guest wasm deletions and renames to the VM shadow root --- crates/native-sidecar/src/filesystem.rs | 75 ++++++++++++++++++++--- packages/core/tests/wasm-commands.test.ts | 14 +++++ 2 files changed, 79 insertions(+), 10 deletions(-) diff --git a/crates/native-sidecar/src/filesystem.rs b/crates/native-sidecar/src/filesystem.rs index cdddf0b20a..d85aba7153 100644 --- a/crates/native-sidecar/src/filesystem.rs +++ b/crates/native-sidecar/src/filesystem.rs @@ -1964,8 +1964,13 @@ pub(crate) fn service_javascript_fs_sync_rpc( } kernel .rename(source, destination) - .map(|()| Value::Null) - .map_err(kernel_error) + .map_err(kernel_error)?; + // Mirror the rename into the process shadow tree, otherwise the + // exit-time shadow->kernel sync resurrects the stale source path + // (the shadow walk only copies entries in, it cannot express + // deletions). + rename_process_shadow_path(process, source, destination)?; + Ok(Value::Null) } "fs.rmdirSync" | "fs.promises.rmdir" => { let path = @@ -1997,10 +2002,11 @@ pub(crate) fn service_javascript_fs_sync_rpc( } None => {} } - kernel - .remove_dir(path) - .map(|()| Value::Null) - .map_err(kernel_error) + kernel.remove_dir(path).map_err(kernel_error)?; + // Mirror the removal into the process shadow tree, otherwise the + // exit-time shadow->kernel sync resurrects the deleted directory. + remove_process_shadow_path(process, path)?; + Ok(Value::Null) } "fs.unlinkSync" | "fs.promises.unlink" => { let path = @@ -2034,10 +2040,13 @@ pub(crate) fn service_javascript_fs_sync_rpc( } None => {} } - kernel - .remove_file(path) - .map(|()| Value::Null) - .map_err(kernel_error) + kernel.remove_file(path).map_err(kernel_error)?; + // Mirror the deletion into the process shadow tree: wasm guest + // deletions route kernel-direct, and without removing the shadow + // copy the exit-time shadow->kernel sync resurrects the file for + // later builtins in the same shell and for subsequent execs. + remove_process_shadow_path(process, path)?; + Ok(Value::Null) } "fs.chmodSync" | "fs.promises.chmod" => { let path = @@ -4349,6 +4358,52 @@ fn resolve_process_guest_path_to_host( )) } +/// Removes the host shadow copy of `guest_path` after a kernel-direct guest +/// deletion so the exit-time shadow->kernel sync cannot resurrect it. +fn remove_process_shadow_path( + process: &ActiveProcess, + guest_path: &str, +) -> Result<(), SidecarError> { + let Some(shadow_path) = resolve_process_guest_path_to_host(process, guest_path) else { + return Ok(()); + }; + remove_shadow_path_if_exists(&shadow_path, guest_path) +} + +/// Mirrors a kernel-direct guest rename into the host shadow tree. If the +/// source shadow entry is missing the stale destination copy is still removed +/// so the shadow walk cannot resurrect pre-rename content. +fn rename_process_shadow_path( + process: &ActiveProcess, + source: &str, + destination: &str, +) -> Result<(), SidecarError> { + let Some(source_shadow) = resolve_process_guest_path_to_host(process, source) else { + return Ok(()); + }; + let Some(destination_shadow) = resolve_process_guest_path_to_host(process, destination) else { + return Ok(()); + }; + + if fs::symlink_metadata(&source_shadow).is_err() { + return remove_shadow_path_if_exists(&destination_shadow, destination); + } + + if let Some(parent) = destination_shadow.parent() { + fs::create_dir_all(parent).map_err(|error| { + SidecarError::Io(format!( + "failed to create shadow parent for rename {source} -> {destination}: {error}" + )) + })?; + } + remove_shadow_path_if_exists(&destination_shadow, destination)?; + fs::rename(&source_shadow, &destination_shadow).map_err(|error| { + SidecarError::Io(format!( + "failed to mirror guest rename {source} -> {destination} into shadow root: {error}" + )) + }) +} + fn sync_host_directory_to_kernel( vm: &mut VmState, guest_path: &str, diff --git a/packages/core/tests/wasm-commands.test.ts b/packages/core/tests/wasm-commands.test.ts index 9001cb1894..ec915894a7 100644 --- a/packages/core/tests/wasm-commands.test.ts +++ b/packages/core/tests/wasm-commands.test.ts @@ -152,6 +152,20 @@ EOF`); expect(r.stdout.trim()).toBe("ok"); }); + test("rm deletion is visible across execs and to the host", async () => { + await vm.writeFile("/tmp/rm-cross-exec.txt", "x"); + const rm = await vm.exec("rm /tmp/rm-cross-exec.txt"); + expect(rm.exitCode).toBe(0); + // The deletion must survive the exit-time shadow sync: the host + // filesystem API and a later exec both observe the file as gone. + expect(await vm.exists("/tmp/rm-cross-exec.txt")).toBe(false); + const probe = await vm.exec( + "sh -c 'test ! -f /tmp/rm-cross-exec.txt && echo gone'", + ); + expect(probe.exitCode).toBe(0); + expect(probe.stdout.trim()).toBe("gone"); + }); + test("mkdir -p and rmdir", async () => { const r = await vm.exec( "mkdir -p /tmp/a/b/c && test -d /tmp/a/b/c && echo ok",