use std::sync::LazyLock; use tempfile::TempDir; use super::*; const FULL_CONFIG_TOML: &str = r#" hardened = true unshare-net = true seccomp = false rw = ["/tmp/a", "/tmp/b"] command = "zsh" [profiles.claude] rw = ["/home/user/.config/claude"] ro = ["/etc/claude", "/etc/shared"] entrypoint = ["claude", "--dangerously-skip-permissions"] command = ["bash", "-c", "echo hi"] [profiles.codex] whitelist = true dry-run = true chdir = "/home/user/project" rw = ["/home/user/.codex"] "#; static CONFIG: LazyLock = LazyLock::new(|| toml::from_str(FULL_CONFIG_TOML).unwrap()); #[test] fn globals_scalars() { assert_eq!(CONFIG.options.hardened, Some(true)); assert_eq!(CONFIG.options.unshare_net, Some(true)); assert_eq!(CONFIG.options.seccomp, Some(false)); } #[test] fn globals_multi_element_vecs() { assert_paths(&CONFIG.options.rw, &["/tmp/a", "/tmp/b"]); } #[test] fn globals_simple_command() { assert_command(&CONFIG.options.command, &["zsh"]); } #[test] fn unset_vecs_default_empty() { assert!(CONFIG.options.ro.is_empty()); } #[test] fn unset_scalars_are_none() { assert_eq!(CONFIG.profiles["claude"].hardened, None); } #[test] fn profile_multi_element_vecs() { assert_paths( &CONFIG.profiles["claude"].ro, &["/etc/claude", "/etc/shared"], ); } #[test] fn profile_entrypoint_with_args() { assert_command( &CONFIG.profiles["claude"].entrypoint, &["claude", "--dangerously-skip-permissions"], ); } #[test] fn profile_command_with_args() { assert_command( &CONFIG.profiles["claude"].command, &["bash", "-c", "echo hi"], ); } #[test] fn profile_kebab_case_scalars() { let codex = &CONFIG.profiles["codex"]; assert_eq!(codex.whitelist, Some(true)); assert_eq!(codex.dry_run, Some(true)); assert_eq!(codex.chdir, Some(PathBuf::from("/home/user/project"))); } fn args_with_command() -> Args { Args { command_and_args: vec!["/usr/bin/true".into()], ..Args::default() } } #[test] fn build_hardened_from_globals() { let file_config = FileConfig { options: Options { hardened: Some(true), ..Options::default() }, ..FileConfig::default() }; let config = build(args_with_command(), Some(file_config)).unwrap(); assert!(config.hardened); } #[test] fn build_profile_overrides_globals() { let file_config = FileConfig { options: Options { hardened: Some(true), ..Options::default() }, profiles: HashMap::from([( "relaxed".into(), Options { hardened: Some(false), ..Options::default() }, )]), ..FileConfig::default() }; let args = Args { profile: Some("relaxed".into()), ..args_with_command() }; let config = build(args, Some(file_config)).unwrap(); assert!(!config.hardened); } #[test] fn build_cli_flag_overrides_profile() { let file_config = FileConfig { profiles: HashMap::from([( "nonet".into(), Options { unshare_net: Some(false), ..Options::default() }, )]), ..FileConfig::default() }; let args = Args { profile: Some("nonet".into()), unshare_net: true, ..args_with_command() }; let config = build(args, Some(file_config)).unwrap(); assert!(config.unshare_net); } #[test] fn build_seccomp_default_is_true() { let config = build(args_with_command(), None).unwrap(); assert!(config.seccomp); } #[test] fn build_seccomp_disabled_via_config() { let file_config = FileConfig { options: Options { seccomp: Some(false), ..Options::default() }, ..FileConfig::default() }; let config = build(args_with_command(), Some(file_config)).unwrap(); assert!(!config.seccomp); } #[test] fn build_cli_seccomp_overrides_profile() { let file_config = FileConfig { options: Options { seccomp: Some(false), ..Options::default() }, ..FileConfig::default() }; let args = Args { seccomp: true, ..args_with_command() }; let config = build(args, Some(file_config)).unwrap(); assert!(config.seccomp); } #[test] fn build_cli_no_seccomp_overrides_profile() { let file_config = FileConfig { options: Options { seccomp: Some(true), ..Options::default() }, ..FileConfig::default() }; let args = Args { no_seccomp: true, ..args_with_command() }; let config = build(args, Some(file_config)).unwrap(); assert!(!config.seccomp); } #[test] fn build_cli_no_hardened_overrides_profile() { let file_config = FileConfig { options: Options { hardened: Some(true), ..Options::default() }, ..FileConfig::default() }; let args = Args { no_hardened: true, ..args_with_command() }; let config = build(args, Some(file_config)).unwrap(); assert!(!config.hardened); } #[test] fn build_cli_share_net_overrides_profile() { let file_config = FileConfig { options: Options { unshare_net: Some(true), ..Options::default() }, ..FileConfig::default() }; let args = Args { share_net: true, ..args_with_command() }; let config = build(args, Some(file_config)).unwrap(); assert!(!config.unshare_net); } #[test] fn build_cli_no_dry_run_overrides_profile() { let file_config = FileConfig { options: Options { dry_run: Some(true), ..Options::default() }, ..FileConfig::default() }; let args = Args { no_dry_run: true, ..args_with_command() }; let config = build(args, Some(file_config)).unwrap(); assert!(!config.dry_run); } #[test] fn build_cli_mode_overrides_profile() { let file_config = FileConfig { profiles: HashMap::from([( "wl".into(), Options { whitelist: Some(true), ..Options::default() }, )]), ..FileConfig::default() }; let args = Args { profile: Some("wl".into()), blacklist: true, ..args_with_command() }; let config = build(args, Some(file_config)).unwrap(); assert!(matches!(config.mode, SandboxMode::Blacklist)); } #[test] fn build_rw_paths_accumulate() { let file_config = FileConfig { options: Options { rw: vec!["/tmp".into()], ..Options::default() }, profiles: HashMap::from([( "extra".into(), Options { rw: vec!["/usr".into()], ..Options::default() }, )]), ..FileConfig::default() }; let args = Args { profile: Some("extra".into()), extra_rw: vec!["/var".into()], ..args_with_command() }; let config = build(args, Some(file_config)).unwrap(); assert_eq!(config.extra_rw.len(), 3); } #[test] fn build_command_from_profile() { let file_config = FileConfig { profiles: HashMap::from([( "test".into(), Options { command: Some(CommandValue::WithArgs(vec![ "/usr/bin/true".into(), "--flag".into(), ])), ..Options::default() }, )]), ..FileConfig::default() }; let args = Args { profile: Some("test".into()), ..Args::default() }; let config = build(args, Some(file_config)).unwrap(); assert_eq!(config.command, PathBuf::from("/usr/bin/true")); assert_eq!(config.command_args, vec![OsString::from("--flag")]); } #[test] fn build_cli_entrypoint_overrides_profile() { let file_config = FileConfig { profiles: HashMap::from([( "test".into(), Options { entrypoint: Some(CommandValue::Simple("/usr/bin/false".into())), ..Options::default() }, )]), ..FileConfig::default() }; let args = Args { profile: Some("test".into()), entrypoint: Some("/usr/bin/true".into()), ..Args::default() }; let config = build(args, Some(file_config)).unwrap(); assert_eq!(config.command, PathBuf::from("/usr/bin/true")); assert!(config.command_args.is_empty()); } #[test] fn build_cli_entrypoint_combines_with_passthrough_args() { let args = Args { entrypoint: Some("/usr/bin/true".into()), command_and_args: vec!["--flag".into(), "value".into()], ..Args::default() }; let config = build(args, None).unwrap(); assert_eq!(config.command, PathBuf::from("/usr/bin/true")); assert_eq!( config.command_args, vec![OsString::from("--flag"), OsString::from("value")] ); } #[test] fn build_cli_entrypoint_falls_back_to_config_command_defaults() { let file_config = FileConfig { options: Options { command: Some(CommandValue::WithArgs(vec![ "--default".into(), "args".into(), ])), ..Options::default() }, ..FileConfig::default() }; let args = Args { entrypoint: Some("/usr/bin/true".into()), ..Args::default() }; let config = build(args, Some(file_config)).unwrap(); assert_eq!(config.command, PathBuf::from("/usr/bin/true")); assert_eq!( config.command_args, vec![OsString::from("--default"), OsString::from("args")] ); } #[test] fn build_cli_command_overrides_config() { let file_config = FileConfig { options: Options { command: Some(CommandValue::Simple("/usr/bin/false".into())), ..Options::default() }, ..FileConfig::default() }; let args = Args { command_and_args: vec!["/usr/bin/true".into()], ..Args::default() }; let config = build(args, Some(file_config)).unwrap(); assert_eq!(config.command, PathBuf::from("/usr/bin/true")); } #[test] fn build_no_file_config() { let config = build(args_with_command(), None).unwrap(); assert!(matches!(config.mode, SandboxMode::Blacklist)); assert!(!config.hardened); } #[test] fn build_top_level_profile_used_when_cli_absent() { let file_config = FileConfig { options: Options { profile: Some("auto".into()), ..Options::default() }, profiles: HashMap::from([( "auto".into(), Options { hardened: Some(true), ..Options::default() }, )]), ..FileConfig::default() }; let config = build(args_with_command(), Some(file_config)).unwrap(); assert!(config.hardened); } #[test] fn build_cli_profile_overrides_top_level_profile() { let file_config = FileConfig { options: Options { profile: Some("auto".into()), ..Options::default() }, profiles: HashMap::from([ ( "auto".into(), Options { hardened: Some(true), ..Options::default() }, ), ( "other".into(), Options { hardened: Some(false), ..Options::default() }, ), ]), ..FileConfig::default() }; let args = Args { profile: Some("other".into()), ..args_with_command() }; let config = build(args, Some(file_config)).unwrap(); assert!(!config.hardened); } #[test] fn build_missing_top_level_profile_errors() { let file_config = FileConfig { options: Options { profile: Some("nope".into()), ..Options::default() }, ..FileConfig::default() }; assert!(matches!( build(args_with_command(), Some(file_config)), Err(SandboxError::ProfileNotFound(_)) )); } #[test] fn build_missing_profile_errors() { let args = Args { profile: Some("nope".into()), ..args_with_command() }; assert!(matches!( build(args, Some(FileConfig::default())), Err(SandboxError::ProfileNotFound(_)) )); } #[test] fn inheritance_merges_parent_into_child() { let file_config = FileConfig { profiles: HashMap::from([ ( "base".into(), Options { hardened: Some(true), unshare_net: Some(true), env: vec!["FROM_PARENT=1".into()], ..Options::default() }, ), ( "child".into(), Options { profile: Some("base".into()), hardened: Some(false), seccomp: Some(false), env: vec!["FROM_CHILD=1".into()], ..Options::default() }, ), ]), ..FileConfig::default() }; let args = Args { profile: Some("child".into()), ..args_with_command() }; let config = build(args, Some(file_config)).unwrap(); assert!(!config.hardened); assert!(config.unshare_net); assert!(!config.seccomp); assert_eq!( config.env, vec![ EnvEntry::Set("FROM_PARENT".into(), "1".into()), EnvEntry::Set("FROM_CHILD".into(), "1".into()), ] ); } #[test] fn inheritance_walks_multi_level_chain() { let file_config = FileConfig { profiles: HashMap::from([ ( "grand".into(), Options { hardened: Some(true), env: vec!["FROM_GRAND=1".into()], ..Options::default() }, ), ( "parent".into(), Options { profile: Some("grand".into()), env: vec!["FROM_PARENT=1".into()], ..Options::default() }, ), ( "child".into(), Options { profile: Some("parent".into()), env: vec!["FROM_CHILD=1".into()], ..Options::default() }, ), ]), ..FileConfig::default() }; let args = Args { profile: Some("child".into()), ..args_with_command() }; let config = build(args, Some(file_config)).unwrap(); assert!(config.hardened); assert_eq!( config.env, vec![ EnvEntry::Set("FROM_GRAND".into(), "1".into()), EnvEntry::Set("FROM_PARENT".into(), "1".into()), EnvEntry::Set("FROM_CHILD".into(), "1".into()), ] ); } #[test] fn top_level_profile_walks_chain() { let file_config = FileConfig { options: Options { profile: Some("child".into()), ..Options::default() }, profiles: HashMap::from([ ( "base".into(), Options { hardened: Some(true), ..Options::default() }, ), ( "child".into(), Options { profile: Some("base".into()), ..Options::default() }, ), ]), ..FileConfig::default() }; let config = build(args_with_command(), Some(file_config)).unwrap(); assert!(config.hardened); } #[test] fn inheritance_cycle_errors() { let file_config = FileConfig { profiles: HashMap::from([ ( "a".into(), Options { profile: Some("b".into()), ..Options::default() }, ), ( "b".into(), Options { profile: Some("a".into()), ..Options::default() }, ), ]), ..FileConfig::default() }; let args = Args { profile: Some("a".into()), ..args_with_command() }; assert!(matches!( build(args, Some(file_config)), Err(SandboxError::ProfileCycle(_)) )); } #[test] fn missing_parent_profile_errors() { let file_config = FileConfig { profiles: HashMap::from([( "child".into(), Options { profile: Some("ghost".into()), ..Options::default() }, )]), ..FileConfig::default() }; let args = Args { profile: Some("child".into()), ..args_with_command() }; assert!(matches!( build(args, Some(file_config)), Err(SandboxError::ProfileNotFound(name)) if name == "ghost" )); } #[test] fn inheritance_chain_spans_extra_config() { let dir = TempDir::new().unwrap(); let base_path = dir.path().join("base.toml"); let extra_path = dir.path().join("extra.toml"); std::fs::write( &extra_path, r#" [profiles.child] profile = "base" env = ["FROM_CHILD=1"] "#, ) .unwrap(); std::fs::write( &base_path, format!( r#" extra-config = "{}" [profiles.base] hardened = true env = ["FROM_PARENT=1"] "#, extra_path.display() ), ) .unwrap(); let file_config = FileConfig::load(&base_path).unwrap(); let args = Args { profile: Some("child".into()), ..args_with_command() }; let config = build(args, Some(file_config)).unwrap(); assert!(config.hardened); assert_eq!( config.env, vec![ EnvEntry::Set("FROM_PARENT".into(), "1".into()), EnvEntry::Set("FROM_CHILD".into(), "1".into()), ] ); } #[test] fn build_conflicting_mode_errors() { let file_config = FileConfig { options: Options { blacklist: Some(true), whitelist: Some(true), ..Options::default() }, ..FileConfig::default() }; assert!(matches!( build(args_with_command(), Some(file_config)), Err(SandboxError::ConflictingMode) )); } #[test] fn build_tilde_expansion_in_config_paths() { let home = std::env::var("HOME").unwrap(); let file_config = FileConfig { options: Options { rw: vec!["~/".into()], ..Options::default() }, ..FileConfig::default() }; let config = build(args_with_command(), Some(file_config)).unwrap(); assert_eq!(config.extra_rw, vec![same_bind(Path::new(&home))]); } #[test] fn build_preserves_symlinks_in_config_paths() { let dir = TempDir::new().unwrap(); let target = dir.path().join("target"); let link = dir.path().join("link"); std::fs::write(&target, "x").unwrap(); std::os::unix::fs::symlink(&target, &link).unwrap(); let link_str = link.to_str().unwrap().to_string(); let file_config = FileConfig { options: Options { ro: vec![link_str.clone()], rw: vec![link_str], ..Options::default() }, ..FileConfig::default() }; let config = build(args_with_command(), Some(file_config)).unwrap(); assert_eq!(config.extra_ro, vec![same_bind(&link)]); assert_eq!(config.extra_rw, vec![same_bind(&link)]); } #[test] fn build_preserves_symlinks_in_cli_paths() { let dir = TempDir::new().unwrap(); let target = dir.path().join("target"); let link = dir.path().join("link"); std::fs::write(&target, "x").unwrap(); std::os::unix::fs::symlink(&target, &link).unwrap(); let link_str = link.to_str().unwrap().to_string(); let args = Args { extra_ro: vec![link_str.clone()], extra_rw: vec![link_str], ..args_with_command() }; let config = build(args, None).unwrap(); assert_eq!(config.extra_ro, vec![same_bind(&link)]); assert_eq!(config.extra_rw, vec![same_bind(&link)]); } #[test] fn build_relative_config_path_rejected() { let file_config = FileConfig { options: Options { rw: vec!["relative/path".into()], ..Options::default() }, ..FileConfig::default() }; assert!(matches!( build(args_with_command(), Some(file_config)), Err(SandboxError::ConfigPathNotAbsolute(_)) )); } #[test] fn build_cli_remap_target() { let dir = TempDir::new().unwrap(); let src = dir.path().join("host"); std::fs::create_dir(&src).unwrap(); let src_str = src.to_str().unwrap(); let args = Args { extra_ro: vec![format!("{src_str}:/inside/elsewhere")], ..args_with_command() }; let config = build(args, None).unwrap(); assert_eq!( config.extra_ro, vec![BindSpec { source: src, target: PathBuf::from("/inside/elsewhere"), }] ); } #[test] fn build_config_remap_target() { let dir = TempDir::new().unwrap(); let src = dir.path().join("host"); std::fs::create_dir(&src).unwrap(); let src_str = src.to_str().unwrap(); let file_config = FileConfig { options: Options { rw: vec![format!("{src_str}:/inside/elsewhere")], ..Options::default() }, ..FileConfig::default() }; let config = build(args_with_command(), Some(file_config)).unwrap(); assert_eq!( config.extra_rw, vec![BindSpec { source: src, target: PathBuf::from("/inside/elsewhere"), }] ); } #[test] fn build_config_tilde_expands_in_target() { let home = std::env::var("HOME").unwrap(); let file_config = FileConfig { options: Options { rw: vec!["~/:~/mounted".into()], ..Options::default() }, ..FileConfig::default() }; let config = build(args_with_command(), Some(file_config)).unwrap(); assert_eq!( config.extra_rw, vec![BindSpec { source: PathBuf::from(&home), target: PathBuf::from(format!("{home}/mounted")), }] ); } #[test] fn build_relative_target_rejected() { let dir = TempDir::new().unwrap(); let src_str = dir.path().to_str().unwrap(); let args = Args { extra_ro: vec![format!("{src_str}:relative/target")], ..args_with_command() }; assert!(matches!( build(args, None), Err(SandboxError::ConfigPathNotAbsolute(_)) )); } #[test] fn build_empty_source_half_rejected() { let args = Args { extra_ro: vec![":/target".into()], ..args_with_command() }; assert!(matches!( build(args, None), Err(SandboxError::InvalidBindSpec(_)) )); } #[test] fn build_empty_target_half_rejected() { let dir = TempDir::new().unwrap(); let src_str = dir.path().to_str().unwrap(); let args = Args { extra_ro: vec![format!("{src_str}:")], ..args_with_command() }; assert!(matches!( build(args, None), Err(SandboxError::InvalidBindSpec(_)) )); } #[test] fn build_chdir_from_config() { let file_config = FileConfig { options: Options { chdir: Some(PathBuf::from("/tmp")), ..Options::default() }, ..FileConfig::default() }; let config = build(args_with_command(), Some(file_config)).unwrap(); assert_eq!(config.chdir, std::fs::canonicalize("/tmp").unwrap()); } #[test] fn build_env_accumulates_disjoint_keys() { let file_config = FileConfig { options: Options { env: vec!["A=global".into(), "B".into()], ..Options::default() }, profiles: HashMap::from([( "p".into(), Options { env: vec!["C=profile".into()], ..Options::default() }, )]), ..FileConfig::default() }; let args = Args { profile: Some("p".into()), env: vec!["D".into()], ..args_with_command() }; let config = build(args, Some(file_config)).unwrap(); assert_eq!( config.env, vec![ EnvEntry::Set("A".into(), "global".into()), EnvEntry::Keep("B".into()), EnvEntry::Set("C".into(), "profile".into()), EnvEntry::Keep("D".into()), ] ); } #[test] fn build_env_later_tier_overrides_earlier() { let file_config = FileConfig { options: Options { env: vec!["A=global".into()], ..Options::default() }, profiles: HashMap::from([( "p".into(), Options { env: vec!["A=profile".into()], ..Options::default() }, )]), ..FileConfig::default() }; let args = Args { profile: Some("p".into()), env: vec!["A=cli".into()], ..args_with_command() }; let config = build(args, Some(file_config)).unwrap(); assert_eq!(config.env, vec![EnvEntry::Set("A".into(), "cli".into())]); } #[test] fn build_env_conflicts_with_unsetenv() { let file_config = FileConfig { options: Options { env: vec!["X=1".into()], unsetenv: vec!["X".into()], ..Options::default() }, ..FileConfig::default() }; assert!(matches!( build(args_with_command(), Some(file_config)), Err(SandboxError::ConflictingEnvKey(k)) if k == "X" )); } #[test] fn build_env_keep_conflicts_with_unsetenv() { let file_config = FileConfig { options: Options { env: vec!["X".into()], unsetenv: vec!["X".into()], ..Options::default() }, ..FileConfig::default() }; assert!(matches!( build(args_with_command(), Some(file_config)), Err(SandboxError::ConflictingEnvKey(k)) if k == "X" )); } #[test] fn build_env_rejects_empty_key() { let file_config = FileConfig { options: Options { env: vec!["=foo".into()], ..Options::default() }, ..FileConfig::default() }; assert!(matches!( build(args_with_command(), Some(file_config)), Err(SandboxError::InvalidEnvEntry(_)) )); } #[test] fn build_env_set_to_empty_string_is_allowed() { let file_config = FileConfig { options: Options { env: vec!["DEBUG=".into()], ..Options::default() }, ..FileConfig::default() }; let config = build(args_with_command(), Some(file_config)).unwrap(); assert_eq!(config.env, vec![EnvEntry::Set("DEBUG".into(), "".into())]); } #[test] fn build_unsetenv_accumulates() { let file_config = FileConfig { options: Options { unsetenv: vec!["G".into()], ..Options::default() }, profiles: HashMap::from([( "p".into(), Options { unsetenv: vec!["P".into()], ..Options::default() }, )]), ..FileConfig::default() }; let args = Args { profile: Some("p".into()), unsetenv: vec!["C".into()], ..args_with_command() }; let config = build(args, Some(file_config)).unwrap(); assert_eq!(config.unsetenv, vec!["G", "P", "C"]); } #[test] fn build_mask_accumulates() { let file_config = FileConfig { options: Options { mask: vec![PathBuf::from("/tmp/a")], ..Options::default() }, profiles: HashMap::from([( "extra".into(), Options { mask: vec![PathBuf::from("/tmp/b")], ..Options::default() }, )]), ..FileConfig::default() }; let args = Args { profile: Some("extra".into()), mask: vec![PathBuf::from("/tmp/c")], ..args_with_command() }; let config = build(args, Some(file_config)).unwrap(); assert_eq!(config.mask.len(), 3); } #[test] fn unknown_option_rejected() { let toml = r#" hardened = true bogus = "nope" "#; assert!(matches!( FileConfig::parse(toml), Err(SandboxError::UnknownConfigKey(_)) )); } #[test] fn unknown_profile_option_rejected() { let toml = r#" [profiles.test] hardened = true frobnicate = 42 "#; assert!(matches!( FileConfig::parse(toml), Err(SandboxError::ConfigParse { .. }) )); } #[test] fn entrypoint_with_passthrough_args() { let (cmd, args) = resolve_command( None, vec!["--verbose".into(), "--model".into(), "opus".into()], &Options { entrypoint: Some(CommandValue::WithArgs(vec![ "claude".into(), "--dangerously-skip-permissions".into(), ])), command: Some(CommandValue::Simple("--default-flag".into())), ..Options::default() }, &Options::default(), ) .unwrap(); assert_eq!(cmd, "claude"); assert_eq!( args, vec![ "--dangerously-skip-permissions", "--verbose", "--model", "opus" ] .into_iter() .map(OsString::from) .collect::>() ); } #[test] fn entrypoint_without_passthrough_uses_command_defaults() { let (cmd, args) = resolve_command( None, vec![], &Options { entrypoint: Some(CommandValue::Simple("claude".into())), command: Some(CommandValue::WithArgs(vec![ "--dangerously-skip-permissions".into(), "--verbose".into(), ])), ..Options::default() }, &Options::default(), ) .unwrap(); assert_eq!(cmd, "claude"); assert_eq!( args, vec!["--dangerously-skip-permissions", "--verbose"] .into_iter() .map(OsString::from) .collect::>() ); } #[test] fn entrypoint_without_passthrough_or_command() { let (cmd, args) = resolve_command( None, vec![], &Options { entrypoint: Some(CommandValue::WithArgs(vec![ "claude".into(), "--dangerously-skip-permissions".into(), ])), ..Options::default() }, &Options::default(), ) .unwrap(); assert_eq!(cmd, "claude"); assert_eq!(args, vec![OsString::from("--dangerously-skip-permissions")]); } #[test] fn profile_entrypoint_overrides_global() { let (cmd, _) = resolve_command( None, vec![], &Options { entrypoint: Some(CommandValue::Simple("claude".into())), ..Options::default() }, &Options { entrypoint: Some(CommandValue::Simple("codex".into())), ..Options::default() }, ) .unwrap(); assert_eq!(cmd, "claude"); } #[test] fn global_entrypoint_with_profile_command() { let (cmd, args) = resolve_command( None, vec![], &Options { command: Some(CommandValue::WithArgs(vec![ "--dangerously-skip-permissions".into(), "--verbose".into(), ])), ..Options::default() }, &Options { entrypoint: Some(CommandValue::Simple("claude".into())), ..Options::default() }, ) .unwrap(); assert_eq!(cmd, "claude"); assert_eq!( args, vec!["--dangerously-skip-permissions", "--verbose"] .into_iter() .map(OsString::from) .collect::>() ); } #[test] fn no_command_errors() { let result = resolve_command(None, vec![], &Options::default(), &Options::default()); assert!(matches!(result, Err(SandboxError::NoCommand))); } #[test] fn merge_options_scalar_extra_overrides_base() { let base = Options { hardened: Some(false), ..Options::default() }; let extra = Options { hardened: Some(true), ..Options::default() }; assert_eq!(base.merge_with(extra).hardened, Some(true)); } #[test] fn merge_options_scalar_extra_unset_inherits_base() { let base = Options { hardened: Some(true), ..Options::default() }; assert_eq!(base.merge_with(Options::default()).hardened, Some(true)); } #[test] fn merge_options_extra_mode_replaces_base_mode() { let base = Options { whitelist: Some(true), ..Options::default() }; let extra = Options { blacklist: Some(true), ..Options::default() }; let merged = base.merge_with(extra); assert_eq!(merged.blacklist, Some(true)); assert_eq!(merged.whitelist, None); } #[test] fn merge_options_base_mode_inherited_when_extra_silent() { let base = Options { whitelist: Some(true), ..Options::default() }; let merged = base.merge_with(Options::default()); assert_eq!(merged.whitelist, Some(true)); assert_eq!(merged.blacklist, None); } #[test] fn merge_options_lists_append_base_then_extra() { let base = Options { rw: vec!["/a".into()], env: vec!["A=1".into()], ..Options::default() }; let extra = Options { rw: vec!["/b".into()], env: vec!["B=2".into()], ..Options::default() }; let merged = base.merge_with(extra); assert_eq!(merged.rw, vec!["/a", "/b"]); assert_eq!(merged.env, vec!["A=1", "B=2"]); } #[test] fn merge_file_config_profile_merged_by_name() { let base = FileConfig { profiles: HashMap::from([( "claude".into(), Options { rw: vec!["/a".into()], hardened: Some(false), ..Options::default() }, )]), ..FileConfig::default() }; let extra = FileConfig { profiles: HashMap::from([ ( "claude".into(), Options { rw: vec!["/b".into()], hardened: Some(true), ..Options::default() }, ), ( "codex".into(), Options { unshare_net: Some(true), ..Options::default() }, ), ]), ..FileConfig::default() }; let merged = base.merge_with(extra); assert_eq!(merged.profiles["claude"].hardened, Some(true)); assert_eq!(merged.profiles["claude"].rw, vec!["/a", "/b"]); assert_eq!(merged.profiles["codex"].unshare_net, Some(true)); } #[test] fn merge_file_config_top_level_profile_extra_overrides() { let base = FileConfig { options: Options { profile: Some("claude".into()), ..Options::default() }, ..FileConfig::default() }; let extra = FileConfig { options: Options { profile: Some("codex".into()), ..Options::default() }, ..FileConfig::default() }; assert_eq!(base.merge_with(extra).options.profile, Some("codex".into())); } #[test] fn merge_file_config_top_level_profile_extra_unset_inherits() { let base = FileConfig { options: Options { profile: Some("claude".into()), ..Options::default() }, ..FileConfig::default() }; assert_eq!( base.merge_with(FileConfig::default()).options.profile, Some("claude".into()) ); } #[test] fn load_extra_applies_overlay() { let dir = TempDir::new().unwrap(); let base_path = dir.path().join("base.toml"); let extra_path = dir.path().join("extra.toml"); std::fs::write(&extra_path, "hardened = true\nrw = [\"/b\"]\n").unwrap(); std::fs::write( &base_path, format!( "hardened = false\nrw = [\"/a\"]\nextra-config = \"{}\"\n", extra_path.display() ), ) .unwrap(); let config = FileConfig::load(&base_path).unwrap(); assert_eq!(config.options.hardened, Some(true)); assert_eq!(config.options.rw, vec!["/a", "/b"]); assert_eq!(config.extra_config, None); } #[test] fn load_extra_missing_file_is_skipped() { let dir = TempDir::new().unwrap(); let base_path = dir.path().join("base.toml"); let extra_path = dir.path().join("does-not-exist.toml"); std::fs::write( &base_path, format!( "hardened = true\nextra-config = \"{}\"\n", extra_path.display() ), ) .unwrap(); let config = FileConfig::load(&base_path).unwrap(); assert_eq!(config.options.hardened, Some(true)); } #[test] fn load_extra_nested_rejected() { let dir = TempDir::new().unwrap(); let base_path = dir.path().join("base.toml"); let extra_path = dir.path().join("extra.toml"); let deeper_path = dir.path().join("deeper.toml"); std::fs::write(&deeper_path, "hardened = true\n").unwrap(); std::fs::write( &extra_path, format!("extra-config = \"{}\"\n", deeper_path.display()), ) .unwrap(); std::fs::write( &base_path, format!("extra-config = \"{}\"\n", extra_path.display()), ) .unwrap(); assert!(matches!( FileConfig::load(&base_path), Err(SandboxError::NestedExtraConfig(_)) )); } #[test] fn load_extra_relative_path_rejected() { let dir = TempDir::new().unwrap(); let base_path = dir.path().join("base.toml"); std::fs::write(&base_path, "extra-config = \"extra.toml\"\n").unwrap(); assert!(matches!( FileConfig::load(&base_path), Err(SandboxError::ConfigPathNotAbsolute(_)) )); } fn assert_paths(actual: &[String], expected: &[&str]) { let expected: Vec = expected.iter().map(|s| s.to_string()).collect(); assert_eq!(actual, &expected); } fn same_bind(path: &Path) -> BindSpec { BindSpec { source: path.to_path_buf(), target: path.to_path_buf(), } } fn assert_command(cmd: &Option, expected: &[&str]) { let actual = cmd.clone().unwrap().into_vec(); let expected: Vec = expected.iter().map(|s| s.to_string()).collect(); assert_eq!(actual, expected); }