mod support;
mod host_dir {
#![allow(dead_code)]
include!("../src/plugins/host_dir.rs");
mod tests {
use super::HostDirFilesystem;
use nix::sys::stat::{utimensat, UtimensatFlags};
use nix::sys::time::{TimeSpec, TimeValLike};
use secure_exec_kernel::command_registry::CommandDriver;
use secure_exec_kernel::fd_table::O_RDWR;
use secure_exec_kernel::kernel::{KernelVm, KernelVmConfig, SpawnOptions};
use secure_exec_kernel::mount_table::{MountOptions, MountTable};
use secure_exec_kernel::permissions::Permissions;
use secure_exec_kernel::vfs::{
MemoryFileSystem, VirtualFileSystem, VirtualTimeSpec, VirtualUtimeSpec,
};
use std::fs;
use std::os::unix::fs::{MetadataExt, PermissionsExt};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir(prefix: &str) -> PathBuf {
let suffix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock should be monotonic enough for temp paths")
.as_nanos();
let path = std::env::temp_dir().join(format!("{prefix}-{suffix}"));
fs::create_dir_all(&path).expect("create temp dir");
path
}
fn spawn_shell_in<F: VirtualFileSystem + 'static>(
kernel: &mut KernelVm<F>,
) -> secure_exec_kernel::kernel::KernelProcessHandle {
kernel
.spawn_process(
"sh",
Vec::new(),
SpawnOptions {
requester_driver: Some(String::from("shell")),
..SpawnOptions::default()
},
)
.expect("spawn shell")
}
#[test]
fn filesystem_host_dir_metadata_ops_reject_symlink_escape_targets() {
let host_dir = temp_dir("secure-exec-sidecar-filesystem-host-dir");
let outside_dir = temp_dir("secure-exec-sidecar-filesystem-host-dir-outside");
let outside_file = outside_dir.join("outside.txt");
fs::write(&outside_file, b"outside").expect("seed outside file");
std::os::unix::fs::symlink(&outside_file, host_dir.join("link"))
.expect("seed escape symlink");
let baseline = fs::metadata(&outside_file).expect("outside metadata before ops");
let baseline_mode = baseline.permissions().mode() & 0o7777;
let baseline_uid = baseline.uid();
let baseline_gid = baseline.gid();
let baseline_mtime = baseline.mtime();
let baseline_mtime_ns = baseline.mtime_nsec();
let mut filesystem = HostDirFilesystem::new(&host_dir).expect("create host dir fs");
let chmod_error = filesystem
.chmod("/link", 0o777)
.expect_err("chmod should reject escaped symlink target");
assert!(matches!(chmod_error.code(), "EPERM" | "EACCES"));
let chown_error = filesystem
.chown("/link", baseline_uid, baseline_gid)
.expect_err("chown should reject escaped symlink target");
assert!(matches!(chown_error.code(), "EPERM" | "EACCES"));
let utimes_error = filesystem
.utimes("/link", 1_000, 2_000)
.expect_err("utimes should reject escaped symlink target");
assert!(matches!(utimes_error.code(), "EPERM" | "EACCES"));
let after = fs::metadata(&outside_file).expect("outside metadata after ops");
assert_eq!(after.permissions().mode() & 0o7777, baseline_mode);
assert_eq!(after.uid(), baseline_uid);
assert_eq!(after.gid(), baseline_gid);
assert_eq!(after.mtime(), baseline_mtime);
assert_eq!(after.mtime_nsec(), baseline_mtime_ns);
fs::remove_dir_all(host_dir).expect("remove temp dir");
fs::remove_dir_all(outside_dir).expect("remove outside temp dir");
}
#[test]
fn filesystem_host_dir_write_file_with_mode_honors_requested_permissions() {
let host_dir = temp_dir("secure-exec-sidecar-filesystem-host-dir-write-mode");
let mut filesystem = HostDirFilesystem::new(&host_dir).expect("create host dir fs");
filesystem
.write_file_with_mode("/private.txt", b"secret".to_vec(), Some(0o600))
.expect("write private host file");
let metadata = fs::metadata(host_dir.join("private.txt")).expect("read file metadata");
assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
fs::remove_dir_all(host_dir).expect("remove temp dir");
}
#[test]
fn filesystem_host_dir_recursive_mkdir_with_mode_honors_requested_permissions() {
let host_dir = temp_dir("secure-exec-sidecar-filesystem-host-dir-mkdir-mode");
let mut filesystem = HostDirFilesystem::new(&host_dir).expect("create host dir fs");
filesystem
.mkdir_with_mode("/private/nested", true, Some(0o700))
.expect("create private directories");
for relative in ["private", "private/nested"] {
let metadata =
fs::metadata(host_dir.join(relative)).expect("read directory metadata");
assert_eq!(
metadata.permissions().mode() & 0o777,
0o700,
"unexpected mode for {relative}"
);
}
fs::remove_dir_all(host_dir).expect("remove temp dir");
}
#[test]
fn filesystem_host_dir_stat_preserves_nanosecond_timestamp_precision() {
let host_dir = temp_dir("secure-exec-sidecar-filesystem-host-dir-stat");
let tracked_file = host_dir.join("tracked.txt");
fs::write(&tracked_file, b"tracked").expect("seed tracked file");
let atime = TimeSpec::nanoseconds(1_700_000_000_123_456_789);
let mtime = TimeSpec::nanoseconds(1_700_000_000_987_654_321);
utimensat(
None,
&tracked_file,
&atime,
&mtime,
UtimensatFlags::NoFollowSymlink,
)
.expect("set tracked file timestamps");
let baseline = fs::metadata(&tracked_file).expect("tracked file metadata");
assert_ne!(
baseline.mtime_nsec(),
0,
"fixture should keep non-zero mtime nsec"
);
let mut filesystem = HostDirFilesystem::new(&host_dir).expect("create host dir fs");
let stat = filesystem.stat("/tracked.txt").expect("stat tracked file");
assert_eq!(
stat.atime_ms,
baseline.atime() as u64 * 1_000 + (baseline.atime_nsec() as u64 / 1_000_000)
);
assert_eq!(stat.atime_nsec, baseline.atime_nsec() as u32);
assert_eq!(
stat.mtime_ms,
baseline.mtime() as u64 * 1_000 + (baseline.mtime_nsec() as u64 / 1_000_000)
);
assert_eq!(stat.mtime_nsec, baseline.mtime_nsec() as u32);
assert_eq!(
stat.ctime_ms,
baseline.ctime() as u64 * 1_000 + (baseline.ctime_nsec() as u64 / 1_000_000)
);
assert_eq!(stat.ctime_nsec, baseline.ctime_nsec() as u32);
fs::remove_dir_all(host_dir).expect("remove temp dir");
}
#[test]
fn filesystem_host_dir_utimes_spec_honors_omit_and_now_controls() {
let host_dir = temp_dir("secure-exec-sidecar-filesystem-host-dir-utimes-spec");
let tracked_file = host_dir.join("tracked.txt");
fs::write(&tracked_file, b"tracked").expect("seed tracked file");
let baseline_atime_sec = 1_700_000_000;
let baseline_atime_nsec = 111_111_111;
let baseline_mtime_sec = 1_700_000_000;
let baseline_mtime_nsec = 222_222_222;
let baseline_atime =
TimeSpec::nanoseconds(baseline_atime_sec * 1_000_000_000 + baseline_atime_nsec);
let baseline_mtime =
TimeSpec::nanoseconds(baseline_mtime_sec * 1_000_000_000 + baseline_mtime_nsec);
utimensat(
None,
&tracked_file,
&baseline_atime,
&baseline_mtime,
UtimensatFlags::FollowSymlink,
)
.expect("seed tracked file timestamps");
let mut filesystem = HostDirFilesystem::new(&host_dir).expect("create host dir fs");
filesystem
.utimes_spec(
"/tracked.txt",
VirtualUtimeSpec::Set(
VirtualTimeSpec::new(1_700_000_123, 987_654_321)
.expect("valid atime timespec"),
),
VirtualUtimeSpec::Omit,
true,
)
.expect("utimes_spec should preserve mtime");
let after_omit = fs::metadata(&tracked_file).expect("tracked file metadata after omit");
assert_eq!(after_omit.mtime(), baseline_mtime_sec);
assert_eq!(after_omit.mtime_nsec(), baseline_mtime_nsec);
assert_eq!(after_omit.atime(), 1_700_000_123);
assert_eq!(after_omit.atime_nsec(), 987_654_321);
filesystem
.utimes_spec(
"/tracked.txt",
VirtualUtimeSpec::Now,
VirtualUtimeSpec::Omit,
true,
)
.expect("utimes_spec should accept UTIME_NOW");
let after_now = fs::metadata(&tracked_file).expect("tracked file metadata after now");
assert_eq!(after_now.mtime(), baseline_mtime_sec);
assert_eq!(after_now.mtime_nsec(), baseline_mtime_nsec);
assert!(
after_now.atime() > after_omit.atime()
|| (after_now.atime() == after_omit.atime()
&& after_now.atime_nsec() >= after_omit.atime_nsec()),
"UTIME_NOW should move atime forward"
);
fs::remove_dir_all(host_dir).expect("remove temp dir");
}
#[test]
fn filesystem_host_dir_lutimes_updates_symlink_without_touching_target() {
let host_dir = temp_dir("secure-exec-sidecar-filesystem-host-dir-lutimes");
let target = host_dir.join("target.txt");
let link = host_dir.join("link.txt");
fs::write(&target, b"target").expect("seed target file");
std::os::unix::fs::symlink("target.txt", &link).expect("create symlink");
let baseline_target = fs::metadata(&target).expect("target metadata before lutimes");
let baseline_target_mtime = baseline_target.mtime();
let baseline_target_mtime_nsec = baseline_target.mtime_nsec();
let mut filesystem = HostDirFilesystem::new(&host_dir).expect("create host dir fs");
filesystem
.utimes_spec(
"/link.txt",
VirtualUtimeSpec::Set(
VirtualTimeSpec::new(1_700_000_444, 123_456_789).expect("valid link atime"),
),
VirtualUtimeSpec::Set(
VirtualTimeSpec::new(1_700_000_555, 987_654_321).expect("valid link mtime"),
),
false,
)
.expect("lutimes should update the symlink itself");
let link_metadata = fs::symlink_metadata(&link).expect("link metadata after lutimes");
let target_metadata = fs::metadata(&target).expect("target metadata after lutimes");
assert_eq!(link_metadata.mtime(), 1_700_000_555);
assert_eq!(link_metadata.mtime_nsec(), 987_654_321);
assert_eq!(link_metadata.atime(), 1_700_000_444);
assert_eq!(link_metadata.atime_nsec(), 123_456_789);
assert_eq!(target_metadata.mtime(), baseline_target_mtime);
assert_eq!(target_metadata.mtime_nsec(), baseline_target_mtime_nsec);
fs::remove_dir_all(host_dir).expect("remove temp dir");
}
#[test]
fn kernel_futimes_updates_host_dir_mount_with_nanosecond_precision() {
let host_dir = temp_dir("secure-exec-sidecar-filesystem-host-dir-futimes");
let tracked_file = host_dir.join("tracked.txt");
fs::write(&tracked_file, b"tracked").expect("seed tracked file");
let mut config = KernelVmConfig::new("vm-host-dir-futimes");
config.permissions = Permissions::allow_all();
let mut kernel = KernelVm::new(MountTable::new(MemoryFileSystem::new()), config);
kernel
.register_driver(CommandDriver::new("shell", ["sh"]))
.expect("register shell driver");
kernel
.mount_filesystem(
"/workspace",
HostDirFilesystem::new(&host_dir).expect("host dir fs"),
MountOptions::new("host_dir"),
)
.expect("mount host dir");
let process = spawn_shell_in(&mut kernel);
let fd = kernel
.fd_open(
"shell",
process.pid(),
"/workspace/tracked.txt",
O_RDWR,
None,
)
.expect("open tracked file");
kernel
.futimes(
"shell",
process.pid(),
fd,
VirtualUtimeSpec::Set(
VirtualTimeSpec::new(1_700_000_666, 111_222_333)
.expect("valid futimes atime"),
),
VirtualUtimeSpec::Set(
VirtualTimeSpec::new(1_700_000_777, 444_555_666)
.expect("valid futimes mtime"),
),
)
.expect("futimes should update host file");
let metadata = fs::metadata(&tracked_file).expect("tracked metadata after futimes");
assert_eq!(metadata.atime(), 1_700_000_666);
assert_eq!(metadata.atime_nsec(), 111_222_333);
assert_eq!(metadata.mtime(), 1_700_000_777);
assert_eq!(metadata.mtime_nsec(), 444_555_666);
fs::remove_dir_all(host_dir).expect("remove temp dir");
}
}
}
mod shadow_root {
use secure_exec_sidecar::wire::{
ConfigureVmRequest, DisposeReason, DisposeVmRequest, EventPayload, ExecuteRequest,
GuestFilesystemCallRequest, GuestFilesystemOperation, GuestRuntimeKind, MountDescriptor,
MountPluginDescriptor, RequestPayload, ResponsePayload, RootFilesystemEntryEncoding,
StreamChannel,
};
use serde_json::json;
use std::collections::HashMap;
use std::fs;
use std::time::Duration;
use crate::support::{
self, authenticate_wire, create_vm_wire, open_session_wire, temp_dir, wire_request,
wire_vm, RecordingBridge,
};
const PROCESS_OUTPUT_BYTE_LIMIT: usize = 1024 * 1024;
fn create_test_sidecar() -> secure_exec_sidecar::NativeSidecar<RecordingBridge> {
support::new_sidecar("filesystem-test")
}
fn authenticate_and_open_session(
sidecar: &mut secure_exec_sidecar::NativeSidecar<RecordingBridge>,
) -> (String, String) {
let connection_id = authenticate_wire(sidecar, "conn-1");
let session_id = open_session_wire(sidecar, 2, &connection_id);
(connection_id, session_id)
}
fn create_vm_with_mounts(
sidecar: &mut secure_exec_sidecar::NativeSidecar<RecordingBridge>,
connection_id: &str,
session_id: &str,
extra_mounts: Vec<MountDescriptor>,
) -> String {
let cwd = temp_dir("filesystem-vm-cwd");
let (vm_id, _) = create_vm_wire(
sidecar,
3,
connection_id,
session_id,
GuestRuntimeKind::JavaScript,
&cwd,
);
let mut mounts = vec![MountDescriptor {
guest_path: String::from("/__secure_exec/commands/0"),
read_only: true,
plugin: MountPluginDescriptor {
id: String::from("host_dir"),
config: serde_json::to_string(&json!({
"hostPath": registry_command_root(),
"readOnly": true,
}))
.expect("serialize command mount config"),
},
}];
mounts.extend(extra_mounts);
sidecar
.dispatch_wire_blocking(wire_request(
4,
wire_vm(connection_id, session_id, &vm_id),
RequestPayload::ConfigureVmRequest(ConfigureVmRequest {
mounts,
software: Vec::new(),
permissions: None,
module_access_cwd: None,
instructions: Vec::new(),
projected_modules: Vec::new(),
command_permissions: HashMap::new(),
loopback_exempt_ports: Vec::new(),
}),
))
.expect("configure command mount");
vm_id
}
fn registry_command_root() -> String {
let repo_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
.expect("canonicalize repo root");
let copied = repo_root.join("registry/software/coreutils/wasm");
if copied.exists() {
return copied.to_string_lossy().into_owned();
}
let fallback = repo_root.join("registry/native/target/wasm32-wasip1/release/commands");
if fallback.exists() {
return fallback.to_string_lossy().into_owned();
}
panic!(
"registry WASM commands are required for filesystem tests: expected {} or {}",
copied.display(),
fallback.display()
);
}
fn guest_filesystem_call(
sidecar: &mut secure_exec_sidecar::NativeSidecar<RecordingBridge>,
connection_id: &str,
session_id: &str,
vm_id: &str,
request_id: i64,
payload: GuestFilesystemCallRequest,
) {
let response = sidecar
.dispatch_wire_blocking(wire_request(
request_id,
wire_vm(connection_id, session_id, vm_id),
RequestPayload::GuestFilesystemCallRequest(payload),
))
.expect("dispatch guest filesystem call");
match response.response.payload {
ResponsePayload::GuestFilesystemResultResponse(_) => {}
other => panic!("expected guest_filesystem_result response, got {other:?}"),
}
}
#[allow(clippy::too_many_arguments)]
fn execute_command(
sidecar: &mut secure_exec_sidecar::NativeSidecar<RecordingBridge>,
connection_id: &str,
session_id: &str,
vm_id: &str,
request_id: i64,
process_id: &str,
command: &str,
args: Vec<String>,
) -> (String, String, Option<i32>) {
let response = sidecar
.dispatch_wire_blocking(wire_request(
request_id,
wire_vm(connection_id, session_id, vm_id),
RequestPayload::ExecuteRequest(ExecuteRequest {
process_id: String::from(process_id),
command: Some(String::from(command)),
runtime: None,
entrypoint: None,
args,
env: HashMap::new(),
cwd: Some(String::from("/workspace")),
wasm_permission_tier: None,
}),
))
.expect("dispatch execute");
match response.response.payload {
ResponsePayload::ProcessStartedResponse(started) => {
assert_eq!(started.process_id, process_id);
}
other => panic!("unexpected execute response: {other:?}"),
}
drain_process_output(sidecar, connection_id, session_id, vm_id, process_id)
}
fn execute_javascript_entrypoint(
sidecar: &mut secure_exec_sidecar::NativeSidecar<RecordingBridge>,
connection_id: &str,
session_id: &str,
vm_id: &str,
request_id: i64,
process_id: &str,
entrypoint: &str,
) -> (String, String, Option<i32>) {
let response = sidecar
.dispatch_wire_blocking(wire_request(
request_id,
wire_vm(connection_id, session_id, vm_id),
RequestPayload::ExecuteRequest(ExecuteRequest {
process_id: String::from(process_id),
command: None,
runtime: Some(GuestRuntimeKind::JavaScript),
entrypoint: Some(String::from(entrypoint)),
args: Vec::new(),
env: HashMap::new(),
cwd: Some(String::from("/workspace")),
wasm_permission_tier: None,
}),
))
.expect("dispatch execute");
match response.response.payload {
ResponsePayload::ProcessStartedResponse(started) => {
assert_eq!(started.process_id, process_id);
}
other => panic!("unexpected execute response: {other:?}"),
}
drain_process_output(sidecar, connection_id, session_id, vm_id, process_id)
}
fn drain_process_output(
sidecar: &mut secure_exec_sidecar::NativeSidecar<RecordingBridge>,
connection_id: &str,
session_id: &str,
vm_id: &str,
process_id: &str,
) -> (String, String, Option<i32>) {
let ownership = wire_vm(connection_id, session_id, vm_id);
let mut stdout = String::new();
let mut stderr = String::new();
let mut exit_code = None;
for _ in 0..64 {
let Some(event) = sidecar
.poll_event_wire_blocking(&ownership, Duration::from_secs(5))
.expect("poll wire process event")
else {
if exit_code.is_some() {
break;
}
panic!("timed out waiting for process {process_id} to exit");
};
match event.payload {
EventPayload::ProcessOutputEvent(output) if output.process_id == process_id => {
match output.channel {
StreamChannel::Stdout => {
append_process_output(
&mut stdout,
&output.chunk,
&output.process_id,
"stdout",
);
}
StreamChannel::Stderr => {
append_process_output(
&mut stderr,
&output.chunk,
&output.process_id,
"stderr",
);
}
}
}
EventPayload::ProcessExitedEvent(exited) if exited.process_id == process_id => {
exit_code = Some(exited.exit_code);
break;
}
_ => {}
}
}
(stdout, stderr, exit_code)
}
fn append_process_output(buffer: &mut String, chunk: &[u8], process_id: &str, channel: &str) {
let text = String::from_utf8_lossy(chunk);
assert!(
buffer.len().saturating_add(text.len()) <= PROCESS_OUTPUT_BYTE_LIMIT,
"filesystem process {process_id} exceeded {PROCESS_OUTPUT_BYTE_LIMIT} bytes on {channel}"
);
buffer.push_str(&text);
}
fn dispose_vm_and_close_session(
sidecar: &mut secure_exec_sidecar::NativeSidecar<RecordingBridge>,
connection_id: &str,
session_id: &str,
vm_id: &str,
) {
sidecar
.dispatch_wire_blocking(wire_request(
90,
wire_vm(connection_id, session_id, vm_id),
RequestPayload::DisposeVmRequest(DisposeVmRequest {
reason: DisposeReason::Requested,
}),
))
.expect("dispose vm");
sidecar
.close_session_blocking(connection_id, session_id)
.expect("close session");
sidecar
.remove_connection_blocking(connection_id)
.expect("remove connection");
}
#[test]
fn filesystem_cross_mount_rename_reports_exdev_to_js_and_falls_back_in_shell() {
let host_dir = temp_dir("secure-exec-sidecar-cross-mount-rename-js");
fs::write(host_dir.join("source.txt"), "mapped-source\n").expect("seed mapped file");
let mut sidecar = create_test_sidecar();
let (connection_id, session_id) = authenticate_and_open_session(&mut sidecar);
let vm_id = create_vm_with_mounts(
&mut sidecar,
&connection_id,
&session_id,
vec![MountDescriptor {
guest_path: String::from("/mapped"),
read_only: false,
plugin: MountPluginDescriptor {
id: String::from("host_dir"),
config: serde_json::to_string(&json!({
"hostPath": host_dir.to_string_lossy().into_owned(),
"readOnly": false,
}))
.expect("serialize mapped mount config"),
},
}],
);
guest_filesystem_call(
&mut sidecar,
&connection_id,
&session_id,
&vm_id,
4,
GuestFilesystemCallRequest {
operation: GuestFilesystemOperation::WriteFile,
path: String::from("/workspace/original.txt"),
content: Some(String::from("original\n")),
encoding: Some(RootFilesystemEntryEncoding::Utf8),
..GuestFilesystemCallRequest {
operation: GuestFilesystemOperation::WriteFile,
path: String::new(),
destination_path: None,
target: None,
content: None,
encoding: None,
recursive: false,
mode: None,
uid: None,
gid: None,
atime_ms: None,
mtime_ms: None,
len: None,
offset: None,
}
},
);
guest_filesystem_call(
&mut sidecar,
&connection_id,
&session_id,
&vm_id,
5,
GuestFilesystemCallRequest {
operation: GuestFilesystemOperation::Symlink,
path: String::from("/workspace/alias.txt"),
target: Some(String::from("/workspace/original.txt")),
..GuestFilesystemCallRequest {
operation: GuestFilesystemOperation::Symlink,
path: String::new(),
destination_path: None,
target: None,
content: None,
encoding: None,
recursive: false,
mode: None,
uid: None,
gid: None,
atime_ms: None,
mtime_ms: None,
len: None,
offset: None,
}
},
);
let (stdout, stderr, exit_code) = execute_command(
&mut sidecar,
&connection_id,
&session_id,
&vm_id,
6,
"proc-ls-symlink",
"/bin/ls",
vec![String::from("-l"), String::from("/workspace")],
);
assert_eq!(exit_code, Some(0), "stderr: {stderr}");
assert!(
stdout.contains("alias.txt"),
"stdout did not render mirrored symlink:\n{stdout}"
);
let (cat_stdout, cat_stderr, cat_exit_code) = execute_command(
&mut sidecar,
&connection_id,
&session_id,
&vm_id,
7,
"proc-cat-symlink",
"/bin/cat",
vec![String::from("/workspace/alias.txt")],
);
assert_eq!(cat_exit_code, Some(0), "stderr: {cat_stderr}");
assert_eq!(cat_stdout, "original\n");
guest_filesystem_call(
&mut sidecar,
&connection_id,
&session_id,
&vm_id,
8,
GuestFilesystemCallRequest {
operation: GuestFilesystemOperation::Link,
path: String::from("/workspace/original.txt"),
destination_path: Some(String::from("/workspace/linked.txt")),
..GuestFilesystemCallRequest {
operation: GuestFilesystemOperation::Link,
path: String::new(),
destination_path: None,
target: None,
content: None,
encoding: None,
recursive: false,
mode: None,
uid: None,
gid: None,
atime_ms: None,
mtime_ms: None,
len: None,
offset: None,
}
},
);
let (ls_stdout, ls_stderr, ls_exit_code) = execute_command(
&mut sidecar,
&connection_id,
&session_id,
&vm_id,
9,
"proc-ls-link",
"/bin/ls",
vec![String::from("-l"), String::from("/workspace")],
);
assert_eq!(ls_exit_code, Some(0), "stderr: {ls_stderr}");
assert!(
ls_stdout.contains("linked.txt"),
"stdout did not render mirrored hard link:\n{ls_stdout}"
);
let (cat_stdout, cat_stderr, cat_exit_code) = execute_command(
&mut sidecar,
&connection_id,
&session_id,
&vm_id,
10,
"proc-cat-link",
"/bin/cat",
vec![String::from("/workspace/linked.txt")],
);
assert_eq!(cat_exit_code, Some(0), "stderr: {cat_stderr}");
assert_eq!(cat_stdout, "original\n");
guest_filesystem_call(
&mut sidecar,
&connection_id,
&session_id,
&vm_id,
11,
GuestFilesystemCallRequest {
operation: GuestFilesystemOperation::Mkdir,
path: String::from("/kernel"),
recursive: false,
..GuestFilesystemCallRequest {
operation: GuestFilesystemOperation::Mkdir,
path: String::new(),
destination_path: None,
target: None,
content: None,
encoding: None,
recursive: false,
mode: None,
uid: None,
gid: None,
atime_ms: None,
mtime_ms: None,
len: None,
offset: None,
}
},
);
guest_filesystem_call(
&mut sidecar,
&connection_id,
&session_id,
&vm_id,
12,
GuestFilesystemCallRequest {
operation: GuestFilesystemOperation::WriteFile,
path: String::from("/workspace/rename-check.js"),
content: Some(String::from(
r#"const fs = require("node:fs");
try {
fs.renameSync("/mapped/source.txt", "/kernel/dest.txt");
console.log(JSON.stringify({ ok: true }));
} catch (error) {
console.log(JSON.stringify({ ok: false, code: error.code, message: error.message }));
}
"#,
)),
encoding: Some(RootFilesystemEntryEncoding::Utf8),
..GuestFilesystemCallRequest {
operation: GuestFilesystemOperation::WriteFile,
path: String::new(),
destination_path: None,
target: None,
content: None,
encoding: None,
recursive: false,
mode: None,
uid: None,
gid: None,
atime_ms: None,
mtime_ms: None,
len: None,
offset: None,
}
},
);
let (stdout, stderr, exit_code) = execute_javascript_entrypoint(
&mut sidecar,
&connection_id,
&session_id,
&vm_id,
13,
"proc-js-rename-exdev",
"/workspace/rename-check.js",
);
assert_eq!(exit_code, Some(0), "stderr: {stderr}");
let result: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("parse renameSync result");
assert_eq!(result["ok"], false);
assert_eq!(result["code"], "EXDEV");
assert!(
!host_dir.join("dest.txt").exists(),
"renameSync should not create a host destination during EXDEV failure"
);
assert!(
host_dir.join("source.txt").exists(),
"renameSync should leave the mapped source in place on EXDEV"
);
fs::write(host_dir.join("source.txt"), "mv-fallback\n").expect("reset mapped file for mv");
let (stdout, stderr, exit_code) = execute_command(
&mut sidecar,
&connection_id,
&session_id,
&vm_id,
14,
"proc-mv-cross-mount",
"/bin/mv",
vec![
String::from("/mapped/source.txt"),
String::from("/kernel/copied.txt"),
],
);
assert_eq!(exit_code, Some(0), "stdout: {stdout}\nstderr: {stderr}");
assert_eq!(stderr, "");
let (cat_stdout, cat_stderr, cat_exit_code) = execute_command(
&mut sidecar,
&connection_id,
&session_id,
&vm_id,
15,
"proc-cat-cross-mount",
"/bin/cat",
vec![String::from("/kernel/copied.txt")],
);
assert_eq!(cat_exit_code, Some(0), "stderr: {cat_stderr}");
assert_eq!(cat_stdout, "mv-fallback\n");
assert!(
!host_dir.join("source.txt").exists(),
"mv should unlink the mapped source after copying"
);
dispose_vm_and_close_session(&mut sidecar, &connection_id, &session_id, &vm_id);
fs::remove_dir_all(host_dir).expect("remove temp dir");
}
}