feat(logging): persist diagnostics across restarts and redact secrets

Backend half of the logging overhaul.

Retention:
- Keep the last 4 rotated files at 20MB each and stop deleting logs on
  startup, so a crash's prior-run logs survive a restart.
- Apply the persisted log level right after plugin registration instead
  of at the end of setup.
- Bound crash.log with size-based rotation (5MB x 2).

Secret redaction on every log path:
- Proxy: strip userinfo/query from upstream URLs (keep path for
  diagnostics), exact-match redact known auth.api_key/access_token,
  classify request/response bodies instead of logging them, header
  allowlist.
- Redact the Gemini `?key=` in cache-trace endpoints.
- Omit MCP custom-field values (headers may carry tokens).
- Redact deeplink and model-fetch URLs before logging.

Docs:
- FAQ points users to the persistent crash.log for support workflows.
This commit is contained in:
Jason
2026-07-16 10:20:44 +08:00
parent 7f028632b5
commit 22d2872c33
14 changed files with 655 additions and 224 deletions
+267 -70
View File
@@ -68,7 +68,7 @@ pub use store::AppState;
use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
use std::sync::Arc;
use std::{fmt, sync::Arc};
#[cfg(target_os = "macos")]
use tauri::image::Image;
use tauri::tray::{TrayIconBuilder, TrayIconEvent};
@@ -92,35 +92,129 @@ fn set_windows_app_user_model_id(app: &tauri::AppHandle) {
}
}
fn redact_url_for_log(url_str: &str) -> String {
match url::Url::parse(url_str) {
Ok(url) => {
let mut output = format!("{}://", url.scheme());
if let Some(host) = url.host_str() {
output.push_str(host);
}
output.push_str(url.path());
pub(crate) struct RedactedUrl<'a> {
url: &'a str,
known_secrets: &'a [String],
}
let mut keys: Vec<String> = url.query_pairs().map(|(k, _)| k.to_string()).collect();
keys.sort();
keys.dedup();
impl fmt::Display for RedactedUrl<'_> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&redact_url_for_log_with_secrets(
self.url,
self.known_secrets,
))
}
}
if !keys.is_empty() {
output.push_str("?[keys:");
output.push_str(&keys.join(","));
output.push(']');
}
/// 为日志提供惰性 URL 脱敏包装;只有日志实际输出时才解析和重建 URL。
pub(crate) fn url_for_log(url: &str) -> RedactedUrl<'_> {
RedactedUrl {
url,
known_secrets: &[],
}
}
output
}
Err(_) => {
let base = url_str.split('#').next().unwrap_or(url_str);
match base.split_once('?') {
Some((prefix, _)) => format!("{prefix}?[redacted]"),
None => base.to_string(),
}
/// 为持有确切认证材料的调用方提供优先精确匹配、再启发式兜底的 URL 脱敏。
pub(crate) fn url_for_log_with_secrets<'a>(
url: &'a str,
known_secrets: &'a [String],
) -> RedactedUrl<'a> {
RedactedUrl { url, known_secrets }
}
/// 已知密钥参与子串脱敏的最短长度:过短的值(如 "api")当作子串会误伤无关文本,
/// 所以只对足够长、几乎不可能是普通词的值做替换。
const MIN_KNOWN_SECRET_LEN: usize = 8;
/// 唯一的密钥脱敏原语:把字符串里出现的、我们确切握有的密钥值替换为 [REDACTED]。
/// 不做任何“看起来像密钥”的形状猜测——只隐藏已知值,天然收敛、不误伤正常路径。
fn redact_known_secrets(text: &str, known_secrets: &[String]) -> String {
let mut output = text.to_string();
for secret in known_secrets {
if secret.chars().count() >= MIN_KNOWN_SECRET_LEN {
output = output.replace(secret.as_str(), "[REDACTED]");
}
}
output
}
/// 无 scheme 的裸 authority 形态(如 `user:pass@host/path`)剥掉 userinfo
/// 仅当 `@` 出现在第一个 `/` 之前时才视为凭据。
fn strip_bare_userinfo(input: &str) -> &str {
let authority_end = input.find('/').unwrap_or(input.len());
match input[..authority_end].rfind('@') {
Some(at) => &input[at + 1..],
None => input,
}
}
pub(crate) fn redact_url_for_log(url_str: &str) -> String {
redact_url_for_log_with_secrets(url_str, &[])
}
/// 为日志脱敏 URL:剥掉 userinfo(user:pass@) 与整个 query/fragment,保留
/// scheme/host/port/path 供诊断(如 base_url 配错路径导致 404),最后再抹掉已知密钥值。
pub(crate) fn redact_url_for_log_with_secrets(url_str: &str, known_secrets: &[String]) -> String {
let scheme_relative = url_str.starts_with("//");
let parsed = if scheme_relative {
url::Url::parse(&format!("https:{url_str}"))
} else {
url::Url::parse(url_str)
};
let sanitized = match parsed {
Ok(mut url) if url.has_host() => {
let _ = url.set_username("");
let _ = url.set_password(None);
url.set_query(None);
url.set_fragment(None);
let rendered = url.as_str();
if scheme_relative {
rendered
.strip_prefix("https:")
.unwrap_or(rendered)
.to_string()
} else {
rendered.to_string()
}
}
_ => {
// 解析失败(相对路径、含裸 userinfo 的非法 URL 等):丢掉 query/fragment
// 尽力剥掉 userinfo,其余原样保留。
let without_tail = url_str.split(['?', '#']).next().unwrap_or(url_str);
strip_bare_userinfo(without_tail).to_string()
}
};
redact_known_secrets(&sanitized, known_secrets)
}
/// 只保留 `scheme://host:port`,丢掉 path/query/userinfo。用于我们手里没有任何
/// 已知密钥可脱敏 path 的场景——凭据可能整个内嵌在 base_url 的 path 里,此时
/// 记录 path 无法保证不泄漏,只能退回到 origin。
pub(crate) fn redact_url_origin_for_log(url_str: &str) -> String {
let scheme_relative = url_str.starts_with("//");
let parsed = if scheme_relative {
url::Url::parse(&format!("https:{url_str}"))
} else {
url::Url::parse(url_str)
};
match parsed {
Ok(url) if url.has_host() => {
let authority = &url[url::Position::BeforeHost..url::Position::AfterPort];
if scheme_relative {
format!("//{authority}")
} else {
format!("{}://{authority}", url.scheme())
}
}
_ => "[invalid target]".to_string(),
}
}
fn runtime_log_level_allows(level: log::Level, max_level: log::LevelFilter) -> bool {
max_level.to_level().is_some_and(|maximum| level <= maximum)
}
/// 统一处理 ccswitch:// 深链接 URL
@@ -138,9 +232,10 @@ fn handle_deeplink_url(
return false;
}
let redacted_url = redact_url_for_log(url_str);
log::info!("✓ Deep link URL detected from {source}: {redacted_url}");
log::debug!("Deep link URL (raw) from {source}: {url_str}");
log::info!(
"✓ Deep link URL detected from {source}: {}",
url_for_log(url_str)
);
match crate::deeplink::parse_deeplink_url(url_str) {
Ok(request) => {
@@ -236,7 +331,7 @@ pub fn run() {
log::info!("=== Single Instance Callback Triggered ===");
log::debug!("Args count: {}", args.len());
for (i, arg) in args.iter().enumerate() {
log::debug!(" arg[{i}]: {}", redact_url_for_log(arg));
log::debug!(" arg[{i}]: {}", url_for_log(arg));
}
if crate::lightweight::is_lightweight_mode() {
@@ -321,21 +416,8 @@ pub fn run() {
// 预先刷新 Store 覆盖配置,确保后续路径读取正确(日志/数据库等)
app_store::refresh_app_config_dir_override(app.handle());
panic_hook::init_app_config_dir(crate::config::get_app_config_dir());
#[cfg(target_os = "windows")]
set_windows_app_user_model_id(app.handle());
// 注册 Updater 插件(桌面端
#[cfg(desktop)]
{
if let Err(e) = app
.handle()
.plugin(tauri_plugin_updater::Builder::new().build())
{
// 若配置不完整(如缺少 pubkey),跳过 Updater 而不中断应用
log::warn!("初始化 Updater 插件失败,已跳过:{e}");
}
}
// 初始化日志(单文件输出到 <app_config_dir>/logs/cc-switch.log
// 初始化日志(输出到 <app_config_dir>/logs/cc-switch.log
{
use tauri_plugin_log::{RotationStrategy, Target, TargetKind, TimezoneStrategy};
@@ -346,14 +428,16 @@ pub fn run() {
eprintln!("创建日志目录失败: {e}");
}
// 启动时删除旧日志文件,实现单文件覆盖效果
let log_file_path = log_dir.join("cc-switch.log");
let _ = std::fs::remove_file(&log_file_path);
app.handle().plugin(
tauri_plugin_log::Builder::default()
// 初始化为 Trace,允许后续通过 log::set_max_level() 动态调级别
// 底层保留 Trace 能力,便于加载用户配置后动态调级别
// 插件注册后会立即把全局级别收紧到 Info,避免启动阶段全量 Trace。
.level(log::LevelFilter::Trace)
// plugin-log 的前端 command 会直达 logger,绕过 log 宏的全局
// max_level;在分发层补一次过滤,确保动态总开关同样约束前端日志。
.filter(|metadata| {
runtime_log_level_allows(metadata.level(), log::max_level())
})
.targets([
Target::new(TargetKind::Stdout),
Target::new(TargetKind::Folder {
@@ -361,15 +445,36 @@ pub fn run() {
file_name: Some("cc-switch".into()),
}),
])
// 单文件模式:启动时删除旧文件,达到大小时轮转
// 注意:KeepSome(n) 内部会做 n-2 运算,n=1 会导致 usize 下溢
// KeepSome(2) 是最小安全值,表示不保留轮转文件
.rotation_strategy(RotationStrategy::KeepSome(2))
// 单文件大小限制 1GB
.max_file_size(1024 * 1024 * 1024)
// KeepSome(4) 保留 4 个轮转归档,加上当前文件最多约 100 MiB。
// 轮转仅按大小触发;跨重启继续追加,不再丢失上一次运行的日志。
.rotation_strategy(RotationStrategy::KeepSome(4))
.max_file_size(20 * 1024 * 1024)
.timezone_strategy(TimezoneStrategy::UseLocal)
.build(),
)?;
// 用户配置存在数据库中,数据库尚未打开时使用保守的 Info 级别。
log::set_max_level(log::LevelFilter::Info);
log::info!("=== CC Switch v{} started ===", env!("CARGO_PKG_VERSION"));
}
// 首次读取覆盖路径时 logger 尚未可用;此处重放一次,
// 让 Store 损坏或路径无效等启动警告能够真正落盘。
let _ = app_store::refresh_app_config_dir_override(app.handle());
#[cfg(target_os = "windows")]
set_windows_app_user_model_id(app.handle());
// 注册 Updater 插件(桌面端);放在 logger 之后,确保失败可诊断。
#[cfg(desktop)]
{
if let Err(e) = app
.handle()
.plugin(tauri_plugin_updater::Builder::new().build())
{
// 若配置不完整(如缺少 pubkey),跳过 Updater 而不中断应用
log::warn!("初始化 Updater 插件失败,已跳过:{e}");
}
}
// 注入 AppHandle 给 usage_events,让无 AppHandle 持有的写日志路径
@@ -466,6 +571,23 @@ pub fn run() {
}
};
// 数据库可用后立即应用持久化日志级别,避免后续服务初始化
// 继续使用启动阶段的 Info 回退。损坏配置显式 fail-closed 到 Info。
match db.get_log_config() {
Ok(log_config) => {
log::set_max_level(log_config.to_level_filter());
log::info!(
"已加载日志配置: enabled={}, level={}",
log_config.enabled,
log_config.level
);
}
Err(e) => {
log::set_max_level(log::LevelFilter::Info);
log::warn!("读取日志配置失败,已回退到 info: {e}");
}
}
// 如果有预加载的配置,执行迁移
if let Some(config) = migration_config {
log::info!("开始执行数据迁移...");
@@ -897,7 +1019,7 @@ pub fn run() {
for (i, url) in urls.iter().enumerate() {
let url_str = url.as_str();
log::debug!(" URL[{i}]: {}", redact_url_for_log(url_str));
log::debug!(" URL[{i}]: {}", url_for_log(url_str));
if handle_deeplink_url(&app_handle, url_str, true, "on_open_url") {
break; // Process only first ccswitch:// URL
@@ -965,19 +1087,6 @@ pub fn run() {
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
app.manage(app_state);
// 从数据库加载日志配置并应用
{
let db = &app.state::<AppState>().db;
if let Ok(log_config) = db.get_log_config() {
log::set_max_level(log_config.to_level_filter());
log::info!(
"已加载日志配置: enabled={}, level={}",
log_config.enabled,
log_config.level
);
}
}
// 初始化 SkillService
let skill_service = SkillService::new();
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
@@ -1611,7 +1720,10 @@ pub fn run() {
RunEvent::Opened { urls } => {
if let Some(url) = urls.first() {
let url_str = url.to_string();
log::info!("RunEvent::Opened with URL: {url_str}");
log::info!(
"RunEvent::Opened with URL: {}",
url_for_log(&url_str)
);
if url_str.starts_with("ccswitch://") {
if crate::lightweight::is_lightweight_mode() {
@@ -2087,9 +2199,94 @@ pub fn restart_process(app_handle: &tauri::AppHandle) -> ! {
#[cfg(test)]
mod tests {
use super::{classify_exit_request, enabled_proxy_apps_on_startup, ExitRequestAction};
use super::{
classify_exit_request, enabled_proxy_apps_on_startup, redact_url_for_log,
redact_url_for_log_with_secrets, redact_url_origin_for_log, runtime_log_level_allows,
ExitRequestAction,
};
use crate::database::Database;
#[test]
fn log_url_redaction_strips_credentials_and_query_keeps_path() {
// userinfo 与整个 query 剥离,path 保留用于诊断 base_url 配错。
assert_eq!(
redact_url_for_log(
"https://user:secret@example.com:8443/v1/models?key=top-secret&alt=sse"
),
"https://example.com:8443/v1/models"
);
// scheme-relative 保持形态,userinfo 去掉。
assert_eq!(
redact_url_for_log("//user:sk-secret@gw.example.com/v1"),
"//gw.example.com/v1"
);
// 无 scheme 的裸 userinfo。
assert_eq!(
redact_url_for_log("user:sk-secret@gw.example.com/v1"),
"gw.example.com/v1"
);
// 无法解析为绝对 URL 时:丢 query,其余原样保留。
assert_eq!(redact_url_for_log("not-a-url?token=secret"), "not-a-url");
// 不再对 path 段做“看起来像密钥”的形状猜测,正常路径完整保留。
assert_eq!(
redact_url_for_log("https://host.example/v1/models/gemini-2.5-pro"),
"https://host.example/v1/models/gemini-2.5-pro"
);
}
#[test]
fn log_url_redaction_replaces_known_secret_values() {
// 精确匹配已知密钥值:无论它出现在 path 还是别处都被抹掉。
let secrets = vec!["k-9f3a7c2b1e".to_string()];
assert_eq!(
redact_url_for_log_with_secrets("https://gw.example.com/k-9f3a7c2b1e/v1", &secrets),
"https://gw.example.com/[REDACTED]/v1"
);
// 过短(<8)的已知值不参与子串脱敏,避免误伤 /v1/ 之类的正常路径。
let short_secrets = vec!["api".to_string()];
assert_eq!(
redact_url_for_log_with_secrets("https://api.example.com/v1", &short_secrets),
"https://api.example.com/v1"
);
}
#[test]
fn log_url_origin_drops_path_for_credential_in_path() {
// 没有已知密钥可脱敏时,凭据可能整个内嵌在 path,只记 origin。
assert_eq!(
redact_url_origin_for_log("https://gw.example.com/k-9f3a7c2b1e/v1"),
"https://gw.example.com"
);
assert_eq!(
redact_url_origin_for_log("https://user:pass@gw.example.com:8443/secret/v1"),
"https://gw.example.com:8443"
);
assert_eq!(
redact_url_origin_for_log("//gw.example.com/secret/v1"),
"//gw.example.com"
);
}
#[test]
fn runtime_log_filter_honors_dynamic_max_level() {
assert!(!runtime_log_level_allows(
log::Level::Error,
log::LevelFilter::Off
));
assert!(runtime_log_level_allows(
log::Level::Error,
log::LevelFilter::Info
));
assert!(runtime_log_level_allows(
log::Level::Info,
log::LevelFilter::Info
));
assert!(!runtime_log_level_allows(
log::Level::Debug,
log::LevelFilter::Info
));
}
#[test]
fn no_code_keeps_app_alive_in_tray() {
assert_eq!(classify_exit_request(None), ExitRequestAction::StayInTray);