diff --git a/devenv-core/src/config.rs b/devenv-core/src/config.rs index 8f4551dcb2..1059ffbfef 100644 --- a/devenv-core/src/config.rs +++ b/devenv-core/src/config.rs @@ -185,6 +185,9 @@ pub struct Config { #[serde(skip_serializing_if = "Option::is_none", default)] #[setting(merge = schematic::merge::replace)] pub profile: Option, + #[setting(nested)] + #[serde(skip_serializing_if = "Option::is_none", default)] + pub sandbox: Option, /// Git repository root path (not serialized, computed during load) #[serde(skip)] pub git_root: Option, @@ -201,6 +204,90 @@ pub struct SecretspecConfig { pub provider: Option, } +#[derive(schematic::Config, Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[config(rename_all = "camelCase")] +#[serde(rename_all = "camelCase")] +pub struct SandboxConfig { + #[serde(skip_serializing_if = "is_false", default = "false_default")] + #[setting(default = false)] + pub enable: bool, + + /// Network configuration for the sandbox + #[setting(nested)] + #[serde(skip_serializing_if = "Option::is_none", default)] + pub network: Option, + + /// Mount resources into sandbox + #[serde(skip_serializing_if = "Vec::is_empty", default)] + #[setting(nested, merge = schematic::merge::append_vec)] + pub mounts: Vec, +} + +#[derive(schematic::Config, Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct SandboxNetwork { + #[serde(skip_serializing_if = "is_true", default = "true_default")] + #[setting(default = true)] + pub enable: bool, + + #[serde(skip_serializing_if = "is_true", default = "true_default")] + #[setting(default = true)] + pub host_resolv: bool, + + #[serde(skip_serializing_if = "is_true", default = "true_default")] + #[setting(default = true)] + pub host_certs: bool, +} + +#[derive(schematic::Config, Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[config(rename_all = "camelCase")] +#[serde(rename_all = "camelCase")] +pub struct SandboxBindMount { + /// Source path on the host + pub path: String, + + /// Destination path in the sandbox (defaults to same as source) + #[serde(skip_serializing_if = "Option::is_none", default)] + pub dest: Option, + + /// Mount mode (readonly, readwrite, device, tmpfs) + #[serde(skip_serializing_if = "Option::is_none", default)] + pub mode: Option, + + /// Skip non-existent resources + #[serde(skip_serializing_if = "is_false", default = "false_default")] + #[setting(default = false)] + pub optional: bool, + + /// Late binding, executed after workspace already mounted into sandbox + #[serde(skip_serializing_if = "is_false", default = "false_default")] + #[setting(default = false)] + pub late: bool, + + #[serde(skip_serializing_if = "is_false", default = "false_default")] + #[setting(default = false)] + pub canonicalize: bool, +} + +#[derive( + schematic::ConfigEnum, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, +)] +#[serde(rename_all = "kebab-case")] +pub enum BindMountMode { + #[default] + #[serde(alias = "ro")] + ReadOnly, + #[serde(alias = "rw")] + ReadWrite, + #[serde(alias = "dev")] + Device, + Tmpfs, + Overlay, + OverlayTmpfs, + OverlayRo, + // persistent private state directory + StateDir, +} + // TODO: https://github.com/moonrepo/schematic/issues/105 pub async fn write_json_schema() -> Result<()> { let schema = schema_for!(Config); @@ -340,6 +427,43 @@ impl Config { })?; } + // Shared ~/.config/devenv/devenv.yaml + if let Ok(home) = std::env::var("HOME") { + if let Ok(home_path) = std::fs::canonicalize(home) { + let shared_config_dir = home_path.join(".config").join("devenv"); + let config_yaml = shared_config_dir.join("devenv.yaml"); + if config_yaml.exists() { + let mut shared_loader = ConfigLoader::::new(); + shared_loader + .file_optional(&config_yaml) + .into_diagnostic() + .wrap_err_with(|| { + format!( + "Failed to load shared configuration file: {}", + config_yaml.display() + ) + })?; + if let Ok(local_result) = shared_loader.load().into_diagnostic() { + for input_name in local_result.config.inputs.keys() { + input_source_dirs + .entry(input_name.clone()) + .or_insert_with(|| shared_config_dir.to_path_buf()); + } + } + + loader + .file_optional(&config_yaml) + .into_diagnostic() + .wrap_err_with(|| { + format!( + "Failed to load shared configuration file: {}", + config_yaml.display() + ) + })?; + } + } + } + // Load devenv.local.yaml last (if it exists) to allow local overrides let local_yaml = base_path.join(YAML_LOCAL_CONFIG); if local_yaml.exists() { diff --git a/devenv-core/src/nix_backend.rs b/devenv-core/src/nix_backend.rs index baea24f61a..292b470cf9 100644 --- a/devenv-core/src/nix_backend.rs +++ b/devenv-core/src/nix_backend.rs @@ -134,6 +134,14 @@ pub trait NixBackend: Send + Sync { /// Get the bash shell executable path async fn get_bash(&self, refresh_cached_output: bool) -> Result; + /// Get package executable path + async fn get_executable( + &self, + package_name: &str, + name: &str, + refresh_cached_output: bool, + ) -> Result; + /// Check if the current user is a trusted user of the Nix store async fn is_trusted_user(&self) -> Result; diff --git a/devenv-nix-backend/src/nix_backend.rs b/devenv-nix-backend/src/nix_backend.rs index 8afc46e6ef..adf26e7b16 100644 --- a/devenv-nix-backend/src/nix_backend.rs +++ b/devenv-nix-backend/src/nix_backend.rs @@ -1884,6 +1884,45 @@ impl NixBackend for NixRustBackend { )) } + async fn get_executable( + &self, + package: &str, + name: &str, + refresh_cached_output: bool, + ) -> Result { + // Get package executable path for this system + let gc_root_base = self.paths.dotfile.join(package); + let gc_root_actual = self.paths.dotfile.join(format!("{package}-{name}")); + + // Try cache first + if !refresh_cached_output + && gc_root_actual.exists() + && let Ok(cached_path) = std::fs::read_link(&gc_root_actual) + { + // Verify the path still exists in the store + if cached_path.exists() { + let path_str = cached_path.to_string_lossy().to_string(); + return Ok(format!("{path_str}/bin/{name}")); + } + } + + // Cache miss or refresh requested - use build() which handles everything + let paths = self + .build(&[package], None, Some(&gc_root_base)) + .await + .wrap_err_with(|| format!("Failed to build {package} attribute from default.nix"))?; + + if paths.is_empty() { + return Err(miette!("No output paths from {package} build")); + } + + // Return the path to the bash executable + Ok(format!( + "{store_path}/bin/{name}", + store_path = paths[0].to_string_lossy() + )) + } + async fn is_trusted_user(&self) -> Result { // Check if the current user is trusted by the Nix daemon/store // This is used to determine if we can safely add substituters diff --git a/devenv-snix-backend/src/lib.rs b/devenv-snix-backend/src/lib.rs index 43d55f0d4a..fbc0c330df 100644 --- a/devenv-snix-backend/src/lib.rs +++ b/devenv-snix-backend/src/lib.rs @@ -199,6 +199,16 @@ impl NixBackend for SnixBackend { bail!("get_bash is not yet implemented for Snix backend") } + async fn get_executable( + &self, + _package: &str, + _name: &str, + _refresh_cached_output: bool, + ) -> Result { + // TODO: Implement bash shell acquisition for Snix backend + bail!("get_executable is not yet implemented for Snix backend") + } + async fn is_trusted_user(&self) -> Result { // TODO: Implement trusted user check for Snix backend bail!("is_trusted_user is not yet implemented for Snix backend") diff --git a/devenv/src/devenv.rs b/devenv/src/devenv.rs index c869f97cd3..26bc1ebaf8 100644 --- a/devenv/src/devenv.rs +++ b/devenv/src/devenv.rs @@ -8,7 +8,7 @@ use devenv_cache_core::compute_string_hash; use devenv_core::{ cachix::{CachixManager, CachixPaths}, cli::GlobalOptions, - config::{Config, NixBackendType}, + config::{Clean, Config, NixBackendType, SandboxConfig}, nix_args::{CliOptionsConfig, NixArgs, SecretspecData, parse_cli_options}, nix_backend::{DevenvPaths, NixBackend, Options}, ports::PortAllocator, @@ -563,17 +563,50 @@ impl Devenv { &owned_dev_env.output }; - let bash = match self.nix.get_bash(false).await { - Err(e) => { - trace!("Failed to get bash: {}. Rebuilding.", e); - self.nix.get_bash(true).await? - } - Ok(bash) => bash, + let (config_clean, config_sandbox) = { + let config = self.config.read().await; + ( + config.clean.clone().unwrap_or_default(), + config.sandbox.clone().unwrap_or_default(), + ) }; - let mut shell_cmd = process::Command::new(&bash); + let bash = self.get_or_rebuild_executable("bash", "bash").await?; + + let script = self.build_shell_script(&output, cmd, args)?; + let script_path = self.write_shell_script(&script)?; + + let mut shell_cmd = if config_sandbox.enable { + self.create_sandboxed_command(&config_sandbox, &config_clean, &bash).await? + } else { + let mut cmd = process::Command::new(&bash); + self.apply_bash_environment(&mut cmd, &config_clean, &bash); + cmd + }; + + self.add_bash_args(&mut shell_cmd, &script_path, cmd); + + Ok(shell_cmd) + } + + async fn get_or_rebuild_executable(&self, package: &str, exe: &str) -> Result { + match self.nix.get_executable(package, exe, false).await { + Ok(path) => Ok(path), + Err(e) => { + trace!("Failed to get {}: {}. Rebuilding.", package, e); + self.nix.get_executable(package, exe, true).await + } + } + .map(|p| PathBuf::from(p)) + } - // The Nix output ends with "exec bash" which would start a new shell without + fn build_shell_script( + &self, + output: &[u8], + cmd: &Option, + args: &[String], + ) -> Result { + // The Nix output ends with "exec bash" which would start a new shell without // the devenv environment. Strip it for ALL modes - we handle shell execution ourselves. let output_str = String::from_utf8_lossy(&output); let shell_env = output_str @@ -611,7 +644,7 @@ impl Devenv { } // Add command for non-interactive mode - if let Some(cmd) = &cmd { + if let Some(cmd) = cmd { let command = format!( "\nexec {} {}", cmd, @@ -623,48 +656,244 @@ impl Devenv { script.push_str(&command); } + Ok(script) + } + + fn write_shell_script(&self, script: &str) -> Result { // Write shell script to a content-addressed file // Using content hash in filename allows eval cache to track it properly while // avoiding race conditions between parallel sessions (same content = same file) - let script_hash = &compute_string_hash(&script)[..16]; + let script_hash = &compute_string_hash(script)[..16]; let script_path = self .devenv_dotfile .join(format!("shell-{}.sh", script_hash)); - std::fs::write(&script_path, &script).expect("Failed to write shell script"); + + std::fs::write(&script_path, script).expect("Failed to write shell script"); std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)) .expect("Failed to set permissions"); - match cmd { - Some(_) => { - shell_cmd.arg(&script_path); + Ok(script_path) + } + + async fn create_sandboxed_command( + &self, + config_sandbox: &SandboxConfig, + config_clean: &Clean, + bash: &Path, + ) -> Result { + let bwrap = self + .get_or_rebuild_executable("pkgs.bubblewrap", "bwrap") + .await?; + + let mut bwrap = process::Command::new(&bwrap); + bwrap.arg("--new-session"); + bwrap.arg("--die-with-parent"); + bwrap.arg("--unshare-all"); + + let net = config_sandbox.network.clone().unwrap_or_default(); + if net.enable { + bwrap.arg("--share-net"); + if net.host_resolv { + bwrap.args(&["--ro-bind-try", "/etc/resolv.conf", "/etc/resolv.conf"]); } - None => { - let dialect = BashDialect; - let interactive_args = dialect.interactive_args(); - shell_cmd.args(&interactive_args.prefix); - shell_cmd.arg(&script_path); - shell_cmd.args(&interactive_args.suffix); + if net.host_certs { + // TODO: support other than NixOS distributions + bwrap.args(&["--ro-bind-try", "/etc/ssl", "/etc/ssl"]); + bwrap.args(&["--ro-bind-try", "/etc/static/ssl", "/etc/static/ssl"]); } } - let config_clean = self.config.read().await.clean.clone().unwrap_or_default(); if self.global_options.clean.is_some() || config_clean.enabled { - let keep = match &self.global_options.clean { - Some(clean) => clean, - None => &config_clean.keep, + bwrap.arg("--clearenv"); + + let keep = self + .global_options + .clean + .as_ref() + .unwrap_or(&config_clean.keep); + for (k, v) in std::env::vars().filter(|(k, _)| keep.contains(k)) { + bwrap.arg("--setenv").arg(&k).arg(&v); + } + } + + bwrap.arg("--setenv").arg("SHELL").arg(bash); + let cmdline = std::env::args().skip(1).collect::>().join(" "); + bwrap.arg("--setenv").arg("DEVENV_CMDLINE").arg(cmdline); + + let sandbox_root = self.devenv_dotfile.join("sandbox"); + let overlays_dir = sandbox_root.join("overlay"); + let state_dir = sandbox_root.join("state"); + + let mut late_bindings = Vec::new(); + + for m in &config_sandbox.mounts { + use devenv_core::config::BindMountMode; + + let src_expanded = util::expand_path(&m.path); + + let mount_point = if let Some(dest_path) = m.dest.as_deref() { + util::expand_path(dest_path) + } else { + src_expanded.clone() + }; + + let src = if m.canonicalize { + match std::fs::canonicalize(&src_expanded) { + Ok(path) => path.to_string_lossy().into_owned(), + Err(_) if m.optional => continue, + Err(e) => bail!( + "Unable to canonicalize sandbox mount point path '{}': {}", + m.path, + e + ), + } + } else { + src_expanded + }; + + let args = match m.mode.clone().unwrap_or_default() { + BindMountMode::Overlay => { + let src_dir_hash = &compute_string_hash(&src)[..16]; + let overlay_rw_dir = overlays_dir.join(src_dir_hash); + let overlay_work_dir = overlays_dir.join(format!("{src_dir_hash}-work")); + + if !std::fs::exists(&overlay_work_dir) + .expect("check if sandbox overlay directory exists") + { + std::fs::create_dir_all(&overlay_rw_dir) + .expect("created sandbox overlay directory"); + std::fs::create_dir(&overlay_work_dir) + .expect("created sandbox overlay work directory"); + } + vec![ + "--overlay-src".to_owned(), + src, + "--overlay".to_owned(), + overlay_rw_dir.to_string_lossy().into_owned(), + overlay_work_dir.to_string_lossy().into_owned(), + mount_point, + ] + } + BindMountMode::OverlayTmpfs => { + vec![ + "--overlay-src".to_owned(), + src, + "--tmp-overlay".to_owned(), + mount_point, + ] + } + BindMountMode::OverlayRo => { + vec![ + "--overlay-src".to_owned(), + src, + "--ro-overlay".to_owned(), + mount_point, + ] + } + BindMountMode::Tmpfs => { + vec!["--tmpfs".to_owned(), mount_point] + } + mode @ (BindMountMode::ReadOnly + | BindMountMode::ReadWrite + | BindMountMode::Device) => { + let cli_mode = match mode { + BindMountMode::ReadOnly => "--ro-bind", + BindMountMode::ReadWrite => "--bind", + BindMountMode::Device => "--dev-bind", + _ => unreachable!(), + }; + + let cli_mode = if m.optional { + format!("{cli_mode}-try") + } else { + cli_mode.to_owned() + }; + + vec![cli_mode, src, mount_point] + } + BindMountMode::StateDir => { + let src_dir_hash = &compute_string_hash(&src)[..16]; + let src_state_dir = state_dir.join(src_dir_hash); + + if !std::fs::exists(&src_state_dir) + .expect("check if sandbox state directory exists") + { + std::fs::create_dir_all(&src_state_dir) + .expect("created sandbox state directory"); + } + vec![ + "--bind".to_owned(), + src_state_dir.to_string_lossy().into_owned(), + mount_point, + ] + } }; + if m.late { + late_bindings.extend(args); + } else { + bwrap.args(args); + } + } + + bwrap.arg("--ro-bind").args(&[bash, bash]); + + bwrap.args(&["--tmpfs", "/tmp"]); + bwrap.args(&["--proc", "/proc"]); + bwrap.arg("--bind").args(&[self.root(), self.root()]); + bwrap.arg("--tmpfs").arg(&sandbox_root); + bwrap.arg("--tmpfs").arg(&self.devenv_runtime); + bwrap.args(late_bindings); + + bwrap.arg("--chdir").arg(&self.root()); + + bwrap.arg("--"); + bwrap.arg(bash); + + Ok(bwrap) + } + + fn apply_bash_environment( + &self, + shell_cmd: &mut process::Command, + config_clean: &Clean, + bash: &Path, + ) { + if self.global_options.clean.is_some() || config_clean.enabled { + let keep = self + .global_options + .clean + .as_ref() + .unwrap_or(&config_clean.keep); let filtered_env = std::env::vars().filter(|(k, _)| keep.contains(k)); shell_cmd.env_clear().envs(filtered_env); } - shell_cmd.env("SHELL", &bash); + shell_cmd.env("SHELL", bash); // Pass command args to the shell as DEVENV_CMDLINE let cmdline = std::env::args().skip(1).collect::>().join(" "); shell_cmd.env("DEVENV_CMDLINE", cmdline); + } - Ok(shell_cmd) + fn add_bash_args( + &self, + command: &mut process::Command, + script_path: &Path, + cmd: &Option, + ) { + match cmd { + Some(_) => { + command.arg(script_path); + } + None => { + let dialect = BashDialect; + let interactive_args = dialect.interactive_args(); + command.args(&interactive_args.prefix); + command.arg(script_path); + command.args(&interactive_args.suffix); + } + } } /// Prepare to launch an interactive shell. @@ -1277,7 +1506,7 @@ impl Devenv { } async fn capture_shell_environment(&self) -> Result> { - let temp_dir = tempfile::TempDir::with_prefix("devenv-env") + let temp_dir = tempfile::TempDir::with_prefix_in("devenv-env-", &self.devenv_tmp) .into_diagnostic() .wrap_err("Failed to create temporary directory for environment capture")?; diff --git a/devenv/src/util.rs b/devenv/src/util.rs index 0ce15003a2..d16912af65 100644 --- a/devenv/src/util.rs +++ b/devenv/src/util.rs @@ -71,3 +71,24 @@ pub fn write_file_with_lock, S: AsRef>(path: P, content: S) Ok(false) } } + +pub fn expand_path(path: &str) -> String { + let mut expanded = path.to_string(); + + let re_braces = regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").unwrap(); + let re_simple = regex::Regex::new(r"\$([A-Za-z_][A-Za-z0-9_]*)").unwrap(); + + expanded = re_braces + .replace_all(&expanded, |caps: ®ex::Captures| { + std::env::var(&caps[1]).unwrap_or_default() + }) + .into_owned(); + + expanded = re_simple + .replace_all(&expanded, |caps: ®ex::Captures| { + std::env::var(&caps[1]).unwrap_or_default() + }) + .into_owned(); + + expanded +}