diff --git a/scripts/benchmark_cc_switch.py b/scripts/benchmark_cc_switch.py index cd141574..e585061e 100755 --- a/scripts/benchmark_cc_switch.py +++ b/scripts/benchmark_cc_switch.py @@ -138,6 +138,9 @@ def read_json(path: Path) -> dict: def write_json(path: Path, data: dict) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + # cc-switch checks that sensitive files (.json, .db) have 0600 on Unix. + if sys.platform != "win32": + path.chmod(0o600) @dataclass @@ -188,7 +191,11 @@ def configure_environment(real_env: bool) -> BenchEnvironment: } old_env = {key: os.environ.get(key) for key in env_updates} for path in env_updates.values(): - Path(path).mkdir(parents=True, exist_ok=True) + p = Path(path) + p.mkdir(parents=True, exist_ok=True) + # cc-switch checks that its config dir has 0700 permissions on Unix. + if path == env_updates["CC_SWITCH_CONFIG_DIR"]: + p.chmod(0o700) for key, value in env_updates.items(): os.environ[key] = value return BenchEnvironment(mode="sandbox", root=root, old_env=old_env) @@ -325,6 +332,9 @@ def connect_db(paths: Paths) -> sqlite3.Connection: paths.cc_dir.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(paths.db_path) conn.execute("PRAGMA busy_timeout = 5000") + # cc-switch checks that .db files have 0600 permissions on Unix. + if sys.platform != "win32": + paths.db_path.chmod(0o600) return conn diff --git a/src-tauri/src/cli/i18n.rs b/src-tauri/src/cli/i18n.rs index 7c81804f..5a6e8fef 100644 --- a/src-tauri/src/cli/i18n.rs +++ b/src-tauri/src/cli/i18n.rs @@ -11136,6 +11136,108 @@ pub mod texts { "No live providers were imported" } } + + // ----------------------------------------------------------------- + // config.rs - validate_config_dir & prompt_fix_permissions + // ----------------------------------------------------------------- + + pub fn config_dir_is_system_dir(dir: &str, resolved: &str) -> String { + if is_chinese() { + format!("CC_SWITCH_CONFIG_DIR 不能设置为系统目录: {dir}(解析后: {resolved})") + } else { + format!( + "CC_SWITCH_CONFIG_DIR must not be a system directory: {dir} (resolved: {resolved})" + ) + } + } + + pub fn config_dir_invalid_last_component(path: &str) -> String { + if is_chinese() { + format!("配置目录路径无效,无法解析最后一层目录: {path}") + } else { + format!("Invalid config directory path; unable to resolve the final directory component: {path}") + } + } + + pub fn config_dir_only_final_component_may_be_missing(path: &str) -> String { + if is_chinese() { + format!("配置目录路径无效,仅允许最后一层目录不存在: {path}") + } else { + format!("Invalid config directory path; only the final directory component may be missing: {path}") + } + } + + pub fn config_permissions_insecure_header() -> &'static str { + if is_chinese() { + "⚠ 检测到以下文件/目录权限不安全:" + } else { + "⚠ Insecure file/directory permissions detected:" + } + } + + pub fn config_permissions_detail(path: &str, current: u32, expected: u32) -> String { + if is_chinese() { + format!(" {path} 当前 {current:04o},期望 {expected:04o}") + } else { + format!(" {path} current {current:04o}, expected {expected:04o}") + } + } + + pub fn config_permissions_fix_prompt() -> &'static str { + if is_chinese() { + "是否现在修复权限?(仅所有者可访问)" + } else { + "Fix permissions now? (owner-only access)" + } + } + + pub fn config_permissions_fixed() -> &'static str { + if is_chinese() { + "✓ 权限已修复" + } else { + "✓ Permissions fixed" + } + } + + pub fn config_permissions_fix_warn_interactive() -> &'static str { + if is_chinese() { + "⚠ 未来版本将拒绝在权限不安全的情况下启动,请尽快修复。" + } else { + "⚠ Future versions will refuse to start with insecure permissions. Please fix soon." + } + } + + pub fn config_permissions_fix_warn_noninteractive() -> &'static str { + if is_chinese() { + "⚠ 检测到配置文件权限不安全(非交互模式),跳过修复。未来版本将拒绝启动。" + } else { + "⚠ Insecure config permissions detected (non-interactive). Skipped. Future versions will refuse to start." + } + } + + pub fn config_permissions_custom_dir_notice(path: &str) -> String { + if is_chinese() { + format!("检测到自定义配置目录: {path},请核实此目录不是关键系统目录") + } else { + format!("Custom config directory detected: {path}, please verify this is not a critical system directory") + } + } + + pub fn config_permissions_confirm_custom_dir() -> &'static str { + if is_chinese() { + "确认要修改此目录的权限吗?" + } else { + "Confirm modifying permissions on this directory?" + } + } + + pub fn config_permissions_custom_dir_skipped() -> &'static str { + if is_chinese() { + "已跳过权限修复。" + } else { + "Skipped permission fix." + } + } } #[cfg(test)] @@ -11183,6 +11285,33 @@ mod tests { assert!(!help.contains("Settings:")); } + #[test] + fn config_dir_validation_messages_are_localized() { + { + let _lang = use_test_language(Language::English); + assert_eq!( + texts::config_dir_invalid_last_component("/tmp/child/.."), + "Invalid config directory path; unable to resolve the final directory component: /tmp/child/.." + ); + assert_eq!( + texts::config_dir_only_final_component_may_be_missing("/tmp/child/.."), + "Invalid config directory path; only the final directory component may be missing: /tmp/child/.." + ); + } + + { + let _lang = use_test_language(Language::Chinese); + assert_eq!( + texts::config_dir_invalid_last_component("/tmp/child/.."), + "配置目录路径无效,无法解析最后一层目录: /tmp/child/.." + ); + assert_eq!( + texts::config_dir_only_final_component_may_be_missing("/tmp/child/.."), + "配置目录路径无效,仅允许最后一层目录不存在: /tmp/child/.." + ); + } + } + #[test] fn proxy_dashboard_copy_is_fully_localized_in_chinese() { let _lang = use_test_language(Language::Chinese); diff --git a/src-tauri/src/cli/tui/app/tests.rs b/src-tauri/src/cli/tui/app/tests.rs index ca8b701c..3e343263 100644 --- a/src-tauri/src/cli/tui/app/tests.rs +++ b/src-tauri/src/cli/tui/app/tests.rs @@ -10557,7 +10557,8 @@ mod tests { #[test] #[serial] fn prompt_save_runtime_creates_prompt_from_one_page_form() { - let _guard = TestEnvGuard::isolated(tempfile::tempdir().expect("tempdir").path()); + let temp = tempfile::tempdir().expect("tempdir"); + let _guard = TestEnvGuard::isolated(temp.path()); let state = crate::AppState::try_new().expect("load state"); state.save().expect("persist empty state"); @@ -10646,7 +10647,8 @@ mod tests { #[test] #[serial] fn prompt_create_runtime_clears_filter_when_new_prompt_is_not_visible() { - let _guard = TestEnvGuard::isolated(tempfile::tempdir().expect("tempdir").path()); + let temp = tempfile::tempdir().expect("tempdir"); + let _guard = TestEnvGuard::isolated(temp.path()); let state = crate::AppState::try_new().expect("load state"); state.save().expect("persist empty state"); diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 7d476760..21412249 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -4,6 +4,7 @@ use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; +use crate::cli::i18n::texts; use crate::error::AppError; pub(crate) fn home_dir() -> Option { @@ -94,11 +95,360 @@ pub fn get_app_config_dir() -> PathBuf { home_dir().expect("无法获取用户主目录").join(".cc-switch") } +/// 校验 CC_SWITCH_CONFIG_DIR 是否为安全的应用专属目录 +/// +/// 拒绝系统关键目录(如 `/`、`/etc`、`/usr` 等),防止下游权限操作破坏系统。 +/// 未设置环境变量时默认路径 `~/.cc-switch` 始终安全,直接放行。 +pub fn validate_config_dir() -> Result<(), AppError> { + let path = get_app_config_dir(); + let resolved = resolve_existing_or_new_child_path(&path)?; + + if is_system_dir(&path) || is_system_dir(&resolved) { + return Err(AppError::InvalidInput(texts::config_dir_is_system_dir( + &path.display().to_string(), + &resolved.display().to_string(), + ))); + } + + Ok(()) +} + +pub(crate) fn resolve_existing_or_new_child_path(path: &Path) -> Result { + match path.canonicalize() { + Ok(resolved) => Ok(resolved), + Err(original_err) => { + let file_name = path.file_name().ok_or_else(|| { + AppError::InvalidInput(texts::config_dir_invalid_last_component( + &path.display().to_string(), + )) + })?; + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let parent_resolved = + parent + .canonicalize() + .map_err(|parent_err| AppError::IoContext { + context: texts::config_dir_only_final_component_may_be_missing( + &path.display().to_string(), + ), + source: parent_err, + })?; + + let resolved = parent_resolved.join(file_name); + if is_system_dir(&resolved) { + return Err(AppError::InvalidInput(texts::config_dir_is_system_dir( + &path.display().to_string(), + &resolved.display().to_string(), + ))); + } + + log::debug!( + "Config dir does not exist yet, resolved parent and rebuilt path: {} -> {} ({original_err})", + path.display(), + resolved.display() + ); + Ok(resolved) + } + } +} + +/// 判断路径是否为系统关键目录(不应被应用修改权限) +fn is_system_dir(path: &Path) -> bool { + // 根目录 + if path == Path::new("/") { + return true; + } + + // 一级系统目录 + #[cfg(unix)] + { + const SYSTEM_DIRS: &[&str] = &[ + "/bin", "/boot", "/dev", "/etc", "/home", "/lib", "/lib32", "/lib64", "/opt", "/proc", + "/root", "/run", "/sbin", "/sys", "/tmp", "/usr", "/var", + ]; + if SYSTEM_DIRS.iter().any(|&sys| path == Path::new(sys)) { + return true; + } + } + + // macOS 特有(含 /private/* 变体,/etc、/tmp、/var 在 macOS 上是这些的符号链接) + #[cfg(target_os = "macos")] + { + const MACOS_SYSTEM_DIRS: &[&str] = &[ + "/Applications", + "/Library", + "/System", + "/Volumes", + "/private", + "/private/etc", + "/private/tmp", + "/private/var", + ]; + if MACOS_SYSTEM_DIRS.iter().any(|&sys| path == Path::new(sys)) { + return true; + } + } + + // Windows: 盘符根目录(如 C:\) + #[cfg(windows)] + { + // Should do some more verifications here + false + } + + false +} + /// 获取应用配置文件路径 pub fn get_app_config_path() -> PathBuf { get_app_config_dir().join("config.json") } +/// 将目录权限收紧为仅所有者可访问(Unix: 0o700) +#[cfg(unix)] +pub(crate) fn restrict_dir_permissions(path: &Path) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt; + let meta = fs::metadata(path)?; + if !meta.is_dir() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "path is not a directory", + )); + } + let mut perms = meta.permissions(); + if perms.mode() & 0o777 != 0o700 { + perms.set_mode(0o700); + fs::set_permissions(path, perms)?; + } + Ok(()) +} + +#[cfg(not(unix))] +pub(crate) fn restrict_dir_permissions(_path: &Path) -> std::io::Result<()> { + Ok(()) +} + +/// 将文件权限收紧为仅所有者可读写(Unix: 0o600) +#[cfg(unix)] +pub(crate) fn restrict_file_permissions(path: &Path) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt; + let meta = fs::metadata(path)?; + if !meta.is_file() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "path is not a regular file", + )); + } + let mut perms = meta.permissions(); + if perms.mode() & 0o777 != 0o600 { + perms.set_mode(0o600); + fs::set_permissions(path, perms)?; + } + Ok(()) +} + +#[cfg(not(unix))] +pub(crate) fn restrict_file_permissions(_path: &Path) -> std::io::Result<()> { + Ok(()) +} + +/// 检查配置目录、敏感配置/数据文件和备份目录的权限是否安全(Unix only) +/// +/// 返回不安全的路径列表:`(路径, 当前权限, 期望权限)` +#[cfg(unix)] +pub fn check_permissions() -> Vec<(PathBuf, u32, u32)> { + use std::os::unix::fs::PermissionsExt; + let mut issues = Vec::new(); + let config_dir = get_app_config_dir(); + let backup_dir = config_dir.join("backups"); + + if config_dir.exists() { + if let Ok(meta) = fs::metadata(&config_dir) { + let mode = meta.permissions().mode() & 0o777; + if mode != 0o700 { + issues.push((config_dir.clone(), mode, 0o700)); + } + } + } + + if backup_dir.exists() { + if let Ok(meta) = fs::metadata(&backup_dir) { + let mode = meta.permissions().mode() & 0o777; + if mode != 0o700 { + issues.push((backup_dir, mode, 0o700)); + } + } + } + + collect_sensitive_file_permission_issues(&config_dir, &mut issues); + + issues +} + +#[cfg(not(unix))] +pub fn check_permissions() -> Vec<(PathBuf, u32, u32)> { + Vec::new() +} + +#[cfg(unix)] +fn collect_sensitive_file_permission_issues(dir: &Path, issues: &mut Vec<(PathBuf, u32, u32)>) { + use std::os::unix::fs::PermissionsExt; + + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + + let mut entries = entries.filter_map(Result::ok).collect::>(); + entries.sort_by_key(|entry| entry.path()); + + for entry in entries { + let path = entry.path(); + let Ok(file_type) = entry.file_type() else { + continue; + }; + + if file_type.is_dir() { + collect_sensitive_file_permission_issues(&path, issues); + } else if file_type.is_file() && is_sensitive_config_file(&path) { + let Ok(meta) = fs::metadata(&path) else { + continue; + }; + let mode = meta.permissions().mode() & 0o777; + if is_insecure_sensitive_file_mode(mode) { + issues.push((path, mode, 0o600)); + } + } + } +} + +#[cfg(unix)] +fn is_sensitive_config_file(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .map(|ext| matches!(ext.to_ascii_lowercase().as_str(), "db" | "json" | "sql")) + .unwrap_or(false) +} + +#[cfg(unix)] +fn is_insecure_sensitive_file_mode(mode: u32) -> bool { + mode & !0o600 != 0 +} + +trait PermissionPrompter { + fn confirm_custom_dir(&mut self, path: &Path) -> Result; + fn confirm_fix(&mut self) -> Result; +} + +struct InquirePermissionPrompter; + +impl PermissionPrompter for InquirePermissionPrompter { + fn confirm_custom_dir(&mut self, _path: &Path) -> Result { + inquire::Confirm::new(texts::config_permissions_confirm_custom_dir()) + .with_default(false) + .prompt() + .map_err(|e| AppError::Message(format!("Prompt failed: {}", e))) + } + + fn confirm_fix(&mut self) -> Result { + inquire::Confirm::new(texts::config_permissions_fix_prompt()) + .with_default(true) + .prompt() + .map_err(|e| AppError::Message(format!("Prompt failed: {}", e))) + } +} + +fn prompt_fix_permissions_interactive( + issues: &[(PathBuf, u32, u32)], + custom_dir: Option, + prompter: &mut dyn PermissionPrompter, +) -> Result<(), AppError> { + eprintln!("{}", texts::config_permissions_insecure_header()); + for (path, current, expected) in issues { + eprintln!( + "{}", + texts::config_permissions_detail(&path.display().to_string(), *current, *expected,) + ); + } + + if let Some(custom_path) = custom_dir { + if !custom_path.as_os_str().is_empty() { + eprintln!( + "{}", + texts::config_permissions_custom_dir_notice(&custom_path.display().to_string()) + ); + if !prompter.confirm_custom_dir(&custom_path)? { + eprintln!("{}", texts::config_permissions_custom_dir_skipped()); + return Ok(()); + } + } + } + + if prompter.confirm_fix()? { + for (path, _, _) in issues { + if path.is_dir() { + restrict_dir_permissions(path).map_err(|e| AppError::io(path, e))?; + } else { + restrict_file_permissions(path).map_err(|e| AppError::io(path, e))?; + } + } + eprintln!("{}", texts::config_permissions_fixed()); + } else { + eprintln!("{}", texts::config_permissions_fix_warn_interactive()); + } + + Ok(()) +} + +fn write_permissions_noninteractive_warning( + mut output: W, + issues: &[(PathBuf, u32, u32)], +) -> std::io::Result<()> { + writeln!( + output, + "{}", + texts::config_permissions_fix_warn_noninteractive() + )?; + for (path, current, expected) in issues { + writeln!( + output, + "{}", + texts::config_permissions_detail(&path.display().to_string(), *current, *expected,) + )?; + } + Ok(()) +} + +/// 访问数据库前检查权限,若不安全则提示用户是否修复 +/// +/// - 交互终端:使用 inquire 提示用户,确认后修复,拒绝则警告 +/// - 非交互终端(Docker/管道):仅打印警告到 stderr +pub fn prompt_fix_permissions() -> Result<(), AppError> { + let issues = check_permissions(); + if issues.is_empty() { + return Ok(()); + } + + let is_terminal = !cfg!(test) + && std::io::IsTerminal::is_terminal(&std::io::stdin()) + && std::io::IsTerminal::is_terminal(&std::io::stdout()) + && std::io::IsTerminal::is_terminal(&std::io::stderr()); + + if is_terminal { + let custom_dir = env::var_os("CC_SWITCH_CONFIG_DIR").map(PathBuf::from); + let mut prompter = InquirePermissionPrompter; + prompt_fix_permissions_interactive(&issues, custom_dir, &mut prompter)?; + } else { + let stderr = std::io::stderr(); + let mut stderr = stderr.lock(); + write_permissions_noninteractive_warning(&mut stderr, &issues) + .map_err(|e| AppError::Message(format!("Failed to write permission warning: {e}")))?; + } + + Ok(()) +} + /// 清理供应商名称,确保文件名安全 pub fn sanitize_provider_name(name: &str) -> String { name.chars() @@ -263,6 +613,36 @@ mod tests { } } + struct FakePermissionPrompter { + custom_dir_response: bool, + fix_response: bool, + custom_dir_calls: usize, + fix_calls: usize, + } + + impl FakePermissionPrompter { + fn new(custom_dir_response: bool, fix_response: bool) -> Self { + Self { + custom_dir_response, + fix_response, + custom_dir_calls: 0, + fix_calls: 0, + } + } + } + + impl PermissionPrompter for FakePermissionPrompter { + fn confirm_custom_dir(&mut self, _path: &Path) -> Result { + self.custom_dir_calls += 1; + Ok(self.custom_dir_response) + } + + fn confirm_fix(&mut self) -> Result { + self.fix_calls += 1; + Ok(self.fix_response) + } + } + #[test] fn derive_mcp_path_from_override_preserves_folder_name() { let override_dir = PathBuf::from("/tmp/profile/.claude"); @@ -405,6 +785,453 @@ mod tests { set_test_home_override(None); } + + #[test] + fn validate_config_dir_ok_when_not_set() { + let _guard = lock_test_home_and_settings(); + let _env = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None); + assert!(validate_config_dir().is_ok()); + } + + #[test] + fn validate_config_dir_ok_for_normal_path() { + let _guard = lock_test_home_and_settings(); + let _env = ConfigDirEnvGuard::new( + "CC_SWITCH_CONFIG_DIR", + Some("/tmp/cc-switch-config-override"), + ); + assert!(validate_config_dir().is_ok()); + } + + #[test] + fn validate_config_dir_rejects_root() { + let _guard = lock_test_home_and_settings(); + let _env = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some("/")); + assert!(validate_config_dir().is_err()); + } + + #[test] + fn validate_config_dir_rejects_etc() { + let _guard = lock_test_home_and_settings(); + let _env = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some("/etc")); + assert!(validate_config_dir().is_err()); + } + + #[test] + fn validate_config_dir_rejects_usr() { + let _guard = lock_test_home_and_settings(); + let _env = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some("/usr")); + assert!(validate_config_dir().is_err()); + } + + #[test] + fn validate_config_dir_rejects_tmp() { + let _guard = lock_test_home_and_settings(); + let _env = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some("/tmp")); + assert!(validate_config_dir().is_err()); + } + + #[test] + fn validate_config_dir_allows_parent_dir_components_when_parent_resolves() { + let _guard = lock_test_home_and_settings(); + let temp = tempfile::tempdir().expect("create temp dir"); + std::fs::create_dir(temp.path().join("child")).expect("create child dir"); + let config_dir = temp.path().join("child").join("..").join("cc-switch"); + let _env = ConfigDirEnvGuard::new( + "CC_SWITCH_CONFIG_DIR", + Some(config_dir.to_str().expect("utf8 temp path")), + ); + + assert!(validate_config_dir().is_ok()); + assert_eq!( + resolve_existing_or_new_child_path(&config_dir).expect("resolve config dir"), + temp.path() + .canonicalize() + .expect("canonicalize temp dir") + .join("cc-switch") + ); + } + + #[test] + fn validate_config_dir_rejects_parent_dir_components_when_parent_does_not_resolve() { + let _guard = lock_test_home_and_settings(); + let _env = + ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some("/tmp/cc-switch-new-child/..")); + + assert!(validate_config_dir().is_err()); + } + + #[test] + fn validate_config_dir_rejects_parent_dir_components_when_resolved_to_system_dir() { + let _guard = lock_test_home_and_settings(); + let _env = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some("/usr/bin/..")); + + assert!( + validate_config_dir().is_err(), + "resolved config dir should reject the system parent directory" + ); + } + + #[cfg(unix)] + #[test] + fn check_permissions_returns_empty_for_secure_permissions() { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + let _guard = lock_test_home_and_settings(); + let temp = tempfile::tempdir().expect("create temp dir"); + let _env = + ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some(temp.path().to_str().unwrap())); + + // Ensure dir has 0o700 + std::fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o700)) + .expect("set dir perms"); + + // Create a db file with 0o600 + let db_path = temp.path().join("cc-switch.db"); + std::fs::OpenOptions::new() + .write(true) + .create(true) + .mode(0o600) + .open(&db_path) + .expect("create db file"); + + let issues = check_permissions(); + assert!(issues.is_empty(), "expected no issues, got: {:?}", issues); + } + + #[cfg(unix)] + #[test] + fn check_permissions_detects_insecure_dir() { + use std::os::unix::fs::PermissionsExt; + + let _guard = lock_test_home_and_settings(); + let temp = tempfile::tempdir().expect("create temp dir"); + let _env = + ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some(temp.path().to_str().unwrap())); + + // Set dir to permissive + std::fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o755)) + .expect("set dir perms"); + + let issues = check_permissions(); + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].0, temp.path()); + assert_eq!(issues[0].1, 0o755); + assert_eq!(issues[0].2, 0o700); + } + + #[cfg(unix)] + #[test] + fn check_permissions_detects_insecure_db_file() { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + let _guard = lock_test_home_and_settings(); + let temp = tempfile::tempdir().expect("create temp dir"); + let _env = + ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some(temp.path().to_str().unwrap())); + + // Ensure dir has 0o700 so only the db file is flagged + std::fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o700)) + .expect("set dir perms"); + + // Create db file with permissive mode + let db_path = temp.path().join("cc-switch.db"); + std::fs::OpenOptions::new() + .write(true) + .create(true) + .mode(0o644) + .open(&db_path) + .expect("create db file"); + + let issues = check_permissions(); + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].0, db_path); + assert_eq!(issues[0].1, 0o644); + assert_eq!(issues[0].2, 0o600); + } + + #[cfg(unix)] + #[test] + fn check_permissions_detects_both_insecure() { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + let _guard = lock_test_home_and_settings(); + let temp = tempfile::tempdir().expect("create temp dir"); + let _env = + ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some(temp.path().to_str().unwrap())); + + // Set dir to permissive + std::fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o755)) + .expect("set dir perms"); + + // Create db file with permissive mode + let db_path = temp.path().join("cc-switch.db"); + std::fs::OpenOptions::new() + .write(true) + .create(true) + .mode(0o644) + .open(&db_path) + .expect("create db file"); + + let issues = check_permissions(); + assert_eq!(issues.len(), 2); + } + + #[cfg(unix)] + #[test] + fn check_permissions_detects_insecure_backup_dir() { + use std::os::unix::fs::PermissionsExt; + + let _guard = lock_test_home_and_settings(); + let temp = tempfile::tempdir().expect("create temp dir"); + let _env = + ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some(temp.path().to_str().unwrap())); + + std::fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o700)) + .expect("set config dir perms"); + let backup_dir = temp.path().join("backups"); + std::fs::create_dir(&backup_dir).expect("create backup dir"); + std::fs::set_permissions(&backup_dir, fs::Permissions::from_mode(0o755)) + .expect("set backup dir perms"); + + let issues = check_permissions(); + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].0, backup_dir); + assert_eq!(issues[0].1, 0o755); + assert_eq!(issues[0].2, 0o700); + } + + #[cfg(unix)] + #[test] + fn check_permissions_detects_insecure_sensitive_files_recursively() { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + let _guard = lock_test_home_and_settings(); + let temp = tempfile::tempdir().expect("create temp dir"); + let _env = + ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some(temp.path().to_str().unwrap())); + + std::fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o700)) + .expect("set config dir perms"); + let backup_dir = temp.path().join("backups"); + let nested = backup_dir.join("nested"); + std::fs::create_dir_all(&nested).expect("create nested dir"); + std::fs::set_permissions(&backup_dir, fs::Permissions::from_mode(0o700)) + .expect("set backup dir perms"); + + let root_json = temp.path().join("config.json"); + let nested_sql = nested.join("backup.sql"); + let nested_db = nested.join("snapshot.db"); + for path in [&root_json, &nested_sql, &nested_db] { + std::fs::OpenOptions::new() + .write(true) + .create(true) + .mode(0o644) + .open(path) + .expect("create sensitive file"); + } + + let issues = check_permissions(); + let issue_paths = issues + .iter() + .map(|(path, current, expected)| (path.clone(), *current, *expected)) + .collect::>(); + + assert_eq!(issues.len(), 3); + assert!(issue_paths.contains(&(root_json, 0o644, 0o600))); + assert!(issue_paths.contains(&(nested_sql, 0o644, 0o600))); + assert!(issue_paths.contains(&(nested_db, 0o644, 0o600))); + } + + #[cfg(unix)] + #[test] + fn check_permissions_allows_more_restrictive_sensitive_file_permissions() { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + let _guard = lock_test_home_and_settings(); + let temp = tempfile::tempdir().expect("create temp dir"); + let _env = + ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some(temp.path().to_str().unwrap())); + + std::fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o700)) + .expect("set config dir perms"); + + let read_only = temp.path().join("read-only.json"); + let write_only = temp.path().join("write-only.sql"); + for (path, mode) in [(&read_only, 0o400), (&write_only, 0o200)] { + std::fs::OpenOptions::new() + .write(true) + .create(true) + .mode(mode) + .open(path) + .expect("create sensitive file"); + std::fs::set_permissions(path, fs::Permissions::from_mode(mode)) + .expect("set sensitive file perms"); + } + + let issues = check_permissions(); + assert!(issues.is_empty(), "expected no issues, got: {:?}", issues); + } + + #[cfg(unix)] + #[test] + fn interactive_permission_prompt_fixes_recursive_sensitive_files_when_confirmed() { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + let temp = tempfile::tempdir().expect("create temp dir"); + let nested = temp.path().join("nested"); + std::fs::create_dir(&nested).expect("create nested dir"); + let json_path = nested.join("settings.json"); + std::fs::OpenOptions::new() + .write(true) + .create(true) + .mode(0o644) + .open(&json_path) + .expect("create json file"); + let issues = vec![(json_path.clone(), 0o644, 0o600)]; + let mut prompter = FakePermissionPrompter::new(true, true); + + prompt_fix_permissions_interactive(&issues, None, &mut prompter) + .expect("interactive recursive file fix should succeed"); + + let mode = std::fs::metadata(&json_path) + .expect("metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + assert_eq!(prompter.custom_dir_calls, 0); + assert_eq!(prompter.fix_calls, 1); + } + + #[cfg(unix)] + #[test] + fn prompt_fix_permissions_does_not_fix_in_test_build() { + use std::os::unix::fs::PermissionsExt; + + let _guard = lock_test_home_and_settings(); + let temp = tempfile::tempdir().expect("create temp dir"); + let _env = + ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some(temp.path().to_str().unwrap())); + + std::fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o755)) + .expect("set dir perms"); + + prompt_fix_permissions().expect("test build should only warn"); + + // Permissions should remain unchanged because cfg!(test) skips the fix logic + let mode = std::fs::metadata(temp.path()) + .expect("metadata") + .permissions() + .mode() + & 0o777; + assert_eq!( + mode, 0o755, + "test build should not modify directory permissions" + ); + } + + #[cfg(unix)] + #[test] + fn interactive_permission_prompt_fixes_permissions_when_confirmed() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("create temp dir"); + std::fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o755)) + .expect("set dir perms"); + let issues = vec![(temp.path().to_path_buf(), 0o755, 0o700)]; + let mut prompter = FakePermissionPrompter::new(true, true); + + prompt_fix_permissions_interactive(&issues, None, &mut prompter) + .expect("interactive fix should succeed"); + + let mode = std::fs::metadata(temp.path()) + .expect("metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o700); + assert_eq!(prompter.custom_dir_calls, 0); + assert_eq!(prompter.fix_calls, 1); + } + + #[cfg(unix)] + #[test] + fn interactive_permission_prompt_fixes_file_permissions_when_confirmed() { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + let temp = tempfile::tempdir().expect("create temp dir"); + let db_path = temp.path().join("cc-switch.db"); + std::fs::OpenOptions::new() + .write(true) + .create(true) + .mode(0o644) + .open(&db_path) + .expect("create db file"); + let issues = vec![(db_path.clone(), 0o644, 0o600)]; + let mut prompter = FakePermissionPrompter::new(true, true); + + prompt_fix_permissions_interactive(&issues, None, &mut prompter) + .expect("interactive file fix should succeed"); + + let mode = std::fs::metadata(&db_path) + .expect("metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + assert_eq!(prompter.custom_dir_calls, 0); + assert_eq!(prompter.fix_calls, 1); + } + + #[cfg(unix)] + #[test] + fn interactive_permission_prompt_leaves_permissions_when_fix_declined() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("create temp dir"); + std::fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o755)) + .expect("set dir perms"); + let issues = vec![(temp.path().to_path_buf(), 0o755, 0o700)]; + let mut prompter = FakePermissionPrompter::new(true, false); + + prompt_fix_permissions_interactive(&issues, None, &mut prompter) + .expect("interactive prompt should succeed"); + + let mode = std::fs::metadata(temp.path()) + .expect("metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o755); + assert_eq!(prompter.custom_dir_calls, 0); + assert_eq!(prompter.fix_calls, 1); + } + + #[cfg(unix)] + #[test] + fn interactive_permission_prompt_skips_custom_dir_when_not_confirmed() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("create temp dir"); + std::fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o755)) + .expect("set dir perms"); + let custom_dir = temp.path().to_path_buf(); + let issues = vec![(custom_dir.clone(), 0o755, 0o700)]; + let mut prompter = FakePermissionPrompter::new(false, true); + + prompt_fix_permissions_interactive(&issues, Some(custom_dir), &mut prompter) + .expect("interactive prompt should succeed"); + + let mode = std::fs::metadata(temp.path()) + .expect("metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o755); + assert_eq!(prompter.custom_dir_calls, 1); + assert_eq!(prompter.fix_calls, 0); + } } /// 复制文件 diff --git a/src-tauri/src/database/backup.rs b/src-tauri/src/database/backup.rs index 37b3df25..5a08b726 100644 --- a/src-tauri/src/database/backup.rs +++ b/src-tauri/src/database/backup.rs @@ -2,7 +2,7 @@ //! //! 提供 SQL 导出/导入和二进制快照备份功能。 -use super::{lock_conn, Database, DB_BACKUP_RETAIN}; +use super::{create_secure_dir_all, lock_conn, Database, DB_BACKUP_RETAIN}; use crate::config::get_app_config_dir; use crate::error::AppError; use chrono::Utc; @@ -123,7 +123,7 @@ impl Database { let dump = self.export_sql_string()?; if let Some(parent) = target_path.parent() { - fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + create_secure_dir_all(parent)?; } crate::config::atomic_write(target_path, dump.as_bytes()) @@ -480,7 +480,7 @@ impl Database { .ok_or_else(|| AppError::Config("无效的数据库路径".to_string()))? .join("backups"); - fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?; + create_secure_dir_all(&backup_dir)?; let base_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S")); let mut backup_id = base_id.clone(); @@ -492,6 +492,22 @@ impl Database { counter += 1; } + // 新建备份文件时以 0o600 原子创建 + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + std::fs::OpenOptions::new() + .write(true) + .create(true) + .mode(0o600) + .open(&backup_path) + .map_err(|e| AppError::io(&backup_path, e))?; + } + #[cfg(not(unix))] + { + std::fs::File::create(&backup_path).map_err(|e| AppError::io(&backup_path, e))?; + } + { let conn = lock_conn!(self.conn); let mut dest_conn = diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 0ab9e02c..5fbbaa5c 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -36,12 +36,15 @@ pub(crate) use dao::model_pricing::ModelPricingUpdate; pub(crate) use dao::providers_seed::is_official_seed_id; pub use dao::FailoverQueueItem; -use crate::config::get_app_config_dir; +use crate::config::{ + get_app_config_dir, resolve_existing_or_new_child_path, restrict_dir_permissions, +}; use crate::error::AppError; use rusqlite::{Connection, OpenFlags}; use serde::Serialize; +use std::path::Path; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, Once}; use std::time::Duration; // DAO 方法通过 impl Database 提供,无需额外导出 @@ -51,6 +54,8 @@ const DB_BACKUP_RETAIN: usize = 10; const USAGE_ROLLUP_RETAIN_DAYS: i64 = 30; const USAGE_MAINTENANCE_INTERVAL_SECS: u64 = 24 * 60 * 60; +static DATABASE_PERMISSION_CHECK: Once = Once::new(); + /// 当前 Schema 版本号 /// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑 pub(crate) const SCHEMA_VERSION: i32 = 10; @@ -61,6 +66,51 @@ pub(crate) fn to_json_string(value: &T) -> Result Result { + let resolved_path = resolve_existing_or_new_child_path(path)?; + create_secure_dir_all_resolved(&resolved_path) +} + +fn create_secure_dir_all_resolved(path: &Path) -> Result { + if path.as_os_str().is_empty() || path.is_dir() { + return Ok(false); + } + + if let Some(parent) = path.parent() { + if parent != path && !parent.as_os_str().is_empty() { + create_secure_dir_all(parent)?; + } + } + + #[cfg(unix)] + let create_result = { + use std::os::unix::fs::DirBuilderExt; + + std::fs::DirBuilder::new().mode(0o700).create(path) + }; + + #[cfg(not(unix))] + let create_result = std::fs::DirBuilder::new().create(path); + + match create_result { + Ok(()) => Ok(true), + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists && path.is_dir() => { + // 竞态:目录在 is_dir() 检查后、create 前被其他进程创建, + // 需要收紧权限以确保安全 + #[cfg(unix)] + restrict_dir_permissions(path).map_err(|e| AppError::io(path, e))?; + + Ok(false) + } + Err(err) => Err(AppError::io(path, err)), + } +} + /// 安全地获取 Mutex 锁,避免 unwrap panic macro_rules! lock_conn { ($mutex:expr) => { @@ -95,11 +145,37 @@ impl Database { /// /// 数据库文件位于 `~/.cc-switch/cc-switch.db` pub fn init() -> Result { + if let Err(err) = crate::config::validate_config_dir() { + log::warn!("拒绝初始化数据库:配置目录校验失败: {err}"); + return Err(err); + } + warn_insecure_permissions_once(); + let db_path = get_app_config_dir().join("cc-switch.db"); // 确保父目录存在 if let Some(parent) = db_path.parent() { - std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + create_secure_dir_all(parent)?; + } + + // 新建数据库文件时以 0o600 原子创建,已有文件的权限由 prompt_fix_permissions 处理 + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + if !db_path.exists() { + std::fs::OpenOptions::new() + .write(true) + .create(true) + .mode(0o600) + .open(&db_path) + .map_err(|e| AppError::io(&db_path, e))?; + } + } + #[cfg(not(unix))] + { + if !db_path.exists() { + std::fs::File::create(&db_path).map_err(|e| AppError::io(&db_path, e))?; + } } let conn = Connection::open(&db_path).map_err(|e| AppError::Database(e.to_string()))?; @@ -281,3 +357,22 @@ impl Database { } } } + +fn warn_insecure_permissions_once() { + DATABASE_PERMISSION_CHECK.call_once(|| { + let issues = crate::config::check_permissions(); + if issues.is_empty() { + return; + } + + log::warn!("检测到不安全的 cc-switch 配置权限,请收紧后再继续使用"); + for (path, current, expected) in issues { + log::warn!( + "不安全权限: path={} current={:03o} expected={:03o}", + path.display(), + current, + expected + ); + } + }); +} diff --git a/src-tauri/src/database/tests.rs b/src-tauri/src/database/tests.rs index 30667e7c..56291b88 100644 --- a/src-tauri/src/database/tests.rs +++ b/src-tauri/src/database/tests.rs @@ -248,6 +248,19 @@ fn init_rejects_future_schema_before_creating_tables() { #[test] #[serial_test::serial] +fn init_rejects_unsafe_config_dir() { + let _lock = crate::test_support::lock_test_home_and_settings(); + let _guard = ConfigDirEnvGuard::set(Path::new("/tmp")); + + let err = match Database::init() { + Ok(_) => panic!("unsafe config dir should fail init"), + Err(err) => err, + }; + assert!( + err.to_string().contains("CC_SWITCH_CONFIG_DIR"), + "unexpected error: {err}" + ); +} fn readonly_snapshot_rejects_missing_database_without_creating_file() { let _lock = crate::test_support::lock_test_home_and_settings(); let temp = tempfile::tempdir().expect("create temp dir"); @@ -1759,6 +1772,105 @@ fn schema_model_pricing_is_seeded_on_init() { } #[test] +#[serial_test::serial] +#[cfg(unix)] +fn init_creates_db_file_with_restrictive_permissions() { + use std::os::unix::fs::PermissionsExt; + + let _lock = crate::test_support::lock_test_home_and_settings(); + let temp = tempfile::tempdir().expect("create temp dir"); + let _guard = ConfigDirEnvGuard::set(temp.path()); + + let _db = Database::init().expect("init db"); + + let db_perms = std::fs::metadata(temp.path().join("cc-switch.db")) + .expect("metadata db") + .permissions() + .mode() + & 0o777; + assert_eq!(db_perms, 0o600, "new db file should be created with 0o600"); +} + +#[test] +#[serial_test::serial] +#[cfg(unix)] +fn init_creates_config_dir_with_restrictive_permissions() { + use std::os::unix::fs::PermissionsExt; + + let _lock = crate::test_support::lock_test_home_and_settings(); + let temp = tempfile::tempdir().expect("create temp dir"); + let config_dir = temp.path().join("new-config-dir"); + let _guard = ConfigDirEnvGuard::set(&config_dir); + + let _db = Database::init().expect("init db"); + + let dir_perms = std::fs::metadata(&config_dir) + .expect("metadata dir") + .permissions() + .mode() + & 0o777; + assert_eq!(dir_perms, 0o700, "new config dir should be 0o700"); +} + +#[test] +#[cfg(unix)] +fn create_secure_dir_all_rejects_unresolved_parent_dir_components_without_chmodding_parent() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("create temp dir"); + std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o755)) + .expect("set parent dir perms"); + + let path = temp.path().join("child").join(".."); + let err = create_secure_dir_all(&path).expect_err("unresolved parent should be rejected"); + let message = err.to_string(); + + assert!( + message.contains("配置目录路径无效") || message.contains("Invalid config directory path"), + "unexpected error: {message}" + ); + assert!( + !temp.path().join("child").exists(), + "rejected path should not create intermediate directories" + ); + + let parent_perms = std::fs::metadata(temp.path()) + .expect("metadata parent") + .permissions() + .mode() + & 0o777; + assert_eq!( + parent_perms, 0o755, + "rejected path should not chmod the parent directory" + ); +} + +#[test] +#[serial_test::serial] +#[cfg(unix)] +fn init_does_not_silently_fix_existing_dir_permissions() { + use std::os::unix::fs::PermissionsExt; + + let _lock = crate::test_support::lock_test_home_and_settings(); + let temp = tempfile::tempdir().expect("create temp dir"); + let _guard = ConfigDirEnvGuard::set(temp.path()); + + // Set dir to a permissive mode before init + std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o755)) + .expect("set dir perms"); + + let _db = Database::init().expect("init db"); + + let dir_perms = std::fs::metadata(temp.path()) + .expect("metadata dir") + .permissions() + .mode() + & 0o777; + assert_eq!( + dir_perms, 0o755, + "init should not silently change existing dir permissions" + ); +} fn model_pricing_delete_survives_reseed_until_user_upserts() { let db = Database::memory().expect("create memory db"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fe65b480..0af995d7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -46,7 +46,8 @@ pub use claude_plugin::{ }; pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic}; pub use config::{ - get_app_config_dir, get_claude_mcp_path, get_claude_settings_path, read_json_file, + check_permissions, get_app_config_dir, get_claude_mcp_path, get_claude_settings_path, + prompt_fix_permissions, read_json_file, validate_config_dir, }; pub use database::{Database, FailoverQueueItem}; pub use deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest}; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 7410a85b..a4bd8c56 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -41,6 +41,13 @@ fn command_uses_own_logger(command: &Option) -> bool { } fn run(cli: Cli) -> Result<(), AppError> { + if database_access_required(&cli.command) { + // 在打开数据库前检查已有配置目录、数据库文件和备份目录权限。 + // This ensures the chmod happens before database initialization, + // and also ensures that the user is only queried once. + cc_switch_lib::validate_config_dir()?; + cc_switch_lib::prompt_fix_permissions()?; + } initialize_startup_state_if_needed(&cli.command)?; match cli.command { @@ -105,10 +112,18 @@ fn initialize_startup_state_if_needed(command: &Option) -> Result<(), Ok(()) } +fn database_access_required(command: &Option) -> bool { + match command { + Some(Commands::Completions(_)) | Some(Commands::Update(_)) => false, + _ => true, + } +} + #[cfg(test)] mod tests { use super::{ - command_requires_startup_state, command_uses_own_logger, initialize_startup_state_if_needed, + command_requires_startup_state, command_uses_own_logger, database_access_required, + initialize_startup_state_if_needed, }; use cc_switch_lib::cli::Cli; use clap::Parser; @@ -197,6 +212,38 @@ mod tests { assert!(command_requires_startup_state(&provider.command)); } + #[test] + fn completions_update_skip_database_access() { + let update = Cli::parse_from(["cc-switch", "update"]); + let completions = Cli::parse_from(["cc-switch", "completions", "bash"]); + + assert!(!database_access_required(&update.command)); + assert!(!database_access_required(&completions.command)); + } + + #[test] + fn normal_commands_require_database_access() { + let provider = Cli::parse_from(["cc-switch", "provider", "list"]); + let mcp = Cli::parse_from(["cc-switch", "mcp", "list"]); + let config = Cli::parse_from(["cc-switch", "config", "validate"]); + let proxy = Cli::parse_from(["cc-switch", "proxy", "show"]); + let interactive = Cli::parse_from(["cc-switch"]); + let internal = Cli::parse_from([ + "cc-switch", + "internal", + "capture-codex-temp", + "official", + "/tmp/codex-home", + ]); + + assert!(database_access_required(&provider.command)); + assert!(database_access_required(&mcp.command)); + assert!(database_access_required(&config.command)); + assert!(database_access_required(&proxy.command)); + assert!(database_access_required(&interactive.command)); + assert!(database_access_required(&internal.command)); + } + #[test] #[serial] fn update_bypasses_future_schema_database_gate() { diff --git a/src-tauri/src/services/config.rs b/src-tauri/src/services/config.rs index fd35ef22..21cf8e8c 100644 --- a/src-tauri/src/services/config.rs +++ b/src-tauri/src/services/config.rs @@ -59,11 +59,13 @@ impl ConfigService { .ok_or_else(|| AppError::Config("Invalid config path".into()))? .join("backups"); - fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?; + crate::database::create_secure_dir_all(&backup_dir)?; let backup_path = backup_dir.join(format!("{backup_id}.sql")); let db = Database::init()?; db.export_sql(&backup_path)?; + crate::config::restrict_file_permissions(&backup_path) + .map_err(|e| AppError::io(&backup_path, e))?; Self::cleanup_old_backups(&backup_dir, MAX_BACKUPS)?;