mod sandbox_agent {
include!("../src/plugins/sandbox_agent.rs");
mod tests {
use super::test_support::MockSandboxAgentServer;
use super::{
validate_sandbox_agent_base_url, SandboxAgentFilesystem, SandboxAgentMountConfig,
SandboxAgentMountPlugin,
};
use nix::unistd::{Gid, Uid};
use secure_exec_kernel::mount_plugin::{
FileSystemPluginFactory, OpenFileSystemPluginRequest,
};
use secure_exec_kernel::vfs::VirtualFileSystem;
use serde_json::json;
use std::fs;
use std::os::unix::fs::{MetadataExt, PermissionsExt};
#[test]
fn filesystem_round_trips_small_files_and_uses_http_range_for_large_pread() {
let server = MockSandboxAgentServer::start("secure-exec-sandbox-plugin", None);
fs::write(server.root().join("hello.txt"), "hello from sandbox").expect("seed file");
let large_file = (0..100 * 1024)
.map(|index| (index % 251) as u8)
.collect::<Vec<_>>();
fs::write(server.root().join("large.bin"), &large_file).expect("seed large file");
let mut filesystem = SandboxAgentFilesystem::from_config(SandboxAgentMountConfig {
base_url: server.base_url().to_owned(),
token: None,
headers: None,
base_path: None,
timeout_ms: Some(5_000),
max_full_read_bytes: Some(200 * 1024),
})
.expect("create sandbox_agent filesystem");
assert_eq!(
filesystem
.read_text_file("/hello.txt")
.expect("read remote file"),
"hello from sandbox"
);
filesystem
.write_file("/nested/from-vm.txt", b"native sandbox mount".to_vec())
.expect("write remote file");
assert_eq!(
fs::read_to_string(server.root().join("nested/from-vm.txt"))
.expect("read written file"),
"native sandbox mount"
);
let chunk = filesystem
.pread("/large.bin", 4_096, 1_024)
.expect("pread should use a byte range");
assert_eq!(chunk, large_file[4_096..5_120].to_vec());
let logged_requests = server.requests();
let pread_request = logged_requests
.iter()
.find(|request| {
request.method == "GET"
&& request.path == "/v1/fs/file"
&& request.query.get("path") == Some(&String::from("/large.bin"))
})
.expect("log pread request");
assert_eq!(
pread_request.headers.get("range"),
Some(&String::from("bytes=4096-5119"))
);
assert_eq!(pread_request.response_status, 206);
assert_eq!(pread_request.response_body_bytes, 1_024);
}
#[test]
fn filesystem_pread_falls_back_to_full_fetch_when_remote_ignores_range() {
let server = MockSandboxAgentServer::start_without_range_support(
"secure-exec-sandbox-plugin",
None,
);
let large_file = (0..100 * 1024)
.map(|index| (index % 251) as u8)
.collect::<Vec<_>>();
fs::write(server.root().join("large.bin"), &large_file).expect("seed large file");
let mut filesystem = SandboxAgentFilesystem::from_config(SandboxAgentMountConfig {
base_url: server.base_url().to_owned(),
token: None,
headers: None,
base_path: None,
timeout_ms: Some(5_000),
max_full_read_bytes: Some(200 * 1024),
})
.expect("create sandbox_agent filesystem");
let chunk = filesystem
.pread("/large.bin", 4_096, 1_024)
.expect("pread should fall back to the full response");
assert_eq!(chunk, large_file[4_096..5_120].to_vec());
let logged_requests = server.requests();
let pread_request = logged_requests
.iter()
.find(|request| {
request.method == "GET"
&& request.path == "/v1/fs/file"
&& request.query.get("path") == Some(&String::from("/large.bin"))
})
.expect("log pread request");
assert_eq!(
pread_request.headers.get("range"),
Some(&String::from("bytes=4096-5119"))
);
assert_eq!(pread_request.response_status, 200);
assert_eq!(pread_request.response_body_bytes, large_file.len());
}
#[test]
fn filesystem_pread_rejects_full_fetch_fallback_above_limit() {
let server = MockSandboxAgentServer::start_without_range_support(
"secure-exec-sandbox-plugin-limit",
None,
);
fs::write(server.root().join("large.bin"), vec![b'x'; 4096]).expect("seed large file");
let mut filesystem = SandboxAgentFilesystem::from_config(SandboxAgentMountConfig {
base_url: server.base_url().to_owned(),
token: None,
headers: None,
base_path: None,
timeout_ms: Some(5_000),
max_full_read_bytes: Some(128),
})
.expect("create sandbox_agent filesystem");
let error = filesystem
.pread("/large.bin", 0, 64)
.expect_err("full fetch fallback should be capped");
assert_eq!(error.code(), "EIO");
assert!(
error.to_string().contains("exceeded 128 byte limit"),
"unexpected error: {error}"
);
}
#[test]
fn filesystem_pread_rejects_streamed_full_fetch_fallback_above_limit() {
let server = MockSandboxAgentServer::start_without_range_support(
"secure-exec-sandbox-plugin-stream-limit",
None,
);
fs::write(server.root().join("stream-over-limit"), vec![b'x'; 4096])
.expect("seed large file");
let mut filesystem = SandboxAgentFilesystem::from_config(SandboxAgentMountConfig {
base_url: server.base_url().to_owned(),
token: None,
headers: None,
base_path: None,
timeout_ms: Some(5_000),
max_full_read_bytes: Some(128),
})
.expect("create sandbox_agent filesystem");
let error = filesystem
.pread("/stream-over-limit", 0, 64)
.expect_err("close-delimited full fetch fallback should be capped");
assert_eq!(error.code(), "EIO");
assert!(
error.to_string().contains("exceeded 128 byte limit"),
"unexpected error: {error}"
);
let logged_requests = server.requests();
let pread_request = logged_requests
.iter()
.find(|request| {
request.method == "GET"
&& request.path == "/v1/fs/file"
&& request.query.get("path") == Some(&String::from("/stream-over-limit"))
})
.expect("log pread request");
assert_eq!(pread_request.response_status, 200);
assert_eq!(pread_request.response_body_bytes, 4096);
}
#[test]
fn sandbox_agent_client_does_not_follow_redirects() {
let server = MockSandboxAgentServer::start("secure-exec-sandbox-plugin-redirect", None);
let mut filesystem = SandboxAgentFilesystem::from_config(SandboxAgentMountConfig {
base_url: server.base_url().to_owned(),
token: None,
headers: None,
base_path: None,
timeout_ms: Some(5_000),
max_full_read_bytes: Some(128),
})
.expect("create sandbox_agent filesystem");
let error = filesystem
.read_file("/redirect-to-private")
.expect_err("sandbox_agent client should not follow redirects");
assert_eq!(error.code(), "EIO");
assert!(
error.to_string().contains("status 302"),
"unexpected redirect error: {error}"
);
let logged_requests = server.requests();
assert_eq!(logged_requests.len(), 1);
assert_eq!(logged_requests[0].response_status, 302);
}
#[test]
fn sandbox_agent_base_url_accepts_loopback_over_http() {
for base_url in [
"http://localhost:1234",
"http://127.0.0.1:1234",
"http://[::1]:1234",
] {
assert_eq!(
validate_sandbox_agent_base_url(base_url)
.expect("loopback baseUrl should be accepted"),
base_url
);
}
}
#[test]
fn sandbox_agent_base_url_allows_private_and_public_https_hosts() {
for base_url in [
"https://169.254.169.254/latest",
"https://10.0.0.1:8080",
"https://[2001:db8::1]:8080",
"https://sandbox.example.com",
"https://93.184.216.34",
] {
validate_sandbox_agent_base_url(base_url).unwrap_or_else(|error| {
panic!("https baseUrl {base_url} should be accepted: {error}")
});
}
assert_eq!(
validate_sandbox_agent_base_url("https://sandbox.example.com/api/")
.expect("trailing slash trimmed"),
"https://sandbox.example.com/api"
);
}
#[test]
fn sandbox_agent_base_url_requires_https_for_non_local_targets() {
for base_url in ["http://sandbox.example.com", "http://93.184.216.34"] {
let error = validate_sandbox_agent_base_url(base_url)
.expect_err("non-local http baseUrl should be rejected");
assert!(
error.to_string().contains("must use https"),
"unexpected error for {base_url}: {error}"
);
}
}
#[test]
fn sandbox_agent_base_url_rejects_malformed_urls() {
for base_url in ["", "not a url", "ftp://sandbox.example.com"] {
validate_sandbox_agent_base_url(base_url).expect_err(&format!(
"malformed baseUrl {base_url:?} should be rejected"
));
}
}
#[test]
fn filesystem_truncate_uses_process_api_without_full_file_buffering() {
let server = MockSandboxAgentServer::start("secure-exec-sandbox-plugin-truncate", None);
fs::write(server.root().join("large.bin"), vec![b'x'; 512]).expect("seed large file");
let mut filesystem = SandboxAgentFilesystem::from_config(SandboxAgentMountConfig {
base_url: server.base_url().to_owned(),
token: None,
headers: None,
base_path: None,
timeout_ms: Some(5_000),
max_full_read_bytes: Some(128),
})
.expect("create sandbox_agent filesystem");
filesystem
.truncate("/large.bin", 3)
.expect("truncate large file through process helper");
assert_eq!(
fs::read(server.root().join("large.bin")).expect("read truncated file"),
b"xxx".to_vec()
);
filesystem
.truncate("/large.bin", 6)
.expect("extend file through process helper");
assert_eq!(
fs::read(server.root().join("large.bin")).expect("read extended file"),
vec![b'x', b'x', b'x', 0, 0, 0]
);
filesystem
.truncate("/large.bin", 0)
.expect("truncate to zero through write_file path");
assert_eq!(
fs::metadata(server.root().join("large.bin"))
.expect("stat zero-length file")
.len(),
0
);
let logged_requests = server.requests();
assert!(
logged_requests.iter().any(|request| {
request.method == "POST" && request.path == "/v1/processes/run"
}),
"non-zero truncate should use process helper"
);
assert!(
!logged_requests.iter().any(|request| {
request.method == "GET"
&& request.path == "/v1/fs/file"
&& request.query.get("path") == Some(&String::from("/large.bin"))
}),
"truncate should not issue a full-file GET"
);
assert!(
logged_requests.iter().any(|request| {
request.method == "PUT"
&& request.path == "/v1/fs/file"
&& request.query.get("path") == Some(&String::from("/large.bin"))
}),
"truncate(path, 0) should still use the write_file path"
);
}
#[test]
fn plugin_scopes_base_path_and_preserves_auth_headers() {
let server = MockSandboxAgentServer::start(
"secure-exec-sandbox-plugin-auth",
Some("secret-token"),
);
fs::create_dir_all(server.root().join("scoped")).expect("create scoped root");
fs::write(server.root().join("scoped/hello.txt"), "scoped hello")
.expect("seed scoped file");
let plugin = SandboxAgentMountPlugin;
let mut mounted = plugin
.open(OpenFileSystemPluginRequest {
vm_id: "vm-1",
guest_path: "/sandbox",
read_only: false,
config: &json!({
"baseUrl": server.base_url(),
"token": "secret-token",
"headers": {
"x-sandbox-test": "enabled"
},
"basePath": "/scoped"
}),
context: &(),
})
.expect("open sandbox_agent mount");
assert_eq!(
mounted.read_file("/hello.txt").expect("read scoped file"),
b"scoped hello".to_vec()
);
mounted
.write_file("/from-plugin.txt", b"written through plugin".to_vec())
.expect("write scoped file");
assert_eq!(
fs::read_to_string(server.root().join("scoped/from-plugin.txt"))
.expect("read plugin output"),
"written through plugin"
);
let logged_requests = server.requests();
assert!(logged_requests.iter().any(|request| {
request.headers.get("x-sandbox-test") == Some(&String::from("enabled"))
}));
}
#[test]
fn plugin_normalizes_relative_base_path_before_scoping_requests() {
let server =
MockSandboxAgentServer::start("secure-exec-sandbox-plugin-base-path", None);
fs::create_dir_all(server.root().join("scoped")).expect("create scoped root");
fs::write(
server.root().join("scoped/hello.txt"),
"relative scoped hello",
)
.expect("seed scoped file");
let mut filesystem = SandboxAgentFilesystem::from_config(SandboxAgentMountConfig {
base_url: server.base_url().to_owned(),
token: None,
headers: None,
base_path: Some(String::from("raw/../scoped/")),
timeout_ms: Some(5_000),
max_full_read_bytes: Some(128),
})
.expect("create sandbox_agent filesystem");
assert_eq!(
filesystem
.read_text_file("/hello.txt")
.expect("read scoped file"),
"relative scoped hello"
);
let logged_requests = server.requests();
let read_request = logged_requests
.iter()
.find(|request| request.method == "GET" && request.path == "/v1/fs/file")
.expect("log read request");
assert_eq!(
read_request.query.get("path"),
Some(&String::from("scoped/hello.txt"))
);
}
#[test]
fn plugin_unscopes_process_helper_targets_for_relative_base_path() {
let server =
MockSandboxAgentServer::start("secure-exec-sandbox-plugin-relative-process", None);
fs::create_dir_all(server.root().join("scoped")).expect("create scoped root");
fs::write(
server.root().join("scoped/original.txt"),
"relative symlink target",
)
.expect("seed scoped file");
let mut filesystem = SandboxAgentFilesystem::from_config(SandboxAgentMountConfig {
base_url: server.base_url().to_owned(),
token: None,
headers: None,
base_path: Some(String::from("raw/../scoped/")),
timeout_ms: Some(5_000),
max_full_read_bytes: Some(128),
})
.expect("create sandbox_agent filesystem");
filesystem
.symlink("/original.txt", "/alias.txt")
.expect("create scoped symlink");
assert_eq!(
filesystem.read_link("/alias.txt").expect("read symlink"),
"/original.txt"
);
assert_eq!(
filesystem.realpath("/alias.txt").expect("resolve symlink"),
"/original.txt"
);
filesystem
.symlink("scoped/original.txt", "/relative-alias.txt")
.expect("create relative scoped symlink");
assert_eq!(
filesystem
.read_link("/relative-alias.txt")
.expect("read relative symlink"),
"scoped/original.txt"
);
}
#[test]
fn filesystem_uses_process_api_for_symlink_and_metadata_operations() {
let server = MockSandboxAgentServer::start("secure-exec-sandbox-plugin-process", None);
fs::write(server.root().join("original.txt"), "hello from sandbox")
.expect("seed original file");
let mut filesystem = SandboxAgentFilesystem::from_config(SandboxAgentMountConfig {
base_url: server.base_url().to_owned(),
token: None,
headers: None,
base_path: None,
timeout_ms: Some(5_000),
max_full_read_bytes: Some(128),
})
.expect("create sandbox_agent filesystem");
filesystem
.symlink("/original.txt", "/alias.txt")
.expect("create remote symlink");
assert_eq!(
filesystem
.read_link("/alias.txt")
.expect("read remote symlink"),
"/original.txt"
);
assert_eq!(
filesystem
.realpath("/alias.txt")
.expect("resolve remote symlink"),
"/original.txt"
);
filesystem
.link("/original.txt", "/linked.txt")
.expect("create remote hard link");
let original_metadata =
fs::metadata(server.root().join("original.txt")).expect("stat original hard link");
let linked_metadata =
fs::metadata(server.root().join("linked.txt")).expect("stat linked hard link");
assert_eq!(original_metadata.ino(), linked_metadata.ino());
filesystem
.write_file("/linked.txt", b"updated through hard link".to_vec())
.expect("write through hard link");
assert_eq!(
fs::read_to_string(server.root().join("original.txt"))
.expect("read original after linked write"),
"updated through hard link"
);
filesystem
.chmod("/original.txt", 0o600)
.expect("chmod remote file");
assert_eq!(
fs::metadata(server.root().join("original.txt"))
.expect("stat chmod result")
.permissions()
.mode()
& 0o777,
0o600
);
let uid = Uid::current().as_raw();
let gid = Gid::current().as_raw();
filesystem
.chown("/original.txt", uid, gid)
.expect("chown remote file to current owner");
let chown_metadata =
fs::metadata(server.root().join("original.txt")).expect("stat chown result");
assert_eq!(chown_metadata.uid(), uid);
assert_eq!(chown_metadata.gid(), gid);
let atime_ms = 1_700_000_000_000_u64;
let mtime_ms = 1_710_000_000_000_u64;
filesystem
.utimes("/original.txt", atime_ms, mtime_ms)
.expect("update remote timestamps");
let utimes_metadata =
fs::metadata(server.root().join("original.txt")).expect("stat utimes result");
let observed_atime_ms =
utimes_metadata.atime() * 1000 + utimes_metadata.atime_nsec() / 1_000_000;
let observed_mtime_ms =
utimes_metadata.mtime() * 1000 + utimes_metadata.mtime_nsec() / 1_000_000;
assert_eq!(observed_atime_ms, atime_ms as i64);
assert_eq!(observed_mtime_ms, mtime_ms as i64);
let logged_requests = server.requests();
assert!(logged_requests.iter().any(|request| {
request.method == "POST" && request.path == "/v1/processes/run"
}));
}
#[test]
fn filesystem_reports_clear_error_when_process_api_is_unavailable() {
let server = MockSandboxAgentServer::start_without_process_api(
"secure-exec-sandbox-plugin-no-proc",
None,
);
fs::write(server.root().join("original.txt"), "hello from sandbox")
.expect("seed original file");
let mut filesystem = SandboxAgentFilesystem::from_config(SandboxAgentMountConfig {
base_url: server.base_url().to_owned(),
token: None,
headers: None,
base_path: None,
timeout_ms: Some(5_000),
max_full_read_bytes: Some(128),
})
.expect("create sandbox_agent filesystem");
let error = filesystem
.symlink("/original.txt", "/alias.txt")
.expect_err("symlink should fail clearly without process API");
assert_eq!(error.code(), "ENOSYS");
assert!(
error.to_string().contains("process API"),
"error should mention process API availability: {error}"
);
}
}
}