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};
2122use anyhow:: Context as _;
2223use async_trait:: async_trait;
2324use parking_lot:: Mutex ;
25+
2426use std:: collections:: HashMap ;
27+ use std:: ffi:: OsString ;
2528use std:: path:: { Component , Path , PathBuf } ;
2629use std:: sync:: Arc ;
2730use 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) ]
476517mod 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