From f31d388ac05577b961dda09496849cadb21bcb29 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 20 Mar 2026 15:19:33 -0700 Subject: [PATCH] core: add a sandbox-backed fs helper --- codex-rs/Cargo.lock | 10 ++ codex-rs/Cargo.toml | 8 +- codex-rs/arg0/Cargo.toml | 1 + codex-rs/arg0/src/lib.rs | 7 ++ codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/exec.rs | 150 ++++++++++++++++++------- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/sandboxed_fs.rs | 161 +++++++++++++++++++++++++++ codex-rs/fs-ops/BUILD.bazel | 6 + codex-rs/fs-ops/Cargo.toml | 16 +++ codex-rs/fs-ops/src/command.rs | 37 ++++++ codex-rs/fs-ops/src/command_tests.rs | 21 ++++ codex-rs/fs-ops/src/constants.rs | 8 ++ codex-rs/fs-ops/src/lib.rs | 13 +++ codex-rs/fs-ops/src/runner.rs | 66 +++++++++++ codex-rs/fs-ops/src/runner_tests.rs | 114 +++++++++++++++++++ 16 files changed, 579 insertions(+), 41 deletions(-) create mode 100644 codex-rs/core/src/sandboxed_fs.rs create mode 100644 codex-rs/fs-ops/BUILD.bazel create mode 100644 codex-rs/fs-ops/Cargo.toml create mode 100644 codex-rs/fs-ops/src/command.rs create mode 100644 codex-rs/fs-ops/src/command_tests.rs create mode 100644 codex-rs/fs-ops/src/constants.rs create mode 100644 codex-rs/fs-ops/src/lib.rs create mode 100644 codex-rs/fs-ops/src/runner.rs create mode 100644 codex-rs/fs-ops/src/runner_tests.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index c8eae3be0c3..7761b7e9311 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1572,6 +1572,7 @@ version = "0.0.0" dependencies = [ "anyhow", "codex-apply-patch", + "codex-fs-ops", "codex-linux-sandbox", "codex-shell-escalation", "codex-utils-home-dir", @@ -1863,6 +1864,7 @@ dependencies = [ "codex-execpolicy", "codex-features", "codex-file-search", + "codex-fs-ops", "codex-git", "codex-hooks", "codex-login", @@ -2119,6 +2121,14 @@ dependencies = [ "tokio", ] +[[package]] +name = "codex-fs-ops" +version = "0.0.0" +dependencies = [ + "pretty_assertions", + "tempfile", +] + [[package]] name = "codex-git" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 6d768d69634..cb49e35397c 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -10,8 +10,6 @@ members = [ "debug-client", "apply-patch", "arg0", - "feedback", - "features", "codex-backend-openapi-models", "cloud-requirements", "cloud-tasks", @@ -29,6 +27,9 @@ members = [ "exec-server", "execpolicy", "execpolicy-legacy", + "feedback", + "features", + "fs-ops", "keyring-store", "file-search", "linux-sandbox", @@ -111,9 +112,10 @@ codex-exec = { path = "exec" } codex-exec-server = { path = "exec-server" } codex-execpolicy = { path = "execpolicy" } codex-experimental-api-macros = { path = "codex-experimental-api-macros" } -codex-feedback = { path = "feedback" } codex-features = { path = "features" } +codex-feedback = { path = "feedback" } codex-file-search = { path = "file-search" } +codex-fs-ops = { path = "fs-ops" } codex-git = { path = "utils/git" } codex-hooks = { path = "hooks" } codex-keyring-store = { path = "keyring-store" } diff --git a/codex-rs/arg0/Cargo.toml b/codex-rs/arg0/Cargo.toml index abe7277d949..10c31eac5ee 100644 --- a/codex-rs/arg0/Cargo.toml +++ b/codex-rs/arg0/Cargo.toml @@ -14,6 +14,7 @@ workspace = true [dependencies] anyhow = { workspace = true } codex-apply-patch = { workspace = true } +codex-fs-ops = { workspace = true } codex-linux-sandbox = { workspace = true } codex-shell-escalation = { workspace = true } codex-utils-home-dir = { workspace = true } diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index d8dbaaa30da..bb8f86e127f 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -4,6 +4,7 @@ use std::path::Path; use std::path::PathBuf; use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1; +use codex_fs_ops::CODEX_CORE_FS_OPS_ARG1; use codex_utils_home_dir::find_codex_home; #[cfg(unix)] use std::os::unix::fs::symlink; @@ -105,6 +106,12 @@ pub fn arg0_dispatch() -> Option { }; std::process::exit(exit_code); } + if argv1 == CODEX_CORE_FS_OPS_ARG1 { + let mut stdin = std::io::stdin(); + let mut stdout = std::io::stdout(); + let mut stderr = std::io::stderr(); + codex_fs_ops::run_from_args_and_exit(args, &mut stdin, &mut stdout, &mut stderr); + } // This modifies the environment, which is not thread-safe, so do this // before creating any threads/the Tokio runtime. diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index d648655b242..6d682b62593 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -39,6 +39,7 @@ codex-login = { workspace = true } codex-shell-command = { workspace = true } codex-skills = { workspace = true } codex-execpolicy = { workspace = true } +codex-fs-ops = { workspace = true } codex-file-search = { workspace = true } codex-git = { workspace = true } codex-hooks = { workspace = true } diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 9be0518fa07..21aa05f60fe 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -319,6 +319,62 @@ pub(crate) async fn execute_exec_request( stdout_stream: Option, after_spawn: Option>, ) -> Result { + let PreparedExecRequest { + params, + sandbox, + file_system_sandbox_policy, + network_sandbox_policy, + } = prepare_exec_request(exec_request); + let start = Instant::now(); + let raw_output_result = exec( + params, + sandbox, + sandbox_policy, + &file_system_sandbox_policy, + network_sandbox_policy, + stdout_stream, + after_spawn, + ) + .await; + let duration = start.elapsed(); + Ok(normalize_exec_result(raw_output_result, sandbox, duration)?.to_utf8_lossy_output()) +} + +pub(crate) async fn execute_exec_request_raw_output( + exec_request: ExecRequest, + sandbox_policy: &SandboxPolicy, + stdout_stream: Option, + after_spawn: Option>, +) -> Result { + let PreparedExecRequest { + params, + sandbox, + file_system_sandbox_policy, + network_sandbox_policy, + } = prepare_exec_request(exec_request); + let start = Instant::now(); + let raw_output_result = exec( + params, + sandbox, + sandbox_policy, + &file_system_sandbox_policy, + network_sandbox_policy, + stdout_stream, + after_spawn, + ) + .await; + let duration = start.elapsed(); + normalize_exec_result(raw_output_result, sandbox, duration) +} + +struct PreparedExecRequest { + params: ExecParams, + sandbox: SandboxType, + file_system_sandbox_policy: FileSystemSandboxPolicy, + network_sandbox_policy: NetworkSandboxPolicy, +} + +fn prepare_exec_request(exec_request: ExecRequest) -> PreparedExecRequest { let ExecRequest { command, cwd, @@ -338,33 +394,24 @@ pub(crate) async fn execute_exec_request( } = exec_request; let _ = _sandbox_policy_from_env; - let params = ExecParams { - command, - cwd, - expiration, - capture_policy, - env, - network: network.clone(), - sandbox_permissions, - windows_sandbox_level, - windows_sandbox_private_desktop, - justification, - arg0, - }; - - let start = Instant::now(); - let raw_output_result = exec( - params, + PreparedExecRequest { + params: ExecParams { + command, + cwd, + expiration, + capture_policy, + env, + network, + sandbox_permissions, + windows_sandbox_level, + windows_sandbox_private_desktop, + justification, + arg0, + }, sandbox, - sandbox_policy, - &file_system_sandbox_policy, network_sandbox_policy, - stdout_stream, - after_spawn, - ) - .await; - let duration = start.elapsed(); - finalize_exec_result(raw_output_result, sandbox, duration) + file_system_sandbox_policy, + } } #[cfg(target_os = "windows")] @@ -558,19 +605,25 @@ async fn exec_windows_sandbox( }) } -fn finalize_exec_result( +fn normalize_exec_result( raw_output_result: std::result::Result, sandbox_type: SandboxType, duration: Duration, -) -> Result { +) -> Result { match raw_output_result { Ok(raw_output) => { - #[allow(unused_mut)] - let mut timed_out = raw_output.timed_out; + let RawExecToolCallOutput { + exit_status, + stdout, + stderr, + aggregated_output, + #[cfg_attr(target_os = "windows", allow(unused_mut))] + mut timed_out, + } = raw_output; #[cfg(target_family = "unix")] { - if let Some(signal) = raw_output.exit_status.signal() { + if let Some(signal) = exit_status.signal() { if signal == TIMEOUT_CODE { timed_out = true; } else { @@ -579,15 +632,12 @@ fn finalize_exec_result( } } - let mut exit_code = raw_output.exit_status.code().unwrap_or(-1); + let mut exit_code = exit_status.code().unwrap_or(-1); if timed_out { exit_code = EXEC_TIMEOUT_EXIT_CODE; } - let stdout = raw_output.stdout.from_utf8_lossy(); - let stderr = raw_output.stderr.from_utf8_lossy(); - let aggregated_output = raw_output.aggregated_output.from_utf8_lossy(); - let exec_output = ExecToolCallOutput { + let exec_output = ExecToolCallRawOutput { exit_code, stdout, stderr, @@ -598,13 +648,14 @@ fn finalize_exec_result( if timed_out { return Err(CodexErr::Sandbox(SandboxErr::Timeout { - output: Box::new(exec_output), + output: Box::new(exec_output.to_utf8_lossy_output()), })); } - if is_likely_sandbox_denied(sandbox_type, &exec_output) { + let string_output = exec_output.to_utf8_lossy_output(); + if is_likely_sandbox_denied(sandbox_type, &string_output) { return Err(CodexErr::Sandbox(SandboxErr::Denied { - output: Box::new(exec_output), + output: Box::new(string_output), network_policy_decision: None, })); } @@ -796,6 +847,16 @@ pub struct ExecToolCallOutput { pub timed_out: bool, } +#[derive(Clone, Debug)] +pub(crate) struct ExecToolCallRawOutput { + pub exit_code: i32, + pub stdout: StreamOutput>, + pub stderr: StreamOutput>, + pub aggregated_output: StreamOutput>, + pub duration: Duration, + pub timed_out: bool, +} + impl Default for ExecToolCallOutput { fn default() -> Self { Self { @@ -809,6 +870,19 @@ impl Default for ExecToolCallOutput { } } +impl ExecToolCallRawOutput { + fn to_utf8_lossy_output(&self) -> ExecToolCallOutput { + ExecToolCallOutput { + exit_code: self.exit_code, + stdout: self.stdout.from_utf8_lossy(), + stderr: self.stderr.from_utf8_lossy(), + aggregated_output: self.aggregated_output.from_utf8_lossy(), + duration: self.duration, + timed_out: self.timed_out, + } + } +} + #[cfg_attr(not(target_os = "windows"), allow(unused_variables))] async fn exec( params: ExecParams, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 29436a0d7f9..0ab7bc678db 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -121,6 +121,7 @@ pub mod default_client { pub mod project_doc; mod rollout; pub(crate) mod safety; +mod sandboxed_fs; pub mod seatbelt; pub mod shell; pub mod shell_snapshot; diff --git a/codex-rs/core/src/sandboxed_fs.rs b/codex-rs/core/src/sandboxed_fs.rs new file mode 100644 index 00000000000..bb5425cf572 --- /dev/null +++ b/codex-rs/core/src/sandboxed_fs.rs @@ -0,0 +1,161 @@ +use crate::codex::Session; +use crate::codex::TurnContext; +use crate::exec::ExecCapturePolicy; +use crate::exec::ExecExpiration; +use crate::exec::ExecToolCallRawOutput; +use crate::exec::execute_exec_request_raw_output; +use crate::sandboxing::CommandSpec; +use crate::sandboxing::SandboxPermissions; +use crate::sandboxing::merge_permission_profiles; +use crate::tools::sandboxing::SandboxAttempt; +use crate::tools::sandboxing::SandboxablePreference; +use codex_fs_ops::CODEX_CORE_FS_OPS_ARG1; +use codex_fs_ops::READ_FILE_OPERATION_ARG2; +use codex_protocol::models::PermissionProfile; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +/// Reads the contents of the specified file subject to the sandbox constraints +/// imposed by the provided session and turn context. +/// +/// Note that this function is comparable to `cat FILE`, though unlike `cat +/// FILE`, this function verifies that FILE is a regular file before reading, +/// which means that if you pass `/dev/zero` as the path, it will error (rather +/// than hang forever). +#[allow(dead_code)] +pub(crate) async fn read_file( + path: &AbsolutePathBuf, + session: &Arc, + turn: &Arc, +) -> Result, SandboxedFsError> { + let output = perform_operation( + SandboxedFsOperation::Read { path: path.clone() }, + session, + turn, + ) + .await?; + Ok(output.stdout.text) +} + +/// Operations supported by the [CODEX_CORE_FS_OPS_ARG1] sandbox helper. +enum SandboxedFsOperation { + Read { path: AbsolutePathBuf }, +} + +async fn perform_operation( + operation: SandboxedFsOperation, + session: &Arc, + turn: &Arc, +) -> Result { + let exe = std::env::current_exe().map_err(|error| SandboxedFsError::ResolveExe { + message: error.to_string(), + })?; + let additional_permissions = effective_granted_permissions(session).await; + let sandbox_manager = crate::sandboxing::SandboxManager::new(); + let attempt = SandboxAttempt { + sandbox: sandbox_manager.select_initial( + &turn.file_system_sandbox_policy, + turn.network_sandbox_policy, + SandboxablePreference::Auto, + turn.windows_sandbox_level, + /*has_managed_network_requirements*/ false, + ), + policy: &turn.sandbox_policy, + file_system_policy: &turn.file_system_sandbox_policy, + network_policy: turn.network_sandbox_policy, + enforce_managed_network: false, + manager: &sandbox_manager, + sandbox_cwd: &turn.cwd, + codex_linux_sandbox_exe: turn.codex_linux_sandbox_exe.as_ref(), + use_legacy_landlock: turn.features.use_legacy_landlock(), + windows_sandbox_level: turn.windows_sandbox_level, + windows_sandbox_private_desktop: turn.config.permissions.windows_sandbox_private_desktop, + }; + + let args = match operation { + SandboxedFsOperation::Read { ref path } => vec![ + CODEX_CORE_FS_OPS_ARG1.to_string(), + READ_FILE_OPERATION_ARG2.to_string(), + path.to_string_lossy().to_string(), + ], + }; + + // `FullBuffer` reads ignore exec expiration, but `ExecRequest` still requires + // an `expiration` field, so keep a placeholder timeout here until that API + // changes. + let ignored_expiration = Duration::from_secs(30); + let exec_request = attempt + .env_for( + CommandSpec { + program: exe.to_string_lossy().to_string(), + args, + cwd: turn.cwd.clone(), + env: HashMap::new(), + expiration: ExecExpiration::Timeout(ignored_expiration), + capture_policy: ExecCapturePolicy::FullBuffer, + sandbox_permissions: SandboxPermissions::UseDefault, + additional_permissions, + justification: None, + }, + /*network*/ None, + ) + .map_err(|error| SandboxedFsError::ProcessFailed { + exit_code: -1, + message: error.to_string(), + })?; + + let effective_policy = exec_request.sandbox_policy.clone(); + let output = execute_exec_request_raw_output( + exec_request, + &effective_policy, + /*stdout_stream*/ None, + /*after_spawn*/ None, + ) + .await + .map_err(|error| SandboxedFsError::ProcessFailed { + exit_code: 1, + message: error.to_string(), + })?; + if output.exit_code == 0 { + Ok(output) + } else { + Err(parse_helper_failure( + output.exit_code, + &output.stderr.text, + &output.stdout.text, + )) + } +} + +async fn effective_granted_permissions(session: &Session) -> Option { + let granted_session_permissions = session.granted_session_permissions().await; + let granted_turn_permissions = session.granted_turn_permissions().await; + merge_permission_profiles( + granted_session_permissions.as_ref(), + granted_turn_permissions.as_ref(), + ) +} + +fn parse_helper_failure(exit_code: i32, stderr: &[u8], stdout: &[u8]) -> SandboxedFsError { + let stderr = String::from_utf8_lossy(stderr); + let stdout = String::from_utf8_lossy(stdout); + let message = if !stderr.trim().is_empty() { + stderr.trim().to_string() + } else if !stdout.trim().is_empty() { + stdout.trim().to_string() + } else { + "no error details emitted".to_string() + }; + + SandboxedFsError::ProcessFailed { exit_code, message } +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum SandboxedFsError { + #[error("failed to determine codex executable: {message}")] + ResolveExe { message: String }, + #[error("sandboxed fs helper exited with code {exit_code}: {message}")] + ProcessFailed { exit_code: i32, message: String }, +} diff --git a/codex-rs/fs-ops/BUILD.bazel b/codex-rs/fs-ops/BUILD.bazel new file mode 100644 index 00000000000..87fe4d55171 --- /dev/null +++ b/codex-rs/fs-ops/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "fs-ops", + crate_name = "codex_fs_ops", +) diff --git a/codex-rs/fs-ops/Cargo.toml b/codex-rs/fs-ops/Cargo.toml new file mode 100644 index 00000000000..92ab6fb456d --- /dev/null +++ b/codex-rs/fs-ops/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "codex-fs-ops" +edition.workspace = true +license.workspace = true +version.workspace = true + +[lib] +name = "codex_fs_ops" +path = "src/lib.rs" + +[lints] +workspace = true + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/fs-ops/src/command.rs b/codex-rs/fs-ops/src/command.rs new file mode 100644 index 00000000000..a0d4cecd229 --- /dev/null +++ b/codex-rs/fs-ops/src/command.rs @@ -0,0 +1,37 @@ +use crate::constants::READ_FILE_OPERATION_ARG2; +use std::ffi::OsString; +use std::path::PathBuf; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FsCommand { + ReadFile { path: PathBuf }, +} + +pub fn parse_command_from_args( + mut args: impl Iterator, +) -> Result { + let Some(operation) = args.next() else { + return Err("missing operation".to_string()); + }; + let Some(operation) = operation.to_str() else { + return Err("operation must be valid UTF-8".to_string()); + }; + let Some(path) = args.next() else { + return Err(format!("missing path for operation `{operation}`")); + }; + if args.next().is_some() { + return Err(format!( + "unexpected extra arguments for operation `{operation}`" + )); + } + + let path = PathBuf::from(path); + match operation { + READ_FILE_OPERATION_ARG2 => Ok(FsCommand::ReadFile { path }), + _ => Err(format!("unsupported filesystem operation `{operation}`")), + } +} + +#[cfg(test)] +#[path = "command_tests.rs"] +mod tests; diff --git a/codex-rs/fs-ops/src/command_tests.rs b/codex-rs/fs-ops/src/command_tests.rs new file mode 100644 index 00000000000..4743ed6c19e --- /dev/null +++ b/codex-rs/fs-ops/src/command_tests.rs @@ -0,0 +1,21 @@ +use super::FsCommand; +use super::READ_FILE_OPERATION_ARG2; +use super::parse_command_from_args; +use pretty_assertions::assert_eq; + +#[test] +fn parse_read_command() { + let command = parse_command_from_args( + [READ_FILE_OPERATION_ARG2, "/tmp/example.png"] + .into_iter() + .map(Into::into), + ) + .expect("command should parse"); + + assert_eq!( + command, + FsCommand::ReadFile { + path: "/tmp/example.png".into(), + } + ); +} diff --git a/codex-rs/fs-ops/src/constants.rs b/codex-rs/fs-ops/src/constants.rs new file mode 100644 index 00000000000..b00a4a5e5d8 --- /dev/null +++ b/codex-rs/fs-ops/src/constants.rs @@ -0,0 +1,8 @@ +/// Special argv[1] flag used when the Codex executable self-invokes to run the +/// internal sandbox-backed filesystem helper path. +pub const CODEX_CORE_FS_OPS_ARG1: &str = "--codex-run-as-fs-ops"; + +/// When passed as argv[2] to the Codex filesystem helper, it should be followed +/// by a single path argument, and the helper will read the contents of the file +/// at that path and write it to stdout. +pub const READ_FILE_OPERATION_ARG2: &str = "read"; diff --git a/codex-rs/fs-ops/src/lib.rs b/codex-rs/fs-ops/src/lib.rs new file mode 100644 index 00000000000..512d76274cb --- /dev/null +++ b/codex-rs/fs-ops/src/lib.rs @@ -0,0 +1,13 @@ +//! The codex-fs-ops crate provides a helper binary for performing various +//! filesystem operations when `codex` is invoked with `--codex-run-as-fs-ops` +//! as the first argument. By exposing this functionality via a CLI, this makes +//! it possible to execute the CLI within a sandboxed context in order to ensure +//! the filesystem restrictions of the sandbox are honored. + +mod command; +mod constants; +mod runner; + +pub use constants::CODEX_CORE_FS_OPS_ARG1; +pub use constants::READ_FILE_OPERATION_ARG2; +pub use runner::run_from_args_and_exit; diff --git a/codex-rs/fs-ops/src/runner.rs b/codex-rs/fs-ops/src/runner.rs new file mode 100644 index 00000000000..afaac7cbab0 --- /dev/null +++ b/codex-rs/fs-ops/src/runner.rs @@ -0,0 +1,66 @@ +use crate::command::FsCommand; +use crate::command::parse_command_from_args; +use std::ffi::OsString; +use std::io::Read; +use std::io::Write; + +/// Runs the fs-ops helper with the given arguments and I/O streams. +pub fn run_from_args_and_exit( + args: impl Iterator, + stdin: &mut impl Read, + stdout: &mut impl Write, + stderr: &mut impl Write, +) -> ! { + let exit_code = match run_from_args(args, stdin, stdout, stderr) { + Ok(()) => 0, + Err(_) => { + // Discard the specific error, since we already wrote it to stderr. + 1 + } + }; + std::process::exit(exit_code); +} + +/// Testable version of `run_from_args_and_exit` that returns a Result instead +/// of exiting the process. +fn run_from_args( + args: impl Iterator, + stdin: &mut impl Read, + stdout: &mut impl Write, + stderr: &mut impl Write, +) -> std::io::Result<()> { + try_run_from_args(args, stdin, stdout).inspect_err(|error| { + writeln!(stderr, "error: {error}").ok(); + }) +} + +fn try_run_from_args( + args: impl Iterator, + _stdin: &mut impl Read, + stdout: &mut impl Write, +) -> std::io::Result<()> { + let command = parse_command_from_args(args) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))?; + + match command { + FsCommand::ReadFile { path } => { + let mut file = std::fs::File::open(&path)?; + if !file.metadata()?.is_file() { + let error_message = format!( + "`{path}` is not a regular file", + path = path.to_string_lossy() + ); + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + error_message, + )); + } + + std::io::copy(&mut file, stdout).map(|_| ()) + } + } +} + +#[cfg(test)] +#[path = "runner_tests.rs"] +mod tests; diff --git a/codex-rs/fs-ops/src/runner_tests.rs b/codex-rs/fs-ops/src/runner_tests.rs new file mode 100644 index 00000000000..b74da6c5a6e --- /dev/null +++ b/codex-rs/fs-ops/src/runner_tests.rs @@ -0,0 +1,114 @@ +use super::run_from_args; +use crate::READ_FILE_OPERATION_ARG2; +use pretty_assertions::assert_eq; +use tempfile::tempdir; + +#[test] +fn run_from_args_streams_file_bytes_to_stdout() { + let tempdir = tempdir().expect("tempdir"); + let path = tempdir.path().join("image.bin"); + let expected = b"hello\x00world".to_vec(); + std::fs::write(&path, &expected).expect("write test file"); + + let mut stdin = std::io::empty(); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + run_from_args( + [ + READ_FILE_OPERATION_ARG2, + path.to_str().expect("utf-8 test path"), + ] + .into_iter() + .map(Into::into), + &mut stdin, + &mut stdout, + &mut stderr, + ) + .expect("read should succeed"); + + assert_eq!(stdout, expected); + assert_eq!(stderr, Vec::::new()); +} + +#[test] +#[cfg(unix)] +fn rejects_path_that_is_not_a_regular_file() { + let path = std::path::PathBuf::from("/dev/zero"); + + let mut stdin = std::io::empty(); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let error = run_from_args( + [ + READ_FILE_OPERATION_ARG2, + path.to_str().expect("utf-8 test path"), + ] + .into_iter() + .map(Into::into), + &mut stdin, + &mut stdout, + &mut stderr, + ) + .expect_err( + r#"reading a non-regular file should fail or else +the user risks hanging the process by trying +to read from something like /dev/zero"#, + ); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!(stdout, Vec::::new()); + assert_eq!( + "error: `/dev/zero` is not a regular file\n", + String::from_utf8_lossy(&stderr), + ); +} + +#[test] +fn read_reports_directory_error() { + let tempdir = tempdir().expect("tempdir"); + let mut stdin = std::io::empty(); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + + let error = run_from_args( + [ + READ_FILE_OPERATION_ARG2, + tempdir.path().to_str().expect("utf-8 test path"), + ] + .into_iter() + .map(Into::into), + &mut stdin, + &mut stdout, + &mut stderr, + ) + .expect_err("reading a directory should fail"); + + #[cfg(not(windows))] + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); + #[cfg(windows)] + assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); +} + +#[test] +fn run_from_args_serializes_errors_to_stderr() { + let tempdir = tempdir().expect("tempdir"); + let missing = tempdir.path().join("missing.txt"); + let mut stdin = std::io::empty(); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + + let result = run_from_args( + [ + READ_FILE_OPERATION_ARG2, + missing.to_str().expect("utf-8 test path"), + ] + .into_iter() + .map(Into::into), + &mut stdin, + &mut stdout, + &mut stderr, + ); + + assert!(result.is_err(), "missing file should fail"); + assert_eq!(stdout, Vec::::new()); +}