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
+8 -3
View File
@@ -275,7 +275,12 @@ If none of the above solutions work:
### Log Files
Attach log files when submitting an Issue:
CC Switch stores logs under its application configuration directory. By default this is `.cc-switch` in your home directory; if you changed the configuration directory in Advanced Settings, use that custom location instead.
- macOS/Linux: `~/.cc-switch/logs/`
- Windows: `%APPDATA%\cc-switch\logs\`
When submitting an Issue, attach the files relevant to the problem:
- General, network, or proxy errors: `~/.cc-switch/logs/cc-switch.log` and its rotated files
- Application crashes: `~/.cc-switch/crash.log`, `crash.log.1`, and `crash.log.2`
- The default Windows location is `C:\Users\<username>\.cc-switch\...`
The runtime log rotates at 20 MB and retains the four most recent archives. Restarting the application does not clear existing logs. Logs may contain runtime environment details, so review them before posting publicly.
+8 -3
View File
@@ -275,7 +275,12 @@ Codex OAuth リバースプロキシは **リバースエンジニアリング
### ログファイル
Issue を提出する際にログファイルを添付できます:
CC Switch のログはアプリケーション設定ディレクトリに保存されます。既定ではホームディレクトリ内の `.cc-switch` です。詳細設定で設定ディレクトリを変更した場合は、その場所を使用してください。
- macOS/Linux`~/.cc-switch/logs/`
- Windows`%APPDATA%\cc-switch\logs\`
Issue を提出する際は、問題に応じて次のファイルを添付してください:
- 一般的なエラー、ネットワーク、プロキシの問題:`~/.cc-switch/logs/cc-switch.log` とローテーション済みファイル
- アプリケーションのクラッシュ:`~/.cc-switch/crash.log`、`crash.log.1`、`crash.log.2`
- Windows の既定パス:`C:\Users\<ユーザー名>\.cc-switch\...`
実行ログは 20 MB ごとにローテーションされ、直近 4 個のアーカイブを保持します。アプリを再起動しても既存ログは削除されません。公開する前に、ログの内容を確認してください。
+8 -3
View File
@@ -275,7 +275,12 @@ Codex OAuth 反向代理通过**逆向工程的 OAuth 流程**访问 ChatGPT 账
### 日志文件
提交 Issue 时可附上日志文件:
CC Switch 的日志位于应用配置目录中。默认配置目录是用户主目录下的 `.cc-switch`;如果你在高级设置中修改过配置目录,请以自定义目录为准。
- macOS/Linux`~/.cc-switch/logs/`
- Windows`%APPDATA%\cc-switch\logs\`
提交 Issue 时,请按问题类型附上:
- 普通错误、网络或代理问题:`~/.cc-switch/logs/` 中的 `cc-switch.log` 及其轮转文件
- 应用崩溃:`~/.cc-switch/crash.log` 及 `crash.log.1`、`crash.log.2`
- Windows 默认路径为 `C:\Users\<用户名>\.cc-switch\...`
运行日志按 20 MB 轮转,并保留最近 4 个归档;重启应用不会再清空已有日志。日志可能包含运行环境信息,公开提交前建议先检查内容。
+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]