diff --git a/lib/crates/fabro-sandbox/src/daytona/mod.rs b/lib/crates/fabro-sandbox/src/daytona/mod.rs index 8598c29b0..ab8c9b140 100644 --- a/lib/crates/fabro-sandbox/src/daytona/mod.rs +++ b/lib/crates/fabro-sandbox/src/daytona/mod.rs @@ -140,13 +140,8 @@ impl DaytonaSandbox { } } - /// Resolve a path: relative paths are prepended with the working directory. fn resolve_path(&self, path: &str) -> String { - if Path::new(path).is_absolute() { - path.to_string() - } else { - format!("{WORKING_DIRECTORY}/{path}") - } + crate::sandbox::resolve_path(path, WORKING_DIRECTORY) } /// Get the sandbox, returning an error if not yet initialized. diff --git a/lib/crates/fabro-sandbox/src/exe/mod.rs b/lib/crates/fabro-sandbox/src/exe/mod.rs index 3a371f607..7a4360f31 100644 --- a/lib/crates/fabro-sandbox/src/exe/mod.rs +++ b/lib/crates/fabro-sandbox/src/exe/mod.rs @@ -5,16 +5,19 @@ use std::path::Path; use std::time::Instant; use crate::shell_quote; +use crate::ssh_common; use crate::{ format_lines_numbered, DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent, SandboxEventCallback, }; use async_trait::async_trait; -use base64::Engine; use tokio_util::sync::CancellationToken; +pub use crate::ssh_common::{GitCloneParams, SshOutput, SshRunner}; pub use openssh_runner::OpensshRunner; +pub use fabro_config::sandbox::ExeConfig; + const WORKING_DIRECTORY: &str = "/home/exedev"; const PROVIDER: &str = "exe"; @@ -52,40 +55,6 @@ type DataSshFactory = Box< + Sync, >; -/// Output from an SSH command execution. -pub struct SshOutput { - pub stdout: Vec, - pub stderr: Vec, - pub exit_code: i32, -} - -/// Trait abstracting SSH operations for testability. -#[async_trait] -pub trait SshRunner: Send + Sync { - async fn run_command(&self, command: &str) -> Result; - - async fn run_command_with_timeout( - &self, - command: &str, - timeout: std::time::Duration, - ) -> Result; - - async fn upload_file(&self, path: &str, content: &[u8]) -> Result<(), String>; - - async fn download_file(&self, path: &str) -> Result, String>; -} - -pub use fabro_config::sandbox::ExeConfig; - -/// Parameters for cloning a git repo into the sandbox during initialization. -#[derive(Clone, Debug)] -pub struct GitCloneParams { - /// Clean HTTPS URL (no embedded credentials). - pub url: String, - /// Branch to clone. If None, uses the remote's default. - pub branch: Option, -} - /// Sandbox that runs all operations inside an exe.dev VM via SSH. /// /// Uses two SSH connections: @@ -206,128 +175,22 @@ impl ExeSandbox { Ok(format!("ssh {host}")) } - /// Wrap a shell command in base64 encoding to avoid escaping issues. - fn wrap_bash_command(command: &str) -> String { - let encoded = base64::engine::general_purpose::STANDARD.encode(command); - format!("echo '{encoded}' | base64 -d | sh") - } - - /// Resolve an authenticated clone URL from the clean URL and github_app credentials. - async fn resolve_clone_url(&self, url: &str) -> Result { - match &self.github_app { - Some(creds) => fabro_github::resolve_authenticated_url(creds, url) - .await - .or_else(|_| Ok(url.to_string())), - None => Ok(url.to_string()), - } - } - /// Clone a git repo into the sandbox working directory. async fn clone_repo(&self, params: &GitCloneParams) -> Result<(), String> { let ssh = self.data_ssh()?; - - self.emit(SandboxEvent::GitCloneStarted { - url: params.url.clone(), - branch: params.branch.clone(), - }); - let clone_start = Instant::now(); - - let clone_url = self.resolve_clone_url(¶ms.url).await?; - - let branch_flag = params - .branch - .as_deref() - .map(|b| format!(" --branch {}", shell_quote(b))) - .unwrap_or_default(); - - let clone_script = format!( - "git clone{branch_flag} {} {WORKING_DIRECTORY}", - shell_quote(&clone_url) - ); - let clone_cmd = Self::wrap_bash_command(&clone_script); - let clone_timeout = std::time::Duration::from_secs(300); - let clone_output = ssh - .run_command_with_timeout(&clone_cmd, clone_timeout) - .await - .map_err(|e| { - let err = format!("git clone failed: {e}"); - self.emit(SandboxEvent::GitCloneFailed { - url: params.url.clone(), - error: err.clone(), - }); - err - })?; - - if clone_output.exit_code != 0 { - let stderr = String::from_utf8_lossy(&clone_output.stderr); - - // Fall back to init + fetch + checkout if directory is not empty - if stderr.contains("not an empty directory") - || stderr.contains("already exists and is not an empty") - { - let branch = params.branch.as_deref().unwrap_or("main"); - let fallback_script = format!( - "cd {WORKING_DIRECTORY} && git init && git remote add origin {} && git fetch origin && git checkout {}", - shell_quote(&clone_url), - shell_quote(branch), - ); - let fallback_cmd = Self::wrap_bash_command(&fallback_script); - let fallback_output = ssh - .run_command_with_timeout(&fallback_cmd, clone_timeout) - .await - .map_err(|e| { - let err = format!("git fallback clone failed: {e}"); - self.emit(SandboxEvent::GitCloneFailed { - url: params.url.clone(), - error: err.clone(), - }); - err - })?; - - if fallback_output.exit_code != 0 { - let fallback_stderr = String::from_utf8_lossy(&fallback_output.stderr); - let err = format!( - "git fallback clone failed (exit {}): {fallback_stderr}", - fallback_output.exit_code, - ); - self.emit(SandboxEvent::GitCloneFailed { - url: params.url.clone(), - error: err.clone(), - }); - return Err(err); - } - } else { - let err = format!( - "git clone failed (exit {}): {stderr}", - clone_output.exit_code, - ); - self.emit(SandboxEvent::GitCloneFailed { - url: params.url.clone(), - error: err.clone(), - }); - return Err(err); - } - } - - // Store the clean URL as origin_url for credential refresh - let _ = self.origin_url.set(params.url.clone()); - - let duration_ms = u64::try_from(clone_start.elapsed().as_millis()).unwrap_or(u64::MAX); - self.emit(SandboxEvent::GitCloneCompleted { - url: params.url.clone(), - duration_ms, - }); - - Ok(()) + ssh_common::clone_repo( + ssh, + WORKING_DIRECTORY, + params, + self.github_app.as_ref(), + &self.origin_url, + &|event| self.emit(event), + ) + .await } - /// Resolve a path: relative paths are prepended with the working directory. fn resolve_path(&self, path: &str) -> String { - if Path::new(path).is_absolute() { - path.to_string() - } else { - format!("{WORKING_DIRECTORY}/{path}") - } + crate::sandbox::resolve_path(path, WORKING_DIRECTORY) } } @@ -488,7 +351,7 @@ impl Sandbox for ExeSandbox { }; script.push_str(&format!("cd {} && {command}", shell_quote(&dir))); - let full_cmd = Self::wrap_bash_command(&script); + let full_cmd = ssh_common::wrap_bash_command(&script); let timeout = std::time::Duration::from_millis(timeout_ms); let token = cancel_token.unwrap_or_default(); @@ -841,6 +704,7 @@ impl Sandbox for ExeSandbox { #[cfg(test)] mod tests { use super::*; + use base64::Engine; use std::sync::{Arc, Mutex}; /// A recorded command sent to the mock SSH runner. diff --git a/lib/crates/fabro-sandbox/src/lib.rs b/lib/crates/fabro-sandbox/src/lib.rs index 838e756ef..b54a8d703 100644 --- a/lib/crates/fabro-sandbox/src/lib.rs +++ b/lib/crates/fabro-sandbox/src/lib.rs @@ -2,6 +2,9 @@ pub mod sandbox; pub mod read_guard; +#[cfg(feature = "ssh")] +pub(crate) mod ssh_common; + #[cfg(feature = "local")] pub mod local; diff --git a/lib/crates/fabro-sandbox/src/local.rs b/lib/crates/fabro-sandbox/src/local.rs index 6d52bfab8..e185c413f 100644 --- a/lib/crates/fabro-sandbox/src/local.rs +++ b/lib/crates/fabro-sandbox/src/local.rs @@ -347,15 +347,13 @@ impl Sandbox for LocalSandbox { .map(|p| p.to_string_lossy().into_owned()) .collect(); - // Sort by mtime (newest first) - results.sort_by(|a, b| { - let mtime_a = std::fs::metadata(a) - .and_then(|m| m.modified()) - .unwrap_or(std::time::SystemTime::UNIX_EPOCH); - let mtime_b = std::fs::metadata(b) - .and_then(|m| m.modified()) - .unwrap_or(std::time::SystemTime::UNIX_EPOCH); - mtime_b.cmp(&mtime_a) + // Sort by mtime (newest first), caching metadata to avoid O(n log n) syscalls + results.sort_by_cached_key(|path| { + std::cmp::Reverse( + std::fs::metadata(path) + .and_then(|m| m.modified()) + .unwrap_or(std::time::SystemTime::UNIX_EPOCH), + ) }); Ok(results) diff --git a/lib/crates/fabro-sandbox/src/sandbox.rs b/lib/crates/fabro-sandbox/src/sandbox.rs index 3c31584ac..6c12d7c6c 100644 --- a/lib/crates/fabro-sandbox/src/sandbox.rs +++ b/lib/crates/fabro-sandbox/src/sandbox.rs @@ -431,6 +431,22 @@ pub trait Sandbox: Send + Sync { fn mark_agent_read(&self, _path: &str) {} } +/// Resolve a path: relative paths are prepended with the working directory. +/// Used by feature-gated sandbox implementations (exe, ssh, sprites, daytona). +#[cfg(any( + feature = "exe", + feature = "ssh", + feature = "sprites", + feature = "daytona" +))] +pub(crate) fn resolve_path(path: &str, working_dir: &str) -> String { + if std::path::Path::new(path).is_absolute() { + path.to_string() + } else { + format!("{working_dir}/{path}") + } +} + /// Shell-quote a string using `shlex::try_quote`, with a fallback for edge cases. pub fn shell_quote(s: &str) -> String { shlex::try_quote(s).map_or_else( diff --git a/lib/crates/fabro-sandbox/src/sprites/mod.rs b/lib/crates/fabro-sandbox/src/sprites/mod.rs index 55ea553da..e894d2fdc 100644 --- a/lib/crates/fabro-sandbox/src/sprites/mod.rs +++ b/lib/crates/fabro-sandbox/src/sprites/mod.rs @@ -80,11 +80,7 @@ impl SpritesSandbox { } fn resolve_path(&self, path: &str) -> String { - if Path::new(path).is_absolute() { - path.to_string() - } else { - format!("{WORKING_DIRECTORY}/{path}") - } + crate::sandbox::resolve_path(path, WORKING_DIRECTORY) } fn sprite_name(&self) -> Result<&str, String> { diff --git a/lib/crates/fabro-sandbox/src/ssh/mod.rs b/lib/crates/fabro-sandbox/src/ssh/mod.rs index 4fb0a9618..f2546fd15 100644 --- a/lib/crates/fabro-sandbox/src/ssh/mod.rs +++ b/lib/crates/fabro-sandbox/src/ssh/mod.rs @@ -5,51 +5,20 @@ use std::path::Path; use std::time::Instant; use crate::shell_quote; +use crate::ssh_common; use crate::{ format_lines_numbered, DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent, SandboxEventCallback, }; use async_trait::async_trait; -use base64::Engine; use tokio_util::sync::CancellationToken; +pub use crate::ssh_common::{GitCloneParams, SshOutput, SshRunner}; pub use openssh_runner::OpensshRunner; -const PROVIDER: &str = "ssh"; - -/// Output from an SSH command execution. -pub struct SshOutput { - pub stdout: Vec, - pub stderr: Vec, - pub exit_code: i32, -} - -/// Trait abstracting SSH operations for testability. -#[async_trait] -pub trait SshRunner: Send + Sync { - async fn run_command(&self, command: &str) -> Result; - - async fn run_command_with_timeout( - &self, - command: &str, - timeout: std::time::Duration, - ) -> Result; - - async fn upload_file(&self, path: &str, content: &[u8]) -> Result<(), String>; - - async fn download_file(&self, path: &str) -> Result, String>; -} - pub use fabro_config::sandbox::SshConfig; -/// Parameters for cloning a git repo into the sandbox during initialization. -#[derive(Clone, Debug)] -pub struct GitCloneParams { - /// Clean HTTPS URL (no embedded credentials). - pub url: String, - /// Branch to clone. If None, uses the remote's default. - pub branch: Option, -} +const PROVIDER: &str = "ssh"; /// Sandbox that runs all operations on a user-provided SSH host. /// @@ -127,131 +96,22 @@ impl SshSandbox { format!("ssh {}", self.config.destination) } - /// Wrap a shell command in base64 encoding to avoid escaping issues. - fn wrap_bash_command(command: &str) -> String { - let encoded = base64::engine::general_purpose::STANDARD.encode(command); - format!("echo '{encoded}' | base64 -d | sh") - } - - /// Resolve an authenticated clone URL from the clean URL and github_app credentials. - async fn resolve_clone_url(&self, url: &str) -> Result { - match &self.github_app { - Some(creds) => fabro_github::resolve_authenticated_url(creds, url) - .await - .or_else(|_| Ok(url.to_string())), - None => Ok(url.to_string()), - } - } - /// Clone a git repo into the sandbox working directory. async fn clone_repo(&self, params: &GitCloneParams) -> Result<(), String> { let ssh = self.ssh()?; - let working_dir = &self.config.working_directory; - - self.emit(SandboxEvent::GitCloneStarted { - url: params.url.clone(), - branch: params.branch.clone(), - }); - let clone_start = Instant::now(); - - let clone_url = self.resolve_clone_url(¶ms.url).await?; - - let branch_flag = params - .branch - .as_deref() - .map(|b| format!(" --branch {}", shell_quote(b))) - .unwrap_or_default(); - - let clone_script = format!( - "git clone{branch_flag} {} {}", - shell_quote(&clone_url), - shell_quote(working_dir), - ); - let clone_cmd = Self::wrap_bash_command(&clone_script); - let clone_timeout = std::time::Duration::from_secs(300); - let clone_output = ssh - .run_command_with_timeout(&clone_cmd, clone_timeout) - .await - .map_err(|e| { - let err = format!("git clone failed: {e}"); - self.emit(SandboxEvent::GitCloneFailed { - url: params.url.clone(), - error: err.clone(), - }); - err - })?; - - if clone_output.exit_code != 0 { - let stderr = String::from_utf8_lossy(&clone_output.stderr); - - // Fall back to init + fetch + checkout if directory is not empty - if stderr.contains("not an empty directory") - || stderr.contains("already exists and is not an empty") - { - let branch = params.branch.as_deref().unwrap_or("main"); - let fallback_script = format!( - "cd {} && git init && git remote add origin {} && git fetch origin && git checkout {}", - shell_quote(working_dir), - shell_quote(&clone_url), - shell_quote(branch), - ); - let fallback_cmd = Self::wrap_bash_command(&fallback_script); - let fallback_output = ssh - .run_command_with_timeout(&fallback_cmd, clone_timeout) - .await - .map_err(|e| { - let err = format!("git fallback clone failed: {e}"); - self.emit(SandboxEvent::GitCloneFailed { - url: params.url.clone(), - error: err.clone(), - }); - err - })?; - - if fallback_output.exit_code != 0 { - let fallback_stderr = String::from_utf8_lossy(&fallback_output.stderr); - let err = format!( - "git fallback clone failed (exit {}): {fallback_stderr}", - fallback_output.exit_code, - ); - self.emit(SandboxEvent::GitCloneFailed { - url: params.url.clone(), - error: err.clone(), - }); - return Err(err); - } - } else { - let err = format!( - "git clone failed (exit {}): {stderr}", - clone_output.exit_code, - ); - self.emit(SandboxEvent::GitCloneFailed { - url: params.url.clone(), - error: err.clone(), - }); - return Err(err); - } - } - - // Store the clean URL as origin_url for credential refresh - let _ = self.origin_url.set(params.url.clone()); - - let duration_ms = u64::try_from(clone_start.elapsed().as_millis()).unwrap_or(u64::MAX); - self.emit(SandboxEvent::GitCloneCompleted { - url: params.url.clone(), - duration_ms, - }); - - Ok(()) + ssh_common::clone_repo( + ssh, + &self.config.working_directory, + params, + self.github_app.as_ref(), + &self.origin_url, + &|event| self.emit(event), + ) + .await } - /// Resolve a path: relative paths are prepended with the working directory. fn resolve_path(&self, path: &str) -> String { - if Path::new(path).is_absolute() { - path.to_string() - } else { - format!("{}/{path}", self.config.working_directory) - } + crate::sandbox::resolve_path(path, &self.config.working_directory) } } @@ -362,7 +222,7 @@ impl Sandbox for SshSandbox { }; script.push_str(&format!("cd {} && {command}", shell_quote(&dir))); - let full_cmd = Self::wrap_bash_command(&script); + let full_cmd = ssh_common::wrap_bash_command(&script); let timeout = std::time::Duration::from_millis(timeout_ms); let token = cancel_token.unwrap_or_default(); @@ -725,6 +585,7 @@ impl Sandbox for SshSandbox { #[cfg(test)] mod tests { use super::*; + use base64::Engine; use std::sync::{Arc, Mutex}; /// A recorded command sent to the mock SSH runner. diff --git a/lib/crates/fabro-sandbox/src/ssh_common.rs b/lib/crates/fabro-sandbox/src/ssh_common.rs new file mode 100644 index 000000000..ae552433a --- /dev/null +++ b/lib/crates/fabro-sandbox/src/ssh_common.rs @@ -0,0 +1,169 @@ +//! Shared types and utilities for SSH-based sandbox implementations (exe, ssh). + +use std::time::Instant; + +use async_trait::async_trait; +use base64::Engine; + +use crate::{shell_quote, SandboxEvent}; + +/// Output from an SSH command execution. +pub struct SshOutput { + pub stdout: Vec, + pub stderr: Vec, + pub exit_code: i32, +} + +/// Trait abstracting SSH operations for testability. +#[async_trait] +pub trait SshRunner: Send + Sync { + async fn run_command(&self, command: &str) -> Result; + + async fn run_command_with_timeout( + &self, + command: &str, + timeout: std::time::Duration, + ) -> Result; + + async fn upload_file(&self, path: &str, content: &[u8]) -> Result<(), String>; + + async fn download_file(&self, path: &str) -> Result, String>; +} + +/// Parameters for cloning a git repo into the sandbox during initialization. +#[derive(Clone, Debug)] +pub struct GitCloneParams { + /// Clean HTTPS URL (no embedded credentials). + pub url: String, + /// Branch to clone. If None, uses the remote's default. + pub branch: Option, +} + +/// Wrap a shell command in base64 encoding to avoid escaping issues. +pub(crate) fn wrap_bash_command(command: &str) -> String { + let encoded = base64::engine::general_purpose::STANDARD.encode(command); + format!("echo '{encoded}' | base64 -d | sh") +} + +/// Resolve an authenticated clone URL using GitHub App credentials, falling back to the +/// original URL if authentication fails or no credentials are provided. +pub(crate) async fn resolve_clone_url( + url: &str, + github_app: Option<&fabro_github::GitHubAppCredentials>, +) -> Result { + match github_app { + Some(creds) => fabro_github::resolve_authenticated_url(creds, url) + .await + .or_else(|_| Ok(url.to_string())), + None => Ok(url.to_string()), + } +} + +/// Clone a git repo into a sandbox working directory over SSH. +/// +/// Handles the common clone logic including fallback to init+fetch when the +/// directory is not empty, event emission, and origin URL tracking. +pub(crate) async fn clone_repo( + ssh: &dyn SshRunner, + working_dir: &str, + params: &GitCloneParams, + github_app: Option<&fabro_github::GitHubAppCredentials>, + origin_url: &tokio::sync::OnceCell, + emit: &(dyn Fn(SandboxEvent) + Send + Sync), +) -> Result<(), String> { + emit(SandboxEvent::GitCloneStarted { + url: params.url.clone(), + branch: params.branch.clone(), + }); + let clone_start = Instant::now(); + + let clone_url = resolve_clone_url(¶ms.url, github_app).await?; + + let branch_flag = params + .branch + .as_deref() + .map(|b| format!(" --branch {}", shell_quote(b))) + .unwrap_or_default(); + + let clone_script = format!( + "git clone{branch_flag} {} {}", + shell_quote(&clone_url), + shell_quote(working_dir), + ); + let clone_cmd = wrap_bash_command(&clone_script); + let clone_timeout = std::time::Duration::from_secs(300); + let clone_output = ssh + .run_command_with_timeout(&clone_cmd, clone_timeout) + .await + .map_err(|e| { + let err = format!("git clone failed: {e}"); + emit(SandboxEvent::GitCloneFailed { + url: params.url.clone(), + error: err.clone(), + }); + err + })?; + + if clone_output.exit_code != 0 { + let stderr = String::from_utf8_lossy(&clone_output.stderr); + + // Fall back to init + fetch + checkout if directory is not empty + if stderr.contains("not an empty directory") + || stderr.contains("already exists and is not an empty") + { + let branch = params.branch.as_deref().unwrap_or("main"); + let fallback_script = format!( + "cd {} && git init && git remote add origin {} && git fetch origin && git checkout {}", + shell_quote(working_dir), + shell_quote(&clone_url), + shell_quote(branch), + ); + let fallback_cmd = wrap_bash_command(&fallback_script); + let fallback_output = ssh + .run_command_with_timeout(&fallback_cmd, clone_timeout) + .await + .map_err(|e| { + let err = format!("git fallback clone failed: {e}"); + emit(SandboxEvent::GitCloneFailed { + url: params.url.clone(), + error: err.clone(), + }); + err + })?; + + if fallback_output.exit_code != 0 { + let fallback_stderr = String::from_utf8_lossy(&fallback_output.stderr); + let err = format!( + "git fallback clone failed (exit {}): {fallback_stderr}", + fallback_output.exit_code, + ); + emit(SandboxEvent::GitCloneFailed { + url: params.url.clone(), + error: err.clone(), + }); + return Err(err); + } + } else { + let err = format!( + "git clone failed (exit {}): {stderr}", + clone_output.exit_code, + ); + emit(SandboxEvent::GitCloneFailed { + url: params.url.clone(), + error: err.clone(), + }); + return Err(err); + } + } + + // Store the clean URL as origin_url for credential refresh + let _ = origin_url.set(params.url.clone()); + + let duration_ms = u64::try_from(clone_start.elapsed().as_millis()).unwrap_or(u64::MAX); + emit(SandboxEvent::GitCloneCompleted { + url: params.url.clone(), + duration_ms, + }); + + Ok(()) +}