#![cfg(unix)] mod common; 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::FileSystemPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::ReadOnlyAccess; use codex_protocol::protocol::SandboxPolicy; 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; 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(Some(server.websocket_url().to_string())).await?; 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 { FileSystemSandboxContext::new(SandboxPolicy::ReadOnly { access: ReadOnlyAccess::Restricted { include_platform_defaults: false, readable_roots: vec![absolute_path(readable_root)], }, network_access: false, }) } fn workspace_write_sandbox(writable_root: std::path::PathBuf) -> FileSystemSandboxContext { FileSystemSandboxContext::new(SandboxPolicy::WorkspaceWrite { writable_roots: vec![absolute_path(writable_root)], read_only_access: ReadOnlyAccess::Restricted { include_platform_defaults: false, readable_roots: vec![], }, network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, }) } 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) } #[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 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_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); sandbox.additional_permissions = Some(PermissionProfile { network: None, file_system: Some(FileSystemPermissions { read: None, write: Some(vec![absolute_path(writable_dir)]), }), }); 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_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(()) }