From 5e772771b5c51f6f15892e47b7eabb0c45b325c9 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Wed, 10 Jun 2026 20:02:40 +0000 Subject: [PATCH] Add cross-platform filesystem adapter coverage --- codex-rs/exec-server/tests/file_system.rs | 1247 ----------------- .../exec-server/tests/file_system/shared.rs | 578 ++++++++ .../exec-server/tests/file_system/support.rs | 114 ++ .../exec-server/tests/file_system_unix.rs | 835 +++++++++++ .../exec-server/tests/file_system_windows.rs | 55 + 5 files changed, 1582 insertions(+), 1247 deletions(-) delete mode 100644 codex-rs/exec-server/tests/file_system.rs create mode 100644 codex-rs/exec-server/tests/file_system/shared.rs create mode 100644 codex-rs/exec-server/tests/file_system/support.rs create mode 100644 codex-rs/exec-server/tests/file_system_unix.rs create mode 100644 codex-rs/exec-server/tests/file_system_windows.rs diff --git a/codex-rs/exec-server/tests/file_system.rs b/codex-rs/exec-server/tests/file_system.rs deleted file mode 100644 index b8d1c2189431..000000000000 --- a/codex-rs/exec-server/tests/file_system.rs +++ /dev/null @@ -1,1247 +0,0 @@ -#![cfg(unix)] - -mod common; - -use std::os::unix::fs::MetadataExt; -#[cfg(target_os = "linux")] -use std::os::unix::fs::PermissionsExt; -use std::os::unix::fs::symlink; -use std::path::Path; -use std::path::PathBuf; -use std::process::Command; -use std::sync::Arc; - -use anyhow::Context; -use anyhow::Result; -use codex_exec_server::CopyOptions; -use codex_exec_server::CreateDirectoryOptions; -use codex_exec_server::Environment; -use codex_exec_server::ExecServerRuntimePaths; -use codex_exec_server::ExecutorFileSystem; -use codex_exec_server::FileSystemSandboxContext; -use codex_exec_server::LocalFileSystem; -use codex_exec_server::ReadDirectoryEntry; -use codex_exec_server::RemoveOptions; -use codex_protocol::models::AdditionalPermissionProfile; -use codex_protocol::models::FileSystemPermissions; -use codex_protocol::models::PermissionProfile; -use codex_protocol::permissions::FileSystemAccessMode; -use codex_protocol::permissions::FileSystemPath; -use codex_protocol::permissions::FileSystemSandboxEntry; -use codex_protocol::permissions::FileSystemSandboxPolicy; -use codex_protocol::permissions::NetworkSandboxPolicy; -use codex_sandboxing::policy_transforms::effective_file_system_sandbox_policy; -use codex_sandboxing::policy_transforms::effective_network_sandbox_policy; -use codex_utils_absolute_path::AbsolutePathBuf; -use pretty_assertions::assert_eq; -use tempfile::TempDir; -use test_case::test_case; - -use common::exec_server::ExecServerHarness; -use common::exec_server::TestCodexHelperPaths; -use common::exec_server::exec_server; -#[cfg(target_os = "linux")] -use common::exec_server::exec_server_with_env; -use common::exec_server::test_codex_helper_paths; - -struct FileSystemContext { - file_system: Arc, - _helper_paths: Option, - _server: Option, -} - -async fn create_file_system_context(use_remote: bool) -> Result { - if use_remote { - let server = exec_server().await?; - let environment = Environment::create_for_tests(Some(server.websocket_url().to_string()))?; - Ok(FileSystemContext { - file_system: environment.get_filesystem(), - _helper_paths: None, - _server: Some(server), - }) - } else { - let helper_paths = test_codex_helper_paths()?; - let runtime_paths = ExecServerRuntimePaths::new( - helper_paths.codex_exe.clone(), - helper_paths.codex_linux_sandbox_exe.clone(), - )?; - Ok(FileSystemContext { - file_system: Arc::new(LocalFileSystem::with_runtime_paths(runtime_paths)), - _helper_paths: Some(helper_paths), - _server: None, - }) - } -} - -fn absolute_path(path: std::path::PathBuf) -> AbsolutePathBuf { - assert!( - path.is_absolute(), - "path must be absolute: {}", - path.display() - ); - match AbsolutePathBuf::try_from(path) { - Ok(path) => path, - Err(err) => panic!("path should be absolute: {err}"), - } -} - -fn read_only_sandbox(readable_root: std::path::PathBuf) -> FileSystemSandboxContext { - let readable_root = absolute_path(readable_root); - sandbox_context(vec![FileSystemSandboxEntry { - path: FileSystemPath::Path { - path: readable_root, - }, - access: FileSystemAccessMode::Read, - }]) -} - -fn workspace_write_sandbox(writable_root: std::path::PathBuf) -> FileSystemSandboxContext { - let writable_root = absolute_path(writable_root); - sandbox_context(vec![FileSystemSandboxEntry { - path: FileSystemPath::Path { - path: writable_root, - }, - access: FileSystemAccessMode::Write, - }]) -} - -fn sandbox_context(entries: Vec) -> FileSystemSandboxContext { - FileSystemSandboxContext::from_permission_profile(PermissionProfile::from_runtime_permissions( - &FileSystemSandboxPolicy::restricted(entries), - NetworkSandboxPolicy::Restricted, - )) -} - -#[test] -fn sandbox_context_from_profile_preserves_workspace_write_read_only_subpaths() -> Result<()> { - let tmp = TempDir::new()?; - let writable_dir = tmp.path().join("writable"); - let git_dir = writable_dir.join(".git"); - std::fs::create_dir_all(&git_dir)?; - - let sandbox = workspace_write_sandbox(writable_dir.clone()); - let policy = sandbox.permissions.file_system_sandbox_policy(); - let cwd = absolute_path(writable_dir.clone()); - let writable_roots = policy.get_writable_roots_with_cwd(cwd.as_path()); - let writable_dir = absolute_path(std::fs::canonicalize(writable_dir)?); - let git_dir = absolute_path(std::fs::canonicalize(git_dir)?); - let Some(writable_root) = writable_roots - .iter() - .find(|writable_root| writable_root.root == writable_dir) - else { - panic!("writable root should be preserved"); - }; - - assert!(writable_root.read_only_subpaths.contains(&git_dir)); - - Ok(()) -} - -fn assert_sandbox_denied(error: &std::io::Error) { - match error.kind() { - std::io::ErrorKind::InvalidInput | std::io::ErrorKind::PermissionDenied => { - let message = error.to_string(); - assert!( - message.contains("is not permitted") - || message.contains("Operation not permitted") - || message.contains("Permission denied"), - "unexpected sandbox error message: {message}", - ); - } - std::io::ErrorKind::NotFound => assert!( - error.to_string().contains("No such file or directory"), - "unexpected sandbox not-found message: {error}", - ), - std::io::ErrorKind::Other => assert!( - error.to_string().contains("Read-only file system"), - "unexpected sandbox other error message: {error}", - ), - other => panic!("unexpected sandbox error kind: {other:?}: {error:?}"), - } -} - -fn assert_normalized_path_rejected(error: &std::io::Error) { - match error.kind() { - std::io::ErrorKind::NotFound => assert!( - error.to_string().contains("No such file or directory"), - "unexpected not-found message: {error}", - ), - std::io::ErrorKind::InvalidInput | std::io::ErrorKind::PermissionDenied => { - let message = error.to_string(); - assert!( - message.contains("is not permitted") - || message.contains("Operation not permitted") - || message.contains("Permission denied"), - "unexpected rejection message: {message}", - ); - } - other => panic!("unexpected normalized-path error kind: {other:?}: {error:?}"), - } -} - -fn alias_root_candidate() -> Result> { - for root in [Path::new("/tmp").to_path_buf(), std::env::temp_dir()] { - if root.is_dir() && root.canonicalize().is_ok_and(|canonical| canonical != root) { - return Ok(Some(root)); - } - } - Ok(None) -} - -#[cfg(target_os = "linux")] -fn write_fake_bwrap(bin_dir: &Path) -> Result { - std::fs::create_dir_all(bin_dir)?; - let fake_bwrap = bin_dir.join("bwrap"); - std::fs::write( - &fake_bwrap, - r#"#!/bin/bash -set -euo pipefail - -for arg in "$@"; do - if [[ "${arg}" == "--help" ]]; then - echo "Usage: bwrap --argv0 --perms" - exit 0 - fi -done - -printf '%s\n' "$*" >> "${0}.log" - -args=("$@") -argv0="" -command_start=-1 -for i in "${!args[@]}"; do - if [[ "${args[$i]}" == "--argv0" && $((i + 1)) -lt ${#args[@]} ]]; then - argv0="${args[$((i + 1))]}" - fi - if [[ "${args[$i]}" == "--" ]]; then - command_start=$((i + 1)) - break - fi -done - -if [[ "${command_start}" -lt 0 || "${command_start}" -ge "${#args[@]}" ]]; then - echo "fake bwrap did not find an inner command" >&2 - exit 125 -fi - -cmd=("${args[@]:$command_start}") -if [[ -n "${argv0}" ]]; then - exec -a "${argv0}" "${cmd[@]}" -fi -exec "${cmd[@]}" -"#, - )?; - let mut permissions = std::fs::metadata(&fake_bwrap)?.permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(&fake_bwrap, permissions)?; - Ok(fake_bwrap) -} - -#[cfg(target_os = "linux")] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn sandboxed_file_system_helper_finds_bwrap_on_preserved_path() -> Result<()> { - let tmp = TempDir::new()?; - let fake_bin_dir = tmp.path().join("bin"); - let fake_bwrap = write_fake_bwrap(&fake_bin_dir)?; - let mut path_entries = vec![fake_bin_dir]; - if let Some(path) = std::env::var_os("PATH") { - path_entries.extend(std::env::split_paths(&path)); - } - let helper_path = std::env::join_paths(path_entries)?; - - let server = exec_server_with_env([("PATH", helper_path.as_os_str())]).await?; - let environment = Environment::create_for_tests(Some(server.websocket_url().to_string()))?; - let file_system = environment.get_filesystem(); - let workspace = tmp.path().join("workspace"); - std::fs::create_dir_all(&workspace)?; - let file_path = workspace.join("created.txt"); - let sandbox = workspace_write_sandbox(workspace); - - file_system - .write_file( - &absolute_path(file_path.clone()), - b"written through fs helper".to_vec(), - Some(&sandbox), - ) - .await?; - - assert_eq!(std::fs::read(&file_path)?, b"written through fs helper"); - - let bwrap_log = fake_bwrap.with_file_name("bwrap.log"); - let log = std::fs::read_to_string(&bwrap_log) - .with_context(|| format!("expected fake bwrap log at {}", bwrap_log.display()))?; - assert!( - log.contains("--argv0"), - "expected fs helper sandbox path to invoke PATH bwrap with --argv0, got: {log}" - ); - - Ok(()) -} - -#[test_case(false ; "local")] -#[test_case(true ; "remote")] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn file_system_get_metadata_returns_expected_fields(use_remote: bool) -> Result<()> { - let context = create_file_system_context(use_remote).await?; - let file_system = context.file_system; - - let tmp = TempDir::new()?; - let file_path = tmp.path().join("note.txt"); - std::fs::write(&file_path, "hello")?; - - let metadata = file_system - .get_metadata(&absolute_path(file_path.clone()), /*sandbox*/ None) - .await - .with_context(|| format!("mode={use_remote}"))?; - assert_eq!(metadata.is_directory, false); - assert_eq!(metadata.is_file, true); - assert_eq!(metadata.is_symlink, false); - assert!(metadata.modified_at_ms > 0); - - let symlink_path = tmp.path().join("note-link.txt"); - symlink(&file_path, &symlink_path)?; - let symlink_metadata = file_system - .get_metadata(&absolute_path(symlink_path.clone()), /*sandbox*/ None) - .await - .with_context(|| format!("mode={use_remote}"))?; - assert_eq!(symlink_metadata.is_directory, false); - assert_eq!(symlink_metadata.is_file, true); - assert_eq!(symlink_metadata.is_symlink, true); - assert!(symlink_metadata.modified_at_ms > 0); - - let dir_path = tmp.path().join("notes"); - std::fs::create_dir(&dir_path)?; - let dir_symlink_path = tmp.path().join("notes-link"); - symlink(&dir_path, &dir_symlink_path)?; - let dir_symlink_metadata = file_system - .get_metadata(&absolute_path(dir_symlink_path), /*sandbox*/ None) - .await - .with_context(|| format!("mode={use_remote}"))?; - assert_eq!(dir_symlink_metadata.is_directory, true); - assert_eq!(dir_symlink_metadata.is_file, false); - assert_eq!(dir_symlink_metadata.is_symlink, true); - - Ok(()) -} - -#[test_case(false ; "local")] -#[test_case(true ; "remote")] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn file_system_methods_cover_surface_area(use_remote: bool) -> Result<()> { - let context = create_file_system_context(use_remote).await?; - let file_system = context.file_system; - - let tmp = TempDir::new()?; - let source_dir = tmp.path().join("source"); - let nested_dir = source_dir.join("nested"); - let source_file = source_dir.join("root.txt"); - let nested_file = nested_dir.join("note.txt"); - let copied_dir = tmp.path().join("copied"); - let copied_file = tmp.path().join("copy.txt"); - - file_system - .create_directory( - &absolute_path(nested_dir.clone()), - CreateDirectoryOptions { recursive: true }, - /*sandbox*/ None, - ) - .await - .with_context(|| format!("mode={use_remote}"))?; - - file_system - .write_file( - &absolute_path(nested_file.clone()), - b"hello from trait".to_vec(), - /*sandbox*/ None, - ) - .await - .with_context(|| format!("mode={use_remote}"))?; - file_system - .write_file( - &absolute_path(source_file.clone()), - b"hello from source root".to_vec(), - /*sandbox*/ None, - ) - .await - .with_context(|| format!("mode={use_remote}"))?; - - let source_link = tmp.path().join("source-link"); - symlink(&source_dir, &source_link)?; - let joined_nested = file_system - .join( - &absolute_path(source_link.clone()), - Path::new("nested/note.txt"), - ) - .await - .with_context(|| format!("mode={use_remote}"))?; - assert_eq!( - joined_nested, - absolute_path(source_link.join("nested").join("note.txt")) - ); - let joined_parent = file_system - .parent(&joined_nested) - .await - .with_context(|| format!("mode={use_remote}"))?; - assert_eq!( - joined_parent, - Some(absolute_path(source_link.join("nested"))) - ); - let joined_parent_traversal = file_system - .join(&absolute_path(source_dir.clone()), Path::new("../outside")) - .await - .with_context(|| format!("mode={use_remote}"))?; - assert_eq!( - joined_parent_traversal, - absolute_path(source_dir.join("../outside")) - ); - let canonical_nested = file_system - .canonicalize( - &absolute_path(source_link.join("nested").join("note.txt")), - /*sandbox*/ None, - ) - .await - .with_context(|| format!("mode={use_remote}"))?; - assert_eq!( - canonical_nested, - absolute_path(std::fs::canonicalize( - source_dir.join("nested").join("note.txt") - )?) - ); - - let nested_file_contents = file_system - .read_file(&absolute_path(nested_file.clone()), /*sandbox*/ None) - .await - .with_context(|| format!("mode={use_remote}"))?; - assert_eq!(nested_file_contents, b"hello from trait"); - - let nested_file_text = file_system - .read_file_text(&absolute_path(nested_file.clone()), /*sandbox*/ None) - .await - .with_context(|| format!("mode={use_remote}"))?; - assert_eq!(nested_file_text, "hello from trait"); - - file_system - .copy( - &absolute_path(nested_file), - &absolute_path(copied_file.clone()), - CopyOptions { recursive: false }, - /*sandbox*/ None, - ) - .await - .with_context(|| format!("mode={use_remote}"))?; - assert_eq!(std::fs::read_to_string(copied_file)?, "hello from trait"); - - file_system - .copy( - &absolute_path(source_dir.clone()), - &absolute_path(copied_dir.clone()), - CopyOptions { recursive: true }, - /*sandbox*/ None, - ) - .await - .with_context(|| format!("mode={use_remote}"))?; - assert_eq!( - std::fs::read_to_string(copied_dir.join("nested").join("note.txt"))?, - "hello from trait" - ); - - symlink( - source_dir.join("missing-target"), - source_dir.join("broken-link"), - )?; - - let mut entries = file_system - .read_directory(&absolute_path(source_dir), /*sandbox*/ None) - .await - .with_context(|| format!("mode={use_remote}"))?; - entries.sort_by(|left, right| left.file_name.cmp(&right.file_name)); - assert_eq!( - entries, - vec![ - ReadDirectoryEntry { - file_name: "nested".to_string(), - is_directory: true, - is_file: false, - }, - ReadDirectoryEntry { - file_name: "root.txt".to_string(), - is_directory: false, - is_file: true, - }, - ] - ); - - file_system - .remove( - &absolute_path(copied_dir.clone()), - RemoveOptions { - recursive: true, - force: true, - }, - /*sandbox*/ None, - ) - .await - .with_context(|| format!("mode={use_remote}"))?; - assert!(!copied_dir.exists()); - - Ok(()) -} - -#[test_case(false ; "local")] -#[test_case(true ; "remote")] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn file_system_write_file_reports_missing_parent(use_remote: bool) -> Result<()> { - let context = create_file_system_context(use_remote).await?; - let file_system = context.file_system; - - let tmp = TempDir::new()?; - let missing_parent_path = tmp.path().join("missing").join("note.txt"); - - let error = match file_system - .write_file( - &absolute_path(missing_parent_path.clone()), - b"hello from trait".to_vec(), - /*sandbox*/ None, - ) - .await - { - Ok(()) => anyhow::bail!("write should fail when parent directory is absent"), - Err(error) => error, - }; - assert_eq!( - error.kind(), - std::io::ErrorKind::NotFound, - "mode={use_remote}" - ); - assert!(!missing_parent_path.exists(), "mode={use_remote}"); - - Ok(()) -} - -#[test_case(false ; "local")] -#[test_case(true ; "remote")] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn file_system_copy_rejects_directory_without_recursive(use_remote: bool) -> Result<()> { - let context = create_file_system_context(use_remote).await?; - let file_system = context.file_system; - - let tmp = TempDir::new()?; - let source_dir = tmp.path().join("source"); - std::fs::create_dir_all(&source_dir)?; - - let error = file_system - .copy( - &absolute_path(source_dir), - &absolute_path(tmp.path().join("dest")), - CopyOptions { recursive: false }, - /*sandbox*/ None, - ) - .await; - let error = match error { - Ok(()) => panic!("copy should fail"), - Err(error) => error, - }; - assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); - assert_eq!( - error.to_string(), - "fs/copy requires recursive: true when sourcePath is a directory" - ); - - Ok(()) -} - -#[test_case(false ; "local")] -#[test_case(true ; "remote")] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn file_system_sandboxed_read_allows_readable_root(use_remote: bool) -> Result<()> { - let context = create_file_system_context(use_remote).await?; - let file_system = context.file_system; - - let tmp = TempDir::new()?; - let allowed_dir = tmp.path().join("allowed"); - let file_path = allowed_dir.join("note.txt"); - std::fs::create_dir_all(&allowed_dir)?; - std::fs::write(&file_path, "sandboxed hello")?; - let sandbox = read_only_sandbox(allowed_dir); - - let contents = file_system - .read_file(&absolute_path(file_path), Some(&sandbox)) - .await - .with_context(|| format!("mode={use_remote}"))?; - assert_eq!(contents, b"sandboxed hello"); - - Ok(()) -} - -#[test_case(false ; "local")] -#[test_case(true ; "remote")] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn file_system_sandboxed_canonicalize_allows_readable_root(use_remote: bool) -> Result<()> { - let context = create_file_system_context(use_remote).await?; - let file_system = context.file_system; - - let tmp = TempDir::new()?; - let allowed_dir = tmp.path().join("allowed"); - let file_path = allowed_dir.join("note.txt"); - std::fs::create_dir_all(&allowed_dir)?; - std::fs::write(&file_path, "sandboxed hello")?; - let sandbox = read_only_sandbox(allowed_dir); - - let canonical_path = file_system - .canonicalize(&absolute_path(file_path.clone()), Some(&sandbox)) - .await - .with_context(|| format!("mode={use_remote}"))?; - assert_eq!( - canonical_path, - absolute_path(std::fs::canonicalize(file_path)?) - ); - - Ok(()) -} - -#[test_case(false ; "local")] -#[test_case(true ; "remote")] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn file_system_sandboxed_write_rejects_unwritable_path(use_remote: bool) -> Result<()> { - let context = create_file_system_context(use_remote).await?; - let file_system = context.file_system; - - let tmp = TempDir::new()?; - let blocked_path = tmp.path().join("blocked.txt"); - - let sandbox = read_only_sandbox(tmp.path().to_path_buf()); - let error = match file_system - .write_file( - &absolute_path(blocked_path.clone()), - b"nope".to_vec(), - Some(&sandbox), - ) - .await - { - Ok(()) => anyhow::bail!("write should be blocked"), - Err(error) => error, - }; - assert_sandbox_denied(&error); - assert!(!blocked_path.exists()); - - Ok(()) -} - -#[test_case(false ; "local")] -#[test_case(true ; "remote")] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn file_system_sandboxed_write_allows_explicit_alias_roots(use_remote: bool) -> Result<()> { - let Some(alias_root) = alias_root_candidate()? else { - return Ok(()); - }; - - let context = create_file_system_context(use_remote).await?; - let file_system = context.file_system; - - let tmp = tempfile::Builder::new() - .prefix("codex-fs-sandbox-alias-") - .tempdir_in(&alias_root)?; - let file_path = tmp.path().join("note.txt"); - let sandbox = workspace_write_sandbox(alias_root.clone()); - - file_system - .write_file( - &absolute_path(file_path.clone()), - b"created".to_vec(), - Some(&sandbox), - ) - .await - .with_context(|| format!("write file through alias root mode={use_remote}"))?; - assert_eq!(std::fs::read(&file_path)?, b"created"); - - Ok(()) -} - -#[test_case(false ; "local")] -#[test_case(true ; "remote")] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn file_system_sandboxed_write_allows_additional_write_root(use_remote: bool) -> Result<()> { - let context = create_file_system_context(use_remote).await?; - let file_system = context.file_system; - - let tmp = TempDir::new()?; - let readable_dir = tmp.path().join("readable"); - let writable_dir = tmp.path().join("writable"); - let file_path = writable_dir.join("note.txt"); - std::fs::create_dir_all(&readable_dir)?; - std::fs::create_dir_all(&writable_dir)?; - - let mut sandbox = read_only_sandbox(readable_dir); - let additional_permissions = AdditionalPermissionProfile { - network: None, - file_system: Some(FileSystemPermissions::from_read_write_roots( - /*read*/ None, - Some(vec![absolute_path(writable_dir)]), - )), - }; - let file_system_policy = effective_file_system_sandbox_policy( - &sandbox.permissions.file_system_sandbox_policy(), - Some(&additional_permissions), - ); - let network_policy = effective_network_sandbox_policy( - sandbox.permissions.network_sandbox_policy(), - Some(&additional_permissions), - ); - sandbox.permissions = PermissionProfile::from_runtime_permissions_with_enforcement( - sandbox.permissions.enforcement(), - &file_system_policy, - network_policy, - ); - - file_system - .write_file( - &absolute_path(file_path.clone()), - b"created".to_vec(), - Some(&sandbox), - ) - .await - .with_context(|| format!("write file through additional root mode={use_remote}"))?; - assert_eq!(std::fs::read(&file_path)?, b"created"); - - Ok(()) -} - -#[test_case(false ; "local")] -#[test_case(true ; "remote")] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn file_system_sandboxed_read_rejects_symlink_escape(use_remote: bool) -> Result<()> { - let context = create_file_system_context(use_remote).await?; - let file_system = context.file_system; - - let tmp = TempDir::new()?; - let allowed_dir = tmp.path().join("allowed"); - let outside_dir = tmp.path().join("outside"); - std::fs::create_dir_all(&allowed_dir)?; - std::fs::create_dir_all(&outside_dir)?; - std::fs::write(outside_dir.join("secret.txt"), "nope")?; - symlink(&outside_dir, allowed_dir.join("link"))?; - - let requested_path = allowed_dir.join("link").join("secret.txt"); - let sandbox = read_only_sandbox(allowed_dir); - let error = match file_system - .read_file(&absolute_path(requested_path.clone()), Some(&sandbox)) - .await - { - Ok(_) => anyhow::bail!("read should be blocked"), - Err(error) => error, - }; - assert_sandbox_denied(&error); - - Ok(()) -} - -#[test_case(false ; "local")] -#[test_case(true ; "remote")] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn file_system_sandboxed_read_rejects_symlink_parent_dotdot_escape( - use_remote: bool, -) -> Result<()> { - let context = create_file_system_context(use_remote).await?; - let file_system = context.file_system; - - let tmp = TempDir::new()?; - let allowed_dir = tmp.path().join("allowed"); - let outside_dir = tmp.path().join("outside"); - let secret_path = tmp.path().join("secret.txt"); - std::fs::create_dir_all(&allowed_dir)?; - std::fs::create_dir_all(&outside_dir)?; - std::fs::write(&secret_path, "nope")?; - symlink(&outside_dir, allowed_dir.join("link"))?; - - let requested_path = absolute_path(allowed_dir.join("link").join("..").join("secret.txt")); - let sandbox = read_only_sandbox(allowed_dir); - let error = match file_system.read_file(&requested_path, Some(&sandbox)).await { - Ok(_) => anyhow::bail!("read should fail after path normalization"), - Err(error) => error, - }; - // AbsolutePathBuf normalizes `link/../secret.txt` to `allowed/secret.txt` - // before the request reaches the filesystem layer. Depending on whether - // the platform/runtime resolves that normalized path through a top-level - // symlink alias, the request can surface as either "missing file" or an - // upfront sandbox rejection. - assert_normalized_path_rejected(&error); - - Ok(()) -} - -#[test_case(false ; "local")] -#[test_case(true ; "remote")] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn file_system_sandboxed_write_rejects_symlink_escape(use_remote: bool) -> Result<()> { - let context = create_file_system_context(use_remote).await?; - let file_system = context.file_system; - - let tmp = TempDir::new()?; - let allowed_dir = tmp.path().join("allowed"); - let outside_dir = tmp.path().join("outside"); - std::fs::create_dir_all(&allowed_dir)?; - std::fs::create_dir_all(&outside_dir)?; - symlink(&outside_dir, allowed_dir.join("link"))?; - - let requested_path = allowed_dir.join("link").join("blocked.txt"); - let sandbox = workspace_write_sandbox(allowed_dir); - let error = match file_system - .write_file( - &absolute_path(requested_path.clone()), - b"nope".to_vec(), - Some(&sandbox), - ) - .await - { - Ok(()) => anyhow::bail!("write should be blocked"), - Err(error) => error, - }; - assert_sandbox_denied(&error); - assert!(!outside_dir.join("blocked.txt").exists()); - - Ok(()) -} - -#[test_case(false ; "local")] -#[test_case(true ; "remote")] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn file_system_sandboxed_write_preserves_existing_hard_link(use_remote: bool) -> Result<()> { - let context = create_file_system_context(use_remote).await?; - let file_system = context.file_system; - - let tmp = TempDir::new()?; - let allowed_dir = tmp.path().join("allowed"); - let outside_dir = tmp.path().join("outside"); - std::fs::create_dir_all(&allowed_dir)?; - std::fs::create_dir_all(&outside_dir)?; - - let outside_file = outside_dir.join("outside.txt"); - let hard_link = allowed_dir.join("hard-link.txt"); - std::fs::write(&outside_file, "outside\n")?; - std::fs::hard_link(&outside_file, &hard_link)?; - - let sandbox = workspace_write_sandbox(allowed_dir); - file_system - .write_file( - &absolute_path(hard_link.clone()), - b"updated through existing hard link\n".to_vec(), - Some(&sandbox), - ) - .await - .with_context(|| format!("mode={use_remote}"))?; - - assert_eq!( - std::fs::read_to_string(&outside_file)?, - "updated through existing hard link\n" - ); - assert_eq!( - std::fs::read_to_string(&hard_link)?, - "updated through existing hard link\n" - ); - - let outside_metadata = std::fs::metadata(&outside_file)?; - let link_metadata = std::fs::metadata(&hard_link)?; - assert_eq!( - (link_metadata.dev(), link_metadata.ino()), - (outside_metadata.dev(), outside_metadata.ino()) - ); - - Ok(()) -} - -#[test_case(false ; "local")] -#[test_case(true ; "remote")] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn file_system_create_directory_rejects_symlink_escape(use_remote: bool) -> Result<()> { - let context = create_file_system_context(use_remote).await?; - let file_system = context.file_system; - - let tmp = TempDir::new()?; - let allowed_dir = tmp.path().join("allowed"); - let outside_dir = tmp.path().join("outside"); - std::fs::create_dir_all(&allowed_dir)?; - std::fs::create_dir_all(&outside_dir)?; - symlink(&outside_dir, allowed_dir.join("link"))?; - - let requested_path = allowed_dir.join("link").join("created"); - let sandbox = workspace_write_sandbox(allowed_dir); - let error = match file_system - .create_directory( - &absolute_path(requested_path.clone()), - CreateDirectoryOptions { recursive: false }, - Some(&sandbox), - ) - .await - { - Ok(()) => anyhow::bail!("create_directory should be blocked"), - Err(error) => error, - }; - assert_sandbox_denied(&error); - assert!(!outside_dir.join("created").exists()); - - Ok(()) -} - -#[test_case(false ; "local")] -#[test_case(true ; "remote")] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn file_system_read_directory_rejects_symlink_escape(use_remote: bool) -> Result<()> { - let context = create_file_system_context(use_remote).await?; - let file_system = context.file_system; - - let tmp = TempDir::new()?; - let allowed_dir = tmp.path().join("allowed"); - let outside_dir = tmp.path().join("outside"); - std::fs::create_dir_all(&allowed_dir)?; - std::fs::create_dir_all(&outside_dir)?; - std::fs::write(outside_dir.join("secret.txt"), "nope")?; - symlink(&outside_dir, allowed_dir.join("link"))?; - - let requested_path = allowed_dir.join("link"); - let sandbox = read_only_sandbox(allowed_dir); - let error = match file_system - .read_directory(&absolute_path(requested_path.clone()), Some(&sandbox)) - .await - { - Ok(_) => anyhow::bail!("read_directory should be blocked"), - Err(error) => error, - }; - assert_sandbox_denied(&error); - - Ok(()) -} - -#[test_case(false ; "local")] -#[test_case(true ; "remote")] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn file_system_copy_rejects_symlink_escape_destination(use_remote: bool) -> Result<()> { - let context = create_file_system_context(use_remote).await?; - let file_system = context.file_system; - - let tmp = TempDir::new()?; - let allowed_dir = tmp.path().join("allowed"); - let outside_dir = tmp.path().join("outside"); - std::fs::create_dir_all(&allowed_dir)?; - std::fs::create_dir_all(&outside_dir)?; - std::fs::write(allowed_dir.join("source.txt"), "hello")?; - symlink(&outside_dir, allowed_dir.join("link"))?; - - let requested_destination = allowed_dir.join("link").join("copied.txt"); - let sandbox = workspace_write_sandbox(allowed_dir.clone()); - let error = match file_system - .copy( - &absolute_path(allowed_dir.join("source.txt")), - &absolute_path(requested_destination.clone()), - CopyOptions { recursive: false }, - Some(&sandbox), - ) - .await - { - Ok(()) => anyhow::bail!("copy should be blocked"), - Err(error) => error, - }; - assert_sandbox_denied(&error); - assert!(!outside_dir.join("copied.txt").exists()); - - Ok(()) -} - -#[test_case(false ; "local")] -#[test_case(true ; "remote")] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn file_system_remove_removes_symlink_not_target(use_remote: bool) -> Result<()> { - let context = create_file_system_context(use_remote).await?; - let file_system = context.file_system; - - let tmp = TempDir::new()?; - let allowed_dir = tmp.path().join("allowed"); - let outside_dir = tmp.path().join("outside"); - let outside_file = outside_dir.join("keep.txt"); - std::fs::create_dir_all(&allowed_dir)?; - std::fs::create_dir_all(&outside_dir)?; - std::fs::write(&outside_file, "outside")?; - let symlink_path = allowed_dir.join("link"); - symlink(&outside_file, &symlink_path)?; - - let sandbox = workspace_write_sandbox(allowed_dir); - file_system - .remove( - &absolute_path(symlink_path.clone()), - RemoveOptions { - recursive: false, - force: false, - }, - Some(&sandbox), - ) - .await - .with_context(|| format!("mode={use_remote}"))?; - - assert!(!symlink_path.exists()); - assert!(outside_file.exists()); - assert_eq!(std::fs::read_to_string(outside_file)?, "outside"); - - Ok(()) -} - -#[test_case(false ; "local")] -#[test_case(true ; "remote")] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn file_system_copy_preserves_symlink_source(use_remote: bool) -> Result<()> { - let context = create_file_system_context(use_remote).await?; - let file_system = context.file_system; - - let tmp = TempDir::new()?; - let allowed_dir = tmp.path().join("allowed"); - let outside_dir = tmp.path().join("outside"); - let outside_file = outside_dir.join("outside.txt"); - let source_symlink = allowed_dir.join("link"); - let copied_symlink = allowed_dir.join("copied-link"); - std::fs::create_dir_all(&allowed_dir)?; - std::fs::create_dir_all(&outside_dir)?; - std::fs::write(&outside_file, "outside")?; - symlink(&outside_file, &source_symlink)?; - - let sandbox = workspace_write_sandbox(allowed_dir.clone()); - file_system - .copy( - &absolute_path(source_symlink), - &absolute_path(copied_symlink.clone()), - CopyOptions { recursive: false }, - Some(&sandbox), - ) - .await - .with_context(|| format!("mode={use_remote}"))?; - - let copied_metadata = std::fs::symlink_metadata(&copied_symlink)?; - assert!(copied_metadata.file_type().is_symlink()); - assert_eq!(std::fs::read_link(copied_symlink)?, outside_file); - - Ok(()) -} - -#[test_case(false ; "local")] -#[test_case(true ; "remote")] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn file_system_remove_rejects_symlink_escape(use_remote: bool) -> Result<()> { - let context = create_file_system_context(use_remote).await?; - let file_system = context.file_system; - - let tmp = TempDir::new()?; - let allowed_dir = tmp.path().join("allowed"); - let outside_dir = tmp.path().join("outside"); - let outside_file = outside_dir.join("secret.txt"); - std::fs::create_dir_all(&allowed_dir)?; - std::fs::create_dir_all(&outside_dir)?; - std::fs::write(&outside_file, "outside")?; - symlink(&outside_dir, allowed_dir.join("link"))?; - - let requested_path = allowed_dir.join("link").join("secret.txt"); - let sandbox = workspace_write_sandbox(allowed_dir); - let error = match file_system - .remove( - &absolute_path(requested_path.clone()), - RemoveOptions { - recursive: false, - force: false, - }, - Some(&sandbox), - ) - .await - { - Ok(()) => anyhow::bail!("remove should be blocked"), - Err(error) => error, - }; - assert_sandbox_denied(&error); - assert_eq!(std::fs::read_to_string(outside_file)?, "outside"); - - Ok(()) -} - -#[test_case(false ; "local")] -#[test_case(true ; "remote")] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn file_system_copy_rejects_symlink_escape_source(use_remote: bool) -> Result<()> { - let context = create_file_system_context(use_remote).await?; - let file_system = context.file_system; - - let tmp = TempDir::new()?; - let allowed_dir = tmp.path().join("allowed"); - let outside_dir = tmp.path().join("outside"); - let outside_file = outside_dir.join("secret.txt"); - let requested_destination = allowed_dir.join("copied.txt"); - std::fs::create_dir_all(&allowed_dir)?; - std::fs::create_dir_all(&outside_dir)?; - std::fs::write(&outside_file, "outside")?; - symlink(&outside_dir, allowed_dir.join("link"))?; - - let requested_source = allowed_dir.join("link").join("secret.txt"); - let sandbox = workspace_write_sandbox(allowed_dir); - let error = match file_system - .copy( - &absolute_path(requested_source.clone()), - &absolute_path(requested_destination.clone()), - CopyOptions { recursive: false }, - Some(&sandbox), - ) - .await - { - Ok(()) => anyhow::bail!("copy should be blocked"), - Err(error) => error, - }; - assert_sandbox_denied(&error); - assert!(!requested_destination.exists()); - - Ok(()) -} - -#[test_case(false ; "local")] -#[test_case(true ; "remote")] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn file_system_copy_rejects_copying_directory_into_descendant( - use_remote: bool, -) -> Result<()> { - let context = create_file_system_context(use_remote).await?; - let file_system = context.file_system; - - let tmp = TempDir::new()?; - let source_dir = tmp.path().join("source"); - std::fs::create_dir_all(source_dir.join("nested"))?; - - let error = file_system - .copy( - &absolute_path(source_dir.clone()), - &absolute_path(source_dir.join("nested").join("copy")), - CopyOptions { recursive: true }, - /*sandbox*/ None, - ) - .await; - let error = match error { - Ok(()) => panic!("copy should fail"), - Err(error) => error, - }; - assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); - assert_eq!( - error.to_string(), - "fs/copy cannot copy a directory to itself or one of its descendants" - ); - - Ok(()) -} - -#[test_case(false ; "local")] -#[test_case(true ; "remote")] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn file_system_copy_preserves_symlinks_in_recursive_copy(use_remote: bool) -> Result<()> { - let context = create_file_system_context(use_remote).await?; - let file_system = context.file_system; - - let tmp = TempDir::new()?; - let source_dir = tmp.path().join("source"); - let nested_dir = source_dir.join("nested"); - let copied_dir = tmp.path().join("copied"); - std::fs::create_dir_all(&nested_dir)?; - symlink("nested", source_dir.join("nested-link"))?; - - file_system - .copy( - &absolute_path(source_dir), - &absolute_path(copied_dir.clone()), - CopyOptions { recursive: true }, - /*sandbox*/ None, - ) - .await - .with_context(|| format!("mode={use_remote}"))?; - - let copied_link = copied_dir.join("nested-link"); - let metadata = std::fs::symlink_metadata(&copied_link)?; - assert!(metadata.file_type().is_symlink()); - assert_eq!( - std::fs::read_link(copied_link)?, - std::path::PathBuf::from("nested") - ); - - Ok(()) -} - -#[test_case(false ; "local")] -#[test_case(true ; "remote")] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn file_system_copy_ignores_unknown_special_files_in_recursive_copy( - use_remote: bool, -) -> Result<()> { - let context = create_file_system_context(use_remote).await?; - let file_system = context.file_system; - - let tmp = TempDir::new()?; - let source_dir = tmp.path().join("source"); - let copied_dir = tmp.path().join("copied"); - std::fs::create_dir_all(&source_dir)?; - std::fs::write(source_dir.join("note.txt"), "hello")?; - - let fifo_path = source_dir.join("named-pipe"); - let output = Command::new("mkfifo").arg(&fifo_path).output()?; - if !output.status.success() { - anyhow::bail!( - "mkfifo failed: stdout={} stderr={}", - String::from_utf8_lossy(&output.stdout).trim(), - String::from_utf8_lossy(&output.stderr).trim() - ); - } - - file_system - .copy( - &absolute_path(source_dir), - &absolute_path(copied_dir.clone()), - CopyOptions { recursive: true }, - /*sandbox*/ None, - ) - .await - .with_context(|| format!("mode={use_remote}"))?; - - assert_eq!( - std::fs::read_to_string(copied_dir.join("note.txt"))?, - "hello" - ); - assert!(!copied_dir.join("named-pipe").exists()); - - Ok(()) -} - -#[test_case(false ; "local")] -#[test_case(true ; "remote")] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn file_system_copy_rejects_standalone_fifo_source(use_remote: bool) -> Result<()> { - let context = create_file_system_context(use_remote).await?; - let file_system = context.file_system; - - let tmp = TempDir::new()?; - let fifo_path = tmp.path().join("named-pipe"); - let output = Command::new("mkfifo").arg(&fifo_path).output()?; - if !output.status.success() { - anyhow::bail!( - "mkfifo failed: stdout={} stderr={}", - String::from_utf8_lossy(&output.stdout).trim(), - String::from_utf8_lossy(&output.stderr).trim() - ); - } - - let error = file_system - .copy( - &absolute_path(fifo_path), - &absolute_path(tmp.path().join("copied")), - CopyOptions { recursive: false }, - /*sandbox*/ None, - ) - .await; - let error = match error { - Ok(()) => panic!("copy should fail"), - Err(error) => error, - }; - assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); - assert_eq!( - error.to_string(), - "fs/copy only supports regular files, directories, and symlinks" - ); - - Ok(()) -} diff --git a/codex-rs/exec-server/tests/file_system/shared.rs b/codex-rs/exec-server/tests/file_system/shared.rs new file mode 100644 index 000000000000..34e3111d55cf --- /dev/null +++ b/codex-rs/exec-server/tests/file_system/shared.rs @@ -0,0 +1,578 @@ +use anyhow::Context; +use anyhow::Result; +use codex_exec_server::CopyOptions; +use codex_exec_server::CreateDirectoryOptions; +use codex_exec_server::ReadDirectoryEntry; +use codex_exec_server::RemoveOptions; +use codex_protocol::models::AdditionalPermissionProfile; +use codex_protocol::models::FileSystemPermissions; +use codex_protocol::models::PermissionProfile; +use codex_sandboxing::policy_transforms::effective_file_system_sandbox_policy; +use codex_sandboxing::policy_transforms::effective_network_sandbox_policy; +use pretty_assertions::assert_eq; +use std::path::Path; +use tempfile::TempDir; +use test_case::test_case; + +use super::support::FileSystemImplementation; +use super::support::absolute_path; +use super::support::create_file_system_context; +use super::support::read_only_sandbox; +use super::support::workspace_write_sandbox; + +#[test] +fn sandbox_context_from_profile_preserves_workspace_write_read_only_subpaths() -> Result<()> { + let tmp = TempDir::new()?; + let writable_dir = tmp.path().join("writable"); + let git_dir = writable_dir.join(".git"); + std::fs::create_dir_all(&git_dir)?; + + let sandbox = workspace_write_sandbox(writable_dir.clone()); + let policy = sandbox.permissions.file_system_sandbox_policy(); + let cwd = absolute_path(writable_dir.clone()); + let writable_roots = policy.get_writable_roots_with_cwd(cwd.as_path()); + let writable_dir = absolute_path(std::fs::canonicalize(writable_dir)?); + let git_dir = absolute_path(std::fs::canonicalize(git_dir)?); + let Some(writable_root) = writable_roots + .iter() + .find(|writable_root| writable_root.root == writable_dir) + else { + panic!("writable root should be preserved"); + }; + + assert!(writable_root.read_only_subpaths.contains(&git_dir)); + + Ok(()) +} + +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_get_metadata_reports_files_and_directories( + implementation: FileSystemImplementation, +) -> Result<()> { + let context = create_file_system_context(implementation).await?; + let file_system = context.file_system; + + let tmp = TempDir::new()?; + let file_path = tmp.path().join("note.txt"); + let directory_path = tmp.path().join("notes"); + std::fs::write(&file_path, "hello")?; + std::fs::create_dir(&directory_path)?; + + let file_metadata = file_system + .get_metadata(&absolute_path(&file_path), /*sandbox*/ None) + .await + .with_context(|| format!("mode={implementation}"))?; + assert_eq!(file_metadata.is_directory, false); + assert_eq!(file_metadata.is_file, true); + assert_eq!(file_metadata.is_symlink, false); + assert!(file_metadata.modified_at_ms > 0); + + let directory_metadata = file_system + .get_metadata(&absolute_path(&directory_path), /*sandbox*/ None) + .await + .with_context(|| format!("mode={implementation}"))?; + assert_eq!(directory_metadata.is_directory, true); + assert_eq!(directory_metadata.is_file, false); + assert_eq!(directory_metadata.is_symlink, false); + assert!(directory_metadata.modified_at_ms > 0); + + Ok(()) +} + +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_create_directory_creates_nested_directories( + implementation: FileSystemImplementation, +) -> Result<()> { + let context = create_file_system_context(implementation).await?; + let file_system = context.file_system; + + let tmp = TempDir::new()?; + let nested_dir = tmp.path().join("source").join("nested"); + + file_system + .create_directory( + &absolute_path(&nested_dir), + CreateDirectoryOptions { recursive: true }, + /*sandbox*/ None, + ) + .await + .with_context(|| format!("mode={implementation}"))?; + assert!(nested_dir.is_dir()); + + Ok(()) +} + +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_write_file_writes_bytes( + implementation: FileSystemImplementation, +) -> Result<()> { + let context = create_file_system_context(implementation).await?; + let file_system = context.file_system; + + let tmp = TempDir::new()?; + let file_path = tmp.path().join("note.txt"); + file_system + .write_file( + &absolute_path(&file_path), + b"hello from trait".to_vec(), + /*sandbox*/ None, + ) + .await + .with_context(|| format!("mode={implementation}"))?; + assert_eq!(std::fs::read(file_path)?, b"hello from trait"); + + Ok(()) +} + +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_join_and_parent_preserve_lexical_paths( + implementation: FileSystemImplementation, +) -> Result<()> { + let context = create_file_system_context(implementation).await?; + let file_system = context.file_system; + + let tmp = TempDir::new()?; + let source_dir = tmp.path().join("source"); + let joined_nested = file_system + .join(&absolute_path(&source_dir), Path::new("nested/note.txt")) + .await + .with_context(|| format!("mode={implementation}"))?; + assert_eq!( + joined_nested, + absolute_path(source_dir.join("nested").join("note.txt")) + ); + let joined_parent = file_system + .parent(&joined_nested) + .await + .with_context(|| format!("mode={implementation}"))?; + assert_eq!( + joined_parent, + Some(absolute_path(source_dir.join("nested"))) + ); + let joined_parent_traversal = file_system + .join(&absolute_path(&source_dir), Path::new("../outside")) + .await + .with_context(|| format!("mode={implementation}"))?; + assert_eq!( + joined_parent_traversal, + absolute_path(source_dir.join("../outside")) + ); + + Ok(()) +} + +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_read_file_returns_bytes( + implementation: FileSystemImplementation, +) -> Result<()> { + let context = create_file_system_context(implementation).await?; + let file_system = context.file_system; + + let tmp = TempDir::new()?; + let file_path = tmp.path().join("note.txt"); + std::fs::write(&file_path, "hello from trait")?; + + let contents = file_system + .read_file(&absolute_path(&file_path), /*sandbox*/ None) + .await + .with_context(|| format!("mode={implementation}"))?; + assert_eq!(contents, b"hello from trait"); + + Ok(()) +} + +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_read_file_text_returns_string( + implementation: FileSystemImplementation, +) -> Result<()> { + let context = create_file_system_context(implementation).await?; + let file_system = context.file_system; + + let tmp = TempDir::new()?; + let file_path = tmp.path().join("note.txt"); + std::fs::write(&file_path, "hello from trait")?; + + let contents = file_system + .read_file_text(&absolute_path(&file_path), /*sandbox*/ None) + .await + .with_context(|| format!("mode={implementation}"))?; + assert_eq!(contents, "hello from trait"); + + Ok(()) +} + +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_copy_copies_file(implementation: FileSystemImplementation) -> Result<()> { + let context = create_file_system_context(implementation).await?; + let file_system = context.file_system; + + let tmp = TempDir::new()?; + let source_file = tmp.path().join("source.txt"); + let copied_file = tmp.path().join("copy.txt"); + std::fs::write(&source_file, "hello from trait")?; + + file_system + .copy( + &absolute_path(&source_file), + &absolute_path(&copied_file), + CopyOptions { recursive: false }, + /*sandbox*/ None, + ) + .await + .with_context(|| format!("mode={implementation}"))?; + assert_eq!(std::fs::read_to_string(copied_file)?, "hello from trait"); + + Ok(()) +} + +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_copy_copies_directory_recursively( + implementation: FileSystemImplementation, +) -> Result<()> { + let context = create_file_system_context(implementation).await?; + let file_system = context.file_system; + + let tmp = TempDir::new()?; + let source_dir = tmp.path().join("source"); + let nested_dir = source_dir.join("nested"); + let nested_file = nested_dir.join("note.txt"); + let copied_dir = tmp.path().join("copied"); + std::fs::create_dir_all(&nested_dir)?; + std::fs::write(&nested_file, "hello from trait")?; + + file_system + .copy( + &absolute_path(&source_dir), + &absolute_path(&copied_dir), + CopyOptions { recursive: true }, + /*sandbox*/ None, + ) + .await + .with_context(|| format!("mode={implementation}"))?; + assert_eq!( + std::fs::read_to_string(copied_dir.join("nested").join("note.txt"))?, + "hello from trait" + ); + + Ok(()) +} + +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_read_directory_lists_entries( + implementation: FileSystemImplementation, +) -> Result<()> { + let context = create_file_system_context(implementation).await?; + let file_system = context.file_system; + + let tmp = TempDir::new()?; + let source_dir = tmp.path().join("source"); + std::fs::create_dir_all(source_dir.join("nested"))?; + std::fs::write(source_dir.join("root.txt"), "hello")?; + + let mut entries = file_system + .read_directory(&absolute_path(&source_dir), /*sandbox*/ None) + .await + .with_context(|| format!("mode={implementation}"))?; + entries.sort_by(|left, right| left.file_name.cmp(&right.file_name)); + assert_eq!( + entries, + vec![ + ReadDirectoryEntry { + file_name: "nested".to_string(), + is_directory: true, + is_file: false, + }, + ReadDirectoryEntry { + file_name: "root.txt".to_string(), + is_directory: false, + is_file: true, + }, + ] + ); + + Ok(()) +} + +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_remove_removes_directory( + implementation: FileSystemImplementation, +) -> Result<()> { + let context = create_file_system_context(implementation).await?; + let file_system = context.file_system; + + let tmp = TempDir::new()?; + let directory_path = tmp.path().join("remove-me"); + std::fs::create_dir_all(directory_path.join("nested"))?; + + file_system + .remove( + &absolute_path(&directory_path), + RemoveOptions { + recursive: true, + force: true, + }, + /*sandbox*/ None, + ) + .await + .with_context(|| format!("mode={implementation}"))?; + assert!(!directory_path.exists()); + + Ok(()) +} + +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_write_file_reports_missing_parent( + implementation: FileSystemImplementation, +) -> Result<()> { + let context = create_file_system_context(implementation).await?; + let file_system = context.file_system; + + let tmp = TempDir::new()?; + let missing_parent_path = tmp.path().join("missing").join("note.txt"); + + let error = match file_system + .write_file( + &absolute_path(&missing_parent_path), + b"hello from trait".to_vec(), + /*sandbox*/ None, + ) + .await + { + Ok(()) => anyhow::bail!("write should fail when parent directory is absent"), + Err(error) => error, + }; + assert_eq!( + error.kind(), + std::io::ErrorKind::NotFound, + "mode={implementation}" + ); + assert!(!missing_parent_path.exists(), "mode={implementation}"); + + Ok(()) +} + +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_copy_rejects_directory_without_recursive( + implementation: FileSystemImplementation, +) -> Result<()> { + let context = create_file_system_context(implementation).await?; + let file_system = context.file_system; + + let tmp = TempDir::new()?; + let source_dir = tmp.path().join("source"); + std::fs::create_dir_all(&source_dir)?; + + let error = file_system + .copy( + &absolute_path(&source_dir), + &absolute_path(tmp.path().join("dest")), + CopyOptions { recursive: false }, + /*sandbox*/ None, + ) + .await; + let error = match error { + Ok(()) => panic!("copy should fail"), + Err(error) => error, + }; + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!( + error.to_string(), + "fs/copy requires recursive: true when sourcePath is a directory" + ); + + Ok(()) +} + +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_sandboxed_read_allows_readable_root( + implementation: FileSystemImplementation, +) -> Result<()> { + let context = create_file_system_context(implementation).await?; + let file_system = context.file_system; + + let tmp = TempDir::new()?; + let allowed_dir = tmp.path().join("allowed"); + let file_path = allowed_dir.join("note.txt"); + std::fs::create_dir_all(&allowed_dir)?; + std::fs::write(&file_path, "sandboxed hello")?; + let sandbox = read_only_sandbox(allowed_dir); + + let contents = file_system + .read_file(&absolute_path(&file_path), Some(&sandbox)) + .await + .with_context(|| format!("mode={implementation}"))?; + assert_eq!(contents, b"sandboxed hello"); + + Ok(()) +} + +pub(crate) async fn assert_canonicalize_resolves_directory_alias( + implementation: FileSystemImplementation, + create_directory_alias: impl FnOnce(&Path, &Path) -> Result<()>, +) -> Result<()> { + let context = create_file_system_context(implementation).await?; + let file_system = context.file_system; + + let tmp = TempDir::new()?; + let source_dir = tmp.path().join("source"); + let nested_dir = source_dir.join("nested"); + let file_path = nested_dir.join("note.txt"); + let alias_dir = tmp.path().join("source-alias"); + std::fs::create_dir_all(&nested_dir)?; + std::fs::write(&file_path, "canonical hello")?; + create_directory_alias(&source_dir, &alias_dir)?; + + let requested_path = absolute_path(alias_dir.join("nested").join("note.txt")); + let expected_path = absolute_path(std::fs::canonicalize(&file_path)?); + assert_ne!(requested_path, expected_path); + + let canonical_path = file_system + .canonicalize(&requested_path, /*sandbox*/ None) + .await + .with_context(|| format!("mode={implementation}"))?; + assert_eq!(canonical_path, expected_path); + + Ok(()) +} + +pub(crate) async fn assert_sandboxed_canonicalize_resolves_directory_alias( + implementation: FileSystemImplementation, + create_directory_alias: impl FnOnce(&Path, &Path) -> Result<()>, +) -> Result<()> { + let context = create_file_system_context(implementation).await?; + let file_system = context.file_system; + + let tmp = TempDir::new()?; + let source_dir = tmp.path().join("source"); + let nested_dir = source_dir.join("nested"); + let file_path = nested_dir.join("note.txt"); + let alias_dir = tmp.path().join("source-alias"); + std::fs::create_dir_all(&nested_dir)?; + std::fs::write(&file_path, "sandboxed canonical hello")?; + create_directory_alias(&source_dir, &alias_dir)?; + let sandbox = read_only_sandbox(tmp.path().to_path_buf()); + + let requested_path = absolute_path(alias_dir.join("nested").join("note.txt")); + let expected_path = absolute_path(std::fs::canonicalize(&file_path)?); + assert_ne!(requested_path, expected_path); + + let canonical_path = file_system + .canonicalize(&requested_path, Some(&sandbox)) + .await + .with_context(|| format!("mode={implementation}"))?; + assert_eq!(canonical_path, expected_path); + + Ok(()) +} + +/// Verifies that effective additional permissions extend a read-only sandbox with a writable root. +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_sandboxed_write_allows_additional_write_root( + implementation: FileSystemImplementation, +) -> Result<()> { + let context = create_file_system_context(implementation).await?; + let file_system = context.file_system; + + let tmp = TempDir::new()?; + let readable_dir = tmp.path().join("readable"); + let writable_dir = tmp.path().join("writable"); + let file_path = writable_dir.join("note.txt"); + std::fs::create_dir_all(&readable_dir)?; + std::fs::create_dir_all(&writable_dir)?; + + let mut sandbox = read_only_sandbox(readable_dir); + let additional_permissions = AdditionalPermissionProfile { + network: None, + file_system: Some(FileSystemPermissions::from_read_write_roots( + /*read*/ None, + Some(vec![absolute_path(writable_dir)]), + )), + }; + let file_system_policy = effective_file_system_sandbox_policy( + &sandbox.permissions.file_system_sandbox_policy(), + Some(&additional_permissions), + ); + let network_policy = effective_network_sandbox_policy( + sandbox.permissions.network_sandbox_policy(), + Some(&additional_permissions), + ); + sandbox.permissions = PermissionProfile::from_runtime_permissions_with_enforcement( + sandbox.permissions.enforcement(), + &file_system_policy, + network_policy, + ); + + file_system + .write_file( + &absolute_path(&file_path), + b"created".to_vec(), + Some(&sandbox), + ) + .await + .with_context(|| format!("write file through additional root mode={implementation}"))?; + assert_eq!(std::fs::read(&file_path)?, b"created"); + + Ok(()) +} + +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_copy_rejects_copying_directory_into_descendant( + implementation: FileSystemImplementation, +) -> Result<()> { + let context = create_file_system_context(implementation).await?; + let file_system = context.file_system; + + let tmp = TempDir::new()?; + let source_dir = tmp.path().join("source"); + std::fs::create_dir_all(source_dir.join("nested"))?; + + let error = file_system + .copy( + &absolute_path(&source_dir), + &absolute_path(source_dir.join("nested").join("copy")), + CopyOptions { recursive: true }, + /*sandbox*/ None, + ) + .await; + let error = match error { + Ok(()) => panic!("copy should fail"), + Err(error) => error, + }; + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!( + error.to_string(), + "fs/copy cannot copy a directory to itself or one of its descendants" + ); + + Ok(()) +} diff --git a/codex-rs/exec-server/tests/file_system/support.rs b/codex-rs/exec-server/tests/file_system/support.rs new file mode 100644 index 000000000000..80d0cfc862fe --- /dev/null +++ b/codex-rs/exec-server/tests/file_system/support.rs @@ -0,0 +1,114 @@ +use std::fmt; +use std::path::Path; +use std::sync::Arc; + +use anyhow::Result; +use codex_exec_server::Environment; +use codex_exec_server::ExecServerRuntimePaths; +use codex_exec_server::ExecutorFileSystem; +use codex_exec_server::FileSystemSandboxContext; +use codex_exec_server::LocalFileSystem; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_utils_absolute_path::AbsolutePathBuf; + +use crate::common::exec_server::ExecServerHarness; +use crate::common::exec_server::TestCodexHelperPaths; +use crate::common::exec_server::exec_server; +use crate::common::exec_server::test_codex_helper_paths; + +pub(crate) struct FileSystemContext { + pub(crate) file_system: Arc, + _helper_paths: Option, + _server: Option, +} + +#[derive(Clone, Copy, Debug)] +pub(crate) enum FileSystemImplementation { + Local, + Remote, +} + +impl fmt::Display for FileSystemImplementation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Local => formatter.write_str("local"), + Self::Remote => formatter.write_str("remote"), + } + } +} + +pub(crate) async fn create_file_system_context( + implementation: FileSystemImplementation, +) -> Result { + match implementation { + FileSystemImplementation::Local => { + let helper_paths = test_codex_helper_paths()?; + let runtime_paths = ExecServerRuntimePaths::new( + helper_paths.codex_exe.clone(), + helper_paths.codex_linux_sandbox_exe.clone(), + )?; + Ok(FileSystemContext { + file_system: Arc::new(LocalFileSystem::with_runtime_paths(runtime_paths)), + _helper_paths: Some(helper_paths), + _server: None, + }) + } + FileSystemImplementation::Remote => { + let server = exec_server().await?; + let environment = + Environment::create_for_tests(Some(server.websocket_url().to_string()))?; + Ok(FileSystemContext { + file_system: environment.get_filesystem(), + _helper_paths: None, + _server: Some(server), + }) + } + } +} + +pub(crate) fn absolute_path(path: impl AsRef) -> AbsolutePathBuf { + let path = path.as_ref().to_path_buf(); + assert!( + path.is_absolute(), + "path must be absolute: {}", + path.display() + ); + match AbsolutePathBuf::try_from(path) { + Ok(path) => path, + Err(err) => panic!("path should be absolute: {err}"), + } +} + +pub(crate) fn read_only_sandbox(readable_root: std::path::PathBuf) -> FileSystemSandboxContext { + let readable_root = absolute_path(readable_root); + sandbox_context(vec![FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: readable_root, + }, + access: FileSystemAccessMode::Read, + }]) +} + +pub(crate) fn workspace_write_sandbox( + writable_root: std::path::PathBuf, +) -> FileSystemSandboxContext { + let writable_root = absolute_path(writable_root); + sandbox_context(vec![FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: writable_root, + }, + access: FileSystemAccessMode::Write, + }]) +} + +fn sandbox_context(entries: Vec) -> FileSystemSandboxContext { + FileSystemSandboxContext::from_permission_profile(PermissionProfile::from_runtime_permissions( + &FileSystemSandboxPolicy::restricted(entries), + NetworkSandboxPolicy::Restricted, + )) +} diff --git a/codex-rs/exec-server/tests/file_system_unix.rs b/codex-rs/exec-server/tests/file_system_unix.rs new file mode 100644 index 000000000000..054d59a395a7 --- /dev/null +++ b/codex-rs/exec-server/tests/file_system_unix.rs @@ -0,0 +1,835 @@ +#![cfg(unix)] + +mod common; + +#[path = "file_system/shared.rs"] +mod shared; +#[path = "file_system/support.rs"] +mod support; + +use std::os::unix::fs::MetadataExt; +#[cfg(target_os = "linux")] +use std::os::unix::fs::PermissionsExt; +use std::os::unix::fs::symlink; +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; + +use anyhow::Context; +use anyhow::Result; +use codex_exec_server::CopyOptions; +use codex_exec_server::CreateDirectoryOptions; +#[cfg(target_os = "linux")] +use codex_exec_server::Environment; +use codex_exec_server::RemoveOptions; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use test_case::test_case; + +#[cfg(target_os = "linux")] +use crate::common::exec_server::exec_server_with_env; + +use crate::support::FileSystemImplementation; +use crate::support::absolute_path; +use crate::support::create_file_system_context; +use crate::support::read_only_sandbox; +use crate::support::workspace_write_sandbox; + +fn assert_sandbox_denied(error: &std::io::Error) { + match error.kind() { + std::io::ErrorKind::InvalidInput | std::io::ErrorKind::PermissionDenied => { + let message = error.to_string(); + assert!( + message.contains("is not permitted") + || message.contains("Operation not permitted") + || message.contains("Permission denied"), + "unexpected sandbox error message: {message}", + ); + } + std::io::ErrorKind::NotFound => assert!( + error.to_string().contains("No such file or directory"), + "unexpected sandbox not-found message: {error}", + ), + std::io::ErrorKind::Other => assert!( + error.to_string().contains("Read-only file system"), + "unexpected sandbox other error message: {error}", + ), + other => panic!("unexpected sandbox error kind: {other:?}: {error:?}"), + } +} + +fn assert_normalized_path_rejected(error: &std::io::Error) { + match error.kind() { + std::io::ErrorKind::NotFound => assert!( + error.to_string().contains("No such file or directory"), + "unexpected not-found message: {error}", + ), + std::io::ErrorKind::InvalidInput | std::io::ErrorKind::PermissionDenied => { + let message = error.to_string(); + assert!( + message.contains("is not permitted") + || message.contains("Operation not permitted") + || message.contains("Permission denied"), + "unexpected rejection message: {message}", + ); + } + other => panic!("unexpected normalized-path error kind: {other:?}: {error:?}"), + } +} + +fn alias_root_candidate() -> Result> { + for root in [Path::new("/tmp").to_path_buf(), std::env::temp_dir()] { + if root.is_dir() && root.canonicalize().is_ok_and(|canonical| canonical != root) { + return Ok(Some(root)); + } + } + Ok(None) +} + +fn create_directory_symlink(target: &Path, alias: &Path) -> Result<()> { + symlink(target, alias)?; + Ok(()) +} + +#[cfg(target_os = "linux")] +fn write_fake_bwrap(bin_dir: &Path) -> Result { + std::fs::create_dir_all(bin_dir)?; + let fake_bwrap = bin_dir.join("bwrap"); + std::fs::write( + &fake_bwrap, + r#"#!/bin/bash +set -euo pipefail + +for arg in "$@"; do + if [[ "${arg}" == "--help" ]]; then + echo "Usage: bwrap --argv0 --perms" + exit 0 + fi +done + +printf '%s\n' "$*" >> "${0}.log" + +args=("$@") +argv0="" +command_start=-1 +for i in "${!args[@]}"; do + if [[ "${args[$i]}" == "--argv0" && $((i + 1)) -lt ${#args[@]} ]]; then + argv0="${args[$((i + 1))]}" + fi + if [[ "${args[$i]}" == "--" ]]; then + command_start=$((i + 1)) + break + fi +done + +if [[ "${command_start}" -lt 0 || "${command_start}" -ge "${#args[@]}" ]]; then + echo "fake bwrap did not find an inner command" >&2 + exit 125 +fi + +cmd=("${args[@]:$command_start}") +if [[ -n "${argv0}" ]]; then + exec -a "${argv0}" "${cmd[@]}" +fi +exec "${cmd[@]}" +"#, + )?; + let mut permissions = std::fs::metadata(&fake_bwrap)?.permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&fake_bwrap, permissions)?; + Ok(fake_bwrap) +} + +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_canonicalize_resolves_directory_symlink( + implementation: FileSystemImplementation, +) -> Result<()> { + shared::assert_canonicalize_resolves_directory_alias(implementation, create_directory_symlink) + .await +} + +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_sandboxed_canonicalize_resolves_directory_symlink( + implementation: FileSystemImplementation, +) -> Result<()> { + shared::assert_sandboxed_canonicalize_resolves_directory_alias( + implementation, + create_directory_symlink, + ) + .await +} + +#[cfg(target_os = "linux")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn sandboxed_file_system_helper_finds_bwrap_on_preserved_path() -> Result<()> { + let tmp = TempDir::new()?; + let fake_bin_dir = tmp.path().join("bin"); + let fake_bwrap = write_fake_bwrap(&fake_bin_dir)?; + let mut path_entries = vec![fake_bin_dir]; + if let Some(path) = std::env::var_os("PATH") { + path_entries.extend(std::env::split_paths(&path)); + } + let helper_path = std::env::join_paths(path_entries)?; + + let server = exec_server_with_env([("PATH", helper_path.as_os_str())]).await?; + let environment = Environment::create_for_tests(Some(server.websocket_url().to_string()))?; + let file_system = environment.get_filesystem(); + let workspace = tmp.path().join("workspace"); + std::fs::create_dir_all(&workspace)?; + let file_path = workspace.join("created.txt"); + let sandbox = workspace_write_sandbox(workspace); + + file_system + .write_file( + &absolute_path(&file_path), + b"written through fs helper".to_vec(), + Some(&sandbox), + ) + .await?; + + assert_eq!(std::fs::read(&file_path)?, b"written through fs helper"); + + let bwrap_log = fake_bwrap.with_file_name("bwrap.log"); + let log = std::fs::read_to_string(&bwrap_log) + .with_context(|| format!("expected fake bwrap log at {}", bwrap_log.display()))?; + assert!( + log.contains("--argv0"), + "expected fs helper sandbox path to invoke PATH bwrap with --argv0, got: {log}" + ); + + Ok(()) +} + +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_get_metadata_reports_symlink_targets( + implementation: FileSystemImplementation, +) -> Result<()> { + let context = create_file_system_context(implementation).await?; + let file_system = context.file_system; + + let tmp = TempDir::new()?; + let file_path = tmp.path().join("note.txt"); + std::fs::write(&file_path, "hello")?; + let symlink_path = tmp.path().join("note-link.txt"); + symlink(&file_path, &symlink_path)?; + let symlink_metadata = file_system + .get_metadata(&absolute_path(&symlink_path), /*sandbox*/ None) + .await + .with_context(|| format!("mode={implementation}"))?; + assert_eq!(symlink_metadata.is_directory, false); + assert_eq!(symlink_metadata.is_file, true); + assert_eq!(symlink_metadata.is_symlink, true); + assert!(symlink_metadata.modified_at_ms > 0); + + let dir_path = tmp.path().join("notes"); + std::fs::create_dir(&dir_path)?; + let dir_symlink_path = tmp.path().join("notes-link"); + symlink(&dir_path, &dir_symlink_path)?; + let dir_symlink_metadata = file_system + .get_metadata(&absolute_path(&dir_symlink_path), /*sandbox*/ None) + .await + .with_context(|| format!("mode={implementation}"))?; + assert_eq!(dir_symlink_metadata.is_directory, true); + assert_eq!(dir_symlink_metadata.is_file, false); + assert_eq!(dir_symlink_metadata.is_symlink, true); + + Ok(()) +} + +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_sandboxed_write_rejects_unwritable_path( + implementation: FileSystemImplementation, +) -> Result<()> { + let context = create_file_system_context(implementation).await?; + let file_system = context.file_system; + + let tmp = TempDir::new()?; + let blocked_path = tmp.path().join("blocked.txt"); + + let sandbox = read_only_sandbox(tmp.path().to_path_buf()); + let error = match file_system + .write_file( + &absolute_path(&blocked_path), + b"nope".to_vec(), + Some(&sandbox), + ) + .await + { + Ok(()) => anyhow::bail!("write should be blocked"), + Err(error) => error, + }; + assert_sandbox_denied(&error); + assert!(!blocked_path.exists()); + + Ok(()) +} + +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_sandboxed_write_allows_explicit_alias_roots( + implementation: FileSystemImplementation, +) -> Result<()> { + let Some(alias_root) = alias_root_candidate()? else { + return Ok(()); + }; + + let context = create_file_system_context(implementation).await?; + let file_system = context.file_system; + + let tmp = tempfile::Builder::new() + .prefix("codex-fs-sandbox-alias-") + .tempdir_in(&alias_root)?; + let file_path = tmp.path().join("note.txt"); + let sandbox = workspace_write_sandbox(alias_root.clone()); + + file_system + .write_file( + &absolute_path(&file_path), + b"created".to_vec(), + Some(&sandbox), + ) + .await + .with_context(|| format!("write file through alias root mode={implementation}"))?; + assert_eq!(std::fs::read(&file_path)?, b"created"); + + Ok(()) +} + +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_sandboxed_read_rejects_symlink_escape( + implementation: FileSystemImplementation, +) -> Result<()> { + let context = create_file_system_context(implementation).await?; + let file_system = context.file_system; + + let tmp = TempDir::new()?; + let allowed_dir = tmp.path().join("allowed"); + let outside_dir = tmp.path().join("outside"); + std::fs::create_dir_all(&allowed_dir)?; + std::fs::create_dir_all(&outside_dir)?; + std::fs::write(outside_dir.join("secret.txt"), "nope")?; + symlink(&outside_dir, allowed_dir.join("link"))?; + + let requested_path = allowed_dir.join("link").join("secret.txt"); + let sandbox = read_only_sandbox(allowed_dir); + let error = match file_system + .read_file(&absolute_path(&requested_path), Some(&sandbox)) + .await + { + Ok(_) => anyhow::bail!("read should be blocked"), + Err(error) => error, + }; + assert_sandbox_denied(&error); + + Ok(()) +} + +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_sandboxed_read_rejects_symlink_parent_dotdot_escape( + implementation: FileSystemImplementation, +) -> Result<()> { + let context = create_file_system_context(implementation).await?; + let file_system = context.file_system; + + let tmp = TempDir::new()?; + let allowed_dir = tmp.path().join("allowed"); + let outside_dir = tmp.path().join("outside"); + let secret_path = tmp.path().join("secret.txt"); + std::fs::create_dir_all(&allowed_dir)?; + std::fs::create_dir_all(&outside_dir)?; + std::fs::write(&secret_path, "nope")?; + symlink(&outside_dir, allowed_dir.join("link"))?; + + let requested_path = absolute_path(allowed_dir.join("link").join("..").join("secret.txt")); + let sandbox = read_only_sandbox(allowed_dir); + let error = match file_system.read_file(&requested_path, Some(&sandbox)).await { + Ok(_) => anyhow::bail!("read should fail after path normalization"), + Err(error) => error, + }; + // AbsolutePathBuf normalizes `link/../secret.txt` to + // `allowed/secret.txt` before the request reaches the filesystem layer. + // Depending on whether the platform/runtime resolves that normalized path + // through a top-level symlink alias, the request can surface as either + // "missing file" or an upfront sandbox rejection. + assert_normalized_path_rejected(&error); + + Ok(()) +} + +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_sandboxed_write_rejects_symlink_escape( + implementation: FileSystemImplementation, +) -> Result<()> { + let context = create_file_system_context(implementation).await?; + let file_system = context.file_system; + + let tmp = TempDir::new()?; + let allowed_dir = tmp.path().join("allowed"); + let outside_dir = tmp.path().join("outside"); + std::fs::create_dir_all(&allowed_dir)?; + std::fs::create_dir_all(&outside_dir)?; + symlink(&outside_dir, allowed_dir.join("link"))?; + + let requested_path = allowed_dir.join("link").join("blocked.txt"); + let sandbox = workspace_write_sandbox(allowed_dir); + let error = match file_system + .write_file( + &absolute_path(&requested_path), + b"nope".to_vec(), + Some(&sandbox), + ) + .await + { + Ok(()) => anyhow::bail!("write should be blocked"), + Err(error) => error, + }; + assert_sandbox_denied(&error); + assert!(!outside_dir.join("blocked.txt").exists()); + + Ok(()) +} + +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_sandboxed_write_preserves_existing_hard_link( + implementation: FileSystemImplementation, +) -> Result<()> { + let context = create_file_system_context(implementation).await?; + let file_system = context.file_system; + + let tmp = TempDir::new()?; + let allowed_dir = tmp.path().join("allowed"); + let outside_dir = tmp.path().join("outside"); + std::fs::create_dir_all(&allowed_dir)?; + std::fs::create_dir_all(&outside_dir)?; + + let outside_file = outside_dir.join("outside.txt"); + let hard_link = allowed_dir.join("hard-link.txt"); + std::fs::write(&outside_file, "outside\n")?; + std::fs::hard_link(&outside_file, &hard_link)?; + + let sandbox = workspace_write_sandbox(allowed_dir); + file_system + .write_file( + &absolute_path(&hard_link), + b"updated through existing hard link\n".to_vec(), + Some(&sandbox), + ) + .await + .with_context(|| format!("mode={implementation}"))?; + + assert_eq!( + std::fs::read_to_string(&outside_file)?, + "updated through existing hard link\n" + ); + assert_eq!( + std::fs::read_to_string(&hard_link)?, + "updated through existing hard link\n" + ); + + let outside_metadata = std::fs::metadata(&outside_file)?; + let link_metadata = std::fs::metadata(&hard_link)?; + assert_eq!( + (link_metadata.dev(), link_metadata.ino()), + (outside_metadata.dev(), outside_metadata.ino()) + ); + + Ok(()) +} + +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_create_directory_rejects_symlink_escape( + implementation: FileSystemImplementation, +) -> Result<()> { + let context = create_file_system_context(implementation).await?; + let file_system = context.file_system; + + let tmp = TempDir::new()?; + let allowed_dir = tmp.path().join("allowed"); + let outside_dir = tmp.path().join("outside"); + std::fs::create_dir_all(&allowed_dir)?; + std::fs::create_dir_all(&outside_dir)?; + symlink(&outside_dir, allowed_dir.join("link"))?; + + let requested_path = allowed_dir.join("link").join("created"); + let sandbox = workspace_write_sandbox(allowed_dir); + let error = match file_system + .create_directory( + &absolute_path(&requested_path), + CreateDirectoryOptions { recursive: false }, + Some(&sandbox), + ) + .await + { + Ok(()) => anyhow::bail!("create_directory should be blocked"), + Err(error) => error, + }; + assert_sandbox_denied(&error); + assert!(!outside_dir.join("created").exists()); + + Ok(()) +} + +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_read_directory_rejects_symlink_escape( + implementation: FileSystemImplementation, +) -> Result<()> { + let context = create_file_system_context(implementation).await?; + let file_system = context.file_system; + + let tmp = TempDir::new()?; + let allowed_dir = tmp.path().join("allowed"); + let outside_dir = tmp.path().join("outside"); + std::fs::create_dir_all(&allowed_dir)?; + std::fs::create_dir_all(&outside_dir)?; + std::fs::write(outside_dir.join("secret.txt"), "nope")?; + symlink(&outside_dir, allowed_dir.join("link"))?; + + let requested_path = allowed_dir.join("link"); + let sandbox = read_only_sandbox(allowed_dir); + let error = match file_system + .read_directory(&absolute_path(&requested_path), Some(&sandbox)) + .await + { + Ok(_) => anyhow::bail!("read_directory should be blocked"), + Err(error) => error, + }; + assert_sandbox_denied(&error); + + Ok(()) +} + +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_copy_rejects_symlink_escape_destination( + implementation: FileSystemImplementation, +) -> Result<()> { + let context = create_file_system_context(implementation).await?; + let file_system = context.file_system; + + let tmp = TempDir::new()?; + let allowed_dir = tmp.path().join("allowed"); + let outside_dir = tmp.path().join("outside"); + std::fs::create_dir_all(&allowed_dir)?; + std::fs::create_dir_all(&outside_dir)?; + std::fs::write(allowed_dir.join("source.txt"), "hello")?; + symlink(&outside_dir, allowed_dir.join("link"))?; + + let requested_destination = allowed_dir.join("link").join("copied.txt"); + let sandbox = workspace_write_sandbox(allowed_dir.clone()); + let error = match file_system + .copy( + &absolute_path(allowed_dir.join("source.txt")), + &absolute_path(&requested_destination), + CopyOptions { recursive: false }, + Some(&sandbox), + ) + .await + { + Ok(()) => anyhow::bail!("copy should be blocked"), + Err(error) => error, + }; + assert_sandbox_denied(&error); + assert!(!outside_dir.join("copied.txt").exists()); + + Ok(()) +} + +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_remove_removes_symlink_not_target( + implementation: FileSystemImplementation, +) -> Result<()> { + let context = create_file_system_context(implementation).await?; + let file_system = context.file_system; + + let tmp = TempDir::new()?; + let allowed_dir = tmp.path().join("allowed"); + let outside_dir = tmp.path().join("outside"); + let outside_file = outside_dir.join("keep.txt"); + std::fs::create_dir_all(&allowed_dir)?; + std::fs::create_dir_all(&outside_dir)?; + std::fs::write(&outside_file, "outside")?; + let symlink_path = allowed_dir.join("link"); + symlink(&outside_file, &symlink_path)?; + + let sandbox = workspace_write_sandbox(allowed_dir); + file_system + .remove( + &absolute_path(&symlink_path), + RemoveOptions { + recursive: false, + force: false, + }, + Some(&sandbox), + ) + .await + .with_context(|| format!("mode={implementation}"))?; + + assert!(!symlink_path.exists()); + assert!(outside_file.exists()); + assert_eq!(std::fs::read_to_string(outside_file)?, "outside"); + + Ok(()) +} + +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_copy_preserves_symlink_source( + implementation: FileSystemImplementation, +) -> Result<()> { + let context = create_file_system_context(implementation).await?; + let file_system = context.file_system; + + let tmp = TempDir::new()?; + let allowed_dir = tmp.path().join("allowed"); + let outside_dir = tmp.path().join("outside"); + let outside_file = outside_dir.join("outside.txt"); + let source_symlink = allowed_dir.join("link"); + let copied_symlink = allowed_dir.join("copied-link"); + std::fs::create_dir_all(&allowed_dir)?; + std::fs::create_dir_all(&outside_dir)?; + std::fs::write(&outside_file, "outside")?; + symlink(&outside_file, &source_symlink)?; + + let sandbox = workspace_write_sandbox(allowed_dir.clone()); + file_system + .copy( + &absolute_path(&source_symlink), + &absolute_path(&copied_symlink), + CopyOptions { recursive: false }, + Some(&sandbox), + ) + .await + .with_context(|| format!("mode={implementation}"))?; + + let copied_metadata = std::fs::symlink_metadata(&copied_symlink)?; + assert!(copied_metadata.file_type().is_symlink()); + assert_eq!(std::fs::read_link(copied_symlink)?, outside_file); + + Ok(()) +} + +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_remove_rejects_symlink_escape( + implementation: FileSystemImplementation, +) -> Result<()> { + let context = create_file_system_context(implementation).await?; + let file_system = context.file_system; + + let tmp = TempDir::new()?; + let allowed_dir = tmp.path().join("allowed"); + let outside_dir = tmp.path().join("outside"); + let outside_file = outside_dir.join("secret.txt"); + std::fs::create_dir_all(&allowed_dir)?; + std::fs::create_dir_all(&outside_dir)?; + std::fs::write(&outside_file, "outside")?; + symlink(&outside_dir, allowed_dir.join("link"))?; + + let requested_path = allowed_dir.join("link").join("secret.txt"); + let sandbox = workspace_write_sandbox(allowed_dir); + let error = match file_system + .remove( + &absolute_path(&requested_path), + RemoveOptions { + recursive: false, + force: false, + }, + Some(&sandbox), + ) + .await + { + Ok(()) => anyhow::bail!("remove should be blocked"), + Err(error) => error, + }; + assert_sandbox_denied(&error); + assert_eq!(std::fs::read_to_string(outside_file)?, "outside"); + + Ok(()) +} + +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_copy_rejects_symlink_escape_source( + implementation: FileSystemImplementation, +) -> Result<()> { + let context = create_file_system_context(implementation).await?; + let file_system = context.file_system; + + let tmp = TempDir::new()?; + let allowed_dir = tmp.path().join("allowed"); + let outside_dir = tmp.path().join("outside"); + let outside_file = outside_dir.join("secret.txt"); + let requested_destination = allowed_dir.join("copied.txt"); + std::fs::create_dir_all(&allowed_dir)?; + std::fs::create_dir_all(&outside_dir)?; + std::fs::write(&outside_file, "outside")?; + symlink(&outside_dir, allowed_dir.join("link"))?; + + let requested_source = allowed_dir.join("link").join("secret.txt"); + let sandbox = workspace_write_sandbox(allowed_dir); + let error = match file_system + .copy( + &absolute_path(&requested_source), + &absolute_path(&requested_destination), + CopyOptions { recursive: false }, + Some(&sandbox), + ) + .await + { + Ok(()) => anyhow::bail!("copy should be blocked"), + Err(error) => error, + }; + assert_sandbox_denied(&error); + assert!(!requested_destination.exists()); + + Ok(()) +} + +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_copy_preserves_symlinks_in_recursive_copy( + implementation: FileSystemImplementation, +) -> Result<()> { + let context = create_file_system_context(implementation).await?; + let file_system = context.file_system; + + let tmp = TempDir::new()?; + let source_dir = tmp.path().join("source"); + let nested_dir = source_dir.join("nested"); + let copied_dir = tmp.path().join("copied"); + std::fs::create_dir_all(&nested_dir)?; + symlink("nested", source_dir.join("nested-link"))?; + + file_system + .copy( + &absolute_path(&source_dir), + &absolute_path(&copied_dir), + CopyOptions { recursive: true }, + /*sandbox*/ None, + ) + .await + .with_context(|| format!("mode={implementation}"))?; + + let copied_link = copied_dir.join("nested-link"); + let metadata = std::fs::symlink_metadata(&copied_link)?; + assert!(metadata.file_type().is_symlink()); + assert_eq!( + std::fs::read_link(copied_link)?, + std::path::PathBuf::from("nested") + ); + + Ok(()) +} + +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_copy_ignores_unknown_special_files_in_recursive_copy( + implementation: FileSystemImplementation, +) -> Result<()> { + let context = create_file_system_context(implementation).await?; + let file_system = context.file_system; + + let tmp = TempDir::new()?; + let source_dir = tmp.path().join("source"); + let copied_dir = tmp.path().join("copied"); + std::fs::create_dir_all(&source_dir)?; + std::fs::write(source_dir.join("note.txt"), "hello")?; + + let fifo_path = source_dir.join("named-pipe"); + let output = Command::new("mkfifo").arg(&fifo_path).output()?; + if !output.status.success() { + anyhow::bail!( + "mkfifo failed: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout).trim(), + String::from_utf8_lossy(&output.stderr).trim() + ); + } + + file_system + .copy( + &absolute_path(&source_dir), + &absolute_path(&copied_dir), + CopyOptions { recursive: true }, + /*sandbox*/ None, + ) + .await + .with_context(|| format!("mode={implementation}"))?; + + assert_eq!( + std::fs::read_to_string(copied_dir.join("note.txt"))?, + "hello" + ); + assert!(!copied_dir.join("named-pipe").exists()); + + Ok(()) +} + +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_copy_rejects_standalone_fifo_source( + implementation: FileSystemImplementation, +) -> Result<()> { + let context = create_file_system_context(implementation).await?; + let file_system = context.file_system; + + let tmp = TempDir::new()?; + let fifo_path = tmp.path().join("named-pipe"); + let output = Command::new("mkfifo").arg(&fifo_path).output()?; + if !output.status.success() { + anyhow::bail!( + "mkfifo failed: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout).trim(), + String::from_utf8_lossy(&output.stderr).trim() + ); + } + + let error = file_system + .copy( + &absolute_path(&fifo_path), + &absolute_path(tmp.path().join("copied")), + CopyOptions { recursive: false }, + /*sandbox*/ None, + ) + .await; + let error = match error { + Ok(()) => panic!("copy should fail"), + Err(error) => error, + }; + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!( + error.to_string(), + "fs/copy only supports regular files, directories, and symlinks" + ); + + Ok(()) +} diff --git a/codex-rs/exec-server/tests/file_system_windows.rs b/codex-rs/exec-server/tests/file_system_windows.rs new file mode 100644 index 000000000000..68d826ced34d --- /dev/null +++ b/codex-rs/exec-server/tests/file_system_windows.rs @@ -0,0 +1,55 @@ +#![cfg(windows)] + +mod common; + +#[path = "file_system/shared.rs"] +mod shared; +#[path = "file_system/support.rs"] +mod support; + +use std::path::Path; +use std::process::Command; + +use anyhow::Result; +use test_case::test_case; + +use crate::support::FileSystemImplementation; + +fn create_directory_junction(target: &Path, alias: &Path) -> Result<()> { + let output = Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(alias) + .arg(target) + .output()?; + if !output.status.success() { + anyhow::bail!( + "mklink /J failed: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout).trim(), + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(()) +} + +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_canonicalize_resolves_directory_junction( + implementation: FileSystemImplementation, +) -> Result<()> { + shared::assert_canonicalize_resolves_directory_alias(implementation, create_directory_junction) + .await +} + +#[test_case(FileSystemImplementation::Local ; "local")] +#[test_case(FileSystemImplementation::Remote ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_sandboxed_canonicalize_resolves_directory_junction( + implementation: FileSystemImplementation, +) -> Result<()> { + shared::assert_sandboxed_canonicalize_resolves_directory_alias( + implementation, + create_directory_junction, + ) + .await +}