mise 2026.9.3

Dev tools, env vars, and tasks in one CLI
use super::SandboxConfig;

/// Sanitize a string for use in an SBPL profile.
/// Escapes double quotes and backslashes to prevent injection.
fn sbpl_escape(s: &str) -> String {
    s.replace('\\', "\\\\").replace('"', "\\\"")
}

/// System paths that are always readable on macOS.
const SYSTEM_READ_PATHS: &[&str] = &[
    "/System",
    "/Library",
    "/usr",
    "/bin",
    "/sbin",
    "/dev",
    "/etc",
    "/var/run",
    "/tmp",
    "/private/tmp",
    "/private/etc",
    "/private/var/run",
    "/opt/homebrew",
    "/nix",
];

/// Generate a Seatbelt (SBPL) profile string from sandbox config.
pub(crate) async fn generate_seatbelt_profile(
    config: &SandboxConfig,
    initial_program: Option<&std::path::Path>,
) -> String {
    let mut rules = Vec::new();
    rules.push("(version 1)".to_string());
    rules.push("(allow default)".to_string());

    // Filesystem write restrictions
    if config.effective_deny_write() {
        rules.push("(deny file-write*)".to_string());
        if !config.deny_temp_write {
            rules.push("(allow file-write* (subpath \"/tmp\"))".to_string());
            rules.push("(allow file-write* (subpath \"/private/tmp\"))".to_string());
        }
        rules.push("(allow file-write* (subpath \"/dev\"))".to_string());
        for path in &config.allow_write {
            let path_str = sbpl_escape(&path.to_string_lossy());
            rules.push(format!("(allow file-write* (subpath \"{path_str}\"))"));
            rules.push(format!("(allow file-write* (literal \"{path_str}\"))"));
        }
    }

    // Filesystem read restrictions
    if config.effective_deny_read() {
        rules.push("(deny file-read*)".to_string());
        // Seatbelt requires data access to the root vnode for process startup and getcwd.
        // This exposes names directly under `/`, but descendants still obey the read rules.
        rules.push("(allow file-read-data (literal \"/\"))".to_string());
        // Portable executables may resolve paths through macOS's `/private` hierarchy
        // during startup (for example, Ruby built with --enable-load-relative).
        rules.push("(allow file-read-metadata (literal \"/private\"))".to_string());
        for path in SYSTEM_READ_PATHS {
            rules.push(format!("(allow file-read* (subpath \"{path}\"))"));
        }
        let data_dir = &*crate::env::MISE_DATA_DIR;
        let data_str = sbpl_escape(&data_dir.to_string_lossy());
        rules.push(format!("(allow file-read* (subpath \"{data_str}\"))"));
        for path in &config.allow_read {
            let path_str = sbpl_escape(&path.to_string_lossy());
            rules.push(format!("(allow file-read* (subpath \"{path_str}\"))"));
            rules.push(format!("(allow file-read* (literal \"{path_str}\"))"));
        }
        // allow_write paths are implicitly readable — emit AFTER deny-read
        for path in &config.allow_write {
            let path_str = sbpl_escape(&path.to_string_lossy());
            rules.push(format!("(allow file-read* (subpath \"{path_str}\"))"));
            rules.push(format!("(allow file-read* (literal \"{path_str}\"))"));
        }
    }

    // Network restrictions
    if config.effective_deny_net() {
        rules.push("(deny network*)".to_string());
        // Always allow local/unix sockets
        rules.push("(allow network* (local unix))".to_string());
        if !config.allow_net.is_empty() {
            // Allow DNS lookups via mDNSResponder (needed for hostname resolution)
            rules.push(
                "(allow network* (remote unix-socket (path-literal \"/var/run/mDNSResponder\")))"
                    .to_string(),
            );
            // Resolve all hostnames to IPs in parallel — Seatbelt's `ip` predicate requires IP literals
            let lookups: Vec<_> = config
                .allow_net
                .iter()
                .map(|host| {
                    let host = host.clone();
                    tokio::spawn(async move {
                        match tokio::net::lookup_host(format!("{host}:0")).await {
                            Ok(addrs) => {
                                let ips: Vec<_> = addrs.map(|a| a.ip()).collect();
                                (host, ips)
                            }
                            Err(_) => (host, vec![]),
                        }
                    })
                })
                .collect();
            for handle in lookups {
                if let Ok((host, ips)) = handle.await {
                    if ips.is_empty() {
                        // Resolution failed — use the value directly (might be an IP already)
                        let host = sbpl_escape(&host);
                        rules.push(format!("(allow network* (remote ip \"{host}:*\"))"));
                    } else {
                        for ip in ips {
                            rules.push(format!("(allow network* (remote ip \"{ip}:*\"))"));
                        }
                    }
                }
            }
        }
    }

    if config.deny_process {
        rules.push("(deny process-fork)".to_string());
        rules.push("(deny process-exec)".to_string());
        if let Some(path) = initial_program {
            let path_str = sbpl_escape(&path.to_string_lossy());
            rules.push(format!("(allow process-exec (literal \"{path_str}\"))"));
            if let Ok(canonical) = path.canonicalize()
                && canonical != path
            {
                let canonical = sbpl_escape(&canonical.to_string_lossy());
                rules.push(format!("(allow process-exec (literal \"{canonical}\"))"));
            }
        }
    }

    rules.join("\n")
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::{
        fs,
        path::{Path, PathBuf},
        process::Command,
    };

    #[tokio::test]
    async fn test_deny_write_profile() {
        let config = SandboxConfig {
            deny_write: true,
            ..Default::default()
        };
        let profile = generate_seatbelt_profile(&config, None).await;
        assert!(profile.contains("(deny file-write*)"));
        assert!(profile.contains("(allow file-write* (subpath \"/tmp\"))"));
        assert!(!profile.contains("(deny file-read*)"));
        assert!(!profile.contains("(deny network*)"));
    }

    #[tokio::test]
    async fn test_deny_net_profile() {
        let config = SandboxConfig {
            deny_net: true,
            ..Default::default()
        };
        let profile = generate_seatbelt_profile(&config, None).await;
        assert!(profile.contains("(deny network*)"));
        assert!(!profile.contains("(deny file-write*)"));
    }

    #[tokio::test]
    async fn test_allow_write_implies_deny() {
        let config = SandboxConfig {
            allow_write: vec![PathBuf::from("/tmp/mydir")],
            ..Default::default()
        };
        let profile = generate_seatbelt_profile(&config, None).await;
        assert!(profile.contains("(deny file-write*)"));
        assert!(profile.contains("(allow file-write* (subpath \"/tmp/mydir\"))"));
    }

    #[tokio::test]
    async fn test_allow_net_per_host() {
        // Test with an IP address directly (no DNS resolution needed)
        let config = SandboxConfig {
            allow_net: vec!["1.2.3.4".to_string()],
            ..Default::default()
        };
        let profile = generate_seatbelt_profile(&config, None).await;
        assert!(profile.contains("(deny network*)"));
        assert!(profile.contains("(allow network* (remote ip \"1.2.3.4:*\"))"));
        // mDNSResponder rule should appear exactly once
        assert_eq!(
            profile.matches("mDNSResponder").count(),
            1,
            "mDNSResponder rule should appear once"
        );
    }

    #[tokio::test]
    async fn test_deny_read_includes_system_paths() {
        let config = SandboxConfig {
            deny_read: true,
            ..Default::default()
        };
        let profile = generate_seatbelt_profile(&config, None).await;
        assert!(profile.contains("(deny file-read*)"));
        assert!(profile.contains("(allow file-read* (subpath \"/usr\"))"));
        assert!(profile.contains("(allow file-read* (subpath \"/System\"))"));
    }

    #[tokio::test]
    async fn test_allow_read_executes_shell_without_reading_siblings() {
        let root = tempfile::tempdir().unwrap();
        let allowed_dir = root.path().join("allowed");
        fs::create_dir(&allowed_dir).unwrap();
        let allowed_file = allowed_dir.join("allowed.txt");
        let denied_file = root.path().join("denied.txt");
        fs::write(&allowed_file, "allowed").unwrap();
        fs::write(&denied_file, "denied").unwrap();
        let allowed_file = allowed_file.canonicalize().unwrap();
        let denied_file = denied_file.canonicalize().unwrap();

        let mut config = SandboxConfig {
            deny_read: true,
            allow_read: vec![allowed_file.clone()],
            ..Default::default()
        };
        config.resolve_paths();
        let profile = generate_seatbelt_profile(&config, Some(Path::new("/bin/sh"))).await;
        let read = |path: &std::path::Path| {
            Command::new("sandbox-exec")
                .current_dir("/")
                .args(["-p", &profile, "--", "/bin/sh", "-c", "cat \"$1\"", "sh"])
                .arg(path)
                .output()
                .unwrap()
        };

        let allowed = read(&allowed_file);
        assert!(
            allowed.status.success(),
            "sandboxed shell failed: {}",
            String::from_utf8_lossy(&allowed.stderr)
        );
        assert!(!read(&denied_file).status.success());
    }

    #[cfg(target_os = "macos")]
    #[tokio::test]
    async fn test_private_metadata_does_not_expose_directory_contents() {
        let config = SandboxConfig {
            deny_read: true,
            ..Default::default()
        };
        let profile = generate_seatbelt_profile(&config, None).await;
        let run = |program: &str, args: &[&str]| {
            Command::new("sandbox-exec")
                .args(["-p", &profile, "--", program])
                .args(args)
                .output()
                .unwrap()
        };

        let metadata = run("/usr/bin/stat", &["-f", "%N", "/private"]);
        assert!(
            metadata.status.success(),
            "sandboxed stat failed: {}",
            String::from_utf8_lossy(&metadata.stderr)
        );
        assert!(!run("/bin/ls", &["/private"]).status.success());
    }

    #[tokio::test]
    async fn test_deny_all() {
        let config = SandboxConfig {
            deny_read: true,
            deny_write: true,
            deny_net: true,
            deny_env: true,
            ..Default::default()
        };
        let profile = generate_seatbelt_profile(&config, None).await;
        assert!(profile.contains("(deny file-read*)"));
        assert!(profile.contains("(deny file-write*)"));
        assert!(profile.contains("(deny network*)"));
    }

    #[tokio::test]
    async fn test_deny_process_allows_only_initial_executable() {
        let config = SandboxConfig {
            deny_process: true,
            ..Default::default()
        };
        let profile = generate_seatbelt_profile(&config, Some(Path::new("/usr/bin/ruby"))).await;
        assert!(profile.contains("(deny process-fork)"));
        assert!(profile.contains("(deny process-exec)"));
        assert!(profile.contains("(allow process-exec (literal \"/usr/bin/ruby\"))"));
    }

    #[cfg(target_os = "macos")]
    #[tokio::test]
    async fn test_deny_process_at_runtime() {
        let config = SandboxConfig {
            deny_process: true,
            ..Default::default()
        };
        let profile = generate_seatbelt_profile(&config, Some(Path::new("/usr/bin/ruby"))).await;
        let script = r#"
puts "ruby started"
begin
  fork { exit! }
  abort "fork escaped sandbox"
rescue SystemCallError
end
begin
  exec "/usr/bin/true"
rescue SystemCallError
end
puts "child processes blocked"
"#;
        let output = Command::new("sandbox-exec")
            .args([
                "-p",
                &profile,
                "--",
                "/usr/bin/ruby",
                "--disable-gems",
                "-e",
                script,
            ])
            .output()
            .unwrap();
        assert!(
            output.status.success(),
            "sandboxed Ruby failed: {}",
            String::from_utf8_lossy(&output.stderr)
        );
        assert_eq!(
            String::from_utf8_lossy(&output.stdout),
            "ruby started\nchild processes blocked\n"
        );
    }
}