Skip to content

Commit ada445f

Browse files
authored
Merge pull request #35 from PotLock/fix/sandbox-workspace-path
fix(sandbox): create sandbox in user workspace instead of temp directory
2 parents 963efb8 + aaca621 commit ada445f

1 file changed

Lines changed: 290 additions & 10 deletions

File tree

src/sandbox/local.rs

Lines changed: 290 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
//! Local process sandbox provider — runs commands in an isolated temp directory.
1+
//! Local process sandbox provider — runs commands in an isolated directory.
22
//!
3-
//! No external API key or Docker daemon required. Creates a temporary directory
4-
//! under `$TMPDIR/zerobuild-sandbox-{uuid}/`, runs commands via
5-
//! `tokio::process::Command` with a restricted environment, and constrains all
6-
//! file operations to the sandbox directory (rejects `..` path components).
3+
//! No external API key or Docker daemon required. Creates a sandbox directory
4+
//! under `~/.zerobuild/workspace/sandbox/zerobuild-sandbox-{uuid}/` (or custom
5+
//! path via `$ZEROBUILD_SANDBOX_PATH`), runs commands via `tokio::process::Command`
6+
//! with a restricted environment, and constrains all file operations to the
7+
//! sandbox directory (rejects `..` path components).
78
//!
89
//! **Isolation model:**
910
//! - Filesystem: path-constrained to sandbox dir; `..` components are rejected.
@@ -21,7 +22,9 @@ use super::{CommandOutput, PackageManager, SandboxClient};
2122
use anyhow::Context as _;
2223
use async_trait::async_trait;
2324
use parking_lot::Mutex;
25+
2426
use std::collections::HashMap;
27+
use std::ffi::OsString;
2528
use std::path::{Component, Path, PathBuf};
2629
use std::sync::Arc;
2730
use uuid::Uuid;
@@ -124,11 +127,49 @@ impl SandboxClient for LocalProcessSandboxClient {
124127
}
125128
*self.sandbox_id.lock() = None;
126129

127-
// Create new sandbox dir: $TMPDIR/zerobuild-sandbox-{uuid}/
128-
let tmp_base = std::env::temp_dir();
129-
let sandbox_dir = tmp_base.join(format!("zerobuild-sandbox-{}", Uuid::new_v4()));
130-
std::fs::create_dir_all(&sandbox_dir)
131-
.map_err(|e| anyhow::anyhow!("Failed to create sandbox dir: {e}"))?;
130+
// Determine sandbox base directory
131+
// Priority: $ZEROBUILD_SANDBOX_PATH > ~/.zerobuild/workspace/sandbox/
132+
let sandbox_base = if let Ok(custom_path) = std::env::var("ZEROBUILD_SANDBOX_PATH") {
133+
// Validate custom path
134+
if custom_path.is_empty() {
135+
anyhow::bail!("ZEROBUILD_SANDBOX_PATH environment variable is set but empty");
136+
}
137+
let path = PathBuf::from(&custom_path);
138+
// Reject relative paths - require absolute paths
139+
if !path.is_absolute() {
140+
anyhow::bail!(
141+
"ZEROBUILD_SANDBOX_PATH must be an absolute path, got: {}",
142+
custom_path
143+
);
144+
}
145+
// Reject paths containing parent directory traversal (..)
146+
if path.components().any(|c| matches!(c, Component::ParentDir)) {
147+
anyhow::bail!(
148+
"ZEROBUILD_SANDBOX_PATH contains parent directory traversal (..): {}",
149+
custom_path
150+
);
151+
}
152+
path
153+
} else {
154+
// Default to ~/.zerobuild/workspace/sandbox/
155+
let home = std::env::var("HOME")
156+
.or_else(|_| std::env::var("USERPROFILE"))
157+
.map_err(|_| anyhow::anyhow!("Unable to determine home directory"))?;
158+
PathBuf::from(home)
159+
.join(".zerobuild")
160+
.join("workspace")
161+
.join("sandbox")
162+
};
163+
164+
// Create new sandbox dir: {sandbox_base}/zerobuild-sandbox-{uuid}/
165+
let sandbox_dir = sandbox_base.join(format!("zerobuild-sandbox-{}", Uuid::new_v4()));
166+
std::fs::create_dir_all(&sandbox_dir).map_err(|e| {
167+
anyhow::anyhow!(
168+
"Failed to create sandbox dir at {}: {}",
169+
sandbox_dir.display(),
170+
e
171+
)
172+
})?;
132173

133174
// Pre-create sub-directories used for npm cache redirection
134175
for sub in &[".npm-cache", ".npm-global", "tmp"] {
@@ -475,6 +516,11 @@ fn collect_files_recursive(base: &Path, dir: &Path, out: &mut HashMap<String, St
475516
#[cfg(test)]
476517
mod tests {
477518
use super::*;
519+
use std::sync::Mutex;
520+
521+
// Mutex to serialize tests that mutate ZEROBUILD_SANDBOX_PATH
522+
// Use std::sync::Mutex for test synchronization (parking_lot::Mutex doesn't work well with test isolation)
523+
static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
478524

479525
#[test]
480526
fn safe_join_normal_path() {
@@ -559,6 +605,9 @@ mod tests {
559605

560606
#[tokio::test]
561607
async fn write_and_read_file() {
608+
// Serialize with env var tests to avoid interference
609+
let _guard = ENV_MUTEX.lock().unwrap();
610+
562611
let client = LocalProcessSandboxClient::new();
563612
client.create_sandbox(false, "", 30_000).await.unwrap();
564613
client
@@ -617,4 +666,235 @@ mod tests {
617666
let url = client.get_preview_url(3000).await.unwrap();
618667
assert_eq!(url, "http://localhost:3000");
619668
}
669+
670+
// Tests for sandbox path selection logic (ZEROBUILD_SANDBOX_PATH validation)
671+
672+
#[tokio::test]
673+
async fn sandbox_path_uses_custom_absolute_path_from_env() {
674+
// Serialize tests that mutate environment variables
675+
let _guard = ENV_MUTEX.lock().unwrap();
676+
677+
// Save original env state
678+
let original_env = std::env::var_os("ZEROBUILD_SANDBOX_PATH");
679+
680+
// Cleanup guard - ensures env is restored even on panic
681+
struct EnvGuard(Option<OsString>);
682+
impl Drop for EnvGuard {
683+
fn drop(&mut self) {
684+
match &self.0 {
685+
Some(val) => std::env::set_var("ZEROBUILD_SANDBOX_PATH", val),
686+
None => std::env::remove_var("ZEROBUILD_SANDBOX_PATH"),
687+
}
688+
}
689+
}
690+
let _env_guard = EnvGuard(original_env);
691+
692+
// Create a temporary directory to use as custom sandbox base
693+
let temp_dir = tempfile::tempdir().unwrap();
694+
let custom_path = temp_dir.path().to_path_buf();
695+
696+
// Set the environment variable
697+
std::env::set_var("ZEROBUILD_SANDBOX_PATH", custom_path.as_os_str());
698+
699+
let client = LocalProcessSandboxClient::new();
700+
let id = client.create_sandbox(false, "", 30_000).await.unwrap();
701+
702+
// Verify sandbox was created under the custom path
703+
let custom_path_str = custom_path
704+
.to_str()
705+
.expect("custom path should be valid UTF-8");
706+
assert!(
707+
id.starts_with(custom_path_str),
708+
"Sandbox should be under custom path: {}",
709+
id
710+
);
711+
assert!(
712+
id.contains("zerobuild-sandbox-"),
713+
"Sandbox dir should contain 'zerobuild-sandbox-': {}",
714+
id
715+
);
716+
assert!(
717+
std::path::Path::new(&id).exists(),
718+
"Sandbox directory should exist"
719+
);
720+
721+
// Clean up sandbox (env will be restored by guard)
722+
client.kill_sandbox().await.unwrap();
723+
}
724+
725+
#[tokio::test]
726+
async fn sandbox_path_falls_back_to_default_when_env_not_set() {
727+
// Serialize tests that mutate environment variables
728+
let _guard = ENV_MUTEX.lock().unwrap();
729+
730+
// Save original env state
731+
let original_env = std::env::var_os("ZEROBUILD_SANDBOX_PATH");
732+
733+
// Cleanup guard - ensures env is restored even on panic
734+
struct EnvGuard(Option<OsString>);
735+
impl Drop for EnvGuard {
736+
fn drop(&mut self) {
737+
match &self.0 {
738+
Some(val) => std::env::set_var("ZEROBUILD_SANDBOX_PATH", val),
739+
None => std::env::remove_var("ZEROBUILD_SANDBOX_PATH"),
740+
}
741+
}
742+
}
743+
let _env_guard = EnvGuard(original_env);
744+
745+
// Ensure env var is not set for this test
746+
std::env::remove_var("ZEROBUILD_SANDBOX_PATH");
747+
748+
let client = LocalProcessSandboxClient::new();
749+
let id = client.create_sandbox(false, "", 30_000).await.unwrap();
750+
751+
// Verify sandbox was created under default location (~/.zerobuild/workspace/sandbox/)
752+
let home = std::env::var("HOME")
753+
.or_else(|_| std::env::var("USERPROFILE"))
754+
.expect("HOME or USERPROFILE should be set");
755+
let expected_base = std::path::PathBuf::from(home)
756+
.join(".zerobuild")
757+
.join("workspace")
758+
.join("sandbox");
759+
760+
let expected_base_str = expected_base
761+
.to_str()
762+
.expect("expected base should be valid UTF-8");
763+
assert!(
764+
id.starts_with(expected_base_str),
765+
"Sandbox should be under default path {} but got: {}",
766+
expected_base.display(),
767+
id
768+
);
769+
assert!(
770+
std::path::Path::new(&id).exists(),
771+
"Sandbox directory should exist"
772+
);
773+
774+
// Clean up sandbox (env will be restored by guard)
775+
client.kill_sandbox().await.unwrap();
776+
}
777+
778+
#[tokio::test]
779+
async fn sandbox_path_rejects_empty_env_var() {
780+
// Serialize tests that mutate environment variables
781+
let _guard = ENV_MUTEX.lock().unwrap();
782+
783+
// Save original env state
784+
let original_env = std::env::var_os("ZEROBUILD_SANDBOX_PATH");
785+
786+
// Cleanup guard - ensures env is restored even on panic
787+
struct EnvGuard(Option<OsString>);
788+
impl Drop for EnvGuard {
789+
fn drop(&mut self) {
790+
match &self.0 {
791+
Some(val) => std::env::set_var("ZEROBUILD_SANDBOX_PATH", val),
792+
None => std::env::remove_var("ZEROBUILD_SANDBOX_PATH"),
793+
}
794+
}
795+
}
796+
let _env_guard = EnvGuard(original_env);
797+
798+
// Set empty environment variable
799+
std::env::set_var("ZEROBUILD_SANDBOX_PATH", "");
800+
801+
let client = LocalProcessSandboxClient::new();
802+
let result = client.create_sandbox(false, "", 30_000).await;
803+
804+
// Should fail with error about empty path
805+
assert!(
806+
result.is_err(),
807+
"Should fail when ZEROBUILD_SANDBOX_PATH is empty"
808+
);
809+
let err = result.unwrap_err().to_string();
810+
assert!(
811+
err.contains("empty"),
812+
"Error should mention empty variable: {}",
813+
err
814+
);
815+
816+
// Env will be restored by guard
817+
}
818+
819+
#[tokio::test]
820+
async fn sandbox_path_rejects_relative_path() {
821+
// Serialize tests that mutate environment variables
822+
let _guard = ENV_MUTEX.lock().unwrap();
823+
824+
// Save original env state
825+
let original_env = std::env::var_os("ZEROBUILD_SANDBOX_PATH");
826+
827+
// Cleanup guard - ensures env is restored even on panic
828+
struct EnvGuard(Option<OsString>);
829+
impl Drop for EnvGuard {
830+
fn drop(&mut self) {
831+
match &self.0 {
832+
Some(val) => std::env::set_var("ZEROBUILD_SANDBOX_PATH", val),
833+
None => std::env::remove_var("ZEROBUILD_SANDBOX_PATH"),
834+
}
835+
}
836+
}
837+
let _env_guard = EnvGuard(original_env);
838+
839+
// Set a relative path
840+
std::env::set_var("ZEROBUILD_SANDBOX_PATH", "relative/path/to/sandbox");
841+
842+
let client = LocalProcessSandboxClient::new();
843+
let result = client.create_sandbox(false, "", 30_000).await;
844+
845+
// Should fail with error about relative path
846+
assert!(
847+
result.is_err(),
848+
"Should fail when ZEROBUILD_SANDBOX_PATH is relative"
849+
);
850+
let err = result.unwrap_err().to_string();
851+
assert!(
852+
err.contains("absolute"),
853+
"Error should mention absolute path requirement: {}",
854+
err
855+
);
856+
857+
// Env will be restored by guard
858+
}
859+
860+
#[tokio::test]
861+
async fn sandbox_path_rejects_parent_traversal() {
862+
// Serialize tests that mutate environment variables
863+
let _guard = ENV_MUTEX.lock().unwrap();
864+
865+
// Save original env state
866+
let original_env = std::env::var_os("ZEROBUILD_SANDBOX_PATH");
867+
868+
// Cleanup guard - ensures env is restored even on panic
869+
struct EnvGuard(Option<OsString>);
870+
impl Drop for EnvGuard {
871+
fn drop(&mut self) {
872+
match &self.0 {
873+
Some(val) => std::env::set_var("ZEROBUILD_SANDBOX_PATH", val),
874+
None => std::env::remove_var("ZEROBUILD_SANDBOX_PATH"),
875+
}
876+
}
877+
}
878+
let _env_guard = EnvGuard(original_env);
879+
880+
// Set a path with parent directory traversal
881+
std::env::set_var("ZEROBUILD_SANDBOX_PATH", "/tmp/../etc/sandbox");
882+
883+
let client = LocalProcessSandboxClient::new();
884+
let result = client.create_sandbox(false, "", 30_000).await;
885+
886+
// Should fail with error about parent traversal
887+
assert!(
888+
result.is_err(),
889+
"Should fail when ZEROBUILD_SANDBOX_PATH contains .."
890+
);
891+
let err = result.unwrap_err().to_string();
892+
assert!(
893+
err.contains("parent directory traversal") || err.contains(".."),
894+
"Error should mention parent directory traversal: {}",
895+
err
896+
);
897+
898+
// Env will be restored by guard
899+
}
620900
}

0 commit comments

Comments
 (0)