mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[codex] add cross-platform filesystem adapter coverage (#27454)
## Why The exec-server's existing filesystem tests only run on `#[cfg(unix)]`. We should be running the applicable ones on Windows, and also include the basic filesystem operations that will be modified by migrating to `PathUri`. ## What Split platform-neutral local/remote tests into a shared Unix/Windows suite while keeping the existing `AbsolutePathBuf` API, and add Windows junction canonicalization coverage.
This commit is contained in:
committed by
GitHub
Unverified
parent
eb76336f60
commit
cc97839068
File diff suppressed because it is too large
Load Diff
@@ -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(())
|
||||
}
|
||||
@@ -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<dyn ExecutorFileSystem>,
|
||||
_helper_paths: Option<TestCodexHelperPaths>,
|
||||
_server: Option<ExecServerHarness>,
|
||||
}
|
||||
|
||||
#[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<FileSystemContext> {
|
||||
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<Path>) -> 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<FileSystemSandboxEntry>) -> FileSystemSandboxContext {
|
||||
FileSystemSandboxContext::from_permission_profile(PermissionProfile::from_runtime_permissions(
|
||||
&FileSystemSandboxPolicy::restricted(entries),
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
))
|
||||
}
|
||||
@@ -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<Option<PathBuf>> {
|
||||
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<PathBuf> {
|
||||
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(())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user