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
+1 -1
View File
@@ -8,7 +8,7 @@ use tauri::State;
/// Parse a deep link URL and return the parsed request for frontend confirmation
#[tauri::command]
pub fn parse_deeplink(url: String) -> Result<DeepLinkImportRequest, String> {
log::info!("Parsing deep link URL: {url}");
log::info!("Parsing deep link URL: {}", crate::url_for_log(&url));
parse_deeplink_url(&url).map_err(|e| e.to_string())
}
+4 -1
View File
@@ -122,7 +122,10 @@ pub fn import_provider_from_deeplink(
&provider_id,
normalized.clone(),
) {
log::warn!("Failed to add custom endpoint '{normalized}': {e}");
log::warn!(
"Failed to add custom endpoint '{}': {e}",
crate::url_for_log(&normalized)
);
}
}
}
+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);
+44 -6
View File
@@ -84,7 +84,9 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
// 核心字段(需要手动处理的字段)
let core_fields = match typ {
"stdio" => vec!["type", "command", "args", "env", "cwd"],
"http" | "sse" => vec!["type", "url", "http_headers"],
// DB 中的统一规范使用 headersCodex TOML 使用 http_headers。
// 两者都必须视为核心字段,避免鉴权值落入通用日志路径。
"http" | "sse" => vec!["type", "url", "headers", "http_headers"],
_ => vec!["type"],
};
@@ -204,7 +206,7 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
if let Some(val) = json_val {
spec.insert(key.clone(), val);
log::debug!("导入扩展字段 '{key}' = {toml_val:?}");
log::debug!("导入扩展字段 '{key}'(值已省略)");
}
}
@@ -567,7 +569,7 @@ pub(super) fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table
// 定义核心字段(已在下方处理,跳过通用转换)
let core_fields = match typ {
"stdio" => vec!["type", "command", "args", "env", "cwd"],
"http" | "sse" => vec!["type", "url", "http_headers"],
"http" | "sse" => vec!["type", "url", "headers", "http_headers"],
_ => vec!["type"],
};
@@ -664,11 +666,11 @@ pub(super) fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table
if let Some(toml_item) = json_value_to_toml_item(value, key) {
t[&key[..]] = toml_item;
// 记录扩展字段的处理
// 记录字段名:未知字段同样可能携带 token / secret。
if extended_fields.contains(&key.as_str()) {
log::debug!("已转换扩展字段 '{key}' = {value:?}");
log::debug!("已转换扩展字段 '{key}'(值已省略)");
} else {
log::info!("已转换自定义字段 '{key}' = {value:?}");
log::debug!("已转换自定义字段 '{key}'(值已省略)");
}
}
}
@@ -676,3 +678,39 @@ pub(super) fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table
Ok(t)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn http_headers_are_only_written_to_codex_http_headers() {
let table = json_server_to_toml_table(&json!({
"type": "http",
"url": "https://mcp.example.com",
"headers": {
"Authorization": "Bearer top-secret",
"X-Api-Key": "also-secret"
},
"timeout": 30
}))
.unwrap();
let headers = table
.get("http_headers")
.and_then(|item| item.as_table())
.expect("Codex http_headers table should be written");
assert_eq!(
headers.get("Authorization").and_then(|item| item.as_str()),
Some("Bearer top-secret")
);
assert!(
table.get("headers").is_none(),
"legacy headers must not be emitted a second time"
);
assert_eq!(
table.get("timeout").and_then(|item| item.as_integer()),
Some(30)
);
}
}
+103 -8
View File
@@ -3,16 +3,19 @@
//! 在应用崩溃时捕获 panic 信息并记录到 `<app_config_dir>/crash.log` 文件中(默认 `~/.cc-switch/crash.log`)。
//! 便于用户和开发者诊断闪退问题。
use std::fs::OpenOptions;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::panic;
use std::path::PathBuf;
use std::sync::OnceLock;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
/// 应用版本号(从 Cargo.toml 读取)
const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
const CRASH_LOG_MAX_SIZE: u64 = 5 * 1024 * 1024;
const CRASH_LOG_ARCHIVES_TO_KEEP: usize = 2;
static APP_CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
static CRASH_LOG_LOCK: Mutex<()> = Mutex::new(());
pub fn init_app_config_dir(dir: PathBuf) {
let _ = APP_CONFIG_DIR.set(dir);
@@ -38,6 +41,50 @@ fn get_crash_log_path() -> PathBuf {
get_app_config_dir().join("crash.log")
}
fn rotated_crash_log_path(path: &Path, index: usize) -> PathBuf {
let mut rotated = path.as_os_str().to_os_string();
rotated.push(format!(".{index}"));
PathBuf::from(rotated)
}
fn rotate_crash_log_if_needed_with_limit(
path: &Path,
max_size: u64,
archives_to_keep: usize,
) -> std::io::Result<()> {
let size = match fs::metadata(path) {
Ok(metadata) => metadata.len(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e),
};
if size < max_size || archives_to_keep == 0 {
return Ok(());
}
for index in (1..=archives_to_keep).rev() {
let source = if index == 1 {
path.to_path_buf()
} else {
rotated_crash_log_path(path, index - 1)
};
if !source.exists() {
continue;
}
let destination = rotated_crash_log_path(path, index);
if destination.exists() {
fs::remove_file(&destination)?;
}
fs::rename(source, destination)?;
}
Ok(())
}
fn rotate_crash_log_if_needed(path: &Path) -> std::io::Result<()> {
rotate_crash_log_if_needed_with_limit(path, CRASH_LOG_MAX_SIZE, CRASH_LOG_ARCHIVES_TO_KEEP)
}
/// 获取日志目录路径
pub fn get_log_dir() -> PathBuf {
get_app_config_dir().join("logs")
@@ -167,12 +214,23 @@ Stack Trace (Backtrace)
"#
);
// 写入文件(追加模式)
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(&log_path) {
let _ = file.write_all(crash_entry.as_bytes());
let _ = file.flush();
// 将 size check、轮转和追加合成同一个临界区,避免多线程同时 panic
// 时两个 hook 竞争 rename 而丢失归档。
let crash_log_guard = CRASH_LOG_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let _ = rotate_crash_log_if_needed(&log_path);
let saved =
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(&log_path) {
let _ = file.write_all(crash_entry.as_bytes());
let _ = file.flush();
true
} else {
false
};
drop(crash_log_guard);
// 记录日志文件位置到 stderr
if saved {
eprintln!("\n[CC-Switch] Crash log saved to: {}", log_path.display());
}
@@ -202,4 +260,41 @@ mod tests {
assert!(info.contains("Arch:"));
assert!(info.contains("App Version:"));
}
#[test]
fn crash_log_rotation_keeps_bounded_archives() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("crash.log");
fs::write(&path, b"first").unwrap();
rotate_crash_log_if_needed_with_limit(&path, 4, 2).unwrap();
assert!(!path.exists());
assert_eq!(
fs::read(rotated_crash_log_path(&path, 1)).unwrap(),
b"first"
);
fs::write(&path, b"second").unwrap();
rotate_crash_log_if_needed_with_limit(&path, 4, 2).unwrap();
assert_eq!(
fs::read(rotated_crash_log_path(&path, 1)).unwrap(),
b"second"
);
assert_eq!(
fs::read(rotated_crash_log_path(&path, 2)).unwrap(),
b"first"
);
fs::write(&path, b"third").unwrap();
rotate_crash_log_if_needed_with_limit(&path, 4, 2).unwrap();
assert_eq!(
fs::read(rotated_crash_log_path(&path, 1)).unwrap(),
b"third"
);
assert_eq!(
fs::read(rotated_crash_log_path(&path, 2)).unwrap(),
b"second"
);
assert!(!rotated_crash_log_path(&path, 3).exists());
}
}
+36 -32
View File
@@ -1563,7 +1563,9 @@ impl RequestForwarder {
let mut codex_oauth_account_id: Option<String> = None;
let mut should_send_codex_oauth_session_headers = false;
// 获取认证头(提前准备,用于内联替换)
// 获取认证头(提前准备,用于内联替换),同时保留仅用于日志脱敏的
// 精确认证材料。实际日志永远不输出这些值。
let mut log_secrets: Vec<String> = Vec::new();
let mut auth_headers = if let Some(mut auth) = adapter.extract_auth(provider) {
// GitHub Copilot 特殊处理:从 CopilotAuthManager 获取真实 token
if auth.strategy == AuthStrategy::GitHubCopilot {
@@ -1669,6 +1671,12 @@ impl RequestForwarder {
}
}
for secret in std::iter::once(&auth.api_key).chain(auth.access_token.iter()) {
if !secret.is_empty() && !log_secrets.contains(secret) {
log_secrets.push(secret.clone());
}
}
adapter.get_auth_headers(&auth)?
} else {
Vec::new()
@@ -2074,22 +2082,29 @@ impl RequestForwarder {
reject_proxy_placeholder_for_managed_account_upstream(&url, &ordered_headers)?;
// 日志目标 URL 的脱敏分两种情形:
// - 有已知密钥(log_secrets 非空):记录脱敏后的完整 URL,剥 userinfo/query
// 并抹掉已知密钥值,保留 host+path 便于诊断 base_url 配错路径导致的 404。
// - 无已知密钥:凭据可能整个内嵌在 path 里且无从脱敏,只记 origin,
// 避免默认 Info 级把形如 https://gw/<KEY>/v1 的 path 完整落盘。
let target_for_log = if log_secrets.is_empty() {
crate::redact_url_origin_for_log(&url)
} else {
crate::redact_url_for_log_with_secrets(&url, &log_secrets)
};
// 输出请求信息日志
let tag = adapter.name();
let request_model = filtered_body
.get("model")
.and_then(|v| v.as_str())
.unwrap_or("<none>");
log::info!("[{tag}] >>> 请求 URL: {url} (model={request_model})");
if log::log_enabled!(log::Level::Debug) {
if let Ok(body_str) = serde_json::to_string(&filtered_body) {
log::debug!(
"[{tag}] >>> 请求体内容 ({}字节): {}",
body_str.len(),
body_str
);
}
}
log::info!("[{tag}] >>> 请求目标: {target_for_log} (model={request_model})");
log::debug!(
"[{tag}] >>> 请求体已准备: bytes={}, hash={} (content omitted)",
body_bytes.len(),
short_value_hash(Some(&filtered_body))
);
// 确定超时
let timeout = if self.non_streaming_timeout.is_zero() {
@@ -2156,11 +2171,12 @@ impl RequestForwarder {
} else {
// HTTP 代理或直连:走 hyper raw write(保持 header 大小写)
// 如果有 HTTP 代理,hyper_client 会用 CONNECT 隧道穿过代理
let uri: http::Uri = url
.parse()
.map_err(|e| ProxyError::ForwardFailed(format!("Invalid URL '{url}': {e}")))?;
let uri: http::Uri = url.parse().map_err(|e| {
ProxyError::ForwardFailed(format!("Invalid upstream URL ({target_for_log}): {e}"))
})?;
super::hyper_client::send_request(
uri,
&target_for_log,
method.clone(),
ordered_headers,
extensions.clone(),
@@ -3186,11 +3202,11 @@ fn should_force_identity_encoding(
fn map_reqwest_send_error(error: reqwest::Error) -> ProxyError {
if error.is_timeout() {
ProxyError::Timeout(format!("请求超时: {error}"))
ProxyError::Timeout(format!("上游请求超时: {}", error.without_url()))
} else if error.is_connect() {
ProxyError::ForwardFailed(format!("连接失败: {error}"))
ProxyError::ForwardFailed(format!("上游连接失败: {}", error.without_url()))
} else {
ProxyError::ForwardFailed(error.to_string())
ProxyError::ForwardFailed(format!("上游请求发送失败: {}", error.without_url()))
}
}
@@ -3390,7 +3406,8 @@ fn log_prompt_cache_trace(
"[CacheTrace] app={}, provider={}, endpoint={}, api_format={}, session_client_provided={}, prompt_cache_key={}, store={}, stream={}, instructions_hash={}, system_hash={}, tools_hash={}, input_hash={}, messages_hash={}, include_hash={}, cache_controls={}, body_hash={}",
app_type.as_str(),
provider.id,
endpoint,
// Gemini 的 endpoint 带 ?key=<API_KEY>;脱敏剥掉 query 再落盘。
crate::redact_url_for_log(endpoint),
api_format.unwrap_or("native"),
session_client_provided,
prompt_cache_key,
@@ -3527,6 +3544,7 @@ mod tests {
assert_eq!(code, log_fwd::SINGLE_PROVIDER_FAILED);
assert!(message.contains("Provider PackyCode-response 请求失败"));
assert!(message.contains("上游 HTTP 429"));
// 上游错误消息保留(截断),用于诊断失败原因。
assert!(message.contains("rate limit exceeded"));
assert!(!message.contains("切换下一个"));
}
@@ -3559,20 +3577,6 @@ mod tests {
assert!(message.contains("connection reset by peer"));
}
#[test]
fn summarize_upstream_body_prefers_json_message() {
let body = json!({
"error": {
"message": "invalid_request_error: unsupported field"
},
"request_id": "req_123"
});
let summary = summarize_upstream_body(&body.to_string());
assert_eq!(summary, "invalid_request_error: unsupported field");
}
#[test]
fn summarize_text_for_log_collapses_whitespace_and_truncates() {
let summary = summarize_text_for_log("line1\n\n line2 line3", 12);
+89 -51
View File
@@ -538,16 +538,22 @@ async fn handle_claude_transform(
} else {
chat_sse_to_response_value(&body_str)
};
// 聚合也失败时:保留全量 body 服务端日志,并给客户端错误附带同款
// 现场诊断(content-type/body 摘要),否则命中嗅探臂的用户只拿到
// 聚合也失败时:服务端日志只记录长度,并给客户端错误附带同款
// 现场诊断(content-type/body 分类),否则命中嗅探臂的用户只拿到
// 裸聚合错误、丢失非嗅探臂已有的诊断增强(C7)
aggregated.map_err(|e| {
log::error!("[Claude] SSE 聚合兜底失败: {e}, body: {body_str}");
log::error!(
"[Claude] SSE 聚合兜底失败: {e}, body_bytes={}",
body_bytes.len()
);
aggregate_fallback_error(e, &response_headers, &body_str)
})?
}
Err(e) => {
log::error!("[Claude] 解析上游响应失败: {e}, body: {body_str}");
log::error!(
"[Claude] 解析上游响应失败: {e}, body_bytes={}",
body_bytes.len()
);
return Err(upstream_body_parse_error(
"Failed to parse upstream response",
&e,
@@ -1108,14 +1114,20 @@ async fn handle_codex_chat_to_responses_transform(
// 上游对 stream:false 返回未标记 Content-Type 的 SSE 体时按 SSE 聚合。
Err(_) if body_looks_like_sse(&body_str) => {
log::warn!("[Codex] 上游对非流请求返回未标记的 SSE 体,按 Chat SSE 聚合兜底");
// 聚合也失败时:保留全量 body 服务端日志,并给客户端错误附带现场诊断(C7)
// 聚合也失败时:服务端日志只记录长度,并给客户端错误附带现场诊断(C7
chat_sse_to_response_value(&body_str).map_err(|e| {
log::error!("[Codex] SSE 聚合兜底失败: {e}, body: {body_str}");
log::error!(
"[Codex] SSE 聚合兜底失败: {e}, body_bytes={}",
body_bytes.len()
);
aggregate_fallback_error(e, &response_headers, &body_str)
})?
}
Err(e) => {
log::error!("[Codex] 解析 Chat 上游响应失败: {e}, body: {body_str}");
log::error!(
"[Codex] 解析 Chat 上游响应失败: {e}, body_bytes={}",
body_bytes.len()
);
return Err(upstream_body_parse_error(
"Failed to parse upstream chat response",
&e,
@@ -1270,7 +1282,8 @@ async fn handle_codex_anthropic_to_responses_transform(
}
Err(e) => {
log::error!(
"[Codex] Failed to parse Anthropic upstream response: {e}, body: {body_str}"
"[Codex] Failed to parse Anthropic upstream response: {e}, body_bytes={}",
body_bytes.len()
);
return Err(upstream_body_parse_error(
"Failed to parse upstream anthropic response",
@@ -1494,7 +1507,10 @@ async fn handle_codex_chat_error_response(
} else {
lossy.into_owned()
};
log::warn!("[Codex] Chat 错误响应不是合法 JSON,按文本透传: {truncated}");
log::warn!(
"[Codex] Chat 错误响应不是合法 JSON,按文本透传: body_bytes={} (content omitted)",
body_bytes.len()
);
Value::String(truncated)
}
};
@@ -1914,9 +1930,8 @@ fn body_looks_like_sse(body: &str) -> bool {
.any(|prefix| trimmed.starts_with(prefix))
}
/// 构造带现场诊断的上游解析错误:附 content-type / content-encoding 与 body
/// 前缀摘要,让客户端收到的报错自带根因判别("data:"=错标 SSE、"<"=HTML
/// 拦截页、 乱码=未解压二进制),不再依赖向用户索要服务端日志。
/// 构造带现场诊断的上游解析错误:只附结构化分类与元数据,
/// 避免响应正文经错误链间接进入持久化日志。
fn upstream_body_parse_error(
prefix: &str,
err: &serde_json::Error,
@@ -1929,8 +1944,8 @@ fn upstream_body_parse_error(
))
}
/// SSE 聚合兜底失败时,给聚合器内部错误附加同款现场诊断content-type/
/// content-encoding/body 摘要),使命中 #2234 嗅探臂的客户端也拿到根因线索,
/// SSE 聚合兜底失败时,给聚合器内部错误附加同款现场诊断
/// 使命中 #2234 嗅探臂的客户端也拿到根因线索,
/// 而非仅 "No chat completion choices in upstream SSE" 这类无 header/body 的裸消息。
fn aggregate_fallback_error(
err: ProxyError,
@@ -1944,7 +1959,43 @@ fn aggregate_fallback_error(
ProxyError::TransformError(format!("{base} {}", body_diagnostics_suffix(headers, body)))
}
/// 现场诊断后缀:content-type、content-encoding 与 body 前 120 字符摘要
/// 将正文归入有限类别,保留 HTML/SSE/乱码等关键线索而不记录正文
fn classify_body_for_diagnostics(body: &str) -> &'static str {
let trimmed = body.trim_start_matches('\u{feff}').trim_start();
if trimmed.is_empty() {
return "empty";
}
if body_looks_like_sse(trimmed) {
return "sse";
}
// 分类只检查前 4 KiB,避免为了诊断再次线性扫描异常返回的超大正文。
let sample = trimmed.chars().take(4096).collect::<String>();
let prefix = sample
.chars()
.take(256)
.collect::<String>()
.to_ascii_lowercase();
if ["<!doctype html", "<html", "<head", "<body"]
.iter()
.any(|marker| prefix.starts_with(marker))
{
return "html";
}
if sample.contains('\u{fffd}')
|| sample
.chars()
.any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t'))
{
return "binary-or-encoded";
}
if prefix.starts_with('{') || prefix.starts_with('[') {
return "json-like";
}
"text"
}
/// 现场诊断后缀:content-type、content-encoding、body 长度与安全分类,不含正文。
fn body_diagnostics_suffix(headers: &axum::http::HeaderMap, body: &str) -> String {
let header_str = |name: &str| {
headers
@@ -1953,10 +2004,11 @@ fn body_diagnostics_suffix(headers: &axum::http::HeaderMap, body: &str) -> Strin
.unwrap_or("<none>")
};
format!(
"(content-type: {}; content-encoding: {}; body[..120]: '{}')",
"(content-type: {}; content-encoding: {}; body-bytes: {}; body-kind: {}; content omitted)",
header_str("content-type"),
header_str("content-encoding"),
body_snippet(body, 120),
body.len(),
classify_body_for_diagnostics(body),
)
}
@@ -1973,24 +2025,6 @@ fn error_event_message(error: &Value) -> Option<String> {
None
}
/// 取 body 前 `max_chars` 个字符的单行摘要:\r 丢弃、\n 折叠为字面 \n、
/// 其余控制字符替换为 ,超长加省略号。
fn body_snippet(body: &str, max_chars: usize) -> String {
let mut snippet = String::new();
for c in body.chars().take(max_chars) {
match c {
'\n' => snippet.push_str("\\n"),
'\r' => {}
c if c.is_control() => snippet.push('\u{FFFD}'),
c => snippet.push(c),
}
}
if body.chars().nth(max_chars).is_some() {
snippet.push('…');
}
snippet
}
/// 解析单个 SSE 块的 event 名与 data 负载(多行 data 按规范以 \n 连接)。
/// 行首允许前导空白后再匹配字段名——与 body_looks_like_sse 的 trim 宽容度对齐,
/// 否则缩进的 ` data:` 行被嗅探接受却在此静默丢失(C4)。返回 None 表示无 data 行。
@@ -2437,9 +2471,9 @@ async fn log_usage(
#[cfg(test)]
mod tests {
use super::{
body_looks_like_sse, body_snippet, chat_sse_to_response_value, codex_proxy_error_json,
responses_sse_to_response_value, should_use_claude_transform_streaming, transform,
upstream_body_parse_error,
body_looks_like_sse, chat_sse_to_response_value, classify_body_for_diagnostics,
codex_proxy_error_json, responses_sse_to_response_value,
should_use_claude_transform_streaming, transform, upstream_body_parse_error,
};
use crate::proxy::ProxyError;
@@ -2480,7 +2514,9 @@ mod tests {
ProxyError::TransformError(msg) => {
assert!(msg.contains("content-type: text/html"), "{msg}");
assert!(msg.contains("content-encoding: gzip"), "{msg}");
assert!(msg.contains("<html>\\nblocked</html>"), "{msg}");
assert!(msg.contains("body-bytes: 21"), "{msg}");
assert!(msg.contains("body-kind: html"), "{msg}");
assert!(!msg.contains("blocked"), "{msg}");
}
other => panic!("expected TransformError, got {other:?}"),
}
@@ -2497,11 +2533,25 @@ mod tests {
ProxyError::TransformError(msg) => {
assert!(msg.contains("content-type: <none>"), "{msg}");
assert!(msg.contains("content-encoding: <none>"), "{msg}");
assert!(msg.contains("body-kind: sse"), "{msg}");
}
other => panic!("expected TransformError, got {other:?}"),
}
}
#[test]
fn body_diagnostics_classifies_without_exposing_content() {
assert_eq!(classify_body_for_diagnostics(""), "empty");
assert_eq!(classify_body_for_diagnostics(" <HTML>blocked"), "html");
assert_eq!(classify_body_for_diagnostics("data: {}\n\n"), "sse");
assert_eq!(classify_body_for_diagnostics("{\"ok\":true}"), "json-like");
assert_eq!(
classify_body_for_diagnostics("decoded\u{fffd}payload"),
"binary-or-encoded"
);
assert_eq!(classify_body_for_diagnostics("Bad Gateway"), "text");
}
#[test]
fn chat_sse_to_response_value_collects_reasoning_alias() {
// OpenRouter/Kimi 用 reasoning(字符串),部分网关用对象形态
@@ -2641,18 +2691,6 @@ data: {\"id\":\"c1\",\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant
assert_eq!(response["choices"][0]["message"]["content"], "hi");
}
#[test]
fn body_snippet_sanitizes_controls_and_truncates() {
assert_eq!(
body_snippet("<html>\r\nblocked\u{0}</html>", 120),
"<html>\\nblocked\u{FFFD}</html>"
);
let long = "a".repeat(200);
let snippet = body_snippet(&long, 120);
assert_eq!(snippet.chars().count(), 121); // 120 个字符 + 省略号
assert!(snippet.ends_with('…'));
}
#[test]
fn chat_sse_to_response_value_aggregates_text_finish_reason_and_usage() {
let sse = "data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"created\":123,\"model\":\"gpt-5.4\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Hel\"},\"finish_reason\":null}]}\n\n\
+8 -3
View File
@@ -241,8 +241,13 @@ impl ProxyResponse {
/// `proxy_url`: optional upstream HTTP proxy URL (e.g. `http://127.0.0.1:7890`).
/// When set, the raw write path uses HTTP CONNECT tunneling through the proxy,
/// so header-case preservation works even when an upstream proxy is configured.
///
/// `log_display` is a caller-supplied, already-sanitized string used only for
/// logging; this layer never derives a log value from the raw `uri`.
#[allow(clippy::too_many_arguments)]
pub async fn send_request(
uri: http::Uri,
log_display: &str,
method: http::Method,
headers: http::HeaderMap,
original_extensions: http::Extensions,
@@ -256,13 +261,13 @@ pub async fn send_request(
.as_ref()
.map(|c| !c.cases.is_empty())
.unwrap_or(false);
log::debug!(
"[HyperClient] Sending request: uri={uri}, header_count={}, \
"[HyperClient] Sending request: target={}, header_count={}, \
has_host={}, has_original_cases={has_cases}, proxy={:?}",
log_display,
headers.len(),
headers.contains_key(http::header::HOST),
proxy_url,
proxy_url.map(super::http_client::mask_url),
);
if let Some(original_cases) = original_cases
+70 -14
View File
@@ -225,9 +225,9 @@ pub async fn handle_non_streaming(
strip_hop_by_hop_response_headers(&mut response_headers);
log::debug!(
"[{}] 上游响应体内容: {}",
"[{}] 上游响应体已接收: bytes={} (content omitted)",
ctx.tag,
String::from_utf8_lossy(&body_bytes)
body_bytes.len()
);
// 解析并记录使用量。关闭 usage logging 时直接跳过,避免非流式响应整包 JSON parse。
@@ -761,11 +761,10 @@ pub fn create_logged_passthrough_stream(
}
_ => false,
};
if collected {
log::debug!("[{tag}] <<< SSE 事件: {data}");
} else {
log::debug!("[{tag}] <<< SSE 数据: {data}");
}
log::trace!(
"[{tag}] <<< SSE data: bytes={}, usage_collected={collected} (content omitted)",
data.len()
);
} else {
log::debug!("[{tag}] <<< SSE: [DONE]");
}
@@ -798,15 +797,53 @@ pub fn create_logged_passthrough_stream(
}
}
fn is_safe_diagnostic_header(name: &str) -> bool {
matches!(
name,
"content-type"
| "content-encoding"
| "content-length"
| "retry-after"
| "cf-ray"
| "x-request-id"
| "request-id"
| "x-correlation-id"
) || name.starts_with("x-ratelimit-")
|| name.starts_with("ratelimit-")
}
fn bounded_header_value(value: &axum::http::HeaderValue) -> Option<String> {
let value = value.to_str().ok()?;
let mut bounded = value.chars().take(160).collect::<String>();
if value.chars().count() > 160 {
bounded.push('…');
}
Some(bounded)
}
fn format_headers(headers: &HeaderMap) -> String {
headers
.iter()
.map(|(key, value)| {
let value_str = value.to_str().unwrap_or("<non-utf8>");
format!("{key}={value_str}")
let mut entries = headers
.keys()
.map(|key| {
let name = key.as_str();
if !is_safe_diagnostic_header(name) {
return name.to_string();
}
let values = headers
.get_all(key)
.iter()
.filter_map(bounded_header_value)
.collect::<Vec<_>>();
if values.is_empty() {
name.to_string()
} else {
format!("{name}={}", values.join("|"))
}
})
.collect::<Vec<_>>()
.join(", ")
.collect::<Vec<_>>();
entries.sort();
format!("[{}]", entries.join(", "))
}
#[cfg(test)]
@@ -827,6 +864,25 @@ mod tests {
use std::sync::Arc;
use tokio::sync::RwLock;
#[test]
fn format_headers_keeps_only_allowlisted_diagnostic_values() {
let mut headers = HeaderMap::new();
headers.insert("authorization", "Bearer super-secret".parse().unwrap());
headers.insert("set-cookie", "session=cookie-secret".parse().unwrap());
headers.insert("retry-after", "30".parse().unwrap());
headers.insert("x-ratelimit-remaining", "2".parse().unwrap());
headers.insert("cf-ray", "abc123-SJC".parse().unwrap());
let formatted = format_headers(&headers);
assert!(formatted.contains("authorization"), "{formatted}");
assert!(formatted.contains("set-cookie"), "{formatted}");
assert!(formatted.contains("retry-after=30"), "{formatted}");
assert!(formatted.contains("x-ratelimit-remaining=2"), "{formatted}");
assert!(formatted.contains("cf-ray=abc123-SJC"), "{formatted}");
assert!(!formatted.contains("super-secret"), "{formatted}");
assert!(!formatted.contains("cookie-secret"), "{formatted}");
}
#[test]
fn test_strip_sse_field_accepts_optional_space() {
assert_eq!(
+5 -1
View File
@@ -65,9 +65,13 @@ pub async fn fetch_models(
let candidates = build_models_url_candidates(base_url, is_full_url, models_url_override)?;
let client = crate::proxy::http_client::get();
let mut last_err: Option<String> = None;
let log_secrets = vec![api_key.to_string()];
for url in &candidates {
log::debug!("[ModelFetch] Trying endpoint: {url}");
log::debug!(
"[ModelFetch] Trying endpoint: {}",
crate::url_for_log_with_secrets(url, &log_secrets)
);
let mut request = client
.get(url)
.header("Authorization", format!("Bearer {api_key}"))
+4 -28
View File
@@ -401,33 +401,7 @@ pub fn webdav_status_error(op: &str, status: StatusCode, url: &str) -> AppError
}
fn redact_url(raw: &str) -> String {
match Url::parse(raw) {
Ok(mut parsed) => {
let _ = parsed.set_username("");
let _ = parsed.set_password(None);
let mut out = format!("{}://", parsed.scheme());
if let Some(host) = parsed.host_str() {
out.push_str(host);
}
if let Some(port) = parsed.port() {
out.push(':');
out.push_str(&port.to_string());
}
out.push_str(parsed.path());
let mut keys: Vec<String> = parsed.query_pairs().map(|(k, _)| k.into_owned()).collect();
keys.sort();
keys.dedup();
if !keys.is_empty() {
out.push_str("?[keys:");
out.push_str(&keys.join(","));
out.push(']');
}
out
}
Err(_) => raw.split('?').next().unwrap_or(raw).to_string(),
}
crate::redact_url_for_log(raw)
}
fn response_too_large_error(url: &str, max_bytes: usize) -> AppError {
@@ -520,9 +494,11 @@ mod tests {
#[test]
fn redact_url_hides_credentials_and_query_values() {
// userinfo 与整个 query 一并剥离,host+path 保留用于诊断。
let redacted = redact_url("https://alice:secret@example.com:8443/dav?token=abc&foo=1");
assert_eq!(redacted, "https://example.com:8443/dav?[keys:foo,token]");
assert_eq!(redacted, "https://example.com:8443/dav");
assert!(!redacted.contains("secret"));
assert!(!redacted.contains("abc"));
}
#[test]