Files
0a770d9173 feat(kubernetes): support HA gateway rebalancing (#1868)
* feat(kubernetes): support HA gateway rebalancing

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* perf(server): cache peer connections, tokens, and owner lookups

Every forwarded relay rebuilt its setup from scratch: an owner lookup, a
blocking read of the peer token, a TLS connect to the owning replica, and
a TokenReview plus Pod GET on the receiving side. Sandbox service routing
does this per HTTP request, so the apiserver calls scaled with traffic.

Cache all of it on ServerState:

- peer channels pooled per endpoint, so relays multiplex over one
  connection instead of redialing
- peer tokens keyed by SHA-256, expiring at min(ttl, token exp) so a hit
  cannot accept an expired token
- owner records for 3s against a 45s ownership TTL, still freshness
  checked before use

Entries are evicted when a relay fails. Also raise HTTP/2
max_concurrent_streams to 1024, since pooling funnels every relay between
two replicas onto one connection and hyper's default of 200 sits below
the 256 pending-relay budget.

Signed-off-by: divesh <dgude@nvidia.com>

* perf(server): pool upstream connections for sandbox services

Each HTTP request to a sandbox service opened its own supervisor relay,
paying a new TCP connection and HTTP/1 handshake every time. Worse, it
counted against the 32 in-flight relay cap, so a service handling more
than 32 concurrent requests failed outright.

Pool idle upstreams per endpoint and port, up to 8 each for 15s. Reuse is
safe because the pool only returns a connection hyper reports as ready,
and HTTP/1 cannot start a request until the previous body has drained.
Upgrades are never pooled since they take the connection over, and a
failed send evicts that endpoint. Pruning is bounded per key, with the
full sweep limited to once per 30s.

Signed-off-by: divesh <dgude@nvidia.com>

* fix(server): address HA gateway review findings (#3449)

- Let a gateway own supervisor sessions without a peer endpoint. Requiring
  one whenever the store is PostgreSQL broke every single-instance
  PostgreSQL deployment, because no sandbox supervisor could connect.
  A cross-replica request to an owner that advertises no endpoint now fails
  immediately naming the cause, instead of retrying until the wait timeout.
- Close a supervisor session on heartbeat only when another replica owns it,
  or after renewals fail for the ownership TTL. A database error no longer
  drops every session heartbeating during an outage.
- Clamp owner record ages at zero so a skewed or corrupt stored timestamp
  cannot produce a negative age.
- Bound the cross-object advisory lock with a lock timeout, so a stuck holder
  fails instead of blocking every mutation in the fleet.
- Refuse to start when a peer endpoint is configured on a multi-replica
  backend but peer authentication is unavailable, and warn when a
  multi-replica backend has no peer endpoint at all.
- Reject a plaintext peer endpoint when the gateway serves TLS.
- Skip the sandbox watch poller on single-replica backends, where the local
  update bus already sees every write.
- Rate-limit the peer owner cache sweep so an insert no longer scans the
  whole map under the lock.
- Retry GET and HEAD on a pooled upstream the sandbox closed, instead of
  returning 502, and drop an emptied endpoint from the pool right away.
- Document the gateway peer environment variables and the post-rollout
  ownership skew operators should expect.

Signed-off-by: divesh <dgude@nvidia.com>

* fix(server): harden HA supervisor ownership

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

---------

Signed-off-by: Drew Newberry <anewberry@nvidia.com>
Signed-off-by: divesh <dgude@nvidia.com>
Co-authored-by: Drew Newberry <anewberry@nvidia.com>
Co-authored-by: divesh <dgude@nvidia.com>
Co-authored-by: Divesh Chowdary <47188680+FrostGod@users.noreply.github.com>
2026-09-21 21:22:12 +00:00

2643 lines
90 KiB
Rust

// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! SSH connection and proxy utilities.
use crate::color::Colorize;
use crate::tls::{TlsOptions, grpc_client};
use miette::{IntoDiagnostic, Report, Result, WrapErr};
#[cfg(unix)]
use nix::sys::signal::{SaFlags, SigAction, SigHandler, SigSet, Signal, sigaction};
use openshell_core::driver_mounts;
use openshell_core::forward::{
ForwardSpec, build_proxy_command, format_gateway_url, resolve_ssh_gateway, shell_escape,
validate_ssh_session_response, write_forward_pid,
};
use openshell_core::proto::{
CreateSshSessionRequest, GetSandboxRequest, SshRelayTarget, TcpForwardFrame, TcpForwardInit,
tcp_forward_init,
};
use std::fs;
use std::future::Future;
use std::io::{IsTerminal, Write};
#[cfg(unix)]
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::process::{Child, Command as TokioCommand};
use tokio_stream::wrappers::ReceiverStream;
use tonic::Code;
/// Time budget for the local listener to become reachable after `ssh` starts.
/// This is a user-visible readiness deadline for both foreground and background
/// forwards, not a soft cleanup grace period.
const FORWARD_LISTENER_READINESS_TIMEOUT: Duration = Duration::from_secs(10);
/// Delay between listener/PID probes within the configured timeout.
const FORWARD_LISTENER_PROBE_INTERVAL: Duration = Duration::from_millis(50);
/// Per-attempt connect timeout, so one hung probe cannot consume the whole
/// grace period.
const FORWARD_LISTENER_CONNECT_TIMEOUT: Duration = Duration::from_millis(200);
/// Time budget for the supervisor relay to register after a fast canonical
/// command has already reported its terminal result.
const TERMINAL_RELAY_REGISTRATION_TIMEOUT: Duration = Duration::from_secs(5);
const TERMINAL_RELAY_REGISTRATION_INTERVAL: Duration = Duration::from_millis(50);
const SYNC_RETRY_ATTEMPTS: usize = 4;
const SYNC_RETRY_DELAY: Duration = Duration::from_secs(2);
#[derive(Clone, Copy, Debug)]
pub enum Editor {
Vscode,
Cursor,
}
impl Editor {
fn binary(self) -> &'static str {
match self {
Self::Vscode => "code",
Self::Cursor => "cursor",
}
}
fn remote_target(host_alias: &str) -> String {
format!("ssh-remote+{host_alias}")
}
fn label(self) -> &'static str {
match self {
Self::Vscode => "VS Code",
Self::Cursor => "Cursor",
}
}
}
struct SshSessionConfig {
proxy_command: String,
sandbox_id: String,
sandbox_name: String,
gateway_url: String,
token: String,
main_terminal: bool,
}
async fn ssh_session_config(
server: &str,
name: &str,
tls: &TlsOptions,
workspace: &str,
terminal_relay_registration_timeout: Option<Duration>,
) -> Result<SshSessionConfig> {
let mut client = grpc_client(server, tls).await?;
// Resolve the sandbox and retain its ID for local lifecycle tracking.
let sandbox = client
.get_sandbox(GetSandboxRequest {
name: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
(workspace).to_string(),
)),
})
.await
.into_diagnostic()?
.into_inner()
.sandbox
.ok_or_else(|| miette::miette!("sandbox not found"))?;
let relay_registration_deadline =
terminal_relay_registration_timeout.map(|timeout| tokio::time::Instant::now() + timeout);
let response = loop {
match client
.create_ssh_session(CreateSshSessionRequest {
sandbox: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
(workspace).to_string(),
)),
})
.await
{
Ok(response) => break response,
Err(status)
if status.code() == Code::FailedPrecondition
&& relay_registration_deadline
.is_some_and(|deadline| tokio::time::Instant::now() < deadline) =>
{
tokio::time::sleep(TERMINAL_RELAY_REGISTRATION_INTERVAL).await;
}
Err(status) => return Err(status).into_diagnostic(),
}
};
let session = response.into_inner();
validate_ssh_session_response(&session)
.map_err(|err| miette::miette!("gateway returned invalid SSH session response: {err}"))?;
let exe = std::env::current_exe()
.into_diagnostic()
.wrap_err("failed to resolve OpenShell executable")?;
let exe_command = exe.to_string_lossy().into_owned();
// When using Cloudflare bearer auth, the SSH CONNECT must go through the
// external tunnel endpoint (the cluster URL), not the server's internal
// scheme/host/port which may be plaintext HTTP on 127.0.0.1.
let gateway_url = if tls.is_bearer_auth() {
server.trim_end_matches('/').to_string()
} else {
// If the server returned a loopback gateway address, override it with the
// cluster endpoint's host. This handles the case where the server defaults
// to 127.0.0.1 but the cluster is actually running on a remote host.
#[allow(clippy::cast_possible_truncation)]
let gateway_port_u16 = session.gateway_port as u16;
let (gateway_host, gateway_port) =
resolve_ssh_gateway(&session.gateway_host, gateway_port_u16, server);
format_gateway_url(&session.gateway_scheme, &gateway_host, gateway_port)
};
let gateway_name = tls
.gateway_name()
.ok_or_else(|| miette::miette!("gateway name is required to build SSH proxy command"))?;
let proxy_command = build_proxy_command(
&exe_command,
&gateway_url,
name,
workspace,
&session.token,
gateway_name,
);
Ok(SshSessionConfig {
proxy_command,
sandbox_id: session.sandbox_id.clone(),
sandbox_name: name.to_string(),
gateway_url,
token: session.token,
main_terminal: sandbox.spec.as_ref().is_none_or(|spec| spec.tty),
})
}
fn ssh_base_command(proxy_command: &str) -> Command {
// SSH log level follows the program's verbosity. main() maps the `-v`
// count to OPENSHELL_SSH_LOG_LEVEL; an explicit env-var override wins.
let ssh_log_level =
std::env::var("OPENSHELL_SSH_LOG_LEVEL").unwrap_or_else(|_| "ERROR".to_string());
let mut command = Command::new("ssh");
command
.arg("-o")
.arg(format!("ProxyCommand={proxy_command}"))
.arg("-o")
.arg("StrictHostKeyChecking=no")
.arg("-o")
.arg("UserKnownHostsFile=/dev/null")
.arg("-o")
.arg("GlobalKnownHostsFile=/dev/null")
.arg("-o")
.arg(format!("LogLevel={ssh_log_level}"))
// Detect a dead relay within ~45s. The relay rides on a TCP connection
// that the client has no way to observe silently dropping (gateway
// restart, supervisor restart, cluster failover), so fall back to
// SSH-level keepalives instead of hanging forever.
.arg("-o")
.arg("ServerAliveInterval=15")
.arg("-o")
.arg("ServerAliveCountMax=3");
command
}
#[cfg(unix)]
const TRANSIENT_TTY_SIGNALS: &[Signal] = &[Signal::SIGINT, Signal::SIGQUIT, Signal::SIGTERM];
#[cfg(unix)]
struct ParentSignalGuard {
previous: Vec<(Signal, SigAction)>,
}
#[cfg(unix)]
impl ParentSignalGuard {
#[allow(unsafe_code)]
fn ignore_transient_tty_signals() -> Result<Self> {
let mut previous = Vec::with_capacity(TRANSIENT_TTY_SIGNALS.len());
for &signal in TRANSIENT_TTY_SIGNALS {
let action = SigAction::new(SigHandler::SigIgn, SaFlags::empty(), SigSet::empty());
// SAFETY: `sigaction` is the POSIX API for updating process signal
// dispositions. We install `SIG_IGN` for a small fixed set of
// terminal signals and store the previous handlers for restoration.
let old = unsafe { sigaction(signal, &action) }.into_diagnostic()?;
previous.push((signal, old));
}
Ok(Self { previous })
}
}
#[cfg(unix)]
impl Drop for ParentSignalGuard {
#[allow(unsafe_code)]
fn drop(&mut self) {
for &(signal, previous) in self.previous.iter().rev() {
// SAFETY: these `SigAction` values were returned by `sigaction`
// above for this process, so restoring them here returns the parent
// signal handlers to their original state.
let _ = unsafe { sigaction(signal, &previous) };
}
}
}
#[cfg(unix)]
#[allow(unsafe_code)]
fn reset_transient_tty_signals(command: &mut Command) {
// SAFETY: `pre_exec` runs in the forked child immediately before `exec`.
// We only reset a small fixed set of signal handlers to `SIG_DFL`, which is
// required so SSH receives terminal signals normally even though the parent
// process temporarily ignores them to preserve cleanup.
unsafe {
command.pre_exec(|| {
for &signal in TRANSIENT_TTY_SIGNALS {
let action = SigAction::new(SigHandler::SigDfl, SaFlags::empty(), SigSet::empty());
sigaction(signal, &action).map_err(|err| std::io::Error::other(err.to_string()))?;
}
Ok(())
});
}
}
fn exec_or_wait(mut command: Command, replace_process: bool) -> Result<i32> {
if replace_process && std::io::stdin().is_terminal() {
#[cfg(unix)]
{
let err = command.exec();
return Err(miette::miette!("failed to exec ssh: {err}"));
}
}
#[cfg(unix)]
let _signal_guard = if !replace_process && std::io::stdin().is_terminal() {
reset_transient_tty_signals(&mut command);
Some(ParentSignalGuard::ignore_transient_tty_signals()?)
} else {
None
};
let status = command.status().into_diagnostic()?;
Ok(status.code().unwrap_or(1))
}
async fn sandbox_connect_with_mode(
server: &str,
name: &str,
tls: &TlsOptions,
replace_process: bool,
workspace: &str,
terminal_relay_registration_timeout: Option<Duration>,
) -> Result<i32> {
let session = ssh_session_config(
server,
name,
tls,
workspace,
terminal_relay_registration_timeout,
)
.await?;
let mut command = ssh_base_command(&session.proxy_command);
if session.main_terminal {
command.arg("-tt").arg("-o").arg("RequestTTY=force");
} else {
command.arg("-T");
}
command
.arg("-o")
.arg("SetEnv=TERM=xterm-256color")
.arg("-s")
.arg("sandbox")
.arg("openshell-main")
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
let exit_code = tokio::task::spawn_blocking(move || exec_or_wait(command, replace_process))
.await
.into_diagnostic()??;
Ok(exit_code)
}
/// Connect to a sandbox via SSH.
pub async fn sandbox_connect(
server: &str,
name: &str,
tls: &TlsOptions,
workspace: &str,
) -> Result<i32> {
sandbox_connect_with_mode(server, name, tls, true, workspace, None).await
}
pub(crate) async fn sandbox_connect_without_exec(
server: &str,
name: &str,
tls: &TlsOptions,
workspace: &str,
) -> Result<i32> {
sandbox_connect_with_mode(server, name, tls, false, workspace, None).await
}
pub(crate) async fn sandbox_connect_terminal_main(
server: &str,
name: &str,
tls: &TlsOptions,
workspace: &str,
) -> Result<i32> {
sandbox_connect_with_mode(
server,
name,
tls,
false,
workspace,
Some(TERMINAL_RELAY_REGISTRATION_TIMEOUT),
)
.await
}
pub async fn sandbox_connect_editor(
server: &str,
gateway: &str,
name: &str,
editor: Editor,
tls: &TlsOptions,
workspace: &str,
) -> Result<()> {
let session = ssh_session_config(server, name, tls, workspace, None).await?;
let workspace_root = discover_workspace_root(&session).await?;
let host_alias = host_alias(name, workspace);
install_ssh_config(gateway, name, workspace)?;
launch_editor(editor, &host_alias, &workspace_root)?;
eprintln!(
"{} Opened {} for sandbox {}",
"✓".green().bold(),
editor.label(),
name
);
Ok(())
}
/// Forward a local port to a sandbox via SSH.
///
/// Background mode keeps the spawned `ssh -N` child alive and records that PID
/// for later management via [`stop_forward`] or [`list_forwards`].
pub async fn sandbox_forward(
server: &str,
name: &str,
spec: &ForwardSpec,
background: bool,
tls: &TlsOptions,
workspace: &str,
) -> Result<()> {
openshell_core::forward::check_port_available(spec)?;
let session = ssh_session_config(server, name, tls, workspace, None).await?;
let mut command = TokioCommand::from(ssh_base_command(&session.proxy_command));
command
.arg("-N")
.arg("-o")
.arg("ExitOnForwardFailure=yes")
.arg("-o")
.arg(format!(
"SetEnv=OPENSHELL_FORWARD_SANDBOX_ID={}",
session.sandbox_id
))
.arg("-L")
.arg(spec.ssh_forward_arg());
command.arg("sandbox");
if background {
command
.kill_on_drop(false)
.stdin(Stdio::null())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
} else {
command
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
}
let port = spec.port;
if background {
let mut child = command.spawn().into_diagnostic()?;
let pid = child.id().ok_or_else(|| {
miette::miette!("ssh process did not expose a PID for background tracking")
})?;
if let Err(err) = wait_for_forward_start(&mut child, spec)
.await
.wrap_err("ssh process started but local forward listener was not reachable")
{
terminate_owned_forward_child(&mut child);
return Err(err);
}
track_background_forward_or_cleanup(
workspace,
name,
port,
pid,
&session.sandbox_id,
&spec.bind_addr,
|| terminate_owned_forward_child(&mut child),
)?;
return Ok(());
}
let status = {
let mut child = command.spawn().into_diagnostic()?;
if let Err(err) = wait_for_forward_start(&mut child, spec).await {
let _ = child.kill().await;
return Err(err);
}
eprintln!("{}", foreground_forward_started_message(name, spec));
child.wait().await.into_diagnostic()?
};
if !status.success() {
return Err(miette::miette!("ssh exited with status {status}"));
}
Ok(())
}
/// Wait for the local listener, racing the probe against the `ssh` child
/// exiting. An early exit (e.g. `ExitOnForwardFailure=yes` tearing down the
/// session) means forwarding never came up, so it errors instead of waiting
/// out the grace period.
async fn wait_for_forward_start(child: &mut Child, spec: &ForwardSpec) -> Result<()> {
let listener = wait_for_forward_listener(spec, FORWARD_LISTENER_READINESS_TIMEOUT);
tokio::pin!(listener);
tokio::select! {
result = &mut listener => result,
status = child.wait() => {
let status = status.into_diagnostic()?;
if status.success() {
Err(miette::miette!(
"ssh exited before local forward listener opened on {}:{}",
forward_probe_host(spec),
spec.port,
))
} else {
Err(miette::miette!(
"ssh exited with status {status} before local forward listener opened on {}:{}",
forward_probe_host(spec),
spec.port,
))
}
}
}
}
/// Poll the local endpoint until a connect succeeds or `wait_for` elapses. The
/// last probe error is folded into the timeout diagnostic, so a failure reports
/// why the listener never opened, not just that it timed out.
async fn wait_for_forward_listener(spec: &ForwardSpec, wait_for: Duration) -> Result<()> {
wait_for_forward_listener_with_probe(spec, wait_for, |spec| async move {
probe_forward_listener(&spec).await
})
.await
}
async fn wait_for_forward_listener_with_probe<F, Fut>(
spec: &ForwardSpec,
wait_for: Duration,
mut probe: F,
) -> Result<()>
where
F: FnMut(ForwardSpec) -> Fut,
Fut: Future<Output = std::result::Result<(), String>>,
{
let deadline = tokio::time::Instant::now() + wait_for;
loop {
let probe_error = match probe(spec.clone()).await {
Ok(()) => return Ok(()),
Err(err) => err,
};
if tokio::time::Instant::now() >= deadline {
return Err(miette::miette!(
"local forward listener did not open on {}:{} within {}ms: last probe failed with {probe_error}",
forward_probe_host(spec),
spec.port,
wait_for.as_millis(),
));
}
tokio::time::sleep(FORWARD_LISTENER_PROBE_INTERVAL).await;
}
}
/// One bounded TCP connect to the forward endpoint. Returns a `String` error
/// rather than a `miette` diagnostic to stay cheap in the poll loop. The
/// connection only proves reachability and is dropped at once; SSH forwards
/// this throwaway connect to the sandbox-side target.
async fn probe_forward_listener(spec: &ForwardSpec) -> std::result::Result<(), String> {
match tokio::time::timeout(
FORWARD_LISTENER_CONNECT_TIMEOUT,
TcpStream::connect((forward_probe_host(spec), spec.port)),
)
.await
{
Ok(Ok(stream)) => {
drop(stream);
Ok(())
}
Ok(Err(err)) => Err(err.to_string()),
Err(_) => Err(format!(
"connect timed out after {}ms",
FORWARD_LISTENER_CONNECT_TIMEOUT.as_millis()
)),
}
}
/// Resolve the bind address to a connectable host. Wildcard binds (`0.0.0.0`,
/// `::`, empty) are "any-address" listeners, not valid connect targets, so they
/// map to the matching loopback. Specific addresses are probed as-is.
fn forward_probe_host(spec: &ForwardSpec) -> &str {
match spec.bind_addr.as_str() {
"" | "0.0.0.0" => "127.0.0.1",
"::" => "::1",
host => host,
}
}
/// Best-effort cleanup for the SSH child this process spawned.
fn terminate_owned_forward_child(child: &mut Child) {
let _ = child.start_kill();
}
/// Track a verified background forward, cleaning it up if PID-file persistence fails.
fn track_background_forward_or_cleanup(
workspace: &str,
name: &str,
port: u16,
pid: u32,
sandbox_id: &str,
bind_addr: &str,
cleanup: impl FnOnce(),
) -> Result<()> {
if let Err(err) = write_forward_pid(workspace, name, port, pid, sandbox_id, bind_addr) {
cleanup();
return Err(err)
.wrap_err("local forward listener was reachable but tracking the SSH process failed");
}
Ok(())
}
fn foreground_forward_started_message(name: &str, spec: &ForwardSpec) -> String {
format!(
"{} Forwarding port {} to sandbox {name}\n Access at: {}\n Press Ctrl+C to stop\n {}",
"✓".green().bold(),
spec.port,
spec.access_url(),
"Hint: pass --background to start forwarding without blocking your terminal".dimmed(),
)
}
async fn sandbox_exec_with_mode(
server: &str,
name: &str,
command: &[String],
tty: bool,
tls: &TlsOptions,
replace_process: bool,
workspace: &str,
) -> Result<()> {
if command.is_empty() {
return Err(miette::miette!("no command provided"));
}
let session = ssh_session_config(server, name, tls, workspace, None).await?;
let mut ssh = ssh_base_command(&session.proxy_command);
if tty {
ssh.arg("-tt")
.arg("-o")
.arg("RequestTTY=force")
.arg("-o")
.arg("SetEnv=TERM=xterm-256color");
} else {
ssh.arg("-T").arg("-o").arg("RequestTTY=no");
}
let command_str = command
.iter()
.map(|arg| shell_escape(arg))
.collect::<Vec<_>>()
.join(" ");
ssh.arg("sandbox")
.arg(command_str)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
tokio::task::spawn_blocking(move || exec_or_wait(ssh, tty && replace_process))
.await
.into_diagnostic()??;
Ok(())
}
/// Execute a command in a sandbox via SSH.
pub async fn sandbox_exec(
server: &str,
name: &str,
command: &[String],
tty: bool,
tls: &TlsOptions,
workspace: &str,
) -> Result<()> {
sandbox_exec_with_mode(server, name, command, tty, tls, true, workspace).await
}
/// What to pack into the tar archive streamed to the sandbox.
#[derive(Clone)]
enum UploadSource {
/// A single local file or directory. `tar_name` controls the entry name
/// inside the archive (e.g. the target basename for file-to-file uploads).
SinglePath {
local_path: PathBuf,
tar_name: std::ffi::OsString,
},
/// A set of files relative to a base directory (git-filtered uploads).
FileList {
base_dir: PathBuf,
files: Vec<String>,
archive_prefix: Option<PathBuf>,
},
}
fn write_upload_archive<W: Write>(writer: W, source: UploadSource) -> Result<()> {
let mut archive = tar::Builder::new(writer);
match source {
UploadSource::SinglePath {
local_path,
tar_name,
} => {
append_upload_path(&mut archive, &local_path, Path::new(&tar_name), false)?;
}
UploadSource::FileList {
base_dir,
files,
archive_prefix,
} => {
for file in &files {
let full_path = base_dir.join(file);
let archive_path = archive_prefix
.as_ref()
.map_or_else(|| PathBuf::from(file), |prefix| prefix.join(file));
append_upload_path(&mut archive, &full_path, &archive_path, true)?;
}
}
}
archive.finish().into_diagnostic()?;
Ok(())
}
fn append_upload_path<W: Write>(
archive: &mut tar::Builder<W>,
local_path: &Path,
archive_path: &Path,
skip_missing: bool,
) -> Result<()> {
let metadata = match fs::symlink_metadata(local_path) {
Ok(metadata) => metadata,
Err(err) if skip_missing && err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(err) => {
return Err(err)
.into_diagnostic()
.wrap_err_with(|| format!("failed to stat {}", local_path.display()));
}
};
let file_type = metadata.file_type();
if file_type.is_file() {
archive
.append_path_with_name(local_path, archive_path)
.into_diagnostic()
.wrap_err_with(|| format!("failed to add {} to tar archive", archive_path.display()))?;
return Ok(());
}
if file_type.is_dir() {
let dir_archive_path = upload_archive_dir_entry_path(archive_path);
archive
.append_dir(&dir_archive_path, local_path)
.into_diagnostic()
.wrap_err_with(|| {
format!(
"failed to add directory {} to tar archive",
archive_path.display()
)
})?;
append_upload_dir_contents(archive, local_path, archive_path)?;
return Ok(());
}
if file_type.is_symlink() {
append_upload_symlink(archive, local_path, archive_path, &metadata)?;
return Ok(());
}
Err(miette::miette!(
"unsupported file type for upload: {}",
local_path.display()
))
}
fn upload_archive_dir_entry_path(archive_path: &Path) -> PathBuf {
let mut path = archive_path.as_os_str().to_os_string();
path.push("/");
PathBuf::from(path)
}
fn append_upload_dir_contents<W: Write>(
archive: &mut tar::Builder<W>,
local_path: &Path,
archive_path: &Path,
) -> Result<()> {
let mut entries = fs::read_dir(local_path)
.into_diagnostic()
.wrap_err_with(|| format!("failed to read directory {}", local_path.display()))?
.collect::<std::io::Result<Vec<_>>>()
.into_diagnostic()
.wrap_err_with(|| format!("failed to read directory {}", local_path.display()))?;
entries.sort_by_key(fs::DirEntry::file_name);
for entry in entries {
let child_local_path = entry.path();
let child_archive_path = archive_path.join(entry.file_name());
append_upload_path(archive, &child_local_path, &child_archive_path, false)?;
}
Ok(())
}
fn append_upload_symlink<W: Write>(
archive: &mut tar::Builder<W>,
local_path: &Path,
archive_path: &Path,
metadata: &fs::Metadata,
) -> Result<()> {
let target = fs::read_link(local_path)
.into_diagnostic()
.wrap_err_with(|| format!("failed to read symlink {}", local_path.display()))?;
let mut header = tar::Header::new_gnu();
header.set_metadata(metadata);
header.set_entry_type(tar::EntryType::Symlink);
header.set_size(0);
header.set_cksum();
archive
.append_link(&mut header, archive_path, target)
.into_diagnostic()
.wrap_err_with(|| {
format!(
"failed to add symlink {} to tar archive",
archive_path.display()
)
})?;
Ok(())
}
fn local_upload_path_is_file_like(path: &Path) -> bool {
fs::symlink_metadata(path).is_ok_and(|metadata| {
let file_type = metadata.file_type();
file_type.is_file() || file_type.is_symlink()
})
}
/// Core tar-over-SSH upload: streams a tar archive into `dest_dir` on the
/// sandbox. Callers are responsible for splitting the destination path so
/// that `dest_dir` is always a directory.
///
/// When `dest_dir` is `None`, tar extracts relative to the SSH session's
/// working directory.
async fn ssh_tar_upload(
server: &str,
name: &str,
dest_dir: Option<&str>,
source: UploadSource,
tls: &TlsOptions,
workspace: &str,
) -> Result<()> {
let session = ssh_session_config(server, name, tls, workspace, None).await?;
let dest_dir = dest_dir.unwrap_or(".");
let escaped_dest = shell_escape(dest_dir);
let mut ssh = ssh_base_command(&session.proxy_command);
ssh.arg("-T")
.arg("-o")
.arg("RequestTTY=no")
.arg("sandbox")
.arg(format!(
"mkdir -p {escaped_dest} && cat | tar xf - -C {escaped_dest}",
))
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::inherit());
let mut child = ssh.spawn().into_diagnostic()?;
let stdin = child
.stdin
.take()
.ok_or_else(|| miette::miette!("failed to open stdin for ssh process"))?;
// Build the tar archive in a blocking task since the tar crate is synchronous.
tokio::task::spawn_blocking(move || -> Result<()> { write_upload_archive(stdin, source) })
.await
.into_diagnostic()??;
let status = tokio::task::spawn_blocking(move || child.wait())
.await
.into_diagnostic()?
.into_diagnostic()?;
if !status.success() {
return Err(miette::miette!(
"ssh tar extract exited with status {status}"
));
}
Ok(())
}
/// Split a sandbox path into (`parent_directory`, basename).
///
/// Examples:
/// `"/sandbox/.bashrc"` -> `("/sandbox", ".bashrc")`
/// `"/sandbox/sub/file"` -> `("/sandbox/sub", "file")`
/// `"file.txt"` -> `(".", "file.txt")`
fn split_sandbox_path(path: &str) -> (&str, &str) {
match path.rfind('/') {
Some(0) => ("/", &path[1..]),
Some(pos) => (&path[..pos], &path[pos + 1..]),
None => (".", path),
}
}
/// Lexically clean a POSIX-style absolute path by resolving `.` and `..`
/// components, collapsing repeated separators, and stripping any trailing
/// slash. Returns `None` if the input is empty or relative — the caller is
/// expected to reject those before reaching this helper.
///
/// This is *lexical* only: it does not consult the filesystem and so cannot
/// follow symlinks. That trade-off is intentional — the function is used
/// client-side to refuse obvious path-traversal attempts before issuing the
/// SSH command. Symlink-based escapes inside the sandbox must be addressed
/// server-side.
fn lexical_clean_absolute_path(path: &str) -> Option<String> {
if !path.starts_with('/') {
return None;
}
let mut stack: Vec<&str> = Vec::new();
for component in path.split('/') {
match component {
"" | "." => {}
".." => {
stack.pop();
}
other => stack.push(other),
}
}
if stack.is_empty() {
return Some("/".to_string());
}
let mut out = String::with_capacity(path.len());
for component in stack {
out.push('/');
out.push_str(component);
}
Some(out)
}
/// Resolve a sandbox-side source path passed to `sandbox download` under the
/// sandbox writable root.
///
/// Returns the cleaned, traversal-resolved path on success. Refuses any
/// path that lexically escapes the discovered workspace root with a user-facing
/// error. Relative paths are interpreted from the workspace root.
///
/// This is a lexical guard only — it does not follow symlinks. Call
/// `resolve_sandbox_source_path` after this on any path that will be passed
/// to a subsequent SSH I/O operation, so a workspace symlink to `/etc` cannot
/// leak files outside the workspace.
fn validate_sandbox_source_path(workspace_root: &str, path: &str) -> Result<String> {
if path.is_empty() {
return Err(miette::miette!("sandbox source path is empty"));
}
let candidate = if path.starts_with('/') {
path.to_string()
} else {
format!("{workspace_root}/{path}")
};
let cleaned = lexical_clean_absolute_path(&candidate)
.ok_or_else(|| miette::miette!("sandbox source path is invalid (got '{path}')"))?;
if !driver_mounts::path_is_or_under(Path::new(&cleaned), Path::new(workspace_root)) {
return Err(miette::miette!(
"sandbox source path '{path}' is outside the sandbox workspace ({workspace_root})"
));
}
Ok(cleaned)
}
/// Discover the workspace root and resolve every symlink in `sandbox_path` in
/// one SSH probe, then refuse the result if it lands outside the workspace.
///
/// The lexical guard in `validate_sandbox_source_path` cannot see symlinks; a
/// workspace path through `etc-link -> /etc` clears the lexical check but
/// would still leak `/etc/passwd` once `tar -C` follows the link. Resolving
/// symlinks on the remote side and re-validating closes that gap. The returned
/// fully-resolved path is what the caller should hand to probe and tar
/// invocations. Combining discovery and resolution also keeps downloads within
/// the gateway's three-connection limit: this probe, the type probe, and tar.
async fn resolve_sandbox_source_path(
session: &SshSessionConfig,
sandbox_path: &str,
) -> Result<String> {
let resolve_cmd = format!(
"pwd -P && realpath -e -- {path}",
path = shell_escape(sandbox_path)
);
let output = ssh_run_capture_stdout(session, &resolve_cmd)
.await
.wrap_err_with(|| format!("failed to resolve sandbox source path '{sandbox_path}'"))?;
let (workspace_root, resolved) = output.split_once('\n').ok_or_else(|| {
miette::miette!("unexpected response while resolving sandbox source path '{sandbox_path}'")
})?;
if resolved.contains('\n') {
return Err(miette::miette!(
"unexpected response while resolving sandbox source path '{sandbox_path}'"
));
}
let workspace_root = validate_discovered_workspace_root(workspace_root)?;
validate_sandbox_source_path(&workspace_root, sandbox_path)?;
if resolved.is_empty() {
return Err(miette::miette!(
"sandbox source path '{sandbox_path}' does not exist"
));
}
if !driver_mounts::path_is_or_under(Path::new(resolved), Path::new(&workspace_root)) {
return Err(miette::miette!(
"sandbox source path '{sandbox_path}' resolves to '{resolved}', outside the sandbox workspace ({workspace_root})"
));
}
Ok(resolved.to_string())
}
/// Resolve the host-side target path for a downloaded *file*, following
/// `cp`-style semantics.
///
/// - If `dest_str` ends with `/` or already exists as a directory, the file is
/// placed inside it as `<dest>/<source_basename>`.
/// - Otherwise `dest_str` is treated as the exact file path to write.
///
/// `dest_exists_as_dir` is taken as a parameter (rather than queried inside)
/// so this function stays pure and unit-testable; the caller performs the
/// filesystem check.
fn resolve_file_download_target(
dest_str: &str,
source_basename: &str,
dest_exists_as_dir: bool,
) -> PathBuf {
let trailing_slash = dest_str.ends_with('/');
let dest_path = Path::new(dest_str);
if trailing_slash || dest_exists_as_dir {
dest_path.join(source_basename)
} else {
dest_path.to_path_buf()
}
}
/// Push a list of files from a local directory into a sandbox using tar-over-SSH.
///
/// Files are streamed as a tar archive to `ssh ... tar xf - -C <dest>` on
/// the sandbox side. When `dest` is `None`, files are uploaded to the
/// SSH session's working directory.
#[allow(clippy::too_many_arguments)]
pub async fn sandbox_sync_up_files(
server: &str,
name: &str,
base_dir: &Path,
files: &[String],
local_path: &Path,
dest: Option<&str>,
tls: &TlsOptions,
workspace: &str,
) -> Result<()> {
if files.is_empty() {
return Ok(());
}
let source = UploadSource::FileList {
base_dir: base_dir.to_path_buf(),
files: files.to_vec(),
archive_prefix: file_list_archive_prefix(local_path),
};
retry_sandbox_sync("upload", || {
let source = source.clone();
async move { ssh_tar_upload(server, name, dest, source, tls, workspace).await }
})
.await
}
/// Push a local path (file or directory) into a sandbox using tar-over-SSH.
///
/// When `sandbox_path` is `None`, files are uploaded to the SSH session's
/// working directory. When uploading a single file to an explicit destination
/// that does not end with `/`, the destination is treated as a file path: the
/// parent directory is created and the file is written with the destination's
/// basename. This matches `cp` / `scp` semantics.
pub async fn sandbox_sync_up(
server: &str,
name: &str,
local_path: &Path,
sandbox_path: Option<&str>,
tls: &TlsOptions,
workspace: &str,
) -> Result<()> {
// When an explicit destination is given and looks like a file path (does
// not end with '/'), split into parent directory + target basename so that
// `mkdir -p` creates the parent and tar extracts the file with the right
// name.
//
// Exception: if splitting would yield "/" as the parent, fall through to
// directory semantics instead. The sandbox user cannot write to "/" and
// the intent is almost certainly to place the file inside the named
// top-level directory.
let local_path_is_file_like = local_upload_path_is_file_like(local_path);
if let Some(path) = sandbox_path
&& local_path_is_file_like
&& !path.ends_with('/')
{
let (parent, target_name) = split_sandbox_path(path);
if parent != "/" {
let source = UploadSource::SinglePath {
local_path: local_path.to_path_buf(),
tar_name: target_name.into(),
};
return retry_sandbox_sync("upload", || {
let source = source.clone();
async move {
ssh_tar_upload(server, name, Some(parent), source, tls, workspace).await
}
})
.await;
}
}
let tar_name = if local_path_is_file_like {
local_path
.file_name()
.ok_or_else(|| miette::miette!("path has no file name"))?
.to_os_string()
} else {
// For directories, wrap contents under the source basename so uploads
// land at `<dest>/<dirname>/...` — matches `scp -r` and `cp -r`. Falls
// back to "." for paths with no meaningful basename (`.`, `/`), which
// preserves the legacy flatten behavior in those edge cases.
directory_upload_prefix(local_path)
};
let source = UploadSource::SinglePath {
local_path: local_path.to_path_buf(),
tar_name,
};
retry_sandbox_sync("upload", || {
let source = source.clone();
async move { ssh_tar_upload(server, name, sandbox_path, source, tls, workspace).await }
})
.await
}
/// Compute the tar entry prefix for a directory upload.
///
/// Returns the directory's basename for any path with a meaningful basename;
/// callers extracting at `<dest>` will see contents wrapped under
/// `<dest>/<basename>/...`. Returns `"."` for paths without a basename
/// (e.g. `.` or `/`), which produces flat extraction at `<dest>`.
fn directory_upload_prefix(local_path: &Path) -> std::ffi::OsString {
local_path
.file_name()
.map_or_else(|| ".".into(), std::ffi::OsStr::to_os_string)
}
fn file_list_archive_prefix(local_path: &Path) -> Option<PathBuf> {
if !local_path.is_dir() {
return None;
}
let prefix = directory_upload_prefix(local_path);
if prefix == "." {
None
} else {
Some(PathBuf::from(prefix))
}
}
/// Run a small command on the sandbox over SSH and capture its stdout.
///
/// Used by the download flow to probe whether the source path is a regular
/// file or a directory before streaming the tar archive. Stderr is inherited
/// so the user still sees any diagnostic output from ssh itself.
async fn ssh_run_capture_stdout(session: &SshSessionConfig, command: &str) -> Result<String> {
let mut ssh = ssh_base_command(&session.proxy_command);
ssh.arg("-T")
.arg("-o")
.arg("RequestTTY=no")
.arg("sandbox")
.arg(command)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::inherit());
let output = tokio::task::spawn_blocking(move || ssh.output())
.await
.into_diagnostic()?
.into_diagnostic()?;
if !output.status.success() {
return Err(miette::miette!(
"ssh probe exited with status {}",
output.status
));
}
decode_ssh_probe_stdout(output.stdout)
}
fn decode_ssh_probe_stdout(stdout: Vec<u8>) -> Result<String> {
let stdout = String::from_utf8(stdout)
.map_err(|error| miette::miette!("ssh probe returned non-UTF-8 output: {error}"))?;
let stdout = stdout.strip_suffix('\n').unwrap_or(&stdout);
let stdout = stdout.strip_suffix('\r').unwrap_or(stdout);
Ok(stdout.to_string())
}
fn validate_discovered_workspace_root(root: &str) -> Result<String> {
let cleaned = lexical_clean_absolute_path(root)
.ok_or_else(|| miette::miette!("remote workspace must be an absolute path"))?;
if cleaned == "/" {
return Err(miette::miette!(
"remote workspace resolved to the container root"
));
}
if cleaned != root {
return Err(miette::miette!(
"remote workspace '{root}' is not a canonical absolute path"
));
}
Ok(cleaned)
}
async fn discover_workspace_root(session: &SshSessionConfig) -> Result<String> {
let root = ssh_run_capture_stdout(session, "pwd -P")
.await
.wrap_err("failed to discover remote workspace")?;
validate_discovered_workspace_root(&root)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SandboxSourceKind {
File,
Directory,
}
/// Probe the sandbox-side source path. The path is assumed to have already
/// been validated by `validate_sandbox_source_path`.
async fn probe_sandbox_source_kind(
session: &SshSessionConfig,
sandbox_path: &str,
) -> Result<SandboxSourceKind> {
let probe_cmd = format!(
"if [ -d {path} ]; then printf dir; elif [ -e {path} ]; then printf file; else printf missing; fi",
path = shell_escape(sandbox_path),
);
let kind = ssh_run_capture_stdout(session, &probe_cmd).await?;
match kind.as_str() {
"dir" => Ok(SandboxSourceKind::Directory),
"file" => Ok(SandboxSourceKind::File),
"missing" => Err(miette::miette!(
"sandbox source path '{sandbox_path}' does not exist"
)),
other => Err(miette::miette!(
"unexpected probe output for sandbox source path '{sandbox_path}': '{other}'"
)),
}
}
/// Pull a path from a sandbox to a local destination using tar-over-SSH.
///
/// Follows `cp`-style semantics for the destination:
///
/// - If the source is a single file:
/// - When `dest` ends with `/` or already exists as a directory on the host,
/// the file lands at `<dest>/<source-basename>`.
/// - Otherwise `dest` is taken to be the exact file path to write.
/// - If the source is a directory, its contents are extracted into `dest`
/// (creating `dest` if it does not yet exist). This preserves prior
/// behaviour for the directory-source case.
///
/// The sandbox source path is also subjected to a workspace-boundary check
/// before any file probe or archive command is issued; paths that resolve
/// outside the discovered workspace root are refused.
pub async fn sandbox_sync_down(
server: &str,
name: &str,
sandbox_path: &str,
dest: &str,
tls: &TlsOptions,
workspace: &str,
) -> Result<()> {
retry_sandbox_sync("download", || async {
sandbox_sync_down_once(server, name, sandbox_path, dest, tls, workspace).await
})
.await
}
async fn sandbox_sync_down_once(
server: &str,
name: &str,
sandbox_path: &str,
dest: &str,
tls: &TlsOptions,
workspace: &str,
) -> Result<()> {
let session = ssh_session_config(server, name, tls, workspace, None).await?;
let sandbox_path = resolve_sandbox_source_path(&session, sandbox_path).await?;
let kind = probe_sandbox_source_kind(&session, &sandbox_path).await?;
match kind {
SandboxSourceKind::File => sandbox_sync_down_file(&session, &sandbox_path, dest).await,
SandboxSourceKind::Directory => {
sandbox_sync_down_directory(&session, &sandbox_path, dest).await
}
}
}
async fn retry_sandbox_sync<F, Fut>(operation: &str, mut run: F) -> Result<()>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<()>>,
{
let mut attempt = 1;
loop {
match run().await {
Ok(()) => return Ok(()),
Err(error) if attempt < SYNC_RETRY_ATTEMPTS && sync_error_is_retryable(&error) => {
tracing::warn!(
operation,
attempt,
max_attempts = SYNC_RETRY_ATTEMPTS,
error = %error,
"sandbox sync operation failed; retrying"
);
tokio::time::sleep(SYNC_RETRY_DELAY).await;
attempt += 1;
}
Err(error) => return Err(error),
}
}
}
fn sync_error_is_retryable(error: &Report) -> bool {
let message = format!("{error:?}").to_ascii_lowercase();
[
"broken pipe",
"connection",
"early eof",
"http2",
"h2 protocol",
"reset before headers",
"service is currently unavailable",
"transport error",
"unexpected eof",
"unavailable",
"upstream connect error",
"ssh probe exited with status exit status: 255",
"ssh tar create exited",
"ssh tar extract exited",
"failed to extract tar archive from sandbox",
]
.iter()
.any(|needle| message.contains(needle))
}
/// Stream a tar archive from the sandbox and extract it into a fresh
/// destination directory. The source is always wrapped on the sandbox side so
/// the host can pick a basename when needed.
async fn stream_sandbox_tar(
session: &SshSessionConfig,
tar_cmd: String,
extract_into: &Path,
) -> Result<()> {
let mut ssh = ssh_base_command(&session.proxy_command);
ssh.arg("-T")
.arg("-o")
.arg("RequestTTY=no")
.arg("sandbox")
.arg(tar_cmd)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::inherit());
let mut child = ssh.spawn().into_diagnostic()?;
let stdout = child
.stdout
.take()
.ok_or_else(|| miette::miette!("failed to open stdout for ssh process"))?;
let extract_into = extract_into.to_path_buf();
tokio::task::spawn_blocking(move || -> Result<()> {
let mut archive = tar::Archive::new(stdout);
archive
.unpack(&extract_into)
.into_diagnostic()
.wrap_err("failed to extract tar archive from sandbox")?;
Ok(())
})
.await
.into_diagnostic()??;
let status = tokio::task::spawn_blocking(move || child.wait())
.await
.into_diagnostic()?
.into_diagnostic()?;
if !status.success() {
return Err(miette::miette!(
"ssh tar create exited with status {status}"
));
}
Ok(())
}
/// Build the `tar cf - -C <parent> -- <basename>` command used to wrap a
/// single sandbox-side file for download.
///
/// The trailing `--` is required: a sandbox-side file whose basename starts
/// with `-` (e.g. `--checkpoint-action=...`) would otherwise be parsed by GNU
/// tar as an option rather than a member to archive.
fn build_single_file_tar_cmd(parent: &str, basename: &str) -> String {
format!(
"tar cf - -C {parent} -- {name}",
parent = shell_escape(parent),
name = shell_escape(basename),
)
}
async fn sandbox_sync_down_file(
session: &SshSessionConfig,
sandbox_path: &str,
dest: &str,
) -> Result<()> {
let (parent, basename) = split_sandbox_path(sandbox_path);
let dest_exists_as_dir = fs::symlink_metadata(Path::new(dest)).is_ok_and(|m| m.is_dir());
let final_path = resolve_file_download_target(dest, basename, dest_exists_as_dir);
let staging_parent = final_path
.parent()
.ok_or_else(|| miette::miette!("destination '{}' has no parent directory", dest))?;
fs::create_dir_all(staging_parent)
.into_diagnostic()
.wrap_err_with(|| {
format!(
"failed to create local destination directory '{}'",
staging_parent.display()
)
})?;
let staging = tempfile::TempDir::new_in(staging_parent)
.into_diagnostic()
.wrap_err("failed to create download staging directory")?;
let tar_cmd = build_single_file_tar_cmd(parent, basename);
stream_sandbox_tar(session, tar_cmd, staging.path()).await?;
place_downloaded_file(staging.path(), basename, &final_path).wrap_err_with(|| {
format!(
"failed to place downloaded file at '{}'",
final_path.display()
)
})?;
Ok(())
}
/// Move a single file extracted by `stream_sandbox_tar` into its final
/// position on the host.
///
/// `staging_dir` must contain a single regular-file entry named
/// `source_basename` (the wrapper produced by `tar cf - -C <parent> <name>`).
/// The entry is renamed onto `final_path`, atomically when `staging_dir` is
/// on the same filesystem. Refuses to overwrite an existing directory at
/// `final_path` to match `cp` behaviour.
fn place_downloaded_file(
staging_dir: &Path,
source_basename: &str,
final_path: &Path,
) -> Result<()> {
let staged_file = staging_dir.join(source_basename);
let staged_meta = fs::symlink_metadata(&staged_file)
.into_diagnostic()
.wrap_err("downloaded archive did not contain the expected entry")?;
if !staged_meta.is_file() {
return Err(miette::miette!(
"downloaded entry '{source_basename}' is not a regular file"
));
}
if let Ok(existing) = fs::symlink_metadata(final_path)
&& existing.is_dir()
{
return Err(miette::miette!(
"cannot overwrite directory '{}' with downloaded file",
final_path.display()
));
}
fs::rename(&staged_file, final_path)
.into_diagnostic()
.wrap_err_with(|| format!("failed to rename into '{}'", final_path.display()))?;
Ok(())
}
async fn sandbox_sync_down_directory(
session: &SshSessionConfig,
sandbox_path: &str,
dest: &str,
) -> Result<()> {
let dest_path = Path::new(dest);
if let Ok(existing) = fs::symlink_metadata(dest_path)
&& !existing.is_dir()
{
return Err(miette::miette!(
"cannot extract directory '{sandbox_path}' over non-directory destination '{}'",
dest_path.display()
));
}
fs::create_dir_all(dest_path)
.into_diagnostic()
.wrap_err_with(|| {
format!(
"failed to create local destination directory '{}'",
dest_path.display()
)
})?;
let tar_cmd = format!("tar cf - -C {path} .", path = shell_escape(sandbox_path));
stream_sandbox_tar(session, tar_cmd, dest_path).await
}
/// Run the SSH proxy, connecting stdin/stdout to the gateway.
pub async fn sandbox_ssh_proxy(
gateway_url: &str,
sandbox_name: &str,
workspace: &str,
token: &str,
tls: &TlsOptions,
) -> Result<()> {
let server = grpc_server_from_ssh_gateway_url(gateway_url)?;
let mut client = grpc_client(&server, tls).await?;
let (tx, rx) = tokio::sync::mpsc::channel::<TcpForwardFrame>(16);
tx.send(TcpForwardFrame {
payload: Some(openshell_core::proto::tcp_forward_frame::Payload::Init(
TcpForwardInit {
sandbox: sandbox_name.to_string(),
workspace: (workspace).to_string(),
service_id: format!("ssh-proxy:{sandbox_name}"),
target: Some(tcp_forward_init::Target::Ssh(SshRelayTarget {})),
authorization_token: token.to_string(),
},
)),
})
.await
.map_err(|_| miette::miette!("failed to initialize SSH forward stream"))?;
let mut response = client
.forward_tcp(ReceiverStream::new(rx))
.await
.into_diagnostic()?
.into_inner();
let stdin = tokio::io::stdin();
let stdout = tokio::io::stdout();
let to_remote = tokio::spawn(async move {
let mut stdin = stdin;
let mut buf = vec![0u8; 64 * 1024];
while let Ok(n) = stdin.read(&mut buf).await {
if n == 0 {
break;
}
if tx
.send(TcpForwardFrame {
payload: Some(openshell_core::proto::tcp_forward_frame::Payload::Data(
buf[..n].to_vec(),
)),
})
.await
.is_err()
{
break;
}
}
});
let from_remote = tokio::spawn(async move {
let mut stdout = stdout;
loop {
let Ok(Some(frame)) = response.message().await else {
break;
};
let Some(openshell_core::proto::tcp_forward_frame::Payload::Data(data)) = frame.payload
else {
continue;
};
if data.is_empty() {
continue;
}
if stdout.write_all(&data).await.is_err() {
break;
}
let _ = stdout.flush().await;
}
});
let _ = from_remote.await;
to_remote.abort();
Ok(())
}
fn grpc_server_from_ssh_gateway_url(gateway_url: &str) -> Result<String> {
let url: url::Url = gateway_url
.parse()
.into_diagnostic()
.wrap_err("invalid gateway URL")?;
let scheme = url.scheme();
let gateway_host = url
.host_str()
.ok_or_else(|| miette::miette!("gateway URL missing host"))?;
let gateway_port = url
.port_or_known_default()
.ok_or_else(|| miette::miette!("gateway URL missing port"))?;
Ok(format_gateway_url(scheme, gateway_host, gateway_port))
}
/// Run the SSH proxy in "name mode": create a session on the fly, then proxy.
///
/// This is equivalent to [`sandbox_ssh_proxy`] but accepts a cluster endpoint
/// and sandbox name instead of pre-created gateway/token credentials. It is
/// suitable for use as an SSH `ProxyCommand` in `~/.ssh/config` because it
/// creates a fresh session on every invocation.
pub async fn sandbox_ssh_proxy_by_name(
server: &str,
name: &str,
tls: &TlsOptions,
workspace: &str,
) -> Result<()> {
let session = ssh_session_config(server, name, tls, workspace, None).await?;
sandbox_ssh_proxy(
&session.gateway_url,
&session.sandbox_name,
workspace,
&session.token,
tls,
)
.await
}
fn host_alias(name: &str, workspace: &str) -> String {
format!("openshell-{name}.{workspace}")
}
fn render_ssh_config(gateway: &str, name: &str, workspace: &str) -> String {
let exe = std::env::current_exe().expect("failed to resolve OpenShell executable");
let exe = shell_escape(&exe.to_string_lossy());
let proxy_cmd = format!(
"{exe} ssh-proxy --gateway-name {} --name {} --workspace {}",
shell_escape(gateway),
shell_escape(name),
shell_escape(workspace),
);
let host_alias = host_alias(name, workspace);
format!(
"Host {host_alias}\n User sandbox\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null\n GlobalKnownHostsFile /dev/null\n LogLevel ERROR\n ServerAliveInterval 15\n ServerAliveCountMax 3\n ProxyCommand {proxy_cmd}\n"
)
}
fn openshell_ssh_config_path() -> Result<PathBuf> {
Ok(openshell_core::paths::xdg_config_dir()?
.join("openshell")
.join("ssh_config"))
}
fn user_ssh_config_path() -> Result<PathBuf> {
let home = std::env::var("HOME")
.into_diagnostic()
.wrap_err("HOME is not set")?;
Ok(PathBuf::from(home).join(".ssh").join("config"))
}
fn render_include_line(path: &Path) -> String {
format!("Include \"{}\"", path.display())
}
fn ssh_config_includes_path(contents: &str, path: &Path) -> bool {
let quoted = format!("\"{}\"", path.display());
let plain = path.display().to_string();
contents.lines().any(|line| {
let trimmed = line.trim();
if !trimmed.starts_with("Include ") {
return false;
}
trimmed["Include ".len()..]
.split_whitespace()
.any(|token| token == quoted || token == plain)
})
}
fn ensure_openshell_include(main_config: &Path, managed_config: &Path) -> Result<()> {
if let Some(parent) = main_config.parent() {
fs::create_dir_all(parent)
.into_diagnostic()
.wrap_err("failed to create ~/.ssh directory")?;
}
let include_line = render_include_line(managed_config);
let contents = fs::read_to_string(main_config).unwrap_or_default();
let mut lines: Vec<&str> = contents.lines().collect();
lines.retain(|line| !ssh_config_includes_path(line, managed_config));
let insert_at = lines
.iter()
.position(|line| {
let trimmed = line.trim_start();
trimmed.starts_with("Host ") || trimmed.starts_with("Match ")
})
.unwrap_or(lines.len());
let mut out = Vec::new();
out.extend_from_slice(&lines[..insert_at]);
if !out.is_empty() && !out.last().is_some_and(|line| line.is_empty()) {
out.push("");
}
out.push(&include_line);
if insert_at < lines.len() && !lines[insert_at].is_empty() {
out.push("");
}
out.extend_from_slice(&lines[insert_at..]);
let mut rendered = out.join("\n");
if !rendered.is_empty() {
rendered.push('\n');
}
fs::write(main_config, rendered)
.into_diagnostic()
.wrap_err("failed to update ~/.ssh/config")?;
Ok(())
}
fn host_line_matches(line: &str, alias: &str) -> bool {
let trimmed = line.trim_start();
if !trimmed.starts_with("Host ") {
return false;
}
trimmed["Host ".len()..]
.split_whitespace()
.any(|token| token == alias)
}
fn upsert_host_block(contents: &str, alias: &str, block: &str) -> String {
let lines: Vec<&str> = contents.lines().collect();
let start = lines.iter().position(|line| host_line_matches(line, alias));
let mut out = Vec::new();
if let Some(start) = start {
let end = lines
.iter()
.enumerate()
.skip(start + 1)
.find(|(_, line)| line.trim_start().starts_with("Host "))
.map_or(lines.len(), |(idx, _)| idx);
out.extend_from_slice(&lines[..start]);
if !out.is_empty() && !out.last().is_some_and(|line| line.is_empty()) {
out.push("");
}
out.extend(block.lines());
if end < lines.len() && !lines[end..].first().is_some_and(|line| line.is_empty()) {
out.push("");
}
out.extend_from_slice(&lines[end..]);
} else {
out.extend_from_slice(&lines);
if !out.is_empty() && !out.last().is_some_and(|line| line.is_empty()) {
out.push("");
}
out.extend(block.lines());
}
let mut rendered = out.join("\n");
if !rendered.is_empty() {
rendered.push('\n');
}
rendered
}
pub fn install_ssh_config(gateway: &str, name: &str, workspace: &str) -> Result<PathBuf> {
let managed_config = openshell_ssh_config_path()?;
let main_config = user_ssh_config_path()?;
ensure_openshell_include(&main_config, &managed_config)?;
if let Some(parent) = managed_config.parent() {
openshell_core::paths::create_dir_restricted(parent)?;
}
let alias = host_alias(name, workspace);
let block = render_ssh_config(gateway, name, workspace);
let contents = fs::read_to_string(&managed_config).unwrap_or_default();
let updated = upsert_host_block(&contents, &alias, &block);
fs::write(&managed_config, updated)
.into_diagnostic()
.wrap_err("failed to write OpenShell SSH config")?;
Ok(managed_config)
}
fn launch_editor(editor: Editor, host_alias: &str, workspace_root: &str) -> Result<()> {
launch_editor_command(
editor.binary(),
editor.label(),
&Editor::remote_target(host_alias),
workspace_root,
)
}
fn launch_editor_command(
binary: &str,
label: &str,
remote_target: &str,
workspace_root: &str,
) -> Result<()> {
let status = Command::new(binary)
.arg("--remote")
.arg(remote_target)
.arg(workspace_root)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
match status {
Ok(_) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Err(miette::miette!(
"{} is not installed or not on PATH",
binary
)),
Err(err) => Err(err)
.into_diagnostic()
.wrap_err(format!("failed to launch {label}")),
}
}
/// Print an SSH config `Host` block for a sandbox to stdout.
///
/// The output is suitable for appending to `~/.ssh/config` so that tools like
/// `VSCode` Remote-SSH can connect to the sandbox by host alias.
///
/// The `ProxyCommand` uses `--gateway-name` so that `ssh-proxy` resolves the
/// gateway endpoint and TLS certificates from the gateway metadata directory
/// (`~/.config/openshell/gateways/<name>/mtls/`).
pub fn print_ssh_config(gateway: &str, name: &str, workspace: &str) {
print!("{}", render_ssh_config(gateway, name, workspace));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::TEST_ENV_LOCK;
#[test]
fn upsert_host_block_appends_when_missing() {
let input = "Host existing\n HostName example.com\n";
let block = "Host openshell-demo\n User sandbox\n";
let output = upsert_host_block(input, "openshell-demo", block);
assert!(output.contains("Host existing"));
assert!(output.contains("Host openshell-demo"));
assert_eq!(output.matches("Host openshell-demo").count(), 1);
}
#[test]
fn upsert_host_block_replaces_existing_without_duplicates() {
let input = "Host openshell-demo\n User old\n\nHost other\n HostName other.example\n";
let block = "Host openshell-demo\n User sandbox\n LogLevel ERROR\n";
let output = upsert_host_block(input, "openshell-demo", block);
assert!(!output.contains("User old"));
assert!(output.contains("LogLevel ERROR"));
assert!(output.contains("Host other"));
assert_eq!(output.matches("Host openshell-demo").count(), 1);
}
#[test]
fn sync_error_retry_filter_accepts_transport_failures() {
let error = miette::miette!("transport error: connection reset by peer");
assert!(sync_error_is_retryable(&error));
}
#[test]
fn sync_error_retry_filter_accepts_transient_ssh_probe_failures() {
let error = Err::<(), _>(miette::miette!(
"ssh probe exited with status exit status: 255"
))
.wrap_err("failed to resolve sandbox source path '/sandbox/ha-sync/ha-sync-upload'")
.unwrap_err();
assert!(sync_error_is_retryable(&error));
}
#[test]
fn sync_error_retry_filter_rejects_validation_failures() {
let error = miette::miette!("sandbox source path '/etc/passwd' resolves outside /sandbox");
assert!(!sync_error_is_retryable(&error));
}
#[test]
#[allow(unsafe_code)] // Test-only: env vars require unsafe in Rust 2024.
fn install_ssh_config_adds_include_once_and_updates_managed_file() {
let _guard = TEST_ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let home = tempfile::tempdir().unwrap();
let xdg = tempfile::tempdir().unwrap();
let old_home = std::env::var("HOME").ok();
let old_xdg = std::env::var("XDG_CONFIG_HOME").ok();
unsafe {
std::env::set_var("HOME", home.path());
std::env::set_var("XDG_CONFIG_HOME", xdg.path());
}
let ssh_dir = home.path().join(".ssh");
fs::create_dir_all(&ssh_dir).unwrap();
let user_config = ssh_dir.join("config");
fs::write(&user_config, "Host personal\n HostName example.com\n").unwrap();
let managed_path = install_ssh_config("openshell", "demo", "default").unwrap();
install_ssh_config("openshell", "demo", "default").unwrap();
let main_contents = fs::read_to_string(&user_config).unwrap();
assert!(main_contents.contains("Host personal"));
assert_eq!(main_contents.matches("Include ").count(), 1);
assert!(main_contents.contains(&render_include_line(&managed_path)));
let include_idx = main_contents.find("Include ").unwrap();
let host_idx = main_contents.find("Host personal").unwrap();
assert!(include_idx < host_idx);
let managed_contents = fs::read_to_string(&managed_path).unwrap();
assert_eq!(
managed_contents
.matches("Host openshell-demo.default")
.count(),
1
);
assert!(managed_contents.contains("ProxyCommand"));
unsafe {
match old_home {
Some(val) => std::env::set_var("HOME", val),
None => std::env::remove_var("HOME"),
}
match old_xdg {
Some(val) => std::env::set_var("XDG_CONFIG_HOME", val),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
}
#[test]
fn render_ssh_config_includes_workspace_in_proxy_command() {
let config = render_ssh_config("my-gw", "demo", "beta");
assert!(
config.contains("Host openshell-demo.beta"),
"host alias should be workspace-qualified: {config}"
);
assert!(
config.contains("--workspace beta"),
"ProxyCommand should include --workspace: {config}"
);
}
#[test]
fn host_alias_includes_workspace() {
assert_eq!(host_alias("demo", "default"), "openshell-demo.default");
assert_eq!(host_alias("demo", "beta"), "openshell-demo.beta");
}
#[test]
fn launch_editor_returns_friendly_error_when_binary_missing() {
let err = launch_editor_command(
"openshell-test-missing-binary",
"Test Editor",
"ssh-remote+openshell-demo",
"/workspace/project",
)
.unwrap_err();
let text = format!("{err}");
assert!(text.contains("openshell-test-missing-binary is not installed or not on PATH"));
}
#[test]
fn foreground_forward_started_message_includes_port_and_stop_hint() {
let spec = ForwardSpec::new(8080);
let message = foreground_forward_started_message("demo", &spec);
assert!(message.contains("Forwarding port 8080 to sandbox demo"));
assert!(message.contains("Access at: http://127.0.0.1:8080/"));
assert!(message.contains("sandbox demo"));
assert!(message.contains("Press Ctrl+C to stop"));
assert!(message.contains(
"Hint: pass --background to start forwarding without blocking your terminal"
));
}
#[test]
fn foreground_forward_started_message_custom_bind_addr() {
let spec = ForwardSpec::parse("0.0.0.0:3000").unwrap();
let message = foreground_forward_started_message("demo", &spec);
assert!(message.contains("Forwarding port 3000 to sandbox demo"));
assert!(message.contains("Access at: http://localhost:3000/"));
}
#[test]
fn forward_probe_host_uses_connectable_loopback_for_wildcard_binds() {
let ipv4 = ForwardSpec::parse("0.0.0.0:3000").unwrap();
let ipv6 = ForwardSpec::parse(":::3000").unwrap();
let loopback = ForwardSpec::new(3000);
assert_eq!(forward_probe_host(&ipv4), "127.0.0.1");
assert_eq!(forward_probe_host(&ipv6), "::1");
assert_eq!(forward_probe_host(&loopback), "127.0.0.1");
}
#[tokio::test]
async fn wait_for_forward_listener_accepts_ready_listener() {
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
.await
.unwrap();
let port = listener.local_addr().unwrap().port();
let accept = tokio::spawn(async move {
let _ = listener.accept().await;
});
let spec = ForwardSpec::new(port);
wait_for_forward_listener(&spec, Duration::from_secs(1))
.await
.unwrap();
accept.await.unwrap();
}
#[tokio::test]
async fn wait_for_forward_listener_reports_failed_probe() {
let spec = ForwardSpec::new(12345);
let err = wait_for_forward_listener_with_probe(&spec, Duration::ZERO, |_| {
std::future::ready(Err("connection refused".to_string()))
})
.await
.unwrap_err();
let text = format!("{err:?}");
assert!(text.contains("local forward listener did not open"));
assert!(text.contains("connection refused"));
}
#[test]
#[allow(unsafe_code)] // Test-only: env vars require unsafe in Rust 2024.
fn track_background_forward_or_cleanup_runs_cleanup_when_pidfile_write_fails() {
let _guard = TEST_ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let tmp = tempfile::tempdir().unwrap();
// Make forward PID-file writes fail with ENOTDIR after listener readiness.
let blocking_file = tmp.path().join("not-a-dir");
fs::write(&blocking_file, b"x").unwrap();
let old_xdg = std::env::var("XDG_CONFIG_HOME").ok();
unsafe {
std::env::set_var("XDG_CONFIG_HOME", &blocking_file);
}
let mut cleaned_up = false;
let result = track_background_forward_or_cleanup(
"default",
"demo",
8080,
4242,
"sbx-1",
"127.0.0.1",
|| {
cleaned_up = true;
},
);
unsafe {
match old_xdg {
Some(val) => std::env::set_var("XDG_CONFIG_HOME", val),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
assert!(
result.is_err(),
"PID-file write failure must surface as an error"
);
assert!(
cleaned_up,
"the owned SSH child must be cleaned up when tracking fails so no \
reachable-but-untracked forward is left running"
);
}
#[test]
#[allow(unsafe_code)] // Test-only: env vars require unsafe in Rust 2024.
fn track_background_forward_or_cleanup_tracks_pid_without_cleanup_on_success() {
let _guard = TEST_ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let tmp = tempfile::tempdir().unwrap();
let old_xdg = std::env::var("XDG_CONFIG_HOME").ok();
unsafe {
std::env::set_var("XDG_CONFIG_HOME", tmp.path());
}
let mut cleaned_up = false;
let result = track_background_forward_or_cleanup(
"default",
"demo",
8080,
4242,
"sbx-1",
"127.0.0.1",
|| {
cleaned_up = true;
},
);
let pid_file_exists = openshell_core::forward::forward_pid_path("default", "demo", 8080)
.is_ok_and(|path| path.exists());
unsafe {
match old_xdg {
Some(val) => std::env::set_var("XDG_CONFIG_HOME", val),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
assert!(
result.is_ok(),
"a writable PID directory must track successfully"
);
assert!(
pid_file_exists,
"successful tracking must persist a PID file"
);
assert!(
!cleaned_up,
"successful tracking must not terminate the forward process"
);
}
#[test]
fn split_sandbox_path_separates_parent_and_basename() {
assert_eq!(
split_sandbox_path("/sandbox/.bashrc"),
("/sandbox", ".bashrc")
);
assert_eq!(
split_sandbox_path("/sandbox/sub/file"),
("/sandbox/sub", "file")
);
assert_eq!(split_sandbox_path("/a/b/c/d.txt"), ("/a/b/c", "d.txt"));
}
#[test]
fn lexical_clean_resolves_dot_and_dotdot_segments() {
assert_eq!(
lexical_clean_absolute_path("/sandbox/./a"),
Some("/sandbox/a".to_string())
);
assert_eq!(
lexical_clean_absolute_path("/sandbox/sub/../a"),
Some("/sandbox/a".to_string())
);
assert_eq!(
lexical_clean_absolute_path("/sandbox/../etc/passwd"),
Some("/etc/passwd".to_string())
);
assert_eq!(
lexical_clean_absolute_path("//sandbox///foo//"),
Some("/sandbox/foo".to_string())
);
assert_eq!(lexical_clean_absolute_path("/"), Some("/".to_string()));
}
#[test]
fn lexical_clean_refuses_relative_paths() {
assert_eq!(lexical_clean_absolute_path(""), None);
assert_eq!(lexical_clean_absolute_path("sandbox/a"), None);
assert_eq!(lexical_clean_absolute_path("./a"), None);
}
#[test]
fn validate_sandbox_source_path_accepts_workspace_paths() {
let workspace_root = "/workspace/project";
assert_eq!(
validate_sandbox_source_path(workspace_root, "/workspace/project/file.txt").unwrap(),
"/workspace/project/file.txt"
);
assert_eq!(
validate_sandbox_source_path(
workspace_root,
"/workspace/project/.agent/workspace/hello.txt"
)
.unwrap(),
"/workspace/project/.agent/workspace/hello.txt"
);
assert_eq!(
validate_sandbox_source_path(workspace_root, "/workspace/project").unwrap(),
"/workspace/project"
);
assert_eq!(
validate_sandbox_source_path(workspace_root, "/workspace/project/").unwrap(),
"/workspace/project"
);
assert_eq!(
validate_sandbox_source_path(workspace_root, "/workspace/project/sub/../file").unwrap(),
"/workspace/project/file"
);
assert_eq!(
validate_sandbox_source_path(workspace_root, "output/file.txt").unwrap(),
"/workspace/project/output/file.txt"
);
assert_eq!(
validate_sandbox_source_path(workspace_root, "./output/../file.txt").unwrap(),
"/workspace/project/file.txt"
);
}
#[test]
fn validate_sandbox_source_path_rejects_traversal_and_escapes() {
let workspace_root = "/workspace/project";
let traversal = validate_sandbox_source_path(workspace_root, "/etc/passwd").unwrap_err();
assert!(
format!("{traversal}").contains("outside the sandbox workspace"),
"unexpected error: {traversal}"
);
let parent_escape =
validate_sandbox_source_path(workspace_root, "/workspace/project/../../etc/passwd")
.unwrap_err();
assert!(
format!("{parent_escape}").contains("outside the sandbox workspace"),
"unexpected error: {parent_escape}"
);
let prefix_only =
validate_sandbox_source_path(workspace_root, "/workspace/projected/secrets")
.unwrap_err();
assert!(
format!("{prefix_only}").contains("outside the sandbox workspace"),
"unexpected error: {prefix_only}"
);
let empty = validate_sandbox_source_path(workspace_root, "").unwrap_err();
assert!(format!("{empty}").contains("empty"));
let relative_escape =
validate_sandbox_source_path(workspace_root, "../../etc/passwd").unwrap_err();
assert!(format!("{relative_escape}").contains("outside the sandbox workspace"));
}
#[test]
fn discovered_workspace_root_must_be_canonical_absolute_non_root() {
assert_eq!(
validate_discovered_workspace_root("/workspace/project").unwrap(),
"/workspace/project"
);
for invalid in ["", "workspace", "/", "/workspace/../etc", "/workspace/"] {
assert!(
validate_discovered_workspace_root(invalid).is_err(),
"expected '{invalid}' to be rejected"
);
}
}
#[test]
fn ssh_probe_output_only_removes_the_protocol_line_ending() {
assert_eq!(
decode_ssh_probe_stdout(b"/workspace/project \n".to_vec()).unwrap(),
"/workspace/project "
);
assert_eq!(
decode_ssh_probe_stdout(b"/workspace/project\r\n".to_vec()).unwrap(),
"/workspace/project"
);
assert!(decode_ssh_probe_stdout(vec![0xff]).is_err());
}
#[test]
fn build_single_file_tar_cmd_inserts_double_dash_before_basename() {
// Without `--`, a basename such as `--checkpoint-action=...` would be
// parsed by GNU tar as an option. Guard the wire format against this
// regression.
let cmd = build_single_file_tar_cmd("/sandbox", "--checkpoint-action=exec=id");
assert!(
cmd.contains(" -- "),
"expected `--` separator in tar command, got: {cmd}"
);
assert!(
cmd.ends_with(&shell_escape("--checkpoint-action=exec=id")),
"expected basename at end of tar command, got: {cmd}"
);
}
#[test]
fn build_single_file_tar_cmd_escapes_parent_and_basename() {
let cmd = build_single_file_tar_cmd("/sandbox/with space", "name with space");
assert!(cmd.contains(" -- "), "missing `--` separator: {cmd}");
assert!(
cmd.contains(&shell_escape("/sandbox/with space")),
"parent not shell-escaped: {cmd}"
);
assert!(
cmd.contains(&shell_escape("name with space")),
"basename not shell-escaped: {cmd}"
);
}
#[test]
fn resolve_file_download_target_writes_to_dest_when_not_a_directory() {
assert_eq!(
resolve_file_download_target("/tmp/out.txt", "hello.txt", false),
PathBuf::from("/tmp/out.txt")
);
}
#[test]
fn resolve_file_download_target_places_inside_existing_directory() {
assert_eq!(
resolve_file_download_target("/tmp", "hello.txt", true),
PathBuf::from("/tmp/hello.txt")
);
}
#[test]
fn resolve_file_download_target_honors_trailing_slash() {
assert_eq!(
resolve_file_download_target("/tmp/newdir/", "hello.txt", false),
PathBuf::from("/tmp/newdir/hello.txt")
);
}
fn build_single_file_archive(entry_path: &str, bytes: &[u8]) -> Vec<u8> {
let mut buf = Vec::new();
{
let mut builder = tar::Builder::new(&mut buf);
let mut header = tar::Header::new_gnu();
header.set_path(entry_path).expect("set tar entry path");
header.set_size(bytes.len() as u64);
header.set_mode(0o644);
header.set_entry_type(tar::EntryType::Regular);
header.set_cksum();
builder.append(&header, bytes).expect("append tar entry");
builder.finish().expect("finish tar archive");
}
buf
}
fn unpack_into(archive_bytes: &[u8], staging: &Path) {
let mut archive = tar::Archive::new(std::io::Cursor::new(archive_bytes));
archive.unpack(staging).expect("unpack archive");
}
#[test]
fn place_downloaded_file_writes_regular_file_at_dest() {
let workdir = tempfile::tempdir().expect("create workdir");
let staging = workdir.path().join("staging");
fs::create_dir_all(&staging).expect("create staging");
let archive = build_single_file_archive("hello.txt", b"trust me");
unpack_into(&archive, &staging);
let dest = workdir.path().join("out.txt");
place_downloaded_file(&staging, "hello.txt", &dest).expect("place file");
let meta = fs::symlink_metadata(&dest).expect("stat dest");
assert!(meta.is_file(), "dest must be a regular file, got {meta:?}");
assert_eq!(fs::read(&dest).expect("read dest"), b"trust me");
}
#[test]
fn place_downloaded_file_refuses_to_clobber_existing_directory() {
let workdir = tempfile::tempdir().expect("create workdir");
let staging = workdir.path().join("staging");
fs::create_dir_all(&staging).expect("create staging");
let archive = build_single_file_archive("hello.txt", b"trust me");
unpack_into(&archive, &staging);
let dest = workdir.path().join("conflict-dir");
fs::create_dir(&dest).expect("create conflict dir");
let err = place_downloaded_file(&staging, "hello.txt", &dest)
.expect_err("expected directory-clobber refusal");
assert!(
format!("{err}").contains("cannot overwrite directory"),
"unexpected error: {err}"
);
assert!(
fs::symlink_metadata(&dest).expect("stat dest").is_dir(),
"dest should remain a directory after refusal"
);
}
#[test]
fn download_full_pipeline_lands_file_at_exact_dest_path() {
let workdir = tempfile::tempdir().expect("create workdir");
let staging_parent = workdir.path();
let archive = build_single_file_archive("hello.txt", b"trust me");
let dest_str = staging_parent.join("out.txt");
let dest_str = dest_str.to_str().unwrap();
let final_path = resolve_file_download_target(dest_str, "hello.txt", false);
assert_eq!(final_path, Path::new(dest_str));
let staging = tempfile::TempDir::new_in(staging_parent).expect("staging dir");
unpack_into(&archive, staging.path());
place_downloaded_file(staging.path(), "hello.txt", &final_path).expect("place");
let meta = fs::symlink_metadata(&final_path).expect("stat final");
assert!(meta.is_file(), "expected regular file, got {meta:?}");
assert_eq!(fs::read(&final_path).expect("read final"), b"trust me");
}
#[test]
fn download_full_pipeline_places_inside_existing_directory_destination() {
let workdir = tempfile::tempdir().expect("create workdir");
let archive = build_single_file_archive("hello.txt", b"trust me");
let dest_dir = workdir.path().join("out-dir");
fs::create_dir(&dest_dir).expect("create dest dir");
let dest_str = dest_dir.to_str().unwrap();
let final_path = resolve_file_download_target(dest_str, "hello.txt", true);
assert_eq!(final_path, dest_dir.join("hello.txt"));
let staging = tempfile::TempDir::new_in(workdir.path()).expect("staging dir");
unpack_into(&archive, staging.path());
place_downloaded_file(staging.path(), "hello.txt", &final_path).expect("place");
let meta = fs::symlink_metadata(&final_path).expect("stat final");
assert!(meta.is_file());
assert_eq!(fs::read(&final_path).expect("read final"), b"trust me");
}
#[test]
fn directory_upload_prefix_uses_basename_for_named_directories() {
assert_eq!(
directory_upload_prefix(Path::new("/tmp/upload-test")),
std::ffi::OsString::from("upload-test")
);
assert_eq!(
directory_upload_prefix(Path::new("foo")),
std::ffi::OsString::from("foo")
);
assert_eq!(
directory_upload_prefix(Path::new("./parent/nested")),
std::ffi::OsString::from("nested")
);
}
#[test]
fn directory_upload_prefix_falls_back_to_dot_for_basename_less_paths() {
assert_eq!(
directory_upload_prefix(Path::new(".")),
std::ffi::OsString::from(".")
);
assert_eq!(
directory_upload_prefix(Path::new("/")),
std::ffi::OsString::from(".")
);
}
#[test]
fn file_list_archive_prefix_uses_named_directory_basename() {
let tmpdir = tempfile::tempdir().expect("create tmpdir");
let source = tmpdir.path().join("source-dir");
let file = tmpdir.path().join("file.txt");
fs::create_dir_all(&source).expect("create source dir");
fs::write(&file, "file").expect("write file");
assert_eq!(
file_list_archive_prefix(&source),
Some(PathBuf::from("source-dir"))
);
assert_eq!(file_list_archive_prefix(Path::new(".")), None);
assert_eq!(file_list_archive_prefix(&file), None);
}
#[derive(Debug)]
struct UploadArchiveEntry {
path: String,
#[cfg_attr(not(unix), allow(dead_code))]
entry_type: tar::EntryType,
#[cfg_attr(not(unix), allow(dead_code))]
link_name: Option<String>,
}
fn upload_archive_entries(source: UploadSource) -> Vec<UploadArchiveEntry> {
let mut bytes = Vec::new();
write_upload_archive(&mut bytes, source).expect("write upload archive");
let mut archive = tar::Archive::new(std::io::Cursor::new(bytes));
let entries = archive.entries().expect("read archive entries");
let mut entries = entries
.map(|entry| {
let entry = entry.expect("read archive entry");
let path = entry
.path()
.expect("read archive path")
.to_string_lossy()
.into_owned();
let entry_type = entry.header().entry_type();
let link_name = entry
.link_name()
.expect("read archive link")
.map(|link| link.to_string_lossy().into_owned());
UploadArchiveEntry {
path,
entry_type,
link_name,
}
})
.collect::<Vec<_>>();
entries.sort_by(|left, right| left.path.cmp(&right.path));
entries
}
fn upload_archive_paths(source: UploadSource) -> Vec<String> {
let mut paths = upload_archive_entries(source)
.into_iter()
.map(|entry| entry.path)
.collect::<Vec<_>>();
paths.sort();
paths
}
#[test]
fn file_list_archive_preserves_directory_prefix_when_requested() {
let tmpdir = tempfile::tempdir().expect("create tmpdir");
let base_dir = tmpdir.path().join("nested");
fs::create_dir_all(base_dir.join("inner")).expect("create dirs");
fs::write(base_dir.join("file.txt"), "file").expect("write file");
fs::write(base_dir.join("inner/child.txt"), "child").expect("write child");
let paths = upload_archive_paths(UploadSource::FileList {
base_dir,
files: vec!["file.txt".into(), "inner/child.txt".into()],
archive_prefix: Some(PathBuf::from("nested")),
});
assert_eq!(paths, vec!["nested/file.txt", "nested/inner/child.txt"]);
}
#[test]
fn file_list_archive_stays_flat_without_directory_prefix() {
let tmpdir = tempfile::tempdir().expect("create tmpdir");
let base_dir = tmpdir.path().join("nested");
fs::create_dir_all(base_dir.join("inner")).expect("create dirs");
fs::write(base_dir.join("file.txt"), "file").expect("write file");
fs::write(base_dir.join("inner/child.txt"), "child").expect("write child");
let paths = upload_archive_paths(UploadSource::FileList {
base_dir,
files: vec!["file.txt".into(), "inner/child.txt".into()],
archive_prefix: None,
});
assert_eq!(paths, vec!["file.txt", "inner/child.txt"]);
}
#[test]
fn single_directory_archive_preserves_directory_basename() {
let tmpdir = tempfile::tempdir().expect("create tmpdir");
let source = tmpdir.path().join("source-dir");
fs::create_dir_all(source.join("inner")).expect("create dirs");
fs::write(source.join("file.txt"), "file").expect("write file");
fs::write(source.join("inner/child.txt"), "child").expect("write child");
let paths = upload_archive_paths(UploadSource::SinglePath {
local_path: source,
tar_name: "source-dir".into(),
});
assert!(paths.contains(&"source-dir/file.txt".to_string()));
assert!(paths.contains(&"source-dir/inner/child.txt".to_string()));
assert!(paths.iter().all(|path| path.starts_with("source-dir/")));
}
#[cfg(unix)]
#[test]
fn single_directory_archive_preserves_symlink_entries() {
let tmpdir = tempfile::tempdir().expect("create tmpdir");
let source = tmpdir.path().join("source-dir");
fs::create_dir_all(source.join("real-dir")).expect("create dirs");
fs::write(source.join("real-dir/file.txt"), "file").expect("write file");
std::os::unix::fs::symlink("real-dir", source.join("link-dir")).expect("create symlink");
let entries = upload_archive_entries(UploadSource::SinglePath {
local_path: source,
tar_name: "source-dir".into(),
});
let symlink = entries
.iter()
.find(|entry| entry.path == "source-dir/link-dir")
.expect("symlink archive entry");
assert_eq!(symlink.entry_type, tar::EntryType::Symlink);
assert_eq!(symlink.link_name.as_deref(), Some("real-dir"));
assert!(
entries
.iter()
.all(|entry| entry.path != "source-dir/link-dir/file.txt"),
"symlink target should not be expanded into the archive: {entries:?}"
);
}
#[cfg(unix)]
#[test]
fn file_list_archive_preserves_symlink_entries() {
let tmpdir = tempfile::tempdir().expect("create tmpdir");
let base_dir = tmpdir.path().join("nested");
fs::create_dir_all(base_dir.join("real-dir")).expect("create dirs");
fs::write(base_dir.join("real-dir/file.txt"), "file").expect("write file");
std::os::unix::fs::symlink("real-dir", base_dir.join("link-dir")).expect("create symlink");
let entries = upload_archive_entries(UploadSource::FileList {
base_dir,
files: vec!["link-dir".into()],
archive_prefix: Some(PathBuf::from("nested")),
});
assert_eq!(entries.len(), 1, "unexpected archive entries: {entries:?}");
let symlink = &entries[0];
assert_eq!(symlink.path, "nested/link-dir");
assert_eq!(symlink.entry_type, tar::EntryType::Symlink);
assert_eq!(symlink.link_name.as_deref(), Some("real-dir"));
}
#[cfg(unix)]
#[test]
fn single_symlink_archive_preserves_link_target() {
let tmpdir = tempfile::tempdir().expect("create tmpdir");
fs::write(tmpdir.path().join("target.txt"), "target").expect("write target");
let link = tmpdir.path().join("link.txt");
std::os::unix::fs::symlink("target.txt", &link).expect("create symlink");
let entries = upload_archive_entries(UploadSource::SinglePath {
local_path: link,
tar_name: "uploaded-link.txt".into(),
});
assert_eq!(entries.len(), 1, "unexpected archive entries: {entries:?}");
assert_eq!(entries[0].path, "uploaded-link.txt");
assert_eq!(entries[0].entry_type, tar::EntryType::Symlink);
assert_eq!(entries[0].link_name.as_deref(), Some("target.txt"));
}
#[cfg(unix)]
#[test]
fn dangling_single_symlink_archive_preserves_link_target() {
let tmpdir = tempfile::tempdir().expect("create tmpdir");
let link = tmpdir.path().join("dangling-link.txt");
std::os::unix::fs::symlink("missing.txt", &link).expect("create symlink");
let entries = upload_archive_entries(UploadSource::SinglePath {
local_path: link,
tar_name: "dangling-link.txt".into(),
});
assert_eq!(entries.len(), 1, "unexpected archive entries: {entries:?}");
assert_eq!(entries[0].path, "dangling-link.txt");
assert_eq!(entries[0].entry_type, tar::EntryType::Symlink);
assert_eq!(entries[0].link_name.as_deref(), Some("missing.txt"));
}
#[test]
fn split_sandbox_path_handles_root_and_bare_names() {
// File directly under root
assert_eq!(split_sandbox_path("/.bashrc"), ("/", ".bashrc"));
// No directory component at all
assert_eq!(split_sandbox_path("file.txt"), (".", "file.txt"));
}
}