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/codex_history_migration.rs b/src-tauri/src/codex_history_migration.rs index a37ddf11..9d109956 100644 --- a/src-tauri/src/codex_history_migration.rs +++ b/src-tauri/src/codex_history_migration.rs @@ -6,14 +6,16 @@ use crate::codex_config::{ get_codex_config_dir, read_codex_config_text, CC_SWITCH_CODEX_MODEL_PROVIDER_ID, }; -use crate::config::{atomic_write, copy_file, get_app_config_dir}; +use crate::config::{ + atomic_write, copy_file, create_managed_config_parent_dirs, get_app_config_dir, +}; use crate::database::{is_official_seed_id, Database}; use crate::error::AppError; use crate::settings::{ CodexProviderTemplateMigration, CodexThirdPartyHistoryProviderBucketMigration, }; use chrono::{Local, Utc}; -use rusqlite::{backup::Backup, params_from_iter, Connection}; +use rusqlite::{backup::Backup, params_from_iter, Connection, OpenFlags}; use serde_json::Value; use sha2::{Digest, Sha256}; use std::collections::{BTreeSet, HashSet}; @@ -732,12 +734,42 @@ fn backup_codex_state_db( let backup_path = backup_root .join("state") .join(relative_backup_path(db_path, codex_dir)); - if let Some(parent) = backup_path.parent() { - fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + create_managed_config_parent_dirs(&backup_path)?; + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + match std::fs::symlink_metadata(&backup_path) { + Ok(meta) if meta.file_type().is_symlink() => { + return Err(AppError::InvalidInput(format!( + "Codex state DB 备份文件不能是符号链接: {}", + backup_path.display() + ))); + } + Ok(meta) if meta.is_file() => { + return Err(AppError::InvalidInput(format!( + "Codex state DB 备份文件已存在: {}", + backup_path.display() + ))); + } + Ok(_) => { + return Err(AppError::InvalidInput(format!( + "Codex state DB 备份路径不是普通文件: {}", + backup_path.display() + ))); + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&backup_path) + .map_err(|e| AppError::io(&backup_path, e))?; + } + Err(err) => return Err(AppError::io(&backup_path, err)), + } } - let mut backup_conn = Connection::open(&backup_path) - .map_err(|e| AppError::Database(format!("创建 Codex state DB 备份失败: {e}")))?; + let mut backup_conn = open_codex_state_backup_connection(&backup_path)?; let backup = Backup::new(source_conn, &mut backup_conn) .map_err(|e| AppError::Database(format!("初始化 Codex state DB 备份失败: {e}")))?; backup @@ -746,6 +778,31 @@ fn backup_codex_state_db( Ok(()) } +fn open_codex_state_backup_connection(backup_path: &Path) -> Result { + let open_path = canonicalize_existing_parent(backup_path)?; + let flags = OpenFlags::SQLITE_OPEN_READ_WRITE + | OpenFlags::SQLITE_OPEN_CREATE + | OpenFlags::SQLITE_OPEN_NO_MUTEX + | OpenFlags::SQLITE_OPEN_NOFOLLOW; + + Connection::open_with_flags(&open_path, flags) + .map_err(|e| AppError::Database(format!("创建 Codex state DB 备份失败: {e}"))) +} + +fn canonicalize_existing_parent(path: &Path) -> Result { + let Some(file_name) = path.file_name() else { + return Err(AppError::InvalidInput(format!( + "Codex state DB 备份路径缺少文件名: {}", + path.display() + ))); + }; + let parent = path + .parent() + .ok_or_else(|| AppError::InvalidInput(format!("无效路径: {}", path.display())))?; + let parent = parent.canonicalize().map_err(|e| AppError::io(parent, e))?; + Ok(parent.join(file_name)) +} + fn backup_provider_settings_config( provider_id: &str, settings_config: &Value, @@ -754,9 +811,7 @@ fn backup_provider_settings_config( let backup_path = backup_root .join("providers") .join(provider_settings_backup_filename(provider_id)); - if let Some(parent) = backup_path.parent() { - fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; - } + create_managed_config_parent_dirs(&backup_path)?; let payload = serde_json::json!({ "providerId": provider_id, @@ -793,9 +848,7 @@ fn provider_settings_backup_filename(provider_id: &str) -> String { } fn copy_existing_file(source: &Path, target: &Path) -> Result<(), AppError> { - if let Some(parent) = target.parent() { - fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; - } + create_managed_config_parent_dirs(target)?; copy_file(source, target) } @@ -1259,6 +1312,157 @@ base_url = "https://proxy.example/v1" ) .expect("count backed up source providers"); assert_eq!(backed_up_source_count, 2); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(&backup_path) + .expect("metadata backup db") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + } + } + + #[cfg(unix)] + #[test] + fn codex_migration_backups_reject_parent_dir_config_path_before_creating_dirs() { + let dir = tempdir().expect("tempdir"); + let root = dir.path().join("config-root"); + fs::create_dir(&root).expect("create root"); + let backup_root = root.join("child").join("..").join("backups"); + let _env = crate::test_support::TestEnvGuard::isolated(dir.path()); + unsafe { + std::env::set_var("CC_SWITCH_CONFIG_DIR", root.join("child").join("..")); + } + + let codex_dir = dir.path().join(".codex"); + let session_dir = codex_dir.join("sessions"); + fs::create_dir_all(&session_dir).expect("create session dir"); + let jsonl = session_dir.join("session.jsonl"); + fs::write(&jsonl, "{}\n").expect("write jsonl"); + copy_existing_file(&jsonl, &backup_root.join("jsonl").join("session.jsonl")) + .expect_err("jsonl backup should reject invalid config dir"); + + backup_provider_settings_config( + "provider", + &serde_json::json!({ "config": "model_provider = \"custom\"" }), + &backup_root, + ) + .expect_err("provider backup should reject invalid config dir"); + + let state_db = codex_dir.join(CODEX_STATE_DB_FILENAME); + let conn = Connection::open(&state_db).expect("open state db"); + conn.execute_batch( + "CREATE TABLE threads ( + id TEXT PRIMARY KEY, + model_provider TEXT NOT NULL + );", + ) + .expect("seed state db"); + backup_codex_state_db(&state_db, &codex_dir, &backup_root, &conn) + .expect_err("state db backup should reject invalid config dir"); + + assert!( + !root.join("child").exists(), + "backup helpers must not pre-create unvalidated path components" + ); + assert!( + !root.join("backups").exists(), + "backup helpers must not write to the normalized parent directory" + ); + } + + #[cfg(unix)] + #[test] + fn codex_state_db_backup_rejects_symlink_backup_path_without_writing_target() { + use std::os::unix::fs::symlink; + + let dir = tempdir().expect("tempdir"); + let _env = crate::test_support::TestEnvGuard::isolated(dir.path()); + + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).expect("create codex dir"); + let state_db = codex_dir.join(CODEX_STATE_DB_FILENAME); + let conn = Connection::open(&state_db).expect("open state db"); + conn.execute_batch( + "CREATE TABLE threads ( + id TEXT PRIMARY KEY, + model_provider TEXT NOT NULL + );", + ) + .expect("seed state db"); + + let backup_root = get_app_config_dir().join("backups").join("migration"); + let backup_path = backup_root.join("state").join(CODEX_STATE_DB_FILENAME); + fs::create_dir_all(backup_path.parent().expect("backup parent")) + .expect("create backup parent"); + let external_target = dir.path().join("external-state.sqlite"); + symlink(&external_target, &backup_path).expect("create dangling backup symlink"); + + let err = backup_codex_state_db(&state_db, &codex_dir, &backup_root, &conn) + .expect_err("state db backup must reject symlink backup path"); + + assert!( + err.to_string().contains("符号链接") || err.to_string().contains("symlink"), + "unexpected error: {err}" + ); + assert!( + !external_target.exists(), + "state db backup must not follow symlink target" + ); + } + + #[cfg(unix)] + #[test] + fn codex_state_db_backup_rejects_existing_backup_path() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempdir().expect("tempdir"); + let _env = crate::test_support::TestEnvGuard::isolated(dir.path()); + + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).expect("create codex dir"); + let state_db = codex_dir.join(CODEX_STATE_DB_FILENAME); + let conn = Connection::open(&state_db).expect("open state db"); + conn.execute_batch( + "CREATE TABLE threads ( + id TEXT PRIMARY KEY, + model_provider TEXT NOT NULL + );", + ) + .expect("seed state db"); + + let backup_root = get_app_config_dir().join("backups").join("migration"); + let backup_path = backup_root.join("state").join(CODEX_STATE_DB_FILENAME); + fs::create_dir_all(backup_path.parent().expect("backup parent")) + .expect("create backup parent"); + fs::write(&backup_path, b"existing").expect("write existing backup"); + fs::set_permissions(&backup_path, fs::Permissions::from_mode(0o644)) + .expect("set existing backup permissions"); + + let err = backup_codex_state_db(&state_db, &codex_dir, &backup_root, &conn) + .expect_err("existing state db backup path must be rejected"); + + assert!( + err.to_string().contains("已存在") || err.to_string().contains("exists"), + "unexpected error: {err}" + ); + assert_eq!( + fs::read(&backup_path).expect("read existing backup"), + b"existing", + "state db backup must not overwrite an existing backup file" + ); + let mode = fs::metadata(&backup_path) + .expect("metadata existing backup") + .permissions() + .mode() + & 0o777; + assert_eq!( + mode, 0o644, + "rejected existing backup path should be left untouched" + ); } #[test] diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 7d476760..f71c90f0 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -2,8 +2,9 @@ use serde::{Deserialize, Serialize}; use std::env; use std::fs; use std::io::Write; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; +use crate::cli::i18n::texts; use crate::error::AppError; pub(crate) fn home_dir() -> Option { @@ -94,11 +95,444 @@ 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_config_dir_without_following_user_symlinks(&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 { + if path + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + return Err(AppError::InvalidInput(format!( + "配置目录路径不能包含父目录组件: {}", + path.display() + ))); + } + + match path.canonicalize() { + Ok(resolved) => { + 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(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::symlink_metadata(path)?; + if meta.file_type().is_symlink() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "path is a symlink", + )); + } + 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::symlink_metadata(path)?; + if meta.file_type().is_symlink() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "path is a symlink", + )); + } + 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)> { + let mut issues = Vec::new(); + let config_dir = get_app_config_dir(); + if let Err(err) = resolve_config_dir_without_following_user_symlinks(&config_dir) { + log::warn!("跳过配置目录权限扫描:配置目录校验失败: {err}"); + return issues; + } + let backup_dir = config_dir.join("backups"); + + collect_dir_permission_issue(&config_dir, &mut issues); + collect_dir_permission_issue(&backup_dir, &mut issues); + + collect_root_sensitive_file_permission_issues(&config_dir, &mut issues); + collect_sensitive_file_permission_issues(&backup_dir, &mut issues); + + issues +} + +#[cfg(not(unix))] +pub fn check_permissions() -> Vec<(PathBuf, u32, u32)> { + Vec::new() +} + +#[cfg(unix)] +fn collect_dir_permission_issue(dir: &Path, issues: &mut Vec<(PathBuf, u32, u32)>) { + use std::os::unix::fs::PermissionsExt; + + let Ok(meta) = fs::symlink_metadata(dir) else { + return; + }; + if meta.file_type().is_symlink() || !meta.is_dir() { + return; + } + + let mode = meta.permissions().mode() & 0o777; + if mode != 0o700 { + issues.push((dir.to_path_buf(), mode, 0o700)); + } +} + +#[cfg(unix)] +fn collect_root_sensitive_file_permission_issues( + dir: &Path, + issues: &mut Vec<(PathBuf, u32, u32)>, +) { + let Ok(meta) = fs::symlink_metadata(dir) else { + return; + }; + if meta.file_type().is_symlink() || !meta.is_dir() { + return; + } + + 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_file() && is_sensitive_config_file(&path) { + collect_sensitive_file_permission_issue(&path, issues); + } + } +} + +#[cfg(unix)] +fn collect_sensitive_file_permission_issues(dir: &Path, issues: &mut Vec<(PathBuf, u32, u32)>) { + let Ok(meta) = fs::symlink_metadata(dir) else { + return; + }; + if meta.file_type().is_symlink() || !meta.is_dir() { + return; + } + + 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) { + collect_sensitive_file_permission_issue(&path, issues); + } + } +} + +#[cfg(unix)] +fn collect_sensitive_file_permission_issue(path: &Path, issues: &mut Vec<(PathBuf, u32, u32)>) { + use std::os::unix::fs::PermissionsExt; + + let Ok(meta) = fs::symlink_metadata(path) else { + return; + }; + if meta.file_type().is_symlink() || !meta.is_file() { + return; + } + + let mode = meta.permissions().mode() & 0o777; + if is_insecure_sensitive_file_mode(mode) { + issues.push((path.to_path_buf(), 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> { + validate_config_dir()?; + + 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() @@ -132,11 +566,6 @@ pub fn read_json_file Deserialize<'a>>(path: &Path) -> Result(path: &Path, data: &T) -> Result<(), AppError> { - // 确保目录存在 - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; - } - let json = serde_json::to_string_pretty(data).map_err(|e| AppError::JsonSerialize { source: e })?; @@ -145,23 +574,29 @@ pub fn write_json_file(path: &Path, data: &T) -> Result<(), AppErr /// 原子写入文本文件(用于 TOML/纯文本) pub fn write_text_file(path: &Path, data: &str) -> Result<(), AppError> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; - } atomic_write(path, data.as_bytes()) } /// 原子写入:写入临时文件后 rename 替换,避免半写状态 pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> { - if let Some(parent) = path.parent() { + let managed_write_path = resolve_managed_storage_path(path)?; + let should_restrict_file = should_restrict_sensitive_config_file(path)?; + let write_path = managed_write_path.unwrap_or_else(|| path.to_path_buf()); + if path != write_path && should_restrict_file { + debug_assert!(write_path.is_absolute()); + } + + if path.starts_with(get_app_config_dir()) { + create_managed_config_parent_dirs(path)?; + } else if let Some(parent) = write_path.parent() { fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; } - let parent = path + let parent = write_path .parent() .ok_or_else(|| AppError::Config("无效的路径".to_string()))?; let mut tmp = parent.to_path_buf(); - let file_name = path + let file_name = write_path .file_name() .ok_or_else(|| AppError::Config("无效的文件名".to_string()))? .to_string_lossy() @@ -173,7 +608,22 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> { tmp.push(format!("{file_name}.tmp.{ts}")); { + #[cfg(unix)] + let mut f = if should_restrict_file { + use std::os::unix::fs::OpenOptionsExt; + fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&tmp) + .map_err(|e| AppError::io(&tmp, e))? + } else { + fs::File::create(&tmp).map_err(|e| AppError::io(&tmp, e))? + }; + + #[cfg(not(unix))] let mut f = fs::File::create(&tmp).map_err(|e| AppError::io(&tmp, e))?; + f.write_all(data).map_err(|e| AppError::io(&tmp, e))?; f.flush().map_err(|e| AppError::io(&tmp, e))?; } @@ -181,7 +631,9 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - if let Ok(meta) = fs::metadata(path) { + if should_restrict_file { + restrict_file_permissions(&tmp).map_err(|e| AppError::io(&tmp, e))?; + } else if let Ok(meta) = fs::metadata(&write_path) { let perm = meta.permissions().mode(); let _ = fs::set_permissions(&tmp, fs::Permissions::from_mode(perm)); } @@ -190,25 +642,336 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> { #[cfg(windows)] { // Windows 上 rename 目标存在会失败,先移除再重命名(尽量接近原子性) - if path.exists() { - let _ = fs::remove_file(path); + if write_path.exists() { + let _ = fs::remove_file(&write_path); } - fs::rename(&tmp, path).map_err(|e| AppError::IoContext { - context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()), + fs::rename(&tmp, &write_path).map_err(|e| AppError::IoContext { + context: format!( + "原子替换失败: {} -> {}", + tmp.display(), + write_path.display() + ), source: e, })?; } #[cfg(not(windows))] { - fs::rename(&tmp, path).map_err(|e| AppError::IoContext { - context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()), + fs::rename(&tmp, &write_path).map_err(|e| AppError::IoContext { + context: format!( + "原子替换失败: {} -> {}", + tmp.display(), + write_path.display() + ), source: e, })?; } + if should_restrict_file { + restrict_file_permissions(&write_path).map_err(|e| AppError::io(&write_path, e))?; + } + Ok(()) +} + +fn should_restrict_sensitive_config_file(path: &Path) -> Result { + #[cfg(unix)] + { + if !is_sensitive_config_file(path) { + return Ok(false); + } + + is_managed_sensitive_config_path(path) + } + + #[cfg(not(unix))] + { + let _ = path; + Ok(false) + } +} + +pub(crate) fn resolve_managed_storage_path(path: &Path) -> Result, AppError> { + let raw_root = get_app_config_dir(); + if !path.starts_with(&raw_root) { + return Ok(None); + } + + let resolved_root = resolve_config_dir_without_following_user_symlinks(&raw_root)?; + let suffix = path + .strip_prefix(&raw_root) + .unwrap_or_else(|_| Path::new("")); + validate_managed_storage_suffix(suffix, path)?; + Ok(Some(resolved_root.join(suffix))) +} + +fn validate_managed_storage_suffix(suffix: &Path, original_path: &Path) -> Result<(), AppError> { + if suffix + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + return Err(AppError::InvalidInput(format!( + "受管配置路径不能包含父目录组件: {}", + original_path.display() + ))); + } + + Ok(()) +} + +pub(crate) fn resolve_config_dir_without_following_user_symlinks( + path: &Path, +) -> Result { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + env::current_dir() + .map_err(|e| AppError::io(".", e))? + .join(path) + }; + let mut current = PathBuf::new(); + let components = absolute.components().collect::>(); + + for (idx, component) in components.iter().enumerate() { + match component { + Component::Prefix(prefix) => current.push(prefix.as_os_str()), + Component::RootDir => current.push(component.as_os_str()), + Component::CurDir => continue, + Component::ParentDir => { + return Err(AppError::InvalidInput(format!( + "配置目录路径不能包含父目录组件: {}", + path.display() + ))); + } + Component::Normal(part) => { + current.push(part); + match fs::symlink_metadata(¤t) { + Ok(meta) if meta.file_type().is_symlink() => { + if is_allowed_platform_config_symlink(¤t) { + continue; + } + return Err(AppError::InvalidInput(format!( + "配置目录路径不能包含符号链接: {}", + current.display() + ))); + } + Ok(_) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + if idx + 1 != components.len() { + return Err(AppError::IoContext { + context: texts::config_dir_only_final_component_may_be_missing( + &path.display().to_string(), + ), + source: err, + }); + } + break; + } + Err(err) => return Err(AppError::io(¤t, err)), + } + } + } + } + + resolve_existing_or_new_child_path(¤t) +} + +pub(crate) fn create_managed_config_parent_dirs(path: &Path) -> Result<(), AppError> { + if let Some(resolved) = resolve_managed_storage_path(path)? { + if let Some(parent) = resolved.parent() { + #[cfg(unix)] + { + let config_root = + resolve_config_dir_without_following_user_symlinks(&get_app_config_dir())?; + create_secure_config_dir_all_no_symlink(&config_root, parent)?; + } + + #[cfg(not(unix))] + { + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + } + return Ok(()); + } + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } Ok(()) } +pub(crate) fn create_managed_config_dir_all(path: &Path) -> Result<(), AppError> { + if let Some(resolved) = resolve_managed_storage_path(path)? { + #[cfg(unix)] + { + let config_root = + resolve_config_dir_without_following_user_symlinks(&get_app_config_dir())?; + create_secure_config_dir_all_no_symlink(&config_root, &resolved)?; + } + + #[cfg(not(unix))] + { + fs::create_dir_all(&resolved).map_err(|e| AppError::io(&resolved, e))?; + } + + return Ok(()); + } + + fs::create_dir_all(path).map_err(|e| AppError::io(path, e))?; + Ok(()) +} + +#[cfg(unix)] +fn create_secure_config_dir_all_no_symlink( + config_root: &Path, + path: &Path, +) -> Result<(), AppError> { + use std::os::unix::fs::DirBuilderExt; + + let mut current = PathBuf::new(); + let mut managed_component = false; + for component in path.components() { + match component { + Component::Prefix(prefix) => current.push(prefix.as_os_str()), + Component::RootDir => current.push(component.as_os_str()), + Component::CurDir => continue, + Component::ParentDir => unreachable!("paths are resolved before secure creation"), + Component::Normal(part) => { + current.push(part); + if current == config_root { + managed_component = true; + } + match fs::symlink_metadata(¤t) { + Ok(meta) if meta.file_type().is_symlink() => { + if is_allowed_platform_config_symlink(¤t) { + continue; + } + return Err(AppError::InvalidInput(format!( + "配置目录路径不能包含符号链接: {}", + current.display() + ))); + } + Ok(meta) if meta.is_dir() => { + if managed_component { + restrict_dir_permissions(¤t) + .map_err(|e| AppError::io(¤t, e))?; + } + } + Ok(_) => { + return Err(AppError::InvalidInput(format!( + "配置目录路径组件不是目录: {}", + current.display() + ))); + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + if !managed_component { + return Err(AppError::IoContext { + context: texts::config_dir_only_final_component_may_be_missing( + &path.display().to_string(), + ), + source: err, + }); + } + fs::DirBuilder::new() + .mode(0o700) + .create(¤t) + .or_else(|create_err| { + if create_err.kind() != std::io::ErrorKind::AlreadyExists { + return Err(create_err); + } + ensure_existing_secure_config_dir(¤t) + }) + .map_err(|e| AppError::io(¤t, e))?; + } + Err(err) => return Err(AppError::io(¤t, err)), + } + } + } + } + + Ok(()) +} + +#[cfg(unix)] +fn ensure_existing_secure_config_dir(path: &Path) -> std::io::Result<()> { + match fs::symlink_metadata(path) { + Ok(meta) if meta.file_type().is_symlink() => Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "path is a symlink", + )), + Ok(meta) if meta.is_dir() => restrict_dir_permissions(path), + Ok(_) => Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "path exists and is not a directory", + )), + Err(err) => Err(err), + } +} + +#[cfg(unix)] +fn is_allowed_platform_config_symlink(path: &Path) -> bool { + #[cfg(target_os = "macos")] + { + matches!(path.to_str(), Some("/tmp") | Some("/var") | Some("/etc")) + } + + #[cfg(not(target_os = "macos"))] + { + let _ = path; + false + } +} + +#[cfg(unix)] +fn is_managed_sensitive_config_path(path: &Path) -> Result { + let config_dir = normalized_absolute_path(&get_app_config_dir())?; + let path = normalized_absolute_path(path)?; + if !path.starts_with(&config_dir) { + return Ok(false); + } + + let Ok(relative) = path.strip_prefix(&config_dir) else { + return Ok(false); + }; + + let components = relative.components().collect::>(); + if components.len() == 1 { + return Ok(true); + } + + Ok(matches!( + components.first(), + Some(Component::Normal(name)) if *name == "backups" + )) +} + +#[cfg(unix)] +fn normalized_absolute_path(path: &Path) -> Result { + let base = if path.is_absolute() { + PathBuf::new() + } else { + env::current_dir().map_err(|e| AppError::io(".", e))? + }; + + let mut normalized = PathBuf::new(); + for component in base.join(path).components() { + match component { + Component::Prefix(prefix) => normalized.push(prefix.as_os_str()), + Component::RootDir => normalized.push(component.as_os_str()), + Component::CurDir => {} + Component::ParentDir => { + if !normalized.pop() { + return Err(AppError::InvalidInput(format!( + "路径包含无效的父目录组件: {}", + path.display() + ))); + } + } + Component::Normal(part) => normalized.push(part), + } + } + + Ok(normalized) +} + #[cfg(test)] mod tests { use super::*; @@ -263,6 +1026,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 +1198,734 @@ 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_rejects_parent_dir_components_even_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_err(), + "config dir should reject parent components instead of normalizing to the parent" + ); + assert!( + resolve_config_dir_without_following_user_symlinks(&config_dir).is_err(), + "managed config root resolution must reject parent components" + ); + } + + #[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 validate_and_permission_checks_reject_symlink_parent_without_touching_target() { + use std::os::unix::fs::{symlink, PermissionsExt}; + + let _guard = lock_test_home_and_settings(); + let temp = tempfile::tempdir().expect("create temp dir"); + let external_parent = temp.path().join("external"); + let external_config = external_parent.join(".cc-switch"); + let link_parent = temp.path().join("link"); + std::fs::create_dir(&external_parent).expect("create external parent"); + std::fs::create_dir(&external_config).expect("create external config"); + std::fs::set_permissions(&external_config, fs::Permissions::from_mode(0o755)) + .expect("set insecure external config perms"); + symlink(&external_parent, &link_parent).expect("create symlink parent"); + + let raw_config = link_parent.join(".cc-switch"); + let _env = + ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some(raw_config.to_str().unwrap())); + + assert!( + validate_config_dir().is_err(), + "validation should reject the symlink parent component" + ); + assert!( + check_permissions().is_empty(), + "permission scan should not follow rejected symlink parents" + ); + + let err = prompt_fix_permissions().expect_err("prompt should fail before chmod"); + assert!( + err.to_string().contains("符号链接") || err.to_string().contains("symlink"), + "unexpected error: {err}" + ); + let mode = std::fs::metadata(&external_config) + .expect("metadata external config") + .permissions() + .mode() + & 0o777; + assert_eq!( + mode, 0o755, + "prompt must not chmod the symlink target before DB init rejects it" + ); + } + + #[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_ignores_skill_json_metadata() { + 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 skill_dir = temp.path().join("skills").join("demo-skill"); + std::fs::create_dir_all(&skill_dir).expect("create skill dir"); + let plugin_json = skill_dir.join("plugin.json"); + std::fs::OpenOptions::new() + .write(true) + .create(true) + .mode(0o644) + .open(&plugin_json) + .expect("create skill metadata"); + + assert!( + check_permissions().is_empty(), + "skill metadata JSON should not be treated as cc-switch secret state" + ); + } + + #[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); + } + + #[cfg(unix)] + #[test] + fn write_json_file_restricts_sensitive_files_under_cc_switch_config_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 path = temp.path().join("config.json"); + write_json_file(&path, &serde_json::json!({ "token": "secret" })) + .expect("write sensitive json"); + + let mode = std::fs::metadata(&path) + .expect("metadata") + .permissions() + .mode() + & 0o777; + let dir_mode = std::fs::metadata(temp.path()) + .expect("metadata config dir") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + assert_eq!(dir_mode, 0o700); + assert!(check_permissions().is_empty()); + } + + #[cfg(unix)] + #[test] + fn existing_secure_config_dir_recheck_restricts_directory_permissions() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("create temp dir"); + let dir = temp.path().join("cc-switch"); + std::fs::create_dir(&dir).expect("create dir"); + std::fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).expect("set dir perms"); + + ensure_existing_secure_config_dir(&dir).expect("existing directory should be accepted"); + + let mode = std::fs::metadata(&dir) + .expect("metadata dir") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o700); + } + + #[cfg(unix)] + #[test] + fn existing_secure_config_dir_recheck_rejects_symlink() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().expect("create temp dir"); + let target = temp.path().join("target"); + let link = temp.path().join("link"); + std::fs::create_dir(&target).expect("create target"); + symlink(&target, &link).expect("create symlink"); + + let err = ensure_existing_secure_config_dir(&link) + .expect_err("existing symlink must not be accepted"); + assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists); + } + + #[cfg(target_os = "macos")] + #[test] + fn write_json_file_restricts_sensitive_files_under_macos_tmp_alias() { + use std::os::unix::fs::PermissionsExt; + + let _guard = lock_test_home_and_settings(); + let temp = tempfile::tempdir_in("/tmp").expect("create /tmp temp dir"); + let config_dir = temp.path().join("cc-switch"); + std::fs::create_dir(&config_dir).expect("create config dir"); + std::fs::set_permissions(&config_dir, fs::Permissions::from_mode(0o700)) + .expect("set config dir perms"); + let _env = + ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some(config_dir.to_str().unwrap())); + + let path = get_app_config_dir().join("settings.json"); + write_json_file(&path, &serde_json::json!({ "token": "secret" })) + .expect("write sensitive json"); + + let mode = std::fs::metadata(&path) + .expect("metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + } + + #[cfg(unix)] + #[test] + fn write_json_file_rejects_sensitive_file_under_symlinked_config_subdir() { + use std::os::unix::fs::{symlink, PermissionsExt}; + + let _guard = lock_test_home_and_settings(); + let root = tempfile::tempdir().expect("create temp dir"); + let config_dir = root.path().join("cc-switch"); + let external_dir = root.path().join("external-backups"); + std::fs::create_dir(&config_dir).expect("create config dir"); + std::fs::create_dir(&external_dir).expect("create external dir"); + std::fs::set_permissions(&config_dir, fs::Permissions::from_mode(0o700)) + .expect("set config dir perms"); + std::fs::set_permissions(&external_dir, fs::Permissions::from_mode(0o755)) + .expect("set external dir perms"); + symlink(&external_dir, config_dir.join("backups")).expect("create backups symlink"); + + let _env = + ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some(config_dir.to_str().unwrap())); + let err = write_json_file( + &config_dir.join("backups").join("secret.json"), + &serde_json::json!({ "token": "secret" }), + ) + .expect_err("sensitive writes must reject symlinked config subdirs"); + + assert!( + err.to_string().contains("符号链接") || err.to_string().contains("symlink"), + "unexpected error: {err}" + ); + let external_mode = std::fs::metadata(&external_dir) + .expect("metadata external dir") + .permissions() + .mode() + & 0o777; + assert_eq!( + external_mode, 0o755, + "symlink target permissions must not be modified" + ); + assert!( + !external_dir.join("secret.json").exists(), + "write should not follow symlink and create the sensitive file outside config dir" + ); + } + + #[cfg(unix)] + #[test] + fn write_json_file_rejects_unresolved_parent_dir_components_without_chmodding_parent() { + use std::os::unix::fs::PermissionsExt; + + let _guard = lock_test_home_and_settings(); + let root = tempfile::tempdir().expect("create temp dir"); + std::fs::set_permissions(root.path(), fs::Permissions::from_mode(0o755)) + .expect("set root perms"); + let config_dir = root.path().join("child").join(".."); + let _env = + ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some(config_dir.to_str().unwrap())); + + write_json_file( + &get_app_config_dir().join("settings.json"), + &serde_json::json!({ "token": "secret" }), + ) + .expect_err("unresolved parent components should be rejected before chmod"); + + let root_mode = std::fs::metadata(root.path()) + .expect("metadata root") + .permissions() + .mode() + & 0o777; + assert_eq!( + root_mode, 0o755, + "invalid config dir writes must not chmod the resolved parent" + ); + assert!( + !root.path().join("settings.json").exists(), + "invalid config dir write should not create the normalized target" + ); + } + + #[cfg(unix)] + #[test] + fn write_json_file_rejects_symlink_parent_even_when_followed_by_dotdot() { + use std::os::unix::fs::symlink; + + let _guard = lock_test_home_and_settings(); + let root = tempfile::tempdir().expect("create temp dir"); + let real_parent = root.path().join("real-parent"); + let external_parent = root.path().join("external-parent"); + std::fs::create_dir(&real_parent).expect("create real parent"); + std::fs::create_dir(&external_parent).expect("create external parent"); + symlink(&external_parent, real_parent.join("link")).expect("create symlink parent"); + + let config_dir = real_parent.join("link").join("..").join("cc-switch"); + let _env = + ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some(config_dir.to_str().unwrap())); + + let err = write_json_file( + &get_app_config_dir().join("settings.json"), + &serde_json::json!({ "token": "secret" }), + ) + .expect_err("symlink component should be rejected before lexical dotdot collapse"); + + assert!( + err.to_string().contains("符号链接") || err.to_string().contains("symlink"), + "unexpected error: {err}" + ); + assert!( + !real_parent.join("cc-switch/settings.json").exists(), + "rejected write must not create the normalized target" + ); + assert!( + !external_parent.join("cc-switch/settings.json").exists(), + "write must not follow the symlinked parent from the raw path" + ); + } } /// 复制文件 diff --git a/src-tauri/src/database/backup.rs b/src-tauri/src/database/backup.rs index 37b3df25..87609244 100644 --- a/src-tauri/src/database/backup.rs +++ b/src-tauri/src/database/backup.rs @@ -2,15 +2,16 @@ //! //! 提供 SQL 导出/导入和二进制快照备份功能。 -use super::{lock_conn, Database, DB_BACKUP_RETAIN}; -use crate::config::get_app_config_dir; +use super::{create_secure_dir_all, lock_conn, Database, DB_BACKUP_RETAIN}; use crate::error::AppError; use chrono::Utc; use rusqlite::backup::Backup; use rusqlite::types::Value; -use rusqlite::{Connection, OptionalExtension}; +use rusqlite::{Connection, OpenFlags, OptionalExtension}; use std::fs; +use std::io::ErrorKind; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; use tempfile::NamedTempFile; const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出"; @@ -123,7 +124,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()) @@ -470,7 +471,9 @@ impl Database { /// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None) pub(crate) fn backup_database_file(&self) -> Result, AppError> { - let db_path = get_app_config_dir().join("cc-switch.db"); + let Some(db_path) = self.db_path.as_deref() else { + return Ok(None); + }; if !db_path.exists() { return Ok(None); } @@ -480,31 +483,140 @@ 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 backup_path = { + let conn = lock_conn!(self.conn); + let (backup_path, mut dest_conn) = + Self::create_unique_backup_db_connection(&backup_dir)?; + { + let backup = Backup::new(&conn, &mut dest_conn) + .map_err(|e| AppError::Database(e.to_string()))?; + backup + .step(-1) + .map_err(|e| AppError::Database(e.to_string()))?; + } + backup_path + }; + + Self::cleanup_db_backups(&backup_dir)?; + Ok(Some(backup_path)) + } - let base_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S")); - let mut backup_id = base_id.clone(); - let mut backup_path = backup_dir.join(format!("{backup_id}.db")); - let mut counter = 1; - while backup_path.exists() { - backup_id = format!("{base_id}_{counter}"); - backup_path = backup_dir.join(format!("{backup_id}.db")); - counter += 1; + fn create_unique_backup_db_connection( + backup_dir: &Path, + ) -> Result<(PathBuf, Connection), AppError> { + for _ in 0..100 { + let backup_path = backup_dir.join(format!("{}.db", Self::new_db_backup_id())); + match Self::try_create_backup_db_connection(&backup_path)? { + Some(conn) => return Ok((backup_path, conn)), + None => continue, + } } + Err(AppError::Io { + path: backup_dir.display().to_string(), + source: std::io::Error::new( + ErrorKind::AlreadyExists, + "failed to allocate a unique database backup path", + ), + }) + } + + fn new_db_backup_id() -> String { + static NEXT_BACKUP_ID: AtomicU64 = AtomicU64::new(0); + + format!( + "db_backup_{}_{}_{}", + Utc::now().format("%Y%m%d_%H%M%S_%f"), + std::process::id(), + NEXT_BACKUP_ID.fetch_add(1, Ordering::Relaxed) + ) + } + + #[cfg(test)] + pub(super) fn create_backup_db_connection(backup_path: &Path) -> Result { + Self::try_create_backup_db_connection(backup_path)?.ok_or_else(|| AppError::Io { + path: backup_path.display().to_string(), + source: std::io::Error::new( + ErrorKind::AlreadyExists, + "database backup path already exists", + ), + }) + } + + fn try_create_backup_db_connection(backup_path: &Path) -> Result, AppError> { + #[cfg(unix)] { - let conn = lock_conn!(self.conn); - let mut dest_conn = - Connection::open(&backup_path).map_err(|e| AppError::Database(e.to_string()))?; - let backup = Backup::new(&conn, &mut dest_conn) - .map_err(|e| AppError::Database(e.to_string()))?; - backup - .step(-1) - .map_err(|e| AppError::Database(e.to_string()))?; + use std::os::unix::fs::OpenOptionsExt; + match std::fs::symlink_metadata(backup_path) { + Ok(meta) if meta.file_type().is_symlink() => { + return Err(AppError::InvalidInput(format!( + "数据库备份文件不能是符号链接: {}", + backup_path.display() + ))); + } + Ok(meta) if meta.is_file() => return Ok(None), + Ok(_) => { + return Err(AppError::InvalidInput(format!( + "数据库备份路径不是普通文件: {}", + backup_path.display() + ))); + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(backup_path) + { + Ok(_) => {} + Err(err) if err.kind() == ErrorKind::AlreadyExists => return Ok(None), + Err(err) => return Err(AppError::io(backup_path, err)), + } + } + Err(err) => return Err(AppError::io(backup_path, err)), + } + + let open_path = Self::canonicalize_existing_parent(backup_path)?; + let flags = OpenFlags::SQLITE_OPEN_READ_WRITE + | OpenFlags::SQLITE_OPEN_CREATE + | OpenFlags::SQLITE_OPEN_NO_MUTEX + | OpenFlags::SQLITE_OPEN_NOFOLLOW; + Connection::open_with_flags(&open_path, flags) + .map(Some) + .map_err(|e| AppError::Database(e.to_string())) } - Self::cleanup_db_backups(&backup_dir)?; - Ok(Some(backup_path)) + #[cfg(not(unix))] + { + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(backup_path) + { + Ok(_) => {} + Err(err) if err.kind() == ErrorKind::AlreadyExists => return Ok(None), + Err(err) => return Err(AppError::io(backup_path, err)), + } + Connection::open(backup_path) + .map(Some) + .map_err(|e| AppError::Database(e.to_string())) + } + } + + fn canonicalize_existing_parent(path: &Path) -> Result { + let Some(file_name) = path.file_name() else { + return Err(AppError::InvalidInput(format!( + "数据库备份路径缺少文件名: {}", + path.display() + ))); + }; + let parent = path + .parent() + .ok_or_else(|| AppError::InvalidInput(format!("无效路径: {}", path.display())))?; + let parent = parent.canonicalize().map_err(|e| AppError::io(parent, e))?; + Ok(parent.join(file_name)) } /// 清理旧的数据库备份,保留最新的 N 个 @@ -720,10 +832,7 @@ impl Database { return Ok(true); }; - Ok(!policy - .local_settings_keys - .iter() - .any(|local_key| *local_key == key)) + Ok(!policy.local_settings_keys.contains(&key)) } fn neutralize_export_row( @@ -918,6 +1027,60 @@ mod tests { Ok(()) } + #[test] + fn memory_import_does_not_create_global_database_backup() -> Result<(), AppError> { + let temp = tempfile::tempdir().expect("create temp dir"); + let _env = crate::test_support::TestEnvGuard::isolated(temp.path()); + + let global_db = Database::init()?; + { + let conn = crate::database::lock_conn!(global_db.conn); + seed_provider(&conn, "global-provider")?; + } + + let remote_db = Database::memory()?; + { + let conn = crate::database::lock_conn!(remote_db.conn); + seed_provider(&conn, "remote-provider")?; + } + let remote_sql = remote_db.export_sql_string_for_sync()?; + + let local_db = Database::memory()?; + local_db.import_sql_string_for_sync(&remote_sql)?; + + assert!( + !temp.path().join(".cc-switch").join("backups").exists(), + "importing into an in-memory database must not back up the process-global database" + ); + + Ok(()) + } + + #[test] + fn file_database_backups_use_unique_paths() -> Result<(), AppError> { + let temp = tempfile::tempdir().expect("create temp dir"); + let _env = crate::test_support::TestEnvGuard::isolated(temp.path()); + + let db = Database::init()?; + { + let conn = crate::database::lock_conn!(db.conn); + seed_provider(&conn, "local-provider")?; + } + + let first = db + .backup_database_file()? + .expect("first backup should be created"); + let second = db + .backup_database_file()? + .expect("second backup should be created"); + + assert_ne!(first, second, "backup paths should not collide"); + assert!(first.exists(), "first backup should exist"); + assert!(second.exists(), "second backup should exist"); + + Ok(()) + } + #[test] fn sync_import_preserves_local_settings_keys() -> Result<(), AppError> { let remote_db = Database::memory()?; diff --git a/src-tauri/src/database/dao/usage_rollup.rs b/src-tauri/src/database/dao/usage_rollup.rs index 114da3d3..5dbc6a31 100644 --- a/src-tauri/src/database/dao/usage_rollup.rs +++ b/src-tauri/src/database/dao/usage_rollup.rs @@ -108,13 +108,13 @@ impl Database { let effective_filter = effective_usage_log_filter("l"); let aggregation_sql = format!( "INSERT OR REPLACE INTO usage_daily_rollups - (date, app_type, provider_id, model, + (date, app_type, provider_id, model, request_model, pricing_model, request_count, success_count, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms) SELECT - d, a, p, m, + d, a, p, m, rm, pm, COALESCE(old.request_count, 0) + new_req, COALESCE(old.success_count, 0) + new_succ, COALESCE(old.input_tokens, 0) + new_in, @@ -131,6 +131,8 @@ impl Database { SELECT date(l.created_at, 'unixepoch', 'localtime') as d, l.app_type as a, l.provider_id as p, l.model as m, + COALESCE(l.request_model, '') as rm, + COALESCE(l.pricing_model, '') as pm, COUNT(*) as new_req, SUM(CASE WHEN l.status_code >= 200 AND l.status_code < 300 THEN 1 ELSE 0 END) as new_succ, COALESCE(SUM(l.input_tokens), 0) as new_in, @@ -141,11 +143,12 @@ impl Database { COALESCE(AVG(l.latency_ms), 0) as new_lat FROM proxy_request_logs l WHERE l.created_at < ?1 AND {effective_filter} - GROUP BY d, a, p, m + GROUP BY d, a, p, m, rm, pm ) agg LEFT JOIN usage_daily_rollups old ON old.date = agg.d AND old.app_type = agg.a - AND old.provider_id = agg.p AND old.model = agg.m" + AND old.provider_id = agg.p AND old.model = agg.m + AND old.request_model = agg.rm AND old.pricing_model = agg.pm" ); conn.execute(&aggregation_sql, [cutoff]) @@ -332,6 +335,77 @@ mod tests { Ok(()) } + #[test] + fn test_rollup_preserves_request_and_pricing_model_dimensions() -> Result<(), AppError> { + let db = Database::memory()?; + let now = chrono::Utc::now().timestamp(); + let old_ts = now - 40 * 86400; + + { + let conn = crate::database::lock_conn!(db.conn); + for (request_id, request_model, pricing_model, cost) in [ + ("dim-a", "claude-sonnet-4", "claude-sonnet-4", "0.10"), + ("dim-b", "claude-haiku-4", "claude-haiku-4", "0.01"), + ("dim-c", "claude-sonnet-4", "kimi-k2", "0.02"), + ] { + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, request_model, pricing_model, + input_tokens, output_tokens, total_cost_usd, + latency_ms, status_code, created_at + ) VALUES (?1, 'p1', 'claude', 'kimi-k2', ?2, ?3, 100, 50, ?4, 200, 200, ?5)", + rusqlite::params![request_id, request_model, pricing_model, cost, old_ts], + )?; + } + } + + let deleted = db.rollup_and_prune(30)?; + assert_eq!(deleted, 3); + + let conn = crate::database::lock_conn!(db.conn); + let mut stmt = conn.prepare( + "SELECT request_model, pricing_model, request_count, total_cost_usd + FROM usage_daily_rollups + WHERE model = 'kimi-k2' + ORDER BY request_model, pricing_model", + )?; + let rows = stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, String>(3)?, + )) + })? + .collect::, _>>()?; + + assert_eq!( + rows, + vec![ + ( + "claude-haiku-4".to_string(), + "claude-haiku-4".to_string(), + 1, + "0.01".to_string(), + ), + ( + "claude-sonnet-4".to_string(), + "claude-sonnet-4".to_string(), + 1, + "0.1".to_string(), + ), + ( + "claude-sonnet-4".to_string(), + "kimi-k2".to_string(), + 1, + "0.02".to_string(), + ), + ] + ); + Ok(()) + } + #[test] fn test_rollup_merges_with_existing() -> Result<(), AppError> { let db = Database::memory()?; diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 0ab9e02c..dfa4cde5 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -36,12 +36,16 @@ 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_config_dir_without_following_user_symlinks, + resolve_existing_or_new_child_path, +}; use crate::error::AppError; use rusqlite::{Connection, OpenFlags}; use serde::Serialize; +use std::path::{Component, Path, PathBuf}; 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,9 +55,128 @@ 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; +pub(crate) const SCHEMA_VERSION: i32 = 11; + +fn database_open_flags() -> OpenFlags { + OpenFlags::SQLITE_OPEN_READ_WRITE + | OpenFlags::SQLITE_OPEN_CREATE + | OpenFlags::SQLITE_OPEN_NO_MUTEX + | OpenFlags::SQLITE_OPEN_NOFOLLOW +} + +fn readonly_database_open_flags() -> OpenFlags { + OpenFlags::SQLITE_OPEN_READ_ONLY + | OpenFlags::SQLITE_OPEN_NO_MUTEX + | OpenFlags::SQLITE_OPEN_NOFOLLOW +} + +pub(crate) fn database_path() -> Result { + Ok( + resolve_config_dir_without_following_user_symlinks(&get_app_config_dir())? + .join("cc-switch.db"), + ) +} + +#[cfg(unix)] +fn reject_hardlinked_database_file(path: &Path, meta: &std::fs::Metadata) -> Result<(), AppError> { + use std::os::unix::fs::MetadataExt; + + if meta.nlink() > 1 { + return Err(AppError::InvalidInput(format!( + "数据库文件不能有多个硬链接: {}", + path.display() + ))); + } + + Ok(()) +} + +#[cfg(unix)] +fn validate_existing_database_file(path: &Path) -> Result<(), AppError> { + let meta = std::fs::symlink_metadata(path).map_err(|e| AppError::io(path, e))?; + if meta.file_type().is_symlink() { + return Err(AppError::InvalidInput(format!( + "数据库文件不能是符号链接: {}", + path.display() + ))); + } + if !meta.is_file() { + return Err(AppError::InvalidInput(format!( + "数据库路径不是普通文件: {}", + path.display() + ))); + } + + reject_hardlinked_database_file(path, &meta) +} + +#[cfg(unix)] +fn validate_existing_database_init_lock(path: &Path) -> Result<(), AppError> { + let meta = std::fs::symlink_metadata(path).map_err(|e| AppError::io(path, e))?; + if meta.file_type().is_symlink() { + return Err(AppError::InvalidInput(format!( + "数据库初始化锁不能是符号链接: {}", + path.display() + ))); + } + if !meta.is_file() { + return Err(AppError::InvalidInput(format!( + "数据库初始化锁不是普通文件: {}", + path.display() + ))); + } + + reject_hardlinked_database_file(path, &meta) +} + +#[cfg(unix)] +struct DatabaseInitLock { + _file: std::fs::File, +} + +#[cfg(unix)] +fn acquire_database_init_lock(config_dir: &Path) -> Result { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + use std::os::unix::io::AsRawFd; + + let path = config_dir.join("cc-switch.db.init.lock"); + match std::fs::symlink_metadata(&path) { + Ok(_) => validate_existing_database_init_lock(&path)?, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(AppError::io(&path, err)), + } + + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW) + .open(&path) + .map_err(|e| AppError::io(&path, e))?; + + let meta = file.metadata().map_err(|e| AppError::io(&path, e))?; + if !meta.is_file() { + return Err(AppError::InvalidInput(format!( + "数据库初始化锁不是普通文件: {}", + path.display() + ))); + } + reject_hardlinked_database_file(&path, &meta)?; + file.set_permissions(std::fs::Permissions::from_mode(0o600)) + .map_err(|e| AppError::io(&path, e))?; + + let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) }; + if rc != 0 { + return Err(AppError::io(&path, std::io::Error::last_os_error())); + } + + Ok(DatabaseInitLock { _file: file }) +} /// 安全地序列化 JSON,避免 unwrap panic pub(crate) fn to_json_string(value: &T) -> Result { @@ -61,6 +184,152 @@ pub(crate) fn to_json_string(value: &T) -> Result Result { + let path = resolve_create_dir_path(path)?; + + #[cfg(unix)] + { + create_secure_dir_all_no_symlink(&path) + } + + #[cfg(not(unix))] + { + match std::fs::create_dir_all(&path) { + Ok(()) => Ok(true), + Err(err) => Err(AppError::io(&path, err)), + } + } +} + +fn resolve_create_dir_path(path: &Path) -> Result { + if path + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + resolve_existing_or_new_child_path(path)?; + return normalize_path_lexically(path); + } + + Ok(path.to_path_buf()) +} + +fn normalize_path_lexically(path: &Path) -> Result { + let base = if path.is_absolute() { + PathBuf::new() + } else { + std::env::current_dir().map_err(|e| AppError::io(".", e))? + }; + + let mut normalized = PathBuf::new(); + for component in base.join(path).components() { + match component { + Component::Prefix(prefix) => normalized.push(prefix.as_os_str()), + Component::RootDir => normalized.push(component.as_os_str()), + Component::CurDir => {} + Component::ParentDir => { + if !normalized.pop() { + return Err(AppError::InvalidInput(format!( + "路径包含无效的父目录组件: {}", + path.display() + ))); + } + } + Component::Normal(part) => normalized.push(part), + } + } + + Ok(normalized) +} + +#[cfg(unix)] +fn create_secure_dir_all_no_symlink(path: &Path) -> Result { + use std::os::unix::fs::DirBuilderExt; + + let mut current = PathBuf::new(); + let mut created_any = false; + + for component in path.components() { + match component { + Component::Prefix(prefix) => current.push(prefix.as_os_str()), + Component::RootDir => current.push(component.as_os_str()), + Component::CurDir => continue, + Component::ParentDir => unreachable!("parent components are rejected before creation"), + Component::Normal(part) => { + current.push(part); + match std::fs::symlink_metadata(¤t) { + Ok(meta) if meta.file_type().is_symlink() => { + if let Some(resolved) = allowed_platform_symlink_component(¤t)? { + current = resolved; + continue; + } + return Err(AppError::InvalidInput(format!( + "配置目录路径不能包含符号链接: {}", + current.display() + ))); + } + Ok(meta) if meta.is_dir() => {} + Ok(_) => { + return Err(AppError::InvalidInput(format!( + "配置目录路径组件不是目录: {}", + current.display() + ))); + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + match std::fs::DirBuilder::new().mode(0o700).create(¤t) { + Ok(()) => created_any = true, + Err(create_err) + if create_err.kind() == std::io::ErrorKind::AlreadyExists => + { + ensure_existing_secure_dir_component(¤t)?; + } + Err(create_err) => return Err(AppError::io(¤t, create_err)), + } + } + Err(err) => return Err(AppError::io(¤t, err)), + } + } + } + } + + Ok(created_any) +} + +#[cfg(unix)] +fn ensure_existing_secure_dir_component(path: &Path) -> Result<(), AppError> { + match std::fs::symlink_metadata(path) { + Ok(meta) if meta.file_type().is_symlink() => Err(AppError::InvalidInput(format!( + "配置目录路径不能包含符号链接: {}", + path.display() + ))), + Ok(meta) if meta.is_dir() => Ok(()), + Ok(_) => Err(AppError::InvalidInput(format!( + "配置目录路径组件不是目录: {}", + path.display() + ))), + Err(err) => Err(AppError::io(path, err)), + } +} + +#[cfg(unix)] +fn allowed_platform_symlink_component(path: &Path) -> Result, AppError> { + #[cfg(target_os = "macos")] + { + if matches!(path.to_str(), Some("/tmp") | Some("/var") | Some("/etc")) { + let resolved = path.canonicalize().map_err(|e| AppError::io(path, e))?; + let meta = std::fs::metadata(&resolved).map_err(|e| AppError::io(&resolved, e))?; + if meta.is_dir() { + return Ok(Some(resolved)); + } + } + } + + let _ = path; + Ok(None) +} + /// 安全地获取 Mutex 锁,避免 unwrap panic macro_rules! lock_conn { ($mutex:expr) => { @@ -80,6 +349,7 @@ pub(crate) use lock_conn; pub struct Database { pub(crate) conn: Mutex, runtime_key: String, + db_path: Option, } impl Database { @@ -95,14 +365,59 @@ impl Database { /// /// 数据库文件位于 `~/.cc-switch/cc-switch.db` pub fn init() -> Result { - let db_path = get_app_config_dir().join("cc-switch.db"); + if let Err(err) = crate::config::validate_config_dir() { + log::warn!("拒绝初始化数据库:配置目录校验失败: {err}"); + return Err(err); + } + warn_insecure_permissions_once(); + + let db_path = database_path()?; // 确保父目录存在 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)?; + } + + #[cfg(unix)] + let _init_lock = db_path + .parent() + .map(acquire_database_init_lock) + .transpose()?; + + // 新建数据库文件时以 0o600 原子创建,已有文件的权限由 prompt_fix_permissions 处理 + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + match std::fs::symlink_metadata(&db_path) { + Ok(_) => validate_existing_database_file(&db_path)?, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&db_path) + { + Ok(_) => {} + Err(create_err) + if create_err.kind() == std::io::ErrorKind::AlreadyExists => + { + validate_existing_database_file(&db_path)?; + } + Err(create_err) => return Err(AppError::io(&db_path, create_err)), + } + } + Err(err) => return Err(AppError::io(&db_path, err)), + } + } + #[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()))?; + let conn = Connection::open_with_flags(&db_path, database_open_flags()) + .map_err(|e| AppError::Database(e.to_string()))?; Self::configure_connection(&conn)?; // 多进程并发:daemon 与 worker 都会打开这个文件,WAL + busy_timeout 让 @@ -113,6 +428,7 @@ impl Database { let db = Self { conn: Mutex::new(conn), runtime_key: format!("file:{}", db_path.display()), + db_path: Some(db_path.clone()), }; { @@ -146,15 +462,17 @@ impl Database { /// /// 用于 TUI 后台热刷新等只读路径;不会创建目录、建表、迁移、seed 或执行启动维护。 pub fn open_readonly_current_schema() -> Result { - let db_path = get_app_config_dir().join("cc-switch.db"); + let db_path = database_path()?; if !db_path.exists() { return Err(AppError::Database(format!( "database is not initialized: {}", db_path.display() ))); } + #[cfg(unix)] + validate_existing_database_file(&db_path)?; - let conn = Connection::open_with_flags(&db_path, OpenFlags::SQLITE_OPEN_READ_ONLY) + let conn = Connection::open_with_flags(&db_path, readonly_database_open_flags()) .map_err(|e| AppError::Database(e.to_string()))?; Self::configure_connection(&conn)?; @@ -171,6 +489,7 @@ impl Database { Ok(Self { conn: Mutex::new(conn), runtime_key: format!("file:{}", db_path.display()), + db_path: Some(db_path), }) } @@ -188,6 +507,7 @@ impl Database { "memory:{}", NEXT_MEMORY_DB_ID.fetch_add(1, Ordering::Relaxed) ), + db_path: None, }; db.create_tables()?; db.ensure_model_pricing_seeded()?; @@ -281,3 +601,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/schema.rs b/src-tauri/src/database/schema.rs index a949cb7e..e4126297 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -174,9 +174,12 @@ impl Database { )", []).map_err(|e| AppError::Database(e.to_string()))?; // 10. Proxy Request Logs 表 + // pricing_model = 写入时实际用于计价的模型名;NULL 表示 v11 之前的历史行, + // '' 表示未计价的错误行。 conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs ( request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL, request_model TEXT, + pricing_model TEXT, input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0, cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0, input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0', @@ -234,6 +237,8 @@ impl Database { app_type TEXT NOT NULL, provider_id TEXT NOT NULL, model TEXT NOT NULL, + request_model TEXT NOT NULL DEFAULT '', + pricing_model TEXT NOT NULL DEFAULT '', request_count INTEGER NOT NULL DEFAULT 0, success_count INTEGER NOT NULL DEFAULT 0, input_tokens INTEGER NOT NULL DEFAULT 0, @@ -242,7 +247,7 @@ impl Database { cache_creation_tokens INTEGER NOT NULL DEFAULT 0, total_cost_usd TEXT NOT NULL DEFAULT '0', avg_latency_ms INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (date, app_type, provider_id, model) + PRIMARY KEY (date, app_type, provider_id, model, request_model, pricing_model) )", [], ) @@ -401,6 +406,13 @@ impl Database { Self::migrate_v9_to_v10(conn)?; Self::set_user_version(conn, 10)?; } + 10 => { + log::info!( + "迁移数据库从 v10 到 v11(usage_daily_rollups 保留请求/计价模型维度)" + ); + Self::migrate_v10_to_v11(conn)?; + Self::set_user_version(conn, 11)?; + } _ => { return Err(AppError::Database(format!( "未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}" @@ -574,6 +586,7 @@ impl Database { conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs ( request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL, request_model TEXT, + pricing_model TEXT, input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0, cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0, input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0', @@ -1035,6 +1048,8 @@ impl Database { app_type TEXT NOT NULL, provider_id TEXT NOT NULL, model TEXT NOT NULL, + request_model TEXT NOT NULL DEFAULT '', + pricing_model TEXT NOT NULL DEFAULT '', request_count INTEGER NOT NULL DEFAULT 0, success_count INTEGER NOT NULL DEFAULT 0, input_tokens INTEGER NOT NULL DEFAULT 0, @@ -1043,7 +1058,7 @@ impl Database { cache_creation_tokens INTEGER NOT NULL DEFAULT 0, total_cost_usd TEXT NOT NULL DEFAULT '0', avg_latency_ms INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (date, app_type, provider_id, model) + PRIMARY KEY (date, app_type, provider_id, model, request_model, pricing_model) )", [], ) @@ -1212,6 +1227,68 @@ impl Database { Ok(()) } + /// v10 -> v11 迁移:保留请求模型与计价模型维度。 + /// + /// WebDAV 同步会在不同分支之间交换 SQLite 快照;上游 v11 已经把 + /// `usage_daily_rollups` 的主键扩展为 `(model, request_model, pricing_model)`。 + /// 本迁移让当前分支可以接收并继续维护这些快照,而不是把上游 v11 误判为 + /// future schema。 + fn migrate_v10_to_v11(conn: &Connection) -> Result<(), AppError> { + if Self::table_exists(conn, "proxy_request_logs")? { + Self::add_column_if_missing(conn, "proxy_request_logs", "pricing_model", "TEXT")?; + } + + if !Self::table_exists(conn, "usage_daily_rollups")? { + log::info!("v10 -> v11:usage_daily_rollups 不存在,跳过重建"); + return Ok(()); + } + + if Self::has_column(conn, "usage_daily_rollups", "request_model")? + && Self::has_column(conn, "usage_daily_rollups", "pricing_model")? + { + log::info!("v10 -> v11:usage_daily_rollups 已包含 v11 维度,跳过重建"); + return Ok(()); + } + + conn.execute_batch( + "ALTER TABLE usage_daily_rollups RENAME TO usage_daily_rollups_v10; + CREATE TABLE usage_daily_rollups ( + date TEXT NOT NULL, + app_type TEXT NOT NULL, + provider_id TEXT NOT NULL, + model TEXT NOT NULL, + request_model TEXT NOT NULL DEFAULT '', + pricing_model TEXT NOT NULL DEFAULT '', + request_count INTEGER NOT NULL DEFAULT 0, + success_count INTEGER NOT NULL DEFAULT 0, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_creation_tokens INTEGER NOT NULL DEFAULT 0, + total_cost_usd TEXT NOT NULL DEFAULT '0', + avg_latency_ms INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (date, app_type, provider_id, model, request_model, pricing_model) + ); + INSERT INTO usage_daily_rollups + (date, app_type, provider_id, model, request_model, pricing_model, + request_count, success_count, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms) + SELECT date, app_type, provider_id, model, '', '', + request_count, success_count, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms + FROM usage_daily_rollups_v10; + DROP TABLE usage_daily_rollups_v10;", + ) + .map_err(|e| { + AppError::Database(format!("v10 -> v11 重建 usage_daily_rollups 失败: {e}")) + })?; + + log::info!( + "v10 -> v11 迁移完成:usage_daily_rollups 已保留 request_model/pricing_model 维度" + ); + Ok(()) + } + /// 插入默认模型定价数据 /// 格式: (model_id, display_name, input, output, cache_read, cache_creation) /// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致 diff --git a/src-tauri/src/database/tests.rs b/src-tauri/src/database/tests.rs index 30667e7c..eff1e382 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"); @@ -558,6 +571,22 @@ fn schema_create_tables_include_usage_daily_rollups() { Some("0") ); + let request_model = get_column_info(&conn, "usage_daily_rollups", "request_model"); + assert_eq!(request_model.r#type, "TEXT"); + assert_eq!(request_model.notnull, 1); + assert_eq!( + normalize_default(&request_model.default).as_deref(), + Some("") + ); + + let pricing_model = get_column_info(&conn, "usage_daily_rollups", "pricing_model"); + assert_eq!(pricing_model.r#type, "TEXT"); + assert_eq!(pricing_model.notnull, 1); + assert_eq!( + normalize_default(&pricing_model.default).as_deref(), + Some("") + ); + assert!( Database::table_exists(&conn, "session_log_sync").expect("check session_log_sync table"), "session_log_sync should exist after create_tables" @@ -580,6 +609,10 @@ fn schema_create_tables_include_usage_daily_rollups() { Some("proxy") ); + let request_pricing_model = get_column_info(&conn, "proxy_request_logs", "pricing_model"); + assert_eq!(request_pricing_model.r#type, "TEXT"); + assert_eq!(request_pricing_model.notnull, 0); + let mcp_enabled_hermes = get_column_info(&conn, "mcp_servers", "enabled_hermes"); assert_eq!(mcp_enabled_hermes.r#type, "BOOLEAN"); assert_eq!(mcp_enabled_hermes.notnull, 1); @@ -1068,7 +1101,7 @@ fn schema_migration_v7_adds_session_log_tracking_and_corrects_pricing() { } #[test] -fn schema_migration_v8_refreshes_model_pricing_and_reaches_v10() { +fn schema_migration_v8_refreshes_model_pricing_and_reaches_v11() { let conn = Connection::open_in_memory().expect("open memory db"); conn.execute_batch( r#" @@ -1220,6 +1253,112 @@ fn schema_migration_v8_refreshes_model_pricing_and_reaches_v10() { .expect("check skills enabled_hermes"), "v9 -> v10 should add enabled_hermes to skills" ); + assert!( + Database::has_column(&conn, "proxy_request_logs", "pricing_model") + .expect("check proxy_request_logs pricing_model"), + "v10 -> v11 should add pricing_model to proxy_request_logs" + ); + assert!( + Database::has_column(&conn, "usage_daily_rollups", "request_model") + .expect("check rollup request_model"), + "v10 -> v11 should add request_model to usage_daily_rollups" + ); + assert!( + Database::has_column(&conn, "usage_daily_rollups", "pricing_model") + .expect("check rollup pricing_model"), + "v10 -> v11 should add pricing_model to usage_daily_rollups" + ); +} + +#[test] +fn schema_migration_v10_to_v11_preserves_rollup_rows_with_empty_new_dimensions() { + let conn = Connection::open_in_memory().expect("open memory db"); + conn.execute_batch( + r#" + CREATE TABLE proxy_request_logs ( + request_id TEXT PRIMARY KEY, + provider_id TEXT NOT NULL, + app_type TEXT NOT NULL, + model TEXT NOT NULL, + request_model TEXT, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_creation_tokens INTEGER NOT NULL DEFAULT 0, + input_cost_usd TEXT NOT NULL DEFAULT '0', + output_cost_usd TEXT NOT NULL DEFAULT '0', + cache_read_cost_usd TEXT NOT NULL DEFAULT '0', + cache_creation_cost_usd TEXT NOT NULL DEFAULT '0', + total_cost_usd TEXT NOT NULL DEFAULT '0', + latency_ms INTEGER NOT NULL, + status_code INTEGER NOT NULL, + cost_multiplier TEXT NOT NULL DEFAULT '1.0', + created_at INTEGER NOT NULL, + data_source TEXT NOT NULL DEFAULT 'proxy' + ); + INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, request_model, + input_tokens, output_tokens, total_cost_usd, latency_ms, status_code, created_at + ) VALUES ('log-1', 'provider-a', 'claude', 'kimi-k2', 'claude-sonnet-4', 10, 20, '0.03', 123, 200, 1); + CREATE TABLE usage_daily_rollups ( + date TEXT NOT NULL, + app_type TEXT NOT NULL, + provider_id TEXT NOT NULL, + model TEXT NOT NULL, + request_count INTEGER NOT NULL DEFAULT 0, + success_count INTEGER NOT NULL DEFAULT 0, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_creation_tokens INTEGER NOT NULL DEFAULT 0, + total_cost_usd TEXT NOT NULL DEFAULT '0', + avg_latency_ms INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (date, app_type, provider_id, model) + ); + INSERT INTO usage_daily_rollups ( + date, app_type, provider_id, model, request_count, success_count, + input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, + total_cost_usd, avg_latency_ms + ) VALUES ('2026-06-01', 'claude', 'provider-a', 'kimi-k2', 2, 2, 100, 50, 7, 3, '0.42', 222); + "#, + ) + .expect("seed v10 schema"); + + Database::set_user_version(&conn, 10).expect("set user_version=10"); + Database::apply_schema_migrations_on_conn(&conn).expect("apply v11 migration"); + + assert_eq!( + Database::get_user_version(&conn).expect("version after migration"), + SCHEMA_VERSION + ); + assert!( + Database::has_column(&conn, "proxy_request_logs", "pricing_model") + .expect("check request pricing_model"), + "v11 migration should add proxy_request_logs.pricing_model" + ); + + let rollup: (String, String, i64, i64, String) = conn + .query_row( + "SELECT request_model, pricing_model, request_count, input_tokens, total_cost_usd + FROM usage_daily_rollups + WHERE date = '2026-06-01' AND app_type = 'claude' + AND provider_id = 'provider-a' AND model = 'kimi-k2'", + [], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + )) + }, + ) + .expect("read migrated rollup"); + assert_eq!( + rollup, + ("".to_string(), "".to_string(), 2, 100, "0.42".to_string()) + ); } #[test] @@ -1759,6 +1898,331 @@ 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] +#[serial_test::serial] +#[cfg(unix)] +fn concurrent_init_on_fresh_config_dir_all_succeeds() { + use std::os::unix::fs::PermissionsExt; + use std::sync::{Arc, Barrier}; + use std::thread; + + let _lock = crate::test_support::lock_test_home_and_settings(); + let temp = tempfile::tempdir().expect("create temp dir"); + let config_dir = temp.path().join("fresh-config-dir"); + let _guard = ConfigDirEnvGuard::set(&config_dir); + + let thread_count = 8; + let barrier = Arc::new(Barrier::new(thread_count)); + let mut handles = Vec::with_capacity(thread_count); + + for _ in 0..thread_count { + let barrier = Arc::clone(&barrier); + handles.push(thread::spawn(move || { + barrier.wait(); + Database::init().map(|_| ()) + })); + } + + for handle in handles { + handle + .join() + .expect("init thread should not panic") + .expect("concurrent init should succeed"); + } + + let conn = + Connection::open(config_dir.join("cc-switch.db")).expect("open initialized database"); + let version = Database::get_user_version(&conn).expect("read schema version"); + assert_eq!(version, SCHEMA_VERSION); + + let lock_path = config_dir.join("cc-switch.db.init.lock"); + let lock_mode = std::fs::metadata(&lock_path) + .expect("metadata init lock") + .permissions() + .mode() + & 0o777; + assert_eq!(lock_mode, 0o600, "init lock should be owner-only"); +} + +#[test] +#[serial_test::serial] +#[cfg(unix)] +fn init_rejects_symlinked_config_dir_without_writing_target() { + use std::os::unix::fs::symlink; + + let _lock = crate::test_support::lock_test_home_and_settings(); + let temp = tempfile::tempdir().expect("create temp dir"); + let external_dir = temp.path().join("external"); + let config_link = temp.path().join("cc-switch-link"); + std::fs::create_dir(&external_dir).expect("create external dir"); + symlink(&external_dir, &config_link).expect("create config symlink"); + + let _guard = ConfigDirEnvGuard::set(&config_link); + let err = match Database::init() { + Ok(_) => panic!("symlinked config dir should be rejected"), + Err(err) => err, + }; + + assert!( + err.to_string().contains("符号链接") || err.to_string().contains("symlink"), + "unexpected error: {err}" + ); + assert!( + !external_dir.join("cc-switch.db").exists(), + "init should not follow the config-dir symlink and create the database outside cc-switch" + ); +} + +#[test] +#[serial_test::serial] +#[cfg(unix)] +fn init_rejects_symlinked_db_file_without_opening_target() { + use std::os::unix::fs::symlink; + + let _lock = crate::test_support::lock_test_home_and_settings(); + let temp = tempfile::tempdir().expect("create temp dir"); + let config_dir = temp.path().join("cc-switch"); + let external_db = temp.path().join("external.db"); + std::fs::create_dir(&config_dir).expect("create config dir"); + std::fs::write(&external_db, b"not sqlite").expect("write external db"); + symlink(&external_db, config_dir.join("cc-switch.db")).expect("create db symlink"); + + let _guard = ConfigDirEnvGuard::set(&config_dir); + let err = match Database::init() { + Ok(_) => panic!("symlinked db file should be rejected"), + Err(err) => err, + }; + + assert!( + err.to_string().contains("符号链接") || err.to_string().contains("symlink"), + "unexpected error: {err}" + ); + assert_eq!( + std::fs::read(&external_db).expect("read external db"), + b"not sqlite", + "init should not open or modify the symlink target" + ); +} + +#[test] +#[serial_test::serial] +#[cfg(unix)] +fn init_rejects_hardlinked_db_file_without_opening_target() { + use std::os::unix::fs::MetadataExt; + + let _lock = crate::test_support::lock_test_home_and_settings(); + let temp = tempfile::tempdir().expect("create temp dir"); + let config_dir = temp.path().join("cc-switch"); + let external_db = temp.path().join("external.db"); + let linked_db = config_dir.join("cc-switch.db"); + std::fs::create_dir(&config_dir).expect("create config dir"); + std::fs::write(&external_db, b"not sqlite").expect("write external db"); + std::fs::hard_link(&external_db, &linked_db).expect("create db hardlink"); + assert_eq!( + std::fs::metadata(&external_db) + .expect("metadata external db") + .nlink(), + 2, + "test setup should create a multi-link inode" + ); + + let _guard = ConfigDirEnvGuard::set(&config_dir); + let err = match Database::init() { + Ok(_) => panic!("hardlinked db file should be rejected"), + Err(err) => err, + }; + + assert!( + err.to_string().contains("硬链接") || err.to_string().contains("hardlink"), + "unexpected error: {err}" + ); + assert_eq!( + std::fs::read(&external_db).expect("read external db"), + b"not sqlite", + "init should not open or modify a hardlinked database target" + ); +} + +#[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("parent"), + "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_rejects_parent_dir_config_path_even_when_child_exists() { + let _lock = crate::test_support::lock_test_home_and_settings(); + let temp = tempfile::tempdir().expect("create temp dir"); + let parent = temp.path().join("parent"); + let child = parent.join("child"); + std::fs::create_dir_all(&child).expect("create child dir"); + + let _guard = ConfigDirEnvGuard::set(&child.join("..")); + if Database::init().is_ok() { + panic!("parent-dir config path should be rejected"); + } + + assert!( + !parent.join("cc-switch.db").exists(), + "init must not normalize child/.. and create the database in the parent" + ); +} + +#[test] +#[cfg(unix)] +fn create_secure_dir_all_rejects_symlink_component_without_writing_target() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().expect("create temp dir"); + let external_dir = temp.path().join("external"); + let link_dir = temp.path().join("link"); + std::fs::create_dir(&external_dir).expect("create external dir"); + symlink(&external_dir, &link_dir).expect("create symlink"); + + let err = create_secure_dir_all(&link_dir.join("nested")) + .expect_err("symlink components should be rejected"); + + assert!( + err.to_string().contains("符号链接") || err.to_string().contains("symlink"), + "unexpected error: {err}" + ); + assert!( + !external_dir.join("nested").exists(), + "rejected path should not create directories through the symlink target" + ); +} + +#[test] +#[cfg(unix)] +fn create_secure_dir_all_accepts_existing_directory_after_create_race() { + let temp = tempfile::tempdir().expect("create temp dir"); + let dir = temp.path().join("existing"); + std::fs::create_dir(&dir).expect("create existing dir"); + + assert!( + !create_secure_dir_all(&dir).expect("existing directory should be accepted"), + "existing directory should not be reported as newly created" + ); +} + +#[test] +#[cfg(unix)] +fn backup_database_connection_rejects_symlink_path_without_writing_target() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().expect("create temp dir"); + let backup_path = temp.path().join("backup.db"); + let external_target = temp.path().join("external.db"); + symlink(&external_target, &backup_path).expect("create dangling backup symlink"); + + let err = Database::create_backup_db_connection(&backup_path) + .expect_err("backup connection should reject symlink path"); + + assert!( + err.to_string().contains("符号链接") || err.to_string().contains("symlink"), + "unexpected error: {err}" + ); + assert!( + !external_target.exists(), + "backup creation must not follow symlink target" + ); +} + +#[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/hermes_config.rs b/src-tauri/src/hermes_config.rs index 0ae89215..905bda6f 100644 --- a/src-tauri/src/hermes_config.rs +++ b/src-tauri/src/hermes_config.rs @@ -31,7 +31,7 @@ //! args: ["-y", "@modelcontextprotocol/server-filesystem"] //! ``` -use crate::config::{atomic_write, get_app_config_dir, home_dir}; +use crate::config::{atomic_write, create_managed_config_dir_all, get_app_config_dir, home_dir}; use crate::error::AppError; use crate::settings::{effective_backup_retain_count, get_hermes_override_dir}; use chrono::Local; @@ -273,7 +273,7 @@ fn replace_yaml_section( fn create_hermes_backup(source: &str) -> Result { let backup_dir = get_app_config_dir().join("backups").join("hermes"); - fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?; + create_managed_config_dir_all(&backup_dir)?; let base_id = format!("hermes_{}", Local::now().format("%Y%m%d_%H%M%S")); let mut filename = format!("{base_id}.yaml"); 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/openclaw_config.rs b/src-tauri/src/openclaw_config.rs index 14633d3a..07825bba 100644 --- a/src-tauri/src/openclaw_config.rs +++ b/src-tauri/src/openclaw_config.rs @@ -1,4 +1,4 @@ -use crate::config::{atomic_write, get_app_config_dir, home_dir}; +use crate::config::{atomic_write, create_managed_config_dir_all, get_app_config_dir, home_dir}; use crate::error::AppError; use crate::provider::OpenClawProviderConfig; use crate::settings::{effective_backup_retain_count, get_openclaw_override_dir}; @@ -326,7 +326,7 @@ fn write_root_section(section: &str, value: &Value) -> Result Result { let backup_dir = get_app_config_dir().join("backups").join("openclaw"); - fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?; + create_managed_config_dir_all(&backup_dir)?; let base_id = format!("openclaw_{}", Local::now().format("%Y%m%d_%H%M%S")); let mut filename = format!("{base_id}.json5"); diff --git a/src-tauri/src/proxy/usage/logger.rs b/src-tauri/src/proxy/usage/logger.rs index 41302dac..f4113585 100644 --- a/src-tauri/src/proxy/usage/logger.rs +++ b/src-tauri/src/proxy/usage/logger.rs @@ -211,18 +211,19 @@ async fn insert_request_log( match conn.execute( "INSERT OR REPLACE INTO proxy_request_logs ( - request_id, provider_id, app_type, model, request_model, + request_id, provider_id, app_type, model, request_model, pricing_model, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd, latency_ms, first_token_ms, status_code, error_message, session_id, provider_type, is_streaming, cost_multiplier, created_at, data_source - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24)", + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25)", rusqlite::params![ request_id, &context.provider.id, context.app_type.as_str(), model, &context.request_model, + if cost.is_some() { pricing_model } else { "" }, usage.input_tokens, usage.output_tokens, usage.cache_read_tokens, 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)?; diff --git a/src-tauri/src/services/env_manager.rs b/src-tauri/src/services/env_manager.rs index ad83d301..1a8ea491 100644 --- a/src-tauri/src/services/env_manager.rs +++ b/src-tauri/src/services/env_manager.rs @@ -1,5 +1,5 @@ use super::env_checker::EnvConflict; -use crate::config::get_app_config_dir; +use crate::config::{create_managed_config_dir_all, get_app_config_dir, write_json_file}; use chrono::Utc; use serde::{Deserialize, Serialize}; use std::fs; @@ -44,7 +44,7 @@ pub fn delete_env_vars(conflicts: Vec) -> Result Result { // Get backup directory let backup_dir = get_backup_dir()?; - fs::create_dir_all(&backup_dir).map_err(|e| format!("创建备份目录失败: {e}"))?; + create_managed_config_dir_all(&backup_dir).map_err(|e| format!("创建备份目录失败: {e}"))?; // Generate backup file name with timestamp let timestamp = Utc::now().format("%Y%m%d_%H%M%S").to_string(); @@ -57,11 +57,7 @@ fn create_backup(conflicts: &[EnvConflict]) -> Result { conflicts: conflicts.to_vec(), }; - // Write backup file - let json = serde_json::to_string_pretty(&backup_info) - .map_err(|e| format!("序列化备份数据失败: {e}"))?; - - fs::write(&backup_file, json).map_err(|e| format!("写入备份文件失败: {e}"))?; + write_json_file(&backup_file, &backup_info).map_err(|e| format!("写入备份文件失败: {e}"))?; Ok(backup_info) } @@ -237,4 +233,47 @@ mod tests { let backup_dir = get_backup_dir(); assert!(backup_dir.is_ok()); } + + #[cfg(unix)] + #[test] + fn env_backup_json_is_written_owner_only() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("create temp dir"); + let _env = crate::test_support::TestEnvGuard::isolated(temp.path()); + + let backup = create_backup(&[]).expect("create backup"); + let backup_path = PathBuf::from(&backup.backup_path); + let mode = std::fs::metadata(&backup_path) + .expect("metadata backup") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + assert!( + crate::config::check_permissions().is_empty(), + "fresh env backup should not be reported as insecure" + ); + } + + #[cfg(unix)] + #[test] + fn env_backup_rejects_parent_dir_config_path_before_creating_intermediate_dirs() { + let root = tempfile::tempdir().expect("create temp dir"); + let _env = crate::test_support::TestEnvGuard::isolated(root.path()); + unsafe { + std::env::set_var("CC_SWITCH_CONFIG_DIR", root.path().join("child").join("..")); + } + + create_backup(&[]).expect_err("invalid config dir should be rejected before backup"); + + assert!( + !root.path().join("child").exists(), + "backup creation must not pre-create unvalidated path components" + ); + assert!( + !root.path().join("backups").exists(), + "backup creation must not write to the normalized parent directory" + ); + } } diff --git a/src-tauri/src/services/provider/codex_openai_auth_tests.rs b/src-tauri/src/services/provider/codex_openai_auth_tests.rs index cb785bad..c37a2911 100644 --- a/src-tauri/src/services/provider/codex_openai_auth_tests.rs +++ b/src-tauri/src/services/provider/codex_openai_auth_tests.rs @@ -1,56 +1,14 @@ use super::*; use serial_test::serial; -use std::ffi::OsString; -use std::path::Path; use tempfile::TempDir; -use crate::test_support::{ - lock_test_home_and_settings, set_test_home_override, TestHomeSettingsLock, -}; - -struct EnvGuard { - _lock: TestHomeSettingsLock, - old_home: Option, - old_userprofile: Option, -} - -impl EnvGuard { - fn set_home(home: &Path) -> Self { - let lock = lock_test_home_and_settings(); - let old_home = std::env::var_os("HOME"); - let old_userprofile = std::env::var_os("USERPROFILE"); - std::env::set_var("HOME", home); - std::env::set_var("USERPROFILE", home); - set_test_home_override(Some(home)); - crate::settings::reload_test_settings(); - Self { - _lock: lock, - old_home, - old_userprofile, - } - } -} - -impl Drop for EnvGuard { - fn drop(&mut self) { - match &self.old_home { - Some(value) => std::env::set_var("HOME", value), - None => std::env::remove_var("HOME"), - } - match &self.old_userprofile { - Some(value) => std::env::set_var("USERPROFILE", value), - None => std::env::remove_var("USERPROFILE"), - } - set_test_home_override(self.old_home.as_deref().map(Path::new)); - crate::settings::reload_test_settings(); - } -} +use crate::test_support::TestEnvGuard; #[test] #[serial] fn switch_codex_provider_writes_stored_config_directly() { let temp_home = TempDir::new().expect("create temp home"); - let _env = EnvGuard::set_home(temp_home.path()); + let _env = TestEnvGuard::isolated(temp_home.path()); std::fs::create_dir_all(crate::codex_config::get_codex_config_dir()) .expect("create ~/.codex (initialized)"); @@ -97,7 +55,7 @@ fn switch_codex_provider_writes_stored_config_directly() { #[serial] fn switch_codex_provider_migrates_legacy_flat_config() { let temp_home = TempDir::new().expect("create temp home"); - let _env = EnvGuard::set_home(temp_home.path()); + let _env = TestEnvGuard::isolated(temp_home.path()); std::fs::create_dir_all(crate::codex_config::get_codex_config_dir()) .expect("create ~/.codex (initialized)"); diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index b55b9485..35192b2d 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -17,7 +17,7 @@ use tokio::time::timeout; use crate::app_config::AppType; pub use crate::app_config::{InstalledSkill, SkillApps, UnmanagedSkill}; -use crate::config::get_app_config_dir; +use crate::config::{create_managed_config_dir_all, get_app_config_dir}; use crate::database::Database; use crate::error::{format_skill_error, AppError}; @@ -431,7 +431,7 @@ impl SkillService { pub fn get_ssot_dir() -> Result { let dir = get_app_config_dir().join("skills"); - fs::create_dir_all(&dir).map_err(|e| AppError::io(&dir, e))?; + create_managed_config_dir_all(&dir)?; Ok(dir) } diff --git a/src-tauri/src/services/webdav_sync/mod.rs b/src-tauri/src/services/webdav_sync/mod.rs index 98d5f36c..04b9dc1a 100644 --- a/src-tauri/src/services/webdav_sync/mod.rs +++ b/src-tauri/src/services/webdav_sync/mod.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use tempfile::tempdir; -use crate::database::Database; +use crate::database::{Database, SCHEMA_VERSION}; use crate::error::AppError; use crate::services::webdav; use crate::settings::{ @@ -576,13 +576,15 @@ fn apply_snapshot(db_sql: &[u8], skills_zip: &[u8]) -> Result<(), AppError> { format!("SQL is not valid UTF-8: {e}"), ) })?; + validate_sql_user_version_for_import(sql_str)?; let skills_backup = SkillsBackup::backup_current_skills()?; // 先替换 skills,再导入数据库;若导入失败则回滚 skills,避免"半恢复"。 restore_skills_zip(skills_zip)?; - if let Err(db_err) = Database::init()?.import_sql_string_for_sync(sql_str) { + let import_result = Database::init().and_then(|db| db.import_sql_string_for_sync(sql_str)); + if let Err(db_err) = import_result { if let Err(rollback_err) = skills_backup.restore() { return Err(localized( "webdav.sync.db_import_and_rollback_failed", @@ -598,6 +600,39 @@ fn apply_snapshot(db_sql: &[u8], skills_zip: &[u8]) -> Result<(), AppError> { Ok(()) } +fn validate_sql_user_version_for_import(sql: &str) -> Result<(), AppError> { + let Some(version) = extract_sql_user_version(sql) else { + return Ok(()); + }; + + if version > SCHEMA_VERSION { + return Err(localized( + "webdav.sync.db_schema_too_new", + format!( + "远端数据库版本过新({version}),当前应用仅支持 {SCHEMA_VERSION},请先升级应用后再同步" + ), + format!( + "Remote database schema is too new ({version}); this app supports up to {SCHEMA_VERSION}. Upgrade before syncing." + ), + )); + } + + Ok(()) +} + +fn extract_sql_user_version(sql: &str) -> Option { + sql.lines().find_map(|line| { + let trimmed = line.trim_start_matches('\u{feff}').trim(); + let value = trimmed + .strip_prefix("PRAGMA user_version") + .and_then(|rest| rest.trim_start().strip_prefix('=')) + .map(|rest| rest.trim().trim_end_matches(';').trim()) + .or_else(|| trimmed.strip_prefix("-- user_version:").map(str::trim))?; + + value.parse::().ok() + }) +} + // --------------------------------------------------------------------------- // 同步状态持久化 // --------------------------------------------------------------------------- @@ -1142,6 +1177,253 @@ mod tests { assert!(validate_artifact_size_limit("db.sql", MAX_SYNC_ARTIFACT_BYTES + 1).is_err()); } + #[test] + fn extract_sql_user_version_reads_pragma_and_comment() { + assert_eq!( + extract_sql_user_version("-- header\nPRAGMA user_version=10;\n"), + Some(10) + ); + assert_eq!( + extract_sql_user_version("-- user_version: 11\nPRAGMA foreign_keys=OFF;\n"), + Some(11) + ); + } + + #[test] + fn validate_sql_user_version_rejects_future_schema_before_restore() { + let sql = format!( + "-- CC Switch SQLite 导出\nPRAGMA user_version={};\n", + SCHEMA_VERSION + 1 + ); + let err = validate_sql_user_version_for_import(&sql) + .expect_err("future schema should be rejected before applying snapshot"); + assert!( + err.to_string().contains("版本过新") || err.to_string().contains("schema is too new"), + "unexpected error: {err}" + ); + } + + #[test] + fn apply_snapshot_accepts_current_schema_v11_sync_export() -> Result<(), AppError> { + let temp = tempfile::tempdir().expect("create temp dir"); + let _env = crate::test_support::TestEnvGuard::isolated(temp.path()); + + let remote_db = Database::memory().expect("create remote db"); + { + let conn = crate::database::lock_conn!(remote_db.conn); + conn.execute( + "INSERT INTO providers (id, app_type, name, settings_config, meta) + VALUES ('remote-provider', 'claude', 'Remote Provider', '{}', '{}')", + [], + ) + .expect("insert remote provider"); + } + let db_sql = remote_db + .export_sql_string_for_sync() + .expect("export v11 sync sql"); + + let zip_path = temp.path().join("remote-skills.zip"); + { + let file = std::fs::File::create(&zip_path).expect("create remote skills zip"); + let mut writer = zip::ZipWriter::new(file); + writer + .start_file( + "remote-skill/SKILL.md", + crate::services::webdav_sync::archive::zip_file_options(), + ) + .expect("start remote skill"); + use std::io::Write; + writer.write_all(b"remote").expect("write remote skill"); + writer.finish().expect("finish remote skills zip"); + } + let skills_zip = std::fs::read(&zip_path).expect("read remote skills zip"); + + apply_snapshot(db_sql.as_bytes(), &skills_zip).expect("apply current-schema snapshot"); + + let local_db = Database::init().expect("open local db"); + let conn = crate::database::lock_conn!(local_db.conn); + let provider_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM providers WHERE id = 'remote-provider'", + [], + |row| row.get(0), + ) + .expect("count imported provider"); + assert_eq!(provider_count, 1); + assert!( + crate::services::skill::SkillService::get_ssot_dir() + .expect("ssot dir") + .join("remote-skill") + .join("SKILL.md") + .exists(), + "current-schema restore should unpack remote skills" + ); + Ok(()) + } + + #[test] + fn apply_snapshot_rejects_future_schema_without_touching_existing_skills() { + let temp = tempfile::tempdir().expect("create temp dir"); + let _env = crate::test_support::TestEnvGuard::isolated(temp.path()); + + let existing_skill = crate::services::skill::SkillService::get_ssot_dir() + .expect("ssot dir") + .join("existing") + .join("SKILL.md"); + std::fs::create_dir_all(existing_skill.parent().expect("skill parent")) + .expect("create existing skill parent"); + std::fs::write(&existing_skill, "existing").expect("write existing skill"); + + let zip_path = temp.path().join("replacement-skills.zip"); + { + let file = std::fs::File::create(&zip_path).expect("create replacement zip"); + let mut writer = zip::ZipWriter::new(file); + writer + .start_file( + "replacement/SKILL.md", + crate::services::webdav_sync::archive::zip_file_options(), + ) + .expect("start replacement skill"); + use std::io::Write; + writer + .write_all(b"replacement") + .expect("write replacement skill"); + writer.finish().expect("finish replacement zip"); + } + let skills_zip = std::fs::read(&zip_path).expect("read replacement zip"); + let sql = format!( + "-- CC Switch SQLite 导出\nPRAGMA user_version={};\n", + SCHEMA_VERSION + 1 + ); + + apply_snapshot(sql.as_bytes(), &skills_zip) + .expect_err("future schema should be rejected before restoring skills"); + + assert_eq!( + std::fs::read_to_string(&existing_skill).expect("read existing skill"), + "existing", + "future schema restore must leave existing skills untouched" + ); + assert!( + !crate::services::skill::SkillService::get_ssot_dir() + .expect("ssot dir") + .join("replacement") + .exists(), + "future schema restore must not unpack replacement skills" + ); + } + + #[cfg(unix)] + #[test] + fn apply_snapshot_rolls_back_skills_when_database_init_fails() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().expect("create temp dir"); + let _env = crate::test_support::TestEnvGuard::isolated(temp.path()); + + let ssot = crate::services::skill::SkillService::get_ssot_dir().expect("ssot dir"); + let existing_skill = ssot.join("existing").join("SKILL.md"); + std::fs::create_dir_all(existing_skill.parent().expect("skill parent")) + .expect("create existing skill parent"); + std::fs::write(&existing_skill, "existing").expect("write existing skill"); + + let zip_path = temp.path().join("replacement-skills.zip"); + { + let file = std::fs::File::create(&zip_path).expect("create replacement zip"); + let mut writer = zip::ZipWriter::new(file); + writer + .start_file( + "replacement/SKILL.md", + crate::services::webdav_sync::archive::zip_file_options(), + ) + .expect("start replacement skill"); + use std::io::Write; + writer + .write_all(b"replacement") + .expect("write replacement skill"); + writer.finish().expect("finish replacement zip"); + } + let skills_zip = std::fs::read(&zip_path).expect("read replacement zip"); + + let db_target = temp.path().join("external.db"); + std::fs::write(&db_target, b"not sqlite").expect("write external db"); + let db_link = temp.path().join(".cc-switch").join("cc-switch.db"); + symlink(&db_target, &db_link).expect("create db symlink"); + + apply_snapshot( + b"-- CC Switch SQLite export\nPRAGMA user_version=0;\n", + &skills_zip, + ) + .expect_err("db init failure should fail the restore"); + + assert_eq!( + std::fs::read_to_string(&existing_skill).expect("read existing skill"), + "existing", + "DB init failure must roll back restored skills" + ); + assert!( + !ssot.join("replacement").exists(), + "DB init failure must remove replacement skills" + ); + } + + #[cfg(unix)] + #[test] + fn apply_snapshot_rejects_symlink_parent_config_dir_before_restoring_skills() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().expect("create temp dir"); + let _env = crate::test_support::TestEnvGuard::isolated(temp.path()); + let real_parent = temp.path().join("real-parent"); + let external_parent = temp.path().join("external-parent"); + std::fs::create_dir(&real_parent).expect("create real parent"); + std::fs::create_dir(&external_parent).expect("create external parent"); + symlink(&external_parent, real_parent.join("link")).expect("create symlink parent"); + unsafe { + std::env::set_var( + "CC_SWITCH_CONFIG_DIR", + real_parent.join("link").join("..").join("cc-switch"), + ); + } + + let zip_path = temp.path().join("replacement-skills.zip"); + { + let file = std::fs::File::create(&zip_path).expect("create replacement zip"); + let mut writer = zip::ZipWriter::new(file); + writer + .start_file( + "replacement/SKILL.md", + crate::services::webdav_sync::archive::zip_file_options(), + ) + .expect("start replacement skill"); + use std::io::Write; + writer + .write_all(b"replacement") + .expect("write replacement skill"); + writer.finish().expect("finish replacement zip"); + } + let skills_zip = std::fs::read(&zip_path).expect("read replacement zip"); + + let err = apply_snapshot( + b"-- CC Switch SQLite export\nPRAGMA user_version=0;\n", + &skills_zip, + ) + .expect_err("symlink parent config dir should fail before restoring skills"); + + assert!( + err.to_string().contains("符号链接") || err.to_string().contains("symlink"), + "unexpected error: {err}" + ); + assert!( + !real_parent.join("cc-switch/skills").exists(), + "restore must not create the normalized skills directory" + ); + assert!( + !external_parent.join("cc-switch/skills").exists(), + "restore must not follow the symlinked parent into external storage" + ); + } + #[test] fn normalize_device_name_trims() { assert_eq!( diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index b974b574..12f83692 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -1,5 +1,5 @@ use crate::app_config::AppType; -use crate::config::{get_app_config_dir, home_dir}; +use crate::config::{get_app_config_dir, home_dir, write_json_file}; use crate::error::AppError; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -602,13 +602,7 @@ impl AppSettings { normalized.validate()?; let path = Self::settings_path(); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; - } - - let json = serde_json::to_string_pretty(&normalized) - .map_err(|e| AppError::JsonSerialize { source: e })?; - fs::write(&path, json).map_err(|e| AppError::io(&path, e))?; + write_json_file(&path, &normalized)?; Ok(()) } } diff --git a/src-tauri/tests/openclaw_config.rs b/src-tauri/tests/openclaw_config.rs index a90fc414..2e28b18b 100644 --- a/src-tauri/tests/openclaw_config.rs +++ b/src-tauri/tests/openclaw_config.rs @@ -81,6 +81,10 @@ mod config { .join(".cc-switch") } + pub(crate) fn create_managed_config_dir_all(path: &Path) -> Result<(), AppError> { + fs::create_dir_all(path).map_err(|err| AppError::io(path, err)) + } + pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> { if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|err| AppError::io(parent, err))?; diff --git a/src-tauri/tests/proxy_claude_openai_chat/error_cases.rs b/src-tauri/tests/proxy_claude_openai_chat/error_cases.rs index 77f0e0e4..05cb0922 100644 --- a/src-tauri/tests/proxy_claude_openai_chat/error_cases.rs +++ b/src-tauri/tests/proxy_claude_openai_chat/error_cases.rs @@ -108,9 +108,10 @@ async fn proxy_claude_openai_chat_non_success_error_is_transformed_to_anthropic_ assert_eq!(log_values[1], "claude-openai-chat-error"); assert_eq!(log_values[3], "claude-3-7-sonnet"); assert_eq!(log_values[4], "claude-3-7-sonnet"); - assert_eq!(log_values[17], "400"); - assert_eq!(log_values[19], "claude-session-non-success"); - assert!(log_values[18].contains("upstream rejected the request")); + assert_eq!(log_values[5], ""); + assert_eq!(log_values[18], "400"); + assert_eq!(log_values[20], "claude-session-non-success"); + assert!(log_values[19].contains("upstream rejected the request")); service.stop().await.expect("stop proxy service"); upstream_handle.abort(); diff --git a/src-tauri/tests/proxy_claude_openai_chat/logging_cases.rs b/src-tauri/tests/proxy_claude_openai_chat/logging_cases.rs index ea12ce16..28a5f613 100644 --- a/src-tauri/tests/proxy_claude_openai_chat/logging_cases.rs +++ b/src-tauri/tests/proxy_claude_openai_chat/logging_cases.rs @@ -96,14 +96,15 @@ async fn proxy_claude_openai_chat_success_logs_request_with_session_id_and_usage assert_eq!(log_values[1], "claude-openai-chat-log-success"); assert_eq!(log_values[3], "gpt-5.2"); assert_eq!(log_values[4], "claude-3-7-sonnet"); - assert_eq!(log_values[5], "11"); - assert_eq!(log_values[6], "7"); - assert_eq!(log_values[9], "0.00001925"); - assert_eq!(log_values[10], "0.000098"); - assert_eq!(log_values[13], "0.0002345"); - assert_eq!(log_values[17], "201"); - assert_eq!(log_values[19], "claude-session-success"); - assert_eq!(log_values[22], "2"); + assert_eq!(log_values[5], "gpt-5.2"); + assert_eq!(log_values[6], "11"); + assert_eq!(log_values[7], "7"); + assert_eq!(log_values[10], "0.00001925"); + assert_eq!(log_values[11], "0.000098"); + assert_eq!(log_values[14], "0.0002345"); + assert_eq!(log_values[18], "201"); + assert_eq!(log_values[20], "claude-session-success"); + assert_eq!(log_values[23], "2"); service.stop().await.expect("stop proxy service"); upstream_handle.abort(); @@ -202,9 +203,10 @@ async fn proxy_claude_buffered_transform_failure_logs_error_request_with_session assert_eq!(log_values[1], "claude-openai-chat-log-failure"); assert_eq!(log_values[3], "claude-3-7-sonnet"); assert_eq!(log_values[4], "claude-3-7-sonnet"); - assert_eq!(log_values[17], "502"); - assert_eq!(log_values[19], "claude-session-failure"); - assert!(log_values[18].contains("parse upstream json failed")); + assert_eq!(log_values[5], ""); + assert_eq!(log_values[18], "502"); + assert_eq!(log_values[20], "claude-session-failure"); + assert!(log_values[19].contains("parse upstream json failed")); service.stop().await.expect("stop proxy service"); upstream_handle.abort(); diff --git a/src-tauri/tests/proxy_claude_streaming/logging_cases.rs b/src-tauri/tests/proxy_claude_streaming/logging_cases.rs index 60238a21..0af73328 100644 --- a/src-tauri/tests/proxy_claude_streaming/logging_cases.rs +++ b/src-tauri/tests/proxy_claude_streaming/logging_cases.rs @@ -182,15 +182,16 @@ async fn stream_openai_chat_logs_request_with_session_id_and_usage() { assert_eq!(log_values[1], "claude-openai-chat-stream-log"); assert_eq!(log_values[3], "gpt-5.2"); assert_eq!(log_values[4], "claude-3-7-sonnet"); - assert_eq!(log_values[5], "11"); - assert_eq!(log_values[6], "7"); - assert_eq!(log_values[9], "0.00001925"); - assert_eq!(log_values[10], "0.000098"); - assert_eq!(log_values[13], "0.0002345"); - assert_eq!(log_values[17], "200"); - assert_eq!(log_values[19], "claude-stream-session"); - assert_eq!(log_values[21], "1"); - assert_eq!(log_values[22], "2"); + assert_eq!(log_values[5], "gpt-5.2"); + assert_eq!(log_values[6], "11"); + assert_eq!(log_values[7], "7"); + assert_eq!(log_values[10], "0.00001925"); + assert_eq!(log_values[11], "0.000098"); + assert_eq!(log_values[14], "0.0002345"); + assert_eq!(log_values[18], "200"); + assert_eq!(log_values[20], "claude-stream-session"); + assert_eq!(log_values[22], "1"); + assert_eq!(log_values[23], "2"); service.stop().await.expect("stop proxy service"); upstream_handle.abort(); diff --git a/src-tauri/tests/settings_current_provider.rs b/src-tauri/tests/settings_current_provider.rs index 7c927b0b..9050bef6 100644 --- a/src-tauri/tests/settings_current_provider.rs +++ b/src-tauri/tests/settings_current_provider.rs @@ -40,7 +40,11 @@ mod claude_mcp { } mod config { - use std::path::PathBuf; + use serde::Serialize; + use std::fs; + use std::path::{Path, PathBuf}; + + use crate::error::AppError; pub(crate) fn home_dir() -> Option { dirs::home_dir() @@ -56,6 +60,15 @@ mod config { home_dir().expect("无法获取用户主目录").join(".cc-switch") } + + pub(crate) fn write_json_file(path: &Path, data: &T) -> Result<(), AppError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + let json = serde_json::to_string_pretty(data) + .map_err(|e| AppError::JsonSerialize { source: e })?; + fs::write(path, json).map_err(|e| AppError::io(path, e)) + } } mod database { diff --git a/src-tauri/tests/settings_visible_apps.rs b/src-tauri/tests/settings_visible_apps.rs index e80015dc..47faa3c8 100644 --- a/src-tauri/tests/settings_visible_apps.rs +++ b/src-tauri/tests/settings_visible_apps.rs @@ -43,7 +43,11 @@ mod claude_mcp { } mod config { - use std::path::PathBuf; + use serde::Serialize; + use std::fs; + use std::path::{Path, PathBuf}; + + use crate::error::AppError; pub(crate) fn home_dir() -> Option { dirs::home_dir() @@ -59,6 +63,15 @@ mod config { home_dir().expect("无法获取用户主目录").join(".cc-switch") } + + pub(crate) fn write_json_file(path: &Path, data: &T) -> Result<(), AppError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + let json = serde_json::to_string_pretty(data) + .map_err(|e| AppError::JsonSerialize { source: e })?; + fs::write(path, json).map_err(|e| AppError::io(path, e)) + } } mod database { diff --git a/src-tauri/tests/webdav_settings.rs b/src-tauri/tests/webdav_settings.rs index 0db7de70..d793d816 100644 --- a/src-tauri/tests/webdav_settings.rs +++ b/src-tauri/tests/webdav_settings.rs @@ -1,6 +1,6 @@ use cc_switch_lib::{ - get_webdav_sync_settings, set_webdav_sync_settings, webdav_jianguoyun_preset, - WebDavSyncSettings, WebDavSyncStatus, + check_permissions, get_app_config_dir, get_webdav_sync_settings, set_webdav_sync_settings, + webdav_jianguoyun_preset, WebDavSyncSettings, WebDavSyncStatus, }; #[path = "support.rs"] @@ -56,6 +56,55 @@ fn set_webdav_sync_settings_persists_and_normalizes_fields() { assert_eq!(saved.profile, "default"); } +#[cfg(unix)] +#[test] +fn set_webdav_sync_settings_writes_sensitive_settings_json_as_owner_only() { + use std::os::unix::fs::PermissionsExt; + + let _guard = lock_test_mutex(); + reset_test_fs(); + let _home = ensure_test_home(); + + set_webdav_sync_settings(Some(sample_settings())).expect("save webdav settings"); + + let settings_path = get_app_config_dir().join("settings.json"); + let mode = std::fs::metadata(&settings_path) + .expect("metadata settings.json") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + assert!( + check_permissions().is_empty(), + "fresh settings write should not be reported as insecure" + ); +} + +#[cfg(unix)] +#[test] +fn set_webdav_sync_settings_rejects_parent_dir_config_path_before_creating_dirs() { + let _guard = lock_test_mutex(); + reset_test_fs(); + let home = ensure_test_home(); + let root = home.join("invalid-config-root"); + std::fs::create_dir(&root).expect("create invalid config root"); + unsafe { + std::env::set_var("CC_SWITCH_CONFIG_DIR", root.join("child").join("..")); + } + + set_webdav_sync_settings(Some(sample_settings())) + .expect_err("invalid config dir should be rejected before writing settings"); + + assert!( + !root.join("child").exists(), + "settings save must not pre-create unvalidated path components" + ); + assert!( + !root.join("settings.json").exists(), + "settings save must not write to the normalized parent directory" + ); +} + #[test] fn set_webdav_sync_settings_can_clear_config() { let _guard = lock_test_mutex();