From 7c5b65a1534e88493c8ca7695f5390c3160ebcfb Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Mon, 28 Sep 2026 00:15:59 +0800 Subject: [PATCH] fix(config): correct cross-platform paths and v8 config updates - Extend OAuth directory recovery to Windows and Linux - Align credential path resolution and log directory selection with the core - Update v8 TLS and logging fields without requiring a version marker - Support flow-style YAML mappings and preserve null legacy TLS fields - Add regression tests for path recovery and config updates --- src-tauri/src/core_config/settings.rs | 132 ++++++++----- src-tauri/src/core_config/yaml.rs | 210 ++++++++++++--------- src-tauri/src/core_runtime.rs | 8 +- src-tauri/src/management_api.rs | 8 +- src-tauri/src/tests/core_config.rs | 255 ++++++++++++++++++++++---- src-tauri/src/tests/core_runtime.rs | 4 +- 6 files changed, 445 insertions(+), 172 deletions(-) diff --git a/src-tauri/src/core_config/settings.rs b/src-tauri/src/core_config/settings.rs index 8137d2b..5bfad1b 100644 --- a/src-tauri/src/core_config/settings.rs +++ b/src-tauri/src/core_config/settings.rs @@ -837,26 +837,78 @@ pub(crate) fn fixed_oauth_dir() -> Result { Ok(core_base_dir()?.join(OAUTH_DIR_NAME)) } -pub(crate) fn auth_dir_path_for_core(auth_dir: &str, install_dir: &Path) -> PathBuf { - if auth_dir.trim() == DEFAULT_AUTH_DIR { - return install_dir - .parent() - .map(|parent| parent.join(OAUTH_DIR_NAME)) - .unwrap_or_else(|| install_dir.join(auth_dir)); - } - let auth_dir = PathBuf::from(auth_dir); - if auth_dir.is_absolute() { - auth_dir +pub(crate) fn auth_dir_path_for_core(auth_dir: &str, install_dir: &Path) -> Result { + let variable = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; + let user_home = env::var_os(variable).filter(|value| !value.is_empty()).map(PathBuf::from); + auth_dir_path_for_core_with_home(auth_dir, install_dir, user_home.as_deref()) +} + +pub(crate) fn auth_dir_path_for_core_with_home( + auth_dir: &str, + install_dir: &Path, + user_home: Option<&Path>, +) -> Result { + let auth_dir = if auth_dir.is_empty() { "~/.cli-proxy-api" } else { auth_dir }; + let path = if let Some(remainder) = auth_dir.strip_prefix('~') { + let home = user_home.ok_or_else(|| "Unable to determine user home for credentials directory".to_string())?; + home.join(remainder.trim_start_matches(['/', '\\']).replace('\\', "/")) } else { - install_dir.join(auth_dir) + PathBuf::from(auth_dir) + }; + Ok(resolve_core_relative_path(&path, install_dir)) +} + +fn resolve_core_relative_path(path: &Path, install_dir: &Path) -> PathBuf { + normalize_path_lexically(&if path.is_absolute() { + path.to_path_buf() + } else { + install_dir.join(path) + }) +} + +pub(crate) fn core_logs_dir_path(auth_dir: &str, install_dir: &Path) -> Result { + let writable_base = ["WRITABLE_PATH", "writable_path"].into_iter().find_map(|key| { + env::var(key).ok().filter(|value| !value.trim().is_empty()) + }); + core_logs_dir_path_with_base(auth_dir, install_dir, writable_base.as_deref()) +} + +pub(crate) fn core_logs_dir_path_with_base( + auth_dir: &str, + install_dir: &Path, + writable_base: Option<&str>, +) -> Result { + if let Some(base) = writable_base.map(str::trim).filter(|base| !base.is_empty()) { + return Ok(resolve_core_relative_path(&Path::new(base).join("logs"), install_dir)); } + let local_logs = install_dir.join("logs"); + if directory_is_writable(&local_logs) { + return Ok(normalize_path_lexically(&local_logs)); + } + Ok(auth_dir_path_for_core(auth_dir, install_dir)?.join("logs")) } -pub(crate) fn core_logs_dir_path(auth_dir: &str, install_dir: &Path) -> PathBuf { - auth_dir_path_for_core(auth_dir, install_dir).join("logs") +fn directory_is_writable(path: &Path) -> bool { + use std::sync::atomic::{AtomicU64, Ordering}; + + static SEQUENCE: AtomicU64 = AtomicU64::new(0); + if !path.is_dir() { + return false; + } + let probe = path.join(format!( + ".cpa-gui-log-probe-{}-{}-{}", + std::process::id(), + SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos(), + SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + let Ok(file) = File::options().write(true).create_new(true).open(&probe) else { + return false; + }; + drop(file); + let _ = fs::remove_file(probe); + true } -#[cfg(any(target_os = "macos", test))] fn normalize_path_lexically(path: &Path) -> PathBuf { let mut normalized = PathBuf::new(); for component in path.components() { @@ -1063,7 +1115,7 @@ pub(crate) fn migrate_auth_dir_from_macos_app_bundle( install_dir: &Path, persistent_auth_dir: &Path, ) -> Result { - let source = normalize_path_lexically(&auth_dir_path_for_core(&config.auth_dir, install_dir)); + let source = auth_dir_path_for_core(&config.auth_dir, install_dir)?; if !auth_dir_is_inside_macos_app_bundle(&source) { return Ok(false); } @@ -1080,7 +1132,6 @@ pub(crate) fn migrate_auth_dir_from_macos_app_bundle( Ok(true) } -#[cfg(any(target_os = "macos", test))] fn auth_directory_has_json_files(path: &Path) -> Result { let entries = match fs::read_dir(path) { Ok(entries) => entries, @@ -1110,14 +1161,12 @@ fn auth_directory_has_json_files(path: &Path) -> Result { Ok(false) } -#[cfg(any(target_os = "macos", test))] -pub(crate) fn recover_macos_relative_oauth_dir( +pub(crate) fn recover_relative_oauth_dir( config: &mut GuiConfigFile, install_dir: &Path, persistent_auth_dir: &Path, ) -> Result { - let current_auth_dir = - normalize_path_lexically(&auth_dir_path_for_core(&config.auth_dir, install_dir)); + let current_auth_dir = auth_dir_path_for_core(&config.auth_dir, install_dir)?; if current_auth_dir != normalize_path_lexically(&install_dir.join(OAUTH_DIR_NAME)) { return Ok(false); } @@ -1130,23 +1179,18 @@ pub(crate) fn recover_macos_relative_oauth_dir( Ok(true) } -#[cfg(target_os = "macos")] -fn migrate_packaged_macos_auth_dir(config: &mut GuiConfigFile) -> Result { - let install_dir = core_install_dir()?; - let persistent_auth_dir = fixed_oauth_dir()?; - migrate_macos_auth_dir_at(config, &install_dir, &persistent_auth_dir) -} - -#[cfg(any(target_os = "macos", test))] -pub(crate) fn migrate_macos_auth_dir_at( +pub(crate) fn migrate_auth_dir_at( config: &mut GuiConfigFile, install_dir: &Path, persistent_auth_dir: &Path, ) -> Result { let mut candidate = config.clone(); + #[cfg(target_os = "macos")] let migrated = migrate_auth_dir_from_macos_app_bundle(&mut candidate, install_dir, persistent_auth_dir)?; - let recovered = recover_macos_relative_oauth_dir(&mut candidate, install_dir, persistent_auth_dir)?; + #[cfg(not(target_os = "macos"))] + let migrated = false; + let recovered = recover_relative_oauth_dir(&mut candidate, install_dir, persistent_auth_dir)?; if migrated || recovered { patch_core_auth_dir_at(&install_dir.join(CORE_CONFIG_FILE), &candidate.auth_dir) .map_err(|error| format!("Failed to update kernel OAuth directory: {error}"))?; @@ -1411,7 +1455,7 @@ pub(crate) fn load_or_create_gui_config() -> Result { } if !config.auth_dir.trim().is_empty() { let install_dir = core_install_dir()?; - let auth_dir = auth_dir_path_for_core(&config.auth_dir, &install_dir); + let auth_dir = auth_dir_path_for_core(&config.auth_dir, &install_dir)?; fs::create_dir_all(&auth_dir) .map_err(|error| format!("Failed to create credentials directory {}: {error}", path_to_string(&auth_dir)))?; } @@ -1651,6 +1695,16 @@ pub(crate) fn is_loopback_host(host: &str) -> bool { } pub(crate) fn sanitize_gui_config(config: &mut GuiConfigFile) -> Result { + let install_dir = core_install_dir()?; + let persistent_auth_dir = fixed_oauth_dir()?; + sanitize_gui_config_at(config, &install_dir, &persistent_auth_dir) +} + +pub(crate) fn sanitize_gui_config_at( + config: &mut GuiConfigFile, + install_dir: &Path, + persistent_auth_dir: &Path, +) -> Result { let mut changed = false; let normalized_locale = normalize_app_locale(&config.locale); if config.locale != normalized_locale { @@ -1681,18 +1735,14 @@ pub(crate) fn sanitize_gui_config(config: &mut GuiConfigFile) -> Result changed |= migrated, - Err(error) => { - eprintln!("Failed to recover macOS OAuth directory; keeping existing configuration: {error}"); - } + match migrate_auth_dir_at(config, install_dir, persistent_auth_dir) { + Ok(migrated) => changed |= migrated, + Err(error) => { + eprintln!("Failed to recover OAuth directory; keeping existing configuration: {error}"); } } - let legacy_default_auth_dir = fixed_oauth_dir()?; if config.auth_dir.trim().is_empty() - || Path::new(config.auth_dir.trim()) == legacy_default_auth_dir + || Path::new(config.auth_dir.trim()) == persistent_auth_dir { config.auth_dir = DEFAULT_AUTH_DIR.to_string(); changed = true; @@ -1958,7 +2008,7 @@ pub(crate) fn validate_gui_config(config: &GuiConfigFile) -> Result<(), String> } #[cfg(target_os = "macos")] { - let auth_dir = auth_dir_path_for_core(&config.auth_dir, &core_install_dir()?); + let auth_dir = auth_dir_path_for_core(&config.auth_dir, &core_install_dir()?)?; if auth_dir_is_inside_macos_app_bundle(&auth_dir) { return Err("OAuth credentials directory cannot be inside a macOS application bundle".to_string()); } diff --git a/src-tauri/src/core_config/yaml.rs b/src-tauri/src/core_config/yaml.rs index 7c536d5..6cec24f 100644 --- a/src-tauri/src/core_config/yaml.rs +++ b/src-tauri/src/core_config/yaml.rs @@ -185,23 +185,29 @@ pub(crate) fn patch_core_tls_settings_yaml( settings: &CoreTlsSettings, ) -> Result, String> { patch_core_yaml_document(content, |document| { - let v8 = core_config_uses_v8(document); + let root = document.as_mapping() + .ok_or_else(|| "Kernel configuration root must be a YAML mapping".to_string())?; + let v8 = core_config_uses_v8(document) + || nested_yaml_value(root, &["server", "tls"]).is_some(); + let has_legacy_mapping = yaml_mapping_value(root, "tls") + .is_some_and(serde_norway::Value::is_mapping); let mut changed = false; - changed |= set_core_yaml_path_value( - document, - if v8 { &["server", "tls", "enable"] } else { &["tls", "enable"] }, - serde_norway::Value::Bool(settings.enabled), - )?; - changed |= set_core_yaml_path_value( - document, - if v8 { &["server", "tls", "cert"] } else { &["tls", "cert"] }, - serde_norway::Value::String(settings.cert.clone()), - )?; - changed |= set_core_yaml_path_value( - document, - if v8 { &["server", "tls", "key"] } else { &["tls", "key"] }, - serde_norway::Value::String(settings.key.clone()), - )?; + for (key, value) in [ + ("enable", serde_norway::Value::Bool(settings.enabled)), + ("cert", serde_norway::Value::String(settings.cert.clone())), + ("key", serde_norway::Value::String(settings.key.clone())), + ] { + let legacy_path = ["tls", key]; + let v8_path = ["server", "tls", key]; + changed |= set_core_yaml_path_value( + document, + if v8 { &v8_path } else { &legacy_path }, + value.clone(), + )?; + if v8 && has_legacy_mapping { + changed |= set_core_yaml_path_value(document, &["tls", key], value)?; + } + } Ok(changed) }) } @@ -431,7 +437,6 @@ pub(crate) fn patch_core_management_secret_key(secret_key: &str) -> Result<(), S }) } -#[cfg(any(target_os = "macos", test))] pub(crate) fn patch_core_auth_dir_at(config_path: &Path, auth_dir: &str) -> Result<(), String> { patch_existing_core_config_at(config_path, |document| { set_core_yaml_auth_dir(document, auth_dir) @@ -442,26 +447,12 @@ pub(crate) fn set_core_yaml_auth_dir( document: &mut serde_norway::Value, auth_dir: &str, ) -> Result { - let root = document - .as_mapping() - .ok_or_else(|| "Kernel configuration root must be a YAML mapping".to_string())?; - let nested = core_config_uses_v8(document) - || nested_yaml_value(root, &["oauth", "auth-dir"]).is_some(); - let has_legacy = yaml_mapping_value(root, "auth-dir").is_some(); - let value = serde_norway::Value::String(auth_dir.to_string()); - let mut changed = set_core_yaml_path_value( + set_core_yaml_schema_value( document, - if nested { - &["oauth", "auth-dir"] - } else { - &["auth-dir"] - }, - value.clone(), - )?; - if nested && has_legacy { - changed |= set_core_yaml_path_value(document, &["auth-dir"], value)?; - } - Ok(changed) + &["auth-dir"], + &["oauth", "auth-dir"], + serde_norway::Value::String(auth_dir.to_string()), + ) } pub(crate) fn patch_core_plugins_enabled(enabled: bool) -> Result<(), String> { @@ -477,63 +468,64 @@ pub(crate) fn patch_core_plugins_enabled(enabled: bool) -> Result<(), String> { pub(crate) fn patch_core_request_log(enabled: bool) -> Result<(), String> { patch_existing_core_config(|document| { - let v8 = core_config_uses_v8(document); - set_core_yaml_path_value( + set_core_yaml_schema_value( document, - if v8 { &["observability", "logs", "request-log"] } else { &["request-log"] }, + &["request-log"], + &["observability", "logs", "request-log"], serde_norway::Value::Bool(enabled), ) }) } pub(crate) fn patch_core_logging_settings(settings: &CoreConfigSettings) -> Result<(), String> { - let settings = settings.clone(); - patch_existing_core_config(move |document| { - let v8 = core_config_uses_v8(document); - let mut changed = false; - for (key, value) in [ - ("debug", serde_norway::Value::Bool(settings.debug)), - ( - "commercial-mode", - serde_norway::Value::Bool(settings.commercial_mode), - ), - ( - "logging-to-file", - serde_norway::Value::Bool(settings.logging_to_file), - ), - ( - "logs-max-total-size-mb", - serde_norway::to_value(settings.logs_max_total_size_mb) - .map_err(|err| format!("Failed to serialize log size limit: {err}"))?, - ), - ( - "error-logs-max-files", - serde_norway::to_value(settings.error_logs_max_files) - .map_err(|err| format!("Failed to serialize error log retention count: {err}"))?, - ), - ( - "usage-statistics-enabled", - serde_norway::Value::Bool(settings.usage_statistics_enabled), - ), - ( - "redis-usage-queue-retention-seconds", - serde_norway::to_value(settings.redis_usage_queue_retention_seconds) - .map_err(|err| format!("Failed to serialize Redis usage queue retention: {err}"))?, - ), - ] { - let path: &[&str] = if v8 { - match key { - "commercial-mode" => &["server", "commercial-mode"], - "usage-statistics-enabled" | "redis-usage-queue-retention-seconds" => &["observability", "usage", key], - _ => &["observability", "logs", key], - } - } else { - &[key] - }; - changed |= set_core_yaml_path_value(document, path, value)?; - } - Ok(changed) - }) + patch_existing_core_config(|document| apply_core_logging_settings(document, settings)) +} + +pub(crate) fn apply_core_logging_settings( + document: &mut serde_norway::Value, + settings: &CoreConfigSettings, +) -> Result { + let mut changed = false; + for (key, value) in [ + ("debug", serde_norway::Value::Bool(settings.debug)), + ( + "commercial-mode", + serde_norway::Value::Bool(settings.commercial_mode), + ), + ( + "logging-to-file", + serde_norway::Value::Bool(settings.logging_to_file), + ), + ( + "logs-max-total-size-mb", + serde_norway::to_value(settings.logs_max_total_size_mb) + .map_err(|err| format!("Failed to serialize log size limit: {err}"))?, + ), + ( + "error-logs-max-files", + serde_norway::to_value(settings.error_logs_max_files) + .map_err(|err| format!("Failed to serialize error log retention count: {err}"))?, + ), + ( + "usage-statistics-enabled", + serde_norway::Value::Bool(settings.usage_statistics_enabled), + ), + ( + "redis-usage-queue-retention-seconds", + serde_norway::to_value(settings.redis_usage_queue_retention_seconds) + .map_err(|err| format!("Failed to serialize Redis usage queue retention: {err}"))?, + ), + ] { + let path: &[&str] = match key { + "commercial-mode" => &["server", "commercial-mode"], + "usage-statistics-enabled" | "redis-usage-queue-retention-seconds" => { + &["observability", "usage", key] + } + _ => &["observability", "logs", key], + }; + changed |= set_core_yaml_schema_value(document, &[key], path, value)?; + } + Ok(changed) } pub(crate) fn patch_core_routing_strategy(strategy: &str) -> Result<(), String> { @@ -693,7 +685,7 @@ pub(crate) fn render_yaml_value_changes( } for (parent_path, entries) in missing_nested_groups { if let Some(updated_content) = - insert_yaml_block_mapping_values_at_path(&editable_content, &parent_path, &entries)? + insert_yaml_mapping_values_at_path(&editable_content, &parent_path, &entries)? { editable_content = updated_content; } else { @@ -894,11 +886,38 @@ pub(crate) fn yaml_value_at_path<'a>( }) } -pub(crate) fn insert_yaml_block_mapping_values_at_path( +pub(crate) fn insert_yaml_mapping_values_at_path( content: &str, parent_path: &[String], entries: &[(String, serde_norway::Value)], ) -> Result, String> { + let file = content.parse::() + .map_err(|error| format!("Failed to parse editable kernel configuration: {error}"))?; + let document = file.document() + .ok_or_else(|| "Kernel configuration has no YAML document".to_string())?; + let root = document.as_mapping() + .ok_or_else(|| "Kernel configuration root must be a YAML mapping".to_string())?; + let node = yaml_edit_node_at_path(&root, parent_path); + if let Some(mapping) = node.as_ref().and_then(|node| node.as_mapping()) + .filter(|mapping| mapping.is_flow_style()) + { + let range = mapping.byte_range(); + let start = range.start as usize; + let opening = content[start..range.end as usize].find('{') + .ok_or_else(|| "Flow mapping has no opening brace".to_string())?; + let insertion_point = start + opening + 1; + let fields = entries.iter().map(|(key, value)| { + serde_json::to_string(value) + .map(|value| format!("{}: {value}", render_yaml_mapping_key(key))) + .map_err(|error| format!("Failed to serialize kernel configuration value: {error}")) + }).collect::, _>>()?.join(", "); + let separator = if mapping.is_empty() { " " } else { ", " }; + return Ok(Some(format!( + "{} {fields}{separator}{}", + &content[..insertion_point], + &content[insertion_point..] + ))); + } let mut offset = 0; let mut parent_end = None; let mut parent_indent = 0; @@ -1243,8 +1262,19 @@ pub(crate) fn set_core_yaml_schema_value( v8_path: &[&str], value: serde_norway::Value, ) -> Result { - let path = if core_config_uses_v8(document) { v8_path } else { legacy_path }; - set_core_yaml_path_value(document, path, value) + let root = document.as_mapping() + .ok_or_else(|| "Kernel configuration root must be a YAML mapping".to_string())?; + let v8 = core_config_uses_v8(document) || nested_yaml_value(root, v8_path).is_some(); + let has_legacy = nested_yaml_value(root, legacy_path).is_some(); + let mut changed = set_core_yaml_path_value( + document, + if v8 { v8_path } else { legacy_path }, + value.clone(), + )?; + if v8 && has_legacy { + changed |= set_core_yaml_path_value(document, legacy_path, value)?; + } + Ok(changed) } pub(crate) fn patch_core_api_keys_yaml( diff --git a/src-tauri/src/core_runtime.rs b/src-tauri/src/core_runtime.rs index 33437d9..45f9e51 100644 --- a/src-tauri/src/core_runtime.rs +++ b/src-tauri/src/core_runtime.rs @@ -1388,8 +1388,8 @@ impl std::fmt::Display for CoreStartupFailure { } } -pub(crate) fn core_start_log_path(install_dir: &Path, auth_dir: &str) -> PathBuf { - core_logs_dir_path(auth_dir, install_dir).join("core-start-output.log") +pub(crate) fn core_start_log_path(install_dir: &Path, auth_dir: &str) -> Result { + Ok(core_logs_dir_path(auth_dir, install_dir)?.join("core-start-output.log")) } pub(crate) fn core_start_stdio(log_path: &Path) -> io::Result<(Stdio, Stdio)> { @@ -1510,7 +1510,7 @@ pub(crate) fn start_core_process_inner( let gui_config = &resolved_config; let install_dir = core_install_dir()?; if !gui_config.auth_dir.trim().is_empty() { - let auth_dir = auth_dir_path_for_core(&gui_config.auth_dir, &install_dir); + let auth_dir = auth_dir_path_for_core(&gui_config.auth_dir, &install_dir)?; fs::create_dir_all(&auth_dir) .map_err(|error| format!("Failed to create credentials directory {}: {error}", path_to_string(&auth_dir)))?; } @@ -1534,7 +1534,7 @@ pub(crate) fn start_core_process_inner( let config_path = merge_core_config_for_start(&install_dir, gui_config)?; let config_path = path_to_string(&config_path); - let log_path = core_start_log_path(&install_dir, &gui_config.auth_dir); + let log_path = core_start_log_path(&install_dir, &gui_config.auth_dir)?; let start_once = || { start_core_process_once( process_state, diff --git a/src-tauri/src/management_api.rs b/src-tauri/src/management_api.rs index 0195f6d..926c3e4 100644 --- a/src-tauri/src/management_api.rs +++ b/src-tauri/src/management_api.rs @@ -133,7 +133,7 @@ pub(crate) fn open_auth_files_directory( ) -> Result<(), String> { let config = gui_config_state.snapshot()?; let install_dir = core_install_dir()?; - let auth_dir = auth_dir_path_for_core(&config.auth_dir, &install_dir); + let auth_dir = auth_dir_path_for_core(&config.auth_dir, &install_dir)?; fs::create_dir_all(&auth_dir) .map_err(|error| format!("Failed to create credentials directory {}: {error}", path_to_string(&auth_dir)))?; open_directory_in_file_manager(&auth_dir) @@ -145,7 +145,7 @@ pub(crate) fn open_core_logs_directory( ) -> Result<(), String> { let config = gui_config_state.snapshot()?; let install_dir = core_install_dir()?; - let logs_dir = core_logs_dir_path(&config.auth_dir, &install_dir); + let logs_dir = core_logs_dir_path(&config.auth_dir, &install_dir)?; fs::create_dir_all(&logs_dir) .map_err(|error| format!("Failed to create log directory {}: {error}", path_to_string(&logs_dir)))?; @@ -474,7 +474,7 @@ mod tests { let install_dir = base_dir.join("cpa-core"); assert_eq!( - core_logs_dir_path("../oauth", &install_dir), + core_logs_dir_path("../oauth", &install_dir).unwrap(), base_dir.join("oauth").join("logs") ); } @@ -485,7 +485,7 @@ mod tests { let auth_dir = PathBuf::from("custom-auth"); assert_eq!( - core_logs_dir_path(auth_dir.to_str().unwrap(), &install_dir), + core_logs_dir_path(auth_dir.to_str().unwrap(), &install_dir).unwrap(), install_dir.join(auth_dir).join("logs") ); } diff --git a/src-tauri/src/tests/core_config.rs b/src-tauri/src/tests/core_config.rs index 4ec9686..f95aa9c 100644 --- a/src-tauri/src/tests/core_config.rs +++ b/src-tauri/src/tests/core_config.rs @@ -191,7 +191,7 @@ fn default_auth_directory_is_relative_and_legacy_absolute_value_is_migrated() { let base_dir = agent_test_home("relative-default-auth-dir"); let install_dir = base_dir.join("cpa-core"); assert_eq!( - auth_dir_path_for_core(DEFAULT_AUTH_DIR, &install_dir), + auth_dir_path_for_core(DEFAULT_AUTH_DIR, &install_dir).unwrap(), base_dir.join(OAUTH_DIR_NAME) ); @@ -205,8 +205,161 @@ fn default_auth_directory_is_relative_and_legacy_absolute_value_is_migrated() { } #[test] -fn macos_relative_oauth_dir_recovers_existing_persistent_credentials() { - let root = agent_test_home("macos-relative-oauth-dir"); +fn path_configuration_expands_auth_home() { + let variable = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; + let user_home = PathBuf::from(env::var_os(variable).unwrap()); + let install_dir = agent_test_home("auth-home-path").join("cpa-core"); + assert_eq!( + auth_dir_path_for_core("~/.cli-proxy-api", &install_dir).unwrap(), + user_home.join(".cli-proxy-api") + ); + fs::remove_dir_all(install_dir.parent().unwrap()).unwrap(); +} + +#[test] +fn path_configuration_uses_existing_core_logs() { + let root = agent_test_home("existing-core-logs"); + let install_dir = root.join("cpa-core"); + let logs_dir = install_dir.join("logs"); + fs::create_dir_all(&logs_dir).unwrap(); + fs::write(logs_dir.join("main.log"), b"existing-log").unwrap(); + assert_eq!(core_logs_dir_path_with_base(DEFAULT_AUTH_DIR, &install_dir, None).unwrap(), logs_dir); + assert_eq!(fs::read(logs_dir.join("main.log")).unwrap(), b"existing-log"); + assert_eq!(fs::read_dir(&logs_dir).unwrap().count(), 1); + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn path_configuration_respects_log_base_and_fallbacks() { + let root = agent_test_home("core-log-base"); + let install_dir = root.join("cpa-core"); + fs::create_dir_all(&install_dir).unwrap(); + let fallback = root.join("oauth").join("logs"); + assert_eq!( + core_logs_dir_path_with_base(DEFAULT_AUTH_DIR, &install_dir, None).unwrap(), + fallback + ); + fs::write(install_dir.join("logs"), b"not a directory").unwrap(); + assert_eq!( + core_logs_dir_path_with_base(DEFAULT_AUTH_DIR, &install_dir, None).unwrap(), + fallback + ); + fs::remove_file(install_dir.join("logs")).unwrap(); + fs::create_dir(install_dir.join("logs")).unwrap(); + for base in [" ../shared logs/./ ".to_string(), path_to_string(&root.join("shared logs"))] { + assert_eq!( + core_logs_dir_path_with_base(DEFAULT_AUTH_DIR, &install_dir, Some(&base)).unwrap(), + root.join("shared logs").join("logs") + ); + } + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn path_configuration_normalizes_auth_paths_and_rejects_missing_home() { + let root = agent_test_home("core-auth-normalization"); + let install_dir = root.join("cpa-core"); + let user_home = root.join("home"); + for auth_dir in ["~/.cli-proxy-api", r"~\.cli-proxy-api", ""] { + assert_eq!( + auth_dir_path_for_core_with_home(auth_dir, &install_dir, Some(&user_home)).unwrap(), + user_home.join(".cli-proxy-api") + ); + } + assert_eq!( + auth_dir_path_for_core_with_home("~", &install_dir, Some(&user_home)).unwrap(), + user_home + ); + assert_eq!( + auth_dir_path_for_core("missing/../oauth/./", &install_dir).unwrap(), + install_dir.join("oauth") + ); + assert!(auth_dir_path_for_core_with_home("~/oauth", &install_dir, None).is_err()); + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn path_configuration_writes_effective_tls_fields_without_version_marker() { + let settings = CoreTlsSettings { + enabled: true, + cert: "certs/new.crt".to_string(), + key: "certs/new.key".to_string(), + }; + for input in [ + "server:\n tls: {enable: true, cert: old.crt, key: old.key}\n", + "server: {tls: {enable: false}}\n", + "tls: {enable: false, cert: legacy.crt, key: legacy.key}\nserver: {tls: {enable: false}}\n", + ] { + let patched = patch_core_tls_settings_yaml(input, &settings).unwrap().unwrap(); + let document: serde_norway::Value = serde_norway::from_str(&patched).unwrap(); + let effective = core_tls_settings_from_value(&document).unwrap(); + assert!(effective.enabled); + assert_eq!(effective.cert, settings.cert); + assert_eq!(effective.key, settings.key); + if let Some(legacy) = document.get("tls") { + assert_eq!(legacy["cert"].as_str(), Some(settings.cert.as_str())); + } + assert!(patch_core_tls_settings_yaml(&patched, &settings).unwrap().is_none()); + } +} + +#[test] +fn tls_patch_preserves_null_legacy_fields_with_v8_settings() { + let settings = CoreTlsSettings { + enabled: false, + cert: "certs/new.crt".to_string(), + key: "certs/new.key".to_string(), + }; + for version_marker in ["config-version: 8\n", ""] { + let input = format!( + "{version_marker}tls: null\nserver:\n tls:\n enable: true\n cert: old.crt\n key: old.key\n" + ); + let patched = patch_core_tls_settings_yaml(&input, &settings) + .unwrap() + .expect("TLS settings should change"); + let document: serde_norway::Value = serde_norway::from_str(&patched).unwrap(); + assert_eq!(document.get("tls"), Some(&serde_norway::Value::Null)); + let effective = core_tls_settings_from_value(&document).unwrap(); + assert_eq!(effective.enabled, settings.enabled); + assert_eq!(effective.cert, settings.cert); + assert_eq!(effective.key, settings.key); + assert!(patch_core_tls_settings_yaml(&patched, &settings) + .unwrap() + .is_none()); + } +} + +#[test] +fn path_configuration_writes_effective_logging_fields_without_version_marker() { + let config = GuiConfigFile { + logging_to_file: true, + logs_max_total_size_mb: 123, + ..GuiConfigFile::default() + }; + let settings = CoreConfigSettings::from(&config); + for input in [ + "observability:\n logs: {logging-to-file: false, logs-max-total-size-mb: 0}\n", + "logging-to-file: false\nobservability:\n logs: {logging-to-file: false}\n", + ] { + for patched in [ + apply_gui_managed_settings(input, &config).unwrap(), + patch_core_yaml_document(input, |document| apply_core_logging_settings(document, &settings)) + .unwrap().unwrap(), + ] { + let document: serde_norway::Value = serde_norway::from_str(&patched).unwrap(); + let effective = core_config_settings_from_value(&document).unwrap(); + assert!(effective.logging_to_file); + assert_eq!(effective.logs_max_total_size_mb, 123); + if let Some(legacy) = document.get("logging-to-file") { + assert_eq!(legacy.as_bool(), Some(true)); + } + } + } +} + +#[test] +fn relative_oauth_dir_recovers_existing_persistent_credentials() { + let root = agent_test_home("relative-oauth-dir"); let install_dir = root.join("cpa-core"); let persistent_auth_dir = root.join(OAUTH_DIR_NAME); let misplaced_auth_dir = install_dir.join(OAUTH_DIR_NAME); @@ -224,11 +377,11 @@ fn macos_relative_oauth_dir_recovers_existing_persistent_credentials() { }; assert!( - recover_macos_relative_oauth_dir(&mut config, &install_dir, &persistent_auth_dir).unwrap() + recover_relative_oauth_dir(&mut config, &install_dir, &persistent_auth_dir).unwrap() ); assert_eq!(config.auth_dir, DEFAULT_AUTH_DIR); assert_eq!( - auth_dir_path_for_core(&config.auth_dir, &install_dir), + auth_dir_path_for_core(&config.auth_dir, &install_dir).unwrap(), persistent_auth_dir ); assert_eq!( @@ -241,8 +394,8 @@ fn macos_relative_oauth_dir_recovers_existing_persistent_credentials() { } #[test] -fn macos_oauth_recovery_accepts_equivalent_directory_paths() { - let root = agent_test_home("macos-oauth-equivalent-paths"); +fn oauth_recovery_accepts_equivalent_directory_paths() { + let root = agent_test_home("oauth-equivalent-paths"); let install_dir = root.join("cpa-core"); let persistent_auth_dir = root.join(OAUTH_DIR_NAME); fs::create_dir_all(&persistent_auth_dir).unwrap(); @@ -252,13 +405,17 @@ fn macos_oauth_recovery_accepts_equivalent_directory_paths() { "oauth/./".to_string(), "../cpa-core/oauth".to_string(), path_to_string(&install_dir.join(OAUTH_DIR_NAME)), + #[cfg(windows)] + r".\oauth".to_string(), + #[cfg(windows)] + r"..\cpa-core\oauth".to_string(), ] { let mut config = GuiConfigFile { auth_dir: auth_dir.clone(), ..GuiConfigFile::default() }; assert!( - recover_macos_relative_oauth_dir(&mut config, &install_dir, &persistent_auth_dir).unwrap(), + recover_relative_oauth_dir(&mut config, &install_dir, &persistent_auth_dir).unwrap(), "failed to recover {auth_dir}" ); assert_eq!(config.auth_dir, DEFAULT_AUTH_DIR); @@ -267,8 +424,8 @@ fn macos_oauth_recovery_accepts_equivalent_directory_paths() { } #[test] -fn macos_oauth_recovery_preserves_credentials_behind_parent_components() { - let root = agent_test_home("macos-oauth-parent-components"); +fn oauth_recovery_preserves_credentials_behind_parent_components() { + let root = agent_test_home("oauth-parent-components"); let install_dir = root.join("cpa-core"); let active_auth_dir = install_dir.join(OAUTH_DIR_NAME); let persistent_auth_dir = root.join(OAUTH_DIR_NAME); @@ -281,10 +438,12 @@ fn macos_oauth_recovery_preserves_credentials_behind_parent_components() { for auth_dir in [ "missing/../oauth".to_string(), path_to_string(&install_dir.join("missing").join("..").join(OAUTH_DIR_NAME)), + #[cfg(windows)] + r"missing\..\oauth".to_string(), ] { #[cfg(unix)] assert_eq!( - fs::read_dir(auth_dir_path_for_core(&auth_dir, &install_dir)) + fs::read_dir(install_dir.join(&auth_dir)) .unwrap_err() .kind(), io::ErrorKind::NotFound @@ -294,7 +453,7 @@ fn macos_oauth_recovery_preserves_credentials_behind_parent_components() { ..GuiConfigFile::default() }; assert!( - !migrate_macos_auth_dir_at(&mut config, &install_dir, &persistent_auth_dir).unwrap() + !migrate_auth_dir_at(&mut config, &install_dir, &persistent_auth_dir).unwrap() ); assert_eq!(config.auth_dir, auth_dir); } @@ -327,14 +486,14 @@ fn oauth_directory_write_updates_the_effective_v8_field_without_version_marker() } #[test] -fn macos_oauth_recovery_survives_config_reload_and_v8_upgrade() { +fn oauth_recovery_survives_config_reload_and_v8_upgrade() { for input in [ "auth-dir: oauth\n", "config-version: 8\noauth:\n auth-dir: oauth\n", "oauth:\n auth-dir: ./oauth\n", "auth-dir: ../oauth\noauth: {auth-dir: oauth}\n", ] { - let root = agent_test_home("macos-oauth-recovery-reload"); + let root = agent_test_home("oauth-recovery-reload"); let install_dir = root.join("cpa-core"); let persistent_auth_dir = root.join(OAUTH_DIR_NAME); let core_config_path = install_dir.join(CORE_CONFIG_FILE); @@ -355,7 +514,7 @@ fn macos_oauth_recovery_survives_config_reload_and_v8_upgrade() { write_gui_config_to_path(&config, &gui_config_path).unwrap(); assert!( - migrate_macos_auth_dir_at(&mut config, &install_dir, &persistent_auth_dir).unwrap() + sanitize_gui_config_at(&mut config, &install_dir, &persistent_auth_dir).unwrap() ); let patched = fs::read_to_string(&core_config_path).unwrap(); assert!(patched.contains("# user comment")); @@ -373,7 +532,7 @@ fn macos_oauth_recovery_survives_config_reload_and_v8_upgrade() { toml::from_str(&fs::read_to_string(&gui_config_path).unwrap()).unwrap(); assert_eq!(reloaded.auth_dir, DEFAULT_AUTH_DIR); assert!( - !migrate_macos_auth_dir_at(&mut reloaded, &install_dir, &persistent_auth_dir).unwrap() + !sanitize_gui_config_at(&mut reloaded, &install_dir, &persistent_auth_dir).unwrap() ); assert_eq!(fs::read_to_string(&core_config_path).unwrap(), patched); @@ -391,7 +550,7 @@ fn macos_oauth_recovery_survives_config_reload_and_v8_upgrade() { let effective = core_config_settings_from_value(&started_document).unwrap(); assert_eq!(effective.auth_dir, DEFAULT_AUTH_DIR); assert_eq!( - auth_dir_path_for_core(&effective.auth_dir, &install_dir), + auth_dir_path_for_core(&effective.auth_dir, &install_dir).unwrap(), persistent_auth_dir ); assert_eq!(fs::read(&credential_path).unwrap(), b"existing-credential"); @@ -401,9 +560,9 @@ fn macos_oauth_recovery_survives_config_reload_and_v8_upgrade() { } #[test] -fn macos_oauth_recovery_preserves_config_when_inspection_or_patch_fails() { +fn oauth_recovery_preserves_config_when_inspection_or_patch_fails() { for failure in ["invalid-yaml", "unreadable-config", "invalid-auth-directory"] { - let root = agent_test_home("macos-oauth-recovery-failure"); + let root = agent_test_home("oauth-recovery-failure"); let install_dir = root.join("cpa-core"); let persistent_auth_dir = root.join(OAUTH_DIR_NAME); let core_config_path = install_dir.join(CORE_CONFIG_FILE); @@ -426,7 +585,12 @@ fn macos_oauth_recovery_preserves_config_when_inspection_or_patch_fails() { let original_config = toml::to_string(&config).unwrap(); let original_yaml = fs::read(&core_config_path).ok(); - assert!(migrate_macos_auth_dir_at(&mut config, &install_dir, &persistent_auth_dir).is_err()); + assert!(migrate_auth_dir_at(&mut config, &install_dir, &persistent_auth_dir).is_err()); + assert_eq!(toml::to_string(&config).unwrap(), original_config); + assert_eq!(fs::read(&core_config_path).ok(), original_yaml); + assert!( + !sanitize_gui_config_at(&mut config, &install_dir, &persistent_auth_dir).unwrap() + ); assert_eq!(toml::to_string(&config).unwrap(), original_config); assert_eq!(fs::read(&core_config_path).ok(), original_yaml); assert_eq!( @@ -438,8 +602,8 @@ fn macos_oauth_recovery_preserves_config_when_inspection_or_patch_fails() { } #[test] -fn macos_oauth_recovery_requires_existing_persistent_credentials() { - let root = agent_test_home("macos-oauth-recovery-empty"); +fn oauth_recovery_requires_existing_persistent_credentials() { + let root = agent_test_home("oauth-recovery-empty"); let install_dir = root.join("cpa-core"); let persistent_auth_dir = root.join(OAUTH_DIR_NAME); let mut config = GuiConfigFile { @@ -447,13 +611,13 @@ fn macos_oauth_recovery_requires_existing_persistent_credentials() { ..GuiConfigFile::default() }; assert!( - !migrate_macos_auth_dir_at(&mut config, &install_dir, &persistent_auth_dir).unwrap() + !migrate_auth_dir_at(&mut config, &install_dir, &persistent_auth_dir).unwrap() ); fs::create_dir_all(persistent_auth_dir.join("nested")).unwrap(); fs::write(persistent_auth_dir.join("core.log"), b"log").unwrap(); fs::write(persistent_auth_dir.join("nested").join("account.json"), b"nested").unwrap(); assert!( - !migrate_macos_auth_dir_at(&mut config, &install_dir, &persistent_auth_dir).unwrap() + !migrate_auth_dir_at(&mut config, &install_dir, &persistent_auth_dir).unwrap() ); assert_eq!(config.auth_dir, OAUTH_DIR_NAME); fs::remove_dir_all(root).unwrap(); @@ -461,8 +625,8 @@ fn macos_oauth_recovery_requires_existing_persistent_credentials() { #[cfg(unix)] #[test] -fn macos_oauth_recovery_preserves_active_linked_credentials() { - let root = agent_test_home("macos-oauth-recovery-symlink"); +fn oauth_recovery_preserves_active_linked_credentials() { + let root = agent_test_home("oauth-recovery-symlink"); let install_dir = root.join("cpa-core"); let active_auth_dir = install_dir.join(OAUTH_DIR_NAME); let persistent_auth_dir = root.join(OAUTH_DIR_NAME); @@ -476,7 +640,7 @@ fn macos_oauth_recovery_preserves_active_linked_credentials() { ..GuiConfigFile::default() }; assert!( - !migrate_macos_auth_dir_at(&mut config, &install_dir, &persistent_auth_dir).unwrap() + !migrate_auth_dir_at(&mut config, &install_dir, &persistent_auth_dir).unwrap() ); assert_eq!(config.auth_dir, OAUTH_DIR_NAME); assert_eq!(fs::read(active_auth_dir.join("linked.json")).unwrap(), b"existing-credential"); @@ -484,8 +648,8 @@ fn macos_oauth_recovery_preserves_active_linked_credentials() { } #[test] -fn macos_relative_oauth_dir_preserves_populated_or_custom_paths() { - let root = agent_test_home("macos-relative-oauth-dir-custom"); +fn relative_oauth_dir_preserves_populated_or_custom_paths() { + let root = agent_test_home("relative-oauth-dir-custom"); let install_dir = root.join("cpa-core"); let persistent_auth_dir = root.join(OAUTH_DIR_NAME); let active_auth_dir = install_dir.join(OAUTH_DIR_NAME); @@ -499,19 +663,48 @@ fn macos_relative_oauth_dir_preserves_populated_or_custom_paths() { }; assert!( - !recover_macos_relative_oauth_dir(&mut config, &install_dir, &persistent_auth_dir).unwrap() + !recover_relative_oauth_dir(&mut config, &install_dir, &persistent_auth_dir).unwrap() ); assert_eq!(config.auth_dir, OAUTH_DIR_NAME); fs::remove_file(active_auth_dir.join("current.json")).unwrap(); config.auth_dir = "other/oauth".to_string(); assert!( - !recover_macos_relative_oauth_dir(&mut config, &install_dir, &persistent_auth_dir).unwrap() + !recover_relative_oauth_dir(&mut config, &install_dir, &persistent_auth_dir).unwrap() ); assert_eq!(config.auth_dir, "other/oauth"); fs::remove_dir_all(root).unwrap(); } +#[test] +#[cfg(not(target_os = "macos"))] +fn oauth_recovery_preserves_app_named_custom_directories_on_other_platforms() { + let root = agent_test_home("oauth-custom-app-directory"); + let install_dir = root.join("cpa-core"); + let persistent_auth_dir = root.join(OAUTH_DIR_NAME); + let custom_auth_dir = root.join("Custom.app").join("Contents").join(OAUTH_DIR_NAME); + fs::create_dir_all(&custom_auth_dir).unwrap(); + fs::create_dir_all(&persistent_auth_dir).unwrap(); + fs::write(custom_auth_dir.join("current.json"), b"current-credential").unwrap(); + fs::write(persistent_auth_dir.join("old.json"), b"old-credential").unwrap(); + let configured_auth_dir = path_to_string(&custom_auth_dir); + let mut config = GuiConfigFile { + auth_dir: configured_auth_dir.clone(), + ..GuiConfigFile::default() + }; + + assert!( + !sanitize_gui_config_at(&mut config, &install_dir, &persistent_auth_dir).unwrap() + ); + assert_eq!(config.auth_dir, configured_auth_dir); + assert!(!persistent_auth_dir.join("current.json").exists()); + assert_eq!( + fs::read(custom_auth_dir.join("current.json")).unwrap(), + b"current-credential" + ); + fs::remove_dir_all(root).unwrap(); +} + #[test] fn packaged_macos_auth_directory_is_copied_before_config_is_repointed() { let root = agent_test_home("packaged-macos-auth-migration"); diff --git a/src-tauri/src/tests/core_runtime.rs b/src-tauri/src/tests/core_runtime.rs index 171ee6d..e656107 100644 --- a/src-tauri/src/tests/core_runtime.rs +++ b/src-tauri/src/tests/core_runtime.rs @@ -930,14 +930,14 @@ fn core_start_log_path_follows_the_managed_logs_directory() { let install_dir = base_dir.join("cpa-core"); assert_eq!( - core_start_log_path(&install_dir, DEFAULT_AUTH_DIR), + core_start_log_path(&install_dir, DEFAULT_AUTH_DIR).unwrap(), base_dir .join("oauth") .join("logs") .join("core-start-output.log") ); assert_eq!( - core_start_log_path(&install_dir, "custom-auth"), + core_start_log_path(&install_dir, "custom-auth").unwrap(), install_dir .join("custom-auth") .join("logs")