//! Sandbox profiles. Built-in: `workspace`, `devbox`, `read-only`, `strict`,
//! `off`. Custom profiles via `~/.kigi/sandbox.toml` or `.kigi/sandbox.toml`.
//! A custom profile's `deny` list is kernel-enforced (read + write/rename) on
//! both platforms.

#[cfg(all(feature = "enforce", unix))]
use nono::{AccessMode, CapabilitySet};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};

#[cfg(all(feature = "enforce", unix))]
use crate::deny::{
    apply_deny_globs_to_capability_set, apply_deny_paths_to_capability_set, effective_deny_paths,
    partition_deny_entries,
};
use crate::paths::kigi_home;
#[cfg(all(feature = "enforce", unix))]
use crate::paths::{
    DEVICE_DIRS, DEVICE_FILES, essential_writable_paths, essential_writable_paths_minimal,
};

/// A resolved sandbox profile ready to be converted to a `CapabilitySet`.
#[derive(Debug, Clone)]
pub struct SandboxProfile {
    /// Display name
    pub name: String,
    /// Paths the agent can read (but not write)
    pub read_only: Vec<PathBuf>,
    /// Paths the agent can read and write
    pub read_write: Vec<PathBuf>,
    /// Paths denied entirely (overrides read_only/read_write)
    pub deny: Vec<PathBuf>,
    /// Whether to grant read access to the entire filesystem by default
    pub default_read: bool,
    /// Whether child processes should have network blocked
    pub restrict_network: bool,
}

#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct ProfileConfig {
    #[serde(default)]
    pub extends: Option<String>,
    #[serde(default)]
    pub restrict_network: Option<bool>,
    #[serde(default)]
    pub read_only: Vec<String>,
    #[serde(default)]
    pub read_write: Vec<String>,
    #[serde(default)]
    pub deny: Vec<String>,
}

#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct SandboxConfig {
    #[serde(default)]
    pub profiles: HashMap<String, ProfileConfig>,
}

#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum ProfileName {
    #[default]
    Workspace,
    Devbox,
    ReadOnly,
    Strict,
    Off,
    Custom(String),
}

impl ProfileName {
    pub fn restricts_network(&self) -> bool {
        matches!(self, Self::ReadOnly | Self::Strict)
    }

    /// Resolve network restriction from config (handles Custom profiles).
    pub fn restricts_network_resolved(&self, config: &SandboxConfig) -> bool {
        match self {
            Self::ReadOnly | Self::Strict => true,
            Self::Workspace | Self::Devbox | Self::Off => false,
            Self::Custom(name) => config
                .profiles
                .get(name)
                .and_then(|p| p.restrict_network)
                .unwrap_or(false),
        }
    }
}

impl std::fmt::Display for ProfileName {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Workspace => write!(f, "workspace"),
            Self::Devbox => write!(f, "devbox"),
            Self::ReadOnly => write!(f, "read-only"),
            Self::Strict => write!(f, "strict"),
            Self::Off => write!(f, "off"),
            Self::Custom(name) => write!(f, "{name}"),
        }
    }
}

impl std::str::FromStr for ProfileName {
    type Err = String;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "workspace" => Ok(Self::Workspace),
            "devbox" => Ok(Self::Devbox),
            "read-only" | "readonly" => Ok(Self::ReadOnly),
            "strict" => Ok(Self::Strict),
            "off" | "none" => Ok(Self::Off),
            // Anything else is treated as a custom profile name.
            // Validation happens when we try to load it from config.
            other => Ok(Self::Custom(other.to_string())),
        }
    }
}

/// Load sandbox config from `~/.kigi/sandbox.toml` and `.kigi/sandbox.toml`.
///
/// Project config may **add** new profile names only. It cannot redefine a
/// name already present in the global config — last-write-wins would let a
/// malicious workspace hollow out a user/enterprise custom profile (e.g.
/// empty `deny` / broad `read_write`) while keeping the trusted name.
pub fn load_sandbox_config(workspace: &Path) -> SandboxConfig {
    let mut config = SandboxConfig::default();

    // Global config: ~/.kigi/sandbox.toml
    let global_path = kigi_home().join("sandbox.toml");
    if let Some(global) = load_config_file(&global_path) {
        config = global;
    }

    // Project config: <workspace>/.kigi/sandbox.toml (additive only)
    let project_path = workspace.join(".kigi").join("sandbox.toml");
    if let Some(project) = load_config_file(&project_path) {
        merge_project_profiles(&mut config, project);
    }

    config
}

pub fn sandbox_profile_conflicts(workspace: &Path) -> Vec<String> {
    let global = load_config_file(&kigi_home().join("sandbox.toml")).unwrap_or_default();
    let project =
        load_config_file(&workspace.join(".kigi").join("sandbox.toml")).unwrap_or_default();
    mismatched_profile_names(&global, &project)
}

fn mismatched_profile_names(global: &SandboxConfig, project: &SandboxConfig) -> Vec<String> {
    let mut names: Vec<String> = project
        .profiles
        .iter()
        .filter(|(name, _)| matches!(name.parse(), Ok(ProfileName::Custom(_))))
        .filter_map(|(name, project_profile)| {
            global
                .profiles
                .get(name)
                .filter(|global_profile| *global_profile != project_profile)
                .map(|_| name.to_owned())
        })
        .collect();
    names.sort_unstable();
    names
}

/// Merge project profiles into `config`. Names already defined globally are
/// ignored so a workspace cannot replace a global custom profile's policy.
fn merge_project_profiles(config: &mut SandboxConfig, project: SandboxConfig) {
    for (name, profile) in project.profiles {
        config.profiles.entry(name).or_insert(profile);
    }
}

fn load_config_file(path: &Path) -> Option<SandboxConfig> {
    let content = std::fs::read_to_string(path).ok()?;
    match toml::from_str(&content) {
        Ok(config) => Some(config),
        Err(e) => {
            tracing::warn!(path = %path.display(), error = %e, "Failed to parse sandbox config");
            None
        }
    }
}

#[cfg(all(feature = "enforce", unix))]
impl ProfileName {
    /// Convert this profile into a nono `CapabilitySet` for the given workspace.
    pub fn to_capability_set(&self, workspace: &Path) -> anyhow::Result<CapabilitySet> {
        let config = load_sandbox_config(workspace);
        self.to_capability_set_with_config(workspace, &config)
    }

    /// Convert using an already-loaded config (avoids re-reading disk).
    ///
    /// A custom profile's own `deny` list is kernel-enforced (read + write/rename)
    /// on top of the base profile.
    pub fn to_capability_set_with_config(
        &self,
        workspace: &Path,
        config: &SandboxConfig,
    ) -> anyhow::Result<CapabilitySet> {
        if *self == Self::Off {
            return Ok(CapabilitySet::new());
        }

        // Resolve to a SandboxProfile
        let profile = self.resolve(workspace, config)?;

        // Build CapabilitySet from the resolved profile
        let mut caps = CapabilitySet::new();

        // Default read access
        if profile.default_read {
            caps = caps.allow_path("/", AccessMode::Read)?;
        }

        // Explicit read-only paths — skip non-existent (nothing to read)
        for path in &profile.read_only {
            if !path.exists() {
                continue;
            }
            let Some(path_str) = path.to_str() else {
                tracing::warn!(path = ?path, "Skipping non-UTF8 read_only path");
                continue;
            };
            caps = caps.allow_path(path_str, AccessMode::Read)?;
        }

        // Read-write paths. nono/Landlock need the directory to exist at
        // apply time (it opens an O_PATH fd), but new files within it can
        // be created freely after the sandbox is applied. Pre-create
        // directories like ~/.kigi/ that may not exist on first run.
        for path in &profile.read_write {
            if !path.exists() && std::fs::create_dir_all(path).is_err() {
                tracing::warn!(path = ?path, "read_write path does not exist and could not be created, skipping");
                continue;
            }
            let Some(path_str) = path.to_str() else {
                tracing::warn!(path = ?path, "Skipping non-UTF8 read_write path");
                continue;
            };
            caps = caps.allow_path(path_str, AccessMode::ReadWrite)?;
        }

        // Device special files (character devices like /dev/null, /dev/tty, etc.).
        for dev in DEVICE_FILES {
            let p = Path::new(dev);
            if !p.exists() {
                continue;
            }
            if let Err(e) = caps.allow_file_mut(p, AccessMode::ReadWrite) {
                tracing::warn!(path = dev, error = %e, "Could not allow device file");
            }
        }
        // Device directories (e.g. /dev/pts for PTY slaves on Linux).
        for dev in DEVICE_DIRS {
            let p = Path::new(dev);
            if p.exists() && p.is_dir() {
                caps = caps.allow_path(dev, AccessMode::ReadWrite)?;
            }
        }

        // Kernel deny (read+write): macOS Seatbelt rules; Linux via bwrap bind-over.
        // The effective deny set is the profile's own `deny` (custom profiles only;
        // built-ins carry an empty `deny`). An empty set means there is nothing to
        // enforce. Keying on emptiness rather than profile type avoids enforcing
        // unintentional denies.
        //
        // Split exact paths from globs: exact paths keep the literal/subpath flow;
        // globs become anchored Seatbelt regexes on macOS (a no-op here on Linux,
        // where they are expanded and bound over at bwrap re-exec).
        let (exact_deny, glob_deny) = partition_deny_entries(&profile.deny);
        let all_denied = effective_deny_paths(workspace, &exact_deny);
        if !all_denied.is_empty() {
            apply_deny_paths_to_capability_set(&mut caps, &all_denied)?;
        }
        if !glob_deny.is_empty() {
            apply_deny_globs_to_capability_set(&mut caps, workspace, &glob_deny)?;
        }

        Ok(caps)
    }

    /// Resolve this profile into a fully-specified `SandboxProfile` for logging.
    pub fn resolve_profile(
        &self,
        workspace: &Path,
        config: &SandboxConfig,
    ) -> anyhow::Result<SandboxProfile> {
        self.resolve(workspace, config)
    }

    fn resolve(&self, workspace: &Path, config: &SandboxConfig) -> anyhow::Result<SandboxProfile> {
        match self {
            // Selected `off` is handled before resolve (empty CapabilitySet /
            // early return in apply). Reaching here is almost always a custom
            // profile with `extends = "off"` / `"none"` — return Err, never panic.
            Self::Off => anyhow::bail!(
                "sandbox profile 'off' cannot be resolved as a base profile; \
                 choose a built-in base (workspace, devbox, read-only, strict)"
            ),

            Self::Workspace => Ok(SandboxProfile {
                name: "workspace".to_string(),
                read_only: vec![],
                read_write: essential_writable_paths(workspace),
                deny: vec![],
                default_read: true,
                restrict_network: false,
            }),

            Self::Devbox => {
                // Everything writable except /data. Enumerate top-level
                // dirs and skip the exclusion list. Can't grant "/" because
                // Landlock has no deny_path — sub-path exceptions are
                // only possible by not granting the parent.
                //
                // /data is excluded from read_write here (so it is not writable)
                // but is deliberately NOT a kernel-deny: it stays readable via
                // default_read, and its Linux write-deny comes from the
                // bwrap_reexec_command(&["/data"]) re-exec, not from profile.deny.
                // Keeping deny empty stops a custom profile that extends devbox
                // from inheriting /data into the enforced kernel-deny set.
                let exclude = [PathBuf::from("/data")];
                let mut read_write = vec![workspace.to_path_buf()];
                if let Ok(entries) = std::fs::read_dir("/") {
                    for entry in entries.flatten() {
                        let path = entry.path();
                        if exclude.contains(&path) {
                            continue;
                        }
                        // Skip virtual filesystems (handled separately)
                        if matches!(path.to_str(), Some("/proc" | "/sys" | "/dev")) {
                            continue;
                        }
                        if path.is_dir() {
                            read_write.push(path);
                        }
                    }
                }
                Ok(SandboxProfile {
                    name: "devbox".to_string(),
                    read_only: vec![],
                    read_write,
                    deny: vec![],
                    default_read: true,
                    restrict_network: false,
                })
            }

            Self::ReadOnly => Ok(SandboxProfile {
                name: "read-only".to_string(),
                read_only: vec![],
                read_write: essential_writable_paths_minimal(),
                deny: vec![],
                default_read: true,
                restrict_network: true,
            }),

            Self::Strict => {
                let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/root"));
                let system_read: Vec<PathBuf> = [
                    "/usr", "/lib", "/lib64", "/bin", "/sbin", "/etc", "/dev", "/proc", "/sys",
                    "/tmp",
                    // Landlock realpath: /etc/resolv.conf often → /run/systemd/resolve/…
                    "/run",
                    // NSS/SSSD (and similar) under /var — needed beyond resolv.conf alone
                    "/var",
                    // macOS-specific paths (filtered by exists() below)
                    "/System",  // Security framework, dylibs, TLS certificates
                    "/Library", // System-wide frameworks
                    "/private", // Real path behind /etc, /tmp, /var symlinks
                ]
                .iter()
                .map(PathBuf::from)
                .filter(|p| p.exists())
                // ~/Library is needed for macOS keychain access (TLS cert validation)
                .chain(std::iter::once(home.join("Library")))
                .filter(|p| p.exists())
                .chain(std::iter::once(workspace.to_path_buf()))
                .collect();

                Ok(SandboxProfile {
                    name: "strict".to_string(),
                    read_only: system_read,
                    read_write: essential_writable_paths(workspace),
                    deny: vec![],
                    default_read: false,
                    restrict_network: true,
                })
            }

            Self::Custom(name) => {
                let profile_config = config.profiles.get(name).ok_or_else(|| {
                    anyhow::anyhow!(
                        "Custom sandbox profile '{name}' not found. \
                         Define it in ~/.kigi/sandbox.toml or .kigi/sandbox.toml:\n\n\
                         [profiles.{name}]\n\
                         extends = \"workspace\"\n\
                         read_only = [\"/data\"]\n"
                    )
                })?;

                // Start from the base profile if `extends` is set
                let mut profile = if let Some(base_name) = &profile_config.extends {
                    let base: ProfileName = base_name.parse().map_err(|e: String| {
                        anyhow::anyhow!("Profile '{name}' extends invalid base: {e}")
                    })?;
                    if matches!(base, Self::Off) {
                        anyhow::bail!(
                            "Profile '{name}' extends '{base_name}', but 'off'/'none' \
                             is not a valid base profile"
                        );
                    }
                    if matches!(base, Self::Custom(_)) {
                        anyhow::bail!(
                            "Profile '{name}' extends '{base_name}', but custom profiles \
                             cannot extend other custom profiles (only built-ins)"
                        );
                    }
                    base.resolve(workspace, config)?
                } else {
                    // Default: start from workspace
                    Self::Workspace.resolve(workspace, config)?
                };

                profile.name = name.clone();

                // Apply overrides from the custom config
                if let Some(restrict_net) = profile_config.restrict_network {
                    profile.restrict_network = restrict_net;
                }

                // Add custom read-only paths
                for path_str in &profile_config.read_only {
                    profile.read_only.push(PathBuf::from(path_str));
                }

                // Add custom read-write paths
                for path_str in &profile_config.read_write {
                    profile.read_write.push(PathBuf::from(path_str));
                }

                // Add custom deny paths
                for path_str in &profile_config.deny {
                    profile.deny.push(PathBuf::from(path_str));
                }

                Ok(profile)
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_profile_names() {
        assert_eq!(
            "workspace".parse::<ProfileName>().unwrap(),
            ProfileName::Workspace
        );
        assert_eq!(
            "devbox".parse::<ProfileName>().unwrap(),
            ProfileName::Devbox
        );
        assert_eq!(
            "read-only".parse::<ProfileName>().unwrap(),
            ProfileName::ReadOnly
        );
        assert_eq!(
            "readonly".parse::<ProfileName>().unwrap(),
            ProfileName::ReadOnly
        );
        assert_eq!(
            "strict".parse::<ProfileName>().unwrap(),
            ProfileName::Strict
        );
        assert_eq!("off".parse::<ProfileName>().unwrap(), ProfileName::Off);
        assert_eq!("none".parse::<ProfileName>().unwrap(), ProfileName::Off);
        // Unknown names become Custom profiles
        assert_eq!(
            "my-custom-profile".parse::<ProfileName>().unwrap(),
            ProfileName::Custom("my-custom-profile".to_string())
        );
    }

    #[test]
    fn display_roundtrip() {
        for profile in [
            ProfileName::Workspace,
            ProfileName::Devbox,
            ProfileName::ReadOnly,
            ProfileName::Strict,
            ProfileName::Off,
        ] {
            let s = profile.to_string();
            let parsed: ProfileName = s.parse().unwrap();
            assert_eq!(parsed, profile);
        }
    }

    #[test]
    fn display_custom() {
        let p = ProfileName::Custom("my-custom".to_string());
        assert_eq!(p.to_string(), "my-custom");
    }

    #[test]
    fn network_restriction() {
        assert!(!ProfileName::Workspace.restricts_network());
        assert!(!ProfileName::Devbox.restricts_network());
        assert!(ProfileName::ReadOnly.restricts_network());
        assert!(ProfileName::Strict.restricts_network());
        assert!(!ProfileName::Off.restricts_network());
    }

    #[test]
    #[cfg(all(feature = "enforce", unix))]
    fn strict_allowlist_includes_run_and_var_when_present() {
        // Regression: /run (resolv realpath) + /var (NSS/SSSD) when present.
        let workspace = std::env::temp_dir();
        let profile = ProfileName::Strict
            .resolve_profile(&workspace, &SandboxConfig::default())
            .expect("strict resolves");
        assert!(!profile.default_read);
        if PathBuf::from("/run").exists() {
            assert!(
                profile.read_only.iter().any(|p| p == Path::new("/run")),
                "strict read_only must include exact /run for systemd-resolved DNS; got {:?}",
                profile.read_only
            );
        }
        if PathBuf::from("/var").exists() {
            assert!(
                profile.read_only.iter().any(|p| p == Path::new("/var")),
                "strict read_only must include exact /var for NSS/SSSD; got {:?}",
                profile.read_only
            );
        }
    }

    #[test]
    #[cfg(all(feature = "enforce", unix))]
    fn base_profile_capability_set_builds() {
        // A base profile with no `deny` builds a CapabilitySet without erroring.
        let workspace = std::env::current_dir().unwrap();
        let config = SandboxConfig::default();
        let result = ProfileName::Workspace.to_capability_set_with_config(&workspace, &config);
        assert!(result.is_ok(), "Failed: {:?}", result.err());
    }

    #[test]
    #[cfg(all(feature = "enforce", unix))]
    fn custom_profile_from_config() {
        let workspace = std::env::current_dir().unwrap();
        let config = SandboxConfig {
            profiles: HashMap::from([(
                "project".to_string(),
                ProfileConfig {
                    extends: Some("workspace".to_string()),
                    restrict_network: Some(true),
                    read_only: vec!["/data".to_string()],
                    read_write: vec![],
                    deny: vec!["/data/private".to_string()],
                },
            )]),
        };

        let profile = ProfileName::Custom("project".to_string());
        let result = profile.to_capability_set_with_config(&workspace, &config);
        assert!(result.is_ok(), "Failed: {:?}", result.err());
    }

    #[test]
    #[cfg(all(feature = "enforce", unix))]
    fn custom_extends_devbox_has_no_data_in_deny() {
        // Regression: devbox excludes /data via a local list, not profile.deny, so
        // a custom profile extending devbox must not inherit /data into the kernel
        // deny set (which would wrongly read-deny /data and force fail-closed).
        let workspace = std::env::current_dir().unwrap();
        let config = SandboxConfig {
            profiles: HashMap::from([(
                "mydev".to_string(),
                ProfileConfig {
                    extends: Some("devbox".to_string()),
                    restrict_network: None,
                    read_only: vec![],
                    read_write: vec![],
                    deny: vec![],
                },
            )]),
        };
        let profile = ProfileName::Custom("mydev".to_string());
        let resolved = profile.resolve_profile(&workspace, &config).unwrap();
        assert!(
            !resolved.deny.contains(&PathBuf::from("/data")),
            "custom profile extending devbox must not inherit /data into deny: {:?}",
            resolved.deny
        );
    }

    #[test]
    #[cfg(all(feature = "enforce", unix))]
    fn custom_profile_not_found() {
        let workspace = std::env::current_dir().unwrap();
        let config = SandboxConfig::default();

        let profile = ProfileName::Custom("nonexistent".to_string());
        let result = profile.to_capability_set_with_config(&workspace, &config);
        assert!(result.is_err());
        let err = result.unwrap_err().to_string();
        assert!(err.contains("not found"), "Unexpected error: {err}");
    }

    #[test]
    fn mismatched_profile_names_reports_only_changed_custom_profiles() {
        let profile = |restrict_network| ProfileConfig {
            extends: Some("workspace".to_string()),
            restrict_network: Some(restrict_network),
            read_only: vec![],
            read_write: vec![],
            deny: vec![],
        };
        let global = SandboxConfig {
            profiles: HashMap::from([
                ("dev".to_string(), profile(false)),
                ("same".to_string(), profile(false)),
            ]),
        };
        let project = SandboxConfig {
            profiles: HashMap::from([
                ("dev".to_string(), profile(true)),
                ("same".to_string(), profile(false)),
                ("project-only".to_string(), profile(true)),
                ("devbox".to_string(), profile(true)),
            ]),
        };

        assert_eq!(mismatched_profile_names(&global, &project), vec!["dev"]);
    }

    #[test]
    fn parse_toml_config() {
        let toml_str = r#"
[profiles.devbox]
extends = "workspace"
restrict_network = true
read_only = ["/data"]
deny = ["/data/private"]

[profiles.ci]
extends = "strict"
read_write = ["/tmp/ci-artifacts"]
"#;
        let config: SandboxConfig = toml::from_str(toml_str).unwrap();
        assert_eq!(config.profiles.len(), 2);
        assert!(config.profiles.contains_key("devbox"));
        assert!(config.profiles.contains_key("ci"));
        assert_eq!(config.profiles["devbox"].read_only, vec!["/data"]);
        assert_eq!(config.profiles["devbox"].deny, vec!["/data/private"]);
    }

    #[test]
    fn project_cannot_redefine_global_profile() {
        // Global "secure" with a real deny list must win over a project hollow-out.
        let mut config = SandboxConfig {
            profiles: HashMap::from([(
                "secure".to_string(),
                ProfileConfig {
                    extends: Some("workspace".to_string()),
                    restrict_network: Some(true),
                    read_only: vec![],
                    read_write: vec![],
                    deny: vec!["/home/user/.ssh".to_string()],
                },
            )]),
        };
        let project = SandboxConfig {
            profiles: HashMap::from([
                (
                    "secure".to_string(),
                    ProfileConfig {
                        extends: Some("workspace".to_string()),
                        restrict_network: Some(false),
                        read_only: vec![],
                        read_write: vec!["/".to_string()],
                        deny: vec![],
                    },
                ),
                (
                    "project-only".to_string(),
                    ProfileConfig {
                        extends: Some("workspace".to_string()),
                        restrict_network: None,
                        read_only: vec![],
                        read_write: vec![],
                        deny: vec!["./secrets".to_string()],
                    },
                ),
            ]),
        };

        merge_project_profiles(&mut config, project);

        assert_eq!(
            config.profiles["secure"].deny,
            vec!["/home/user/.ssh".to_string()],
            "global deny must be preserved"
        );
        assert_eq!(config.profiles["secure"].restrict_network, Some(true));
        assert!(
            config.profiles["secure"].read_write.is_empty(),
            "project must not widen global read_write"
        );
        assert!(
            config.profiles.contains_key("project-only"),
            "new project-only profile names are still allowed"
        );
    }

    #[test]
    #[cfg(all(feature = "enforce", unix))]
    fn extends_off_returns_err_not_panic() {
        let workspace = std::env::current_dir().unwrap();
        let config = SandboxConfig {
            profiles: HashMap::from([(
                "broken".to_string(),
                ProfileConfig {
                    extends: Some("off".to_string()),
                    restrict_network: None,
                    read_only: vec![],
                    read_write: vec![],
                    deny: vec![],
                },
            )]),
        };
        let err = ProfileName::Custom("broken".to_string())
            .resolve_profile(&workspace, &config)
            .expect_err("extends=off must Err");
        let msg = err.to_string();
        assert!(
            msg.contains("off") || msg.contains("none"),
            "unexpected error: {msg}"
        );
    }

    #[test]
    #[cfg(all(feature = "enforce", unix))]
    fn resolve_off_returns_err_not_panic() {
        let workspace = std::env::current_dir().unwrap();
        let err = ProfileName::Off
            .resolve_profile(&workspace, &SandboxConfig::default())
            .expect_err("Off.resolve must Err");
        assert!(err.to_string().contains("off"), "unexpected error: {err}");
    }
}
