mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-01 12:22:09 +08:00
ff3bc242cc
* fix(security): harden GrokBuild credential handling and Codex/Anthropic transforms against malformed input
Three robustness/security fixes in the upstream v3.18.0 code, each with a
regression test.
1) grok_config: remove the unconditional XAI_API_KEY fallback in
extract_credentials(). Credentials now come only from an explicit inline
api_key or the process env var named by env_key. Silently substituting a
different account's key (when the declared env_key var is unset) could
leak that key to whatever base_url the config points at.
2) deeplink/provider: merge_grokbuild_config no longer resolves env vars
into a plaintext api_key on import. A deeplink is untrusted input;
resolving+inlining would persist the victim's environment secret into the
imported provider's config.toml and ship it to the link's declared
base_url. env_key now stays an indirection (name), not a resolved secret.
3) proxy transforms: stop panicking on malformed upstream data.
- transform_codex_anthropic::anthropic_sse_to_message_value: only store a
`message`/`content_block` when it is an object; otherwise treat it as
empty, so the later `["content"]`/`["text"]`/`["signature"]` index
assignments can't panic on a scalar/array Value.
- streaming_codex_anthropic::responses_sse_events_from_anthropic_message:
bail out with a failed-event when the buffered body is a top-level JSON
array/scalar (a gateway that ignores stream:true), instead of
index-assigning into a non-object.
- mcp/grokbuild sync: normalize a non-table `mcp_servers` before
inserting, avoiding a toml_edit IndexMut panic on a user-edited
config.toml.
Panic findings verified with a minimal repro (serde_json index-assignment on
a non-object Value aborts). cargo test --lib: 2183 passed / 0 failed.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(mcp): normalize a non-table `mcp_servers` in Codex config before insert
Same class of bug as the GrokBuild MCP fix in the previous commit, but in the
much more widely used Codex path.
`sync_single_server_to_codex` guarded only with `contains_key("mcp_servers")`,
so a user-edited `~/.codex/config.toml` where the key exists but is *not* a
table (`mcp_servers = "x"` / `[]` / `42`) skipped the rebuild and then hit
`doc["mcp_servers"][id] = …`, which panics in toml_edit's `IndexMut`
(`.expect("index not found")`). The panic happens inside a Tauri command and
unwinds across the FFI boundary; in the provider-switch flow it also fires
after the DB/live write already committed, leaving a half-applied switch.
Extract the normalize-then-insert step into `upsert_mcp_server_table()` so the
previously panic-prone logic is unit-testable without touching the real
`~/.codex/config.toml`, and normalize any non-table value to an empty table
before inserting.
Audited the sibling MCP writers while here: claude.rs / gemini.rs /
hermes.rs / opencode.rs do not have this pattern (their `contains_key` uses are
read-only checks), and the other index-assignments in codex.rs operate on
freshly built `Table::new()` values, which are safe.
Tests: 2 new regression tests (malformed non-table values normalize and insert;
an existing valid table keeps its entries). cargo test --lib 2185 passed / 0
failed; cargo clippy --lib -D warnings clean.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(security): reject path-traversal entries when extracting a skill repo archive
`SkillService::download_and_extract` built the output path from the raw ZIP
entry name (`file.name()`), stripped only the leading `<repo>-<branch>/`
component, and then `dest.join(relative_path)` → `fs::File::create`. A crafted
entry such as `repo-main/../../../evil.sh` therefore escaped the destination
directory (zip-slip): arbitrary file write outside the temp extraction dir.
The archive is third-party controlled: it is downloaded from
`https://github.com/<owner>/<name>/archive/refs/heads/<branch>.zip`, and a
skill repo (`owner`/`name`) can be added through an untrusted `ccswitch://`
deeplink (`deeplink/skill.rs`), so the attacker fully controls the archive
contents.
Fix: resolve each entry through `zip::read::ZipFile::enclosed_name()`, which
rejects `..` components and absolute paths, before stripping the archive's
root directory and joining onto `dest`. Unsafe entries are skipped with a
warning. This matches what the two sibling extractors in this codebase already
did correctly (`extract_local_zip`, `webdav_sync/archive.rs`) — this call site
was the one that had been missed.
The extraction loop is split out into `extract_repo_archive()` so the guard is
testable without network access.
Verified the test actually catches the bug: with the guard reverted to the old
`file.name()` behaviour the new test fails ("zip-slip entry must not escape
dest (temp root)"); with the guard in place it passes.
cargo test --lib: 2186 passed / 0 failed; cargo clippy --lib -D warnings clean.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(security): strip all credentials from the shared Gemini common-config snippet
`extract_gemini_common_config` skipped only two hardcoded keys
(`GOOGLE_GEMINI_BASE_URL`, `GEMINI_API_KEY`) and copied every other `env`
entry into the shared snippet. But `GOOGLE_API_KEY` is a first-class Gemini
credential — `provider.rs` resolves it via
`first_non_empty(env, &["GEMINI_API_KEY", "GOOGLE_API_KEY"])` — so it was
never stripped.
That snippet is not inert: `apply_common_config_to_settings` (live.rs)
deep-merges it into the `env` of *every other* Gemini provider that uses the
common config, and the snippet is auto-extracted on startup, on import, and on
switch. Net effect: account A's key gets written into provider B and sent to
B's `GOOGLE_GEMINI_BASE_URL`, which may be a third-party relay. Anything else
the user put in `env` (`GOOGLE_APPLICATION_CREDENTIALS`, a proxy
`*_AUTH_TOKEN`, …) leaked the same way.
Fix: also skip `Self::is_sensitive_config_key(key)`, reusing the pattern
matcher the Claude extractor already relies on for exactly this reason (its
comment notes a fixed enumeration "will always miss the next `*_API_KEY`").
`GOOGLE_API_KEY` matches its `_API_KEY` suffix rule. Shareable non-secret
config (e.g. `GEMINI_TIMEOUT_MS`) is preserved.
Verified the test catches the bug: run against the old two-key filter it fails
with "credential GOOGLE_API_KEY must not leak into the shared Gemini snippet";
with the fix it passes.
cargo test --lib: 2187 passed / 0 failed; cargo clippy --lib -D warnings clean.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(security): validate skill directory from backups and sync-imported DB rows
The skill install pipeline sanitizes the install directory name, but two
entry points bypassed it entirely:
- restore_from_backup joined metadata.skill.directory (raw meta.json
content) into the SSOT dir with no validation, allowing a crafted
backup to copy attacker-controlled files outside the skills directory
and to persist the poisoned value into the database.
- Sync import (WebDAV/S3) loads the remote database dump verbatim, so a
malicious or compromised sync snapshot could plant a skills row whose
directory contains path traversal. Every later raw join then operated
on attacker-controlled paths — uninstall/remove_from_app would
remove_dir_all outside the managed dirs (arbitrary directory deletion),
and sync_to_app_dir would write/symlink outside them.
Add require_valid_directory() (built on the existing
sanitize_install_name) and enforce it at restore_from_backup,
sync_to_app_dir, remove_from_app, and uninstall. Regression tests cover
all three sinks plus the metadata path; each was verified against the
unguarded code first (disabling the guard makes the restore/uninstall
tests complete the traversal write/delete, failing the assertions).
Also fixes a pre-existing cargo fmt drift in mcp/grokbuild.rs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(security): reject path separators in sanitize_install_name on all platforms
The previous components()-based check was platform-dependent: on
Linux/macOS a backslash is not a path separator, so "a\b" parsed as a
single Normal component and was accepted as a valid install name — the
same value becomes a nested path when synced or restored onto Windows.
CI caught this on the Linux runner (the Windows runner passed).
Reject both '/' and '\' explicitly so the validation is
platform-independent; the components() check stays as the second layer
for dot segments, roots, and prefixes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(security): close the remaining skill-install attack chain
The zip-slip guard added earlier only covered half the problem, and the
repo coordinates that decide *which* archive gets downloaded were never
validated at all. Together those two gaps let an attacker choose both the
bytes and where they land.
1) zip-slip, second layer. `enclosed_name()` only guarantees the entry does
not escape the *archive's own* root, and it does not normalise the path —
`..` survives verbatim in the returned value. Stripping `root_name`
afterwards spends one level of that depth budget, so `repo-main/../evil`
still escaped `dest`. On Windows it is worse: `root_name` comes from
`split('/')` and may contain backslashes, which Windows treats as
separators, so one `strip_prefix` can eat N components. Re-check the
*actual* relative path for `ParentDir` right before `dest.join()`.
Verified by reverting the guard: the single-`..` case escapes without it.
(The existing test used two `..`, which `enclosed_name()` rejects on its
own — it covered the half that was already guarded.)
2) Repo coordinates are now validated. `download_repo` formats owner/name/
branch straight into
`https://github.com/{owner}/{name}/archive/refs/heads/{branch}.zip`,
and `deeplink/skill.rs` applied no validation whatsoever. URL parsing
resolves dot segments, so a branch of
`../../../releases/download/v1/evil` retargets the request at a *release
asset* — arbitrary attacker-uploaded bytes. That is what made (1)
reachable in practice: git itself will not let a tree contain `..`.
Note the trigger is below "install": repos are enabled by default, so
merely opening the Skills panel downloads and extracts.
`validate_repo_ref` whitelists all three fields. Branch names legally
contain `/` (`feature/x`), so the check is per segment rather than a
blanket separator ban; an empty branch keeps its existing "use the
default branch" sentinel meaning, same as `HEAD`. The main guard sits in
`download_repo` — the single convergence point for all four call paths —
because both `skill_repos` and `skills` can be overwritten wholesale by a
sync snapshot, which no insert-time check can prevent. Entry-point checks
(deeplink, `add_skill_repo`, agents lock) exist so bad values surface
immediately instead of silently failing on every later panel open.
`build_skill_doc_url` is covered too: it feeds `readme_url`, which the
frontend hands to `openExternal`.
3) Regressions from the previous commits in this branch, both fixed:
- `uninstall` ran the directory guard *before* `db.delete_skill`. That
method has exactly one call site and is not exposed as a command, so a
row with a dirty `directory` (pre-v3.11.0 installs, or anything
`import_from_apps` still creates today — it has no sanitiser) became
permanently undeletable from the UI. Now the guard skips the filesystem
work but still deletes the row.
- `sync_to_app` propagated with `?`, so one bad row aborted the entire
app's skill sync — and that runs on provider switch. Now it warns and
continues per entry.
- `require_valid_directory` returned `sanitize_install_name`'s normalised
value, which trims. A DB value of `" foo "` would then join as `"foo"`
and miss the real directory. It now validates without rewriting.
4) Guard applied to the sinks the earlier commits missed: `migrate_storage`
(rename + remove_dir_all), `update_skill` (delete + write attacker-named
paths), `import_from_apps` (both a sink and the source of dirty values —
`selection.directory` arrives raw over IPC), `resolve_uninstall_backup_source`
(copies any directory into the backup area, which the UI then lists),
`check_updates`, and `migrate_skills_to_ssot`.
5) Extraction limits. The archive bytes were fully attacker-controlled via
(2), and this path had no ceiling of any kind, unlike
`webdav_sync/archive.rs`. Added entry count, total extracted bytes, a
symlink-target cap (zip 2.4.2's `make_reader` does not truncate at the
declared `uncompressed_size`, so a symlink-flagged entry that inflates to
gigabytes was read straight into memory), per-directory charging (a
directory-only archive writes no content bytes but still consumes inodes),
and a download-body cap (`response.bytes()` buffered the whole archive
before any limit applied). Budget is charged on bytes actually read and
written, never on sizes declared in the archive header.
Symlink materialisation is charged to the same budget, and a target that
contains the link itself (`dir/link -> ..`) is now rejected: the existing
"must stay inside base" check passes for it, and the recursive copy then
re-copies its own output every level until PATH_MAX.
6) Temp directories are now RAII. `download_repo` and `extract_local_zip`
called `TempDir::keep()` immediately and relied on every exit path
remembering `remove_dir_all`. Several did not — including
`fetch_repo_skills`, the highest-frequency path of all. Returning the
guard makes the leak unrepresentable at the call sites.
* fix(config): make removals and edits survive user-authored config shapes
The previous commits hardened the *write* side of the MCP tables against a
non-table `mcp_servers`. The read/delete side kept using `as_table_mut`,
which returns None for an inline table (`mcp_servers = { foo = {...} }` —
valid TOML). Removal then silently did nothing: the UI reported success, the
entry stayed in the file, and Codex loaded it again on next start. That is
worse than the panic it mirrors, because users usually reach for the toggle
precisely when an MCP server is misbehaving. Both Codex and GrokBuild now use
`as_table_like_mut` on both sides.
Normalisation is no longer silent either. Replacing a user's hand-written
`mcp_servers = "x"` with an empty table destroys data, so all four sites that
do it now log a warning first.
`update_codex_toml_field` had the same asymmetry with a different symptom:
when `model_providers` or `[model_providers.<id>]` was an inline table,
`as_table_mut` returned None and execution fell through to the "write a
top-level field" fallback. The user's `base_url` edit landed at the wrong
level, Codex never read it, and nothing reported a problem.
`opencode_config` parses `~/.config/opencode/opencode.json` with json5 into a
bare Value and never checked the root's shape. A user file of `[]` or a
scalar made `set_provider`, `set_mcp_server` and `add_plugin` all panic on
index-assignment, inside a Tauri command, unwinding across the FFI boundary.
(A top-level `null` is fine — serde_json promotes it.) Rejecting a non-object
root at the single read site fixes all three. Rejecting rather than rebuilding
is deliberate: the file also holds the user's own `model` / `theme` settings,
so a silent rebuild would delete them. Same treatment for the `provider` and
`mcp` sections, whose non-object forms made writes silently no-op.
* fix(proxy): recover a malformed Anthropic content_block as text
Sanitising a non-object `content_block` to `{}` stops the panic but creates a
quieter failure: the final Responses conversion matches on the block's `type`
and silently drops anything it does not recognise, so a garbled block header
turned into a `completed` response with empty output. The client saw the model
say nothing, with no signal that data had been discarded.
The replacement now carries `type: "text"`. The deltas that follow a malformed
header are usually well-formed, so this recovers the common case; a tool-use
block still yields nothing, exactly as before. A warning is logged when the
substitution happens.
The regression test now asserts through `anthropic_response_to_responses`
rather than on the intermediate value — asserting on the intermediate alone
passes while the client still receives an empty response.
* fix(grokbuild): decouple base_url from credential resolution, reject env_key-only links
`resolve_usage_credentials` called `extract_credentials(...).unwrap_or_default()`,
so a missing credential blanked the base_url too — even though it is written
right there in the config. Removing the `XAI_API_KEY` fallback in the earlier
commit widened that considerably: a GUI process on macOS does not inherit the
shell environment, so an `env_key` that resolves fine in a terminal resolves to
nothing here. The result was a Base URL shown in the UI that differed from the
one actually used, `{{baseUrl}}` expanding to empty in usage scripts (turning
requests relative), and native balance queries reporting "API key is empty"
while hiding the real cause. The two values are now resolved independently, via
the existing `grok_config::extract_base_url`, matching how the Codex arm of the
same function already works.
A deeplink whose only credential is `env_key` is now rejected by name. It was
already unimportable — `build_grokbuild_settings` has no `env_key` slot — but it
failed with the generic "API key is required", which reads like a malformed link
and invites the obvious "just carry the name over" fix. Carrying it over is
exactly what must not happen: the forwarder and the usage query both resolve
`env_key` at request time, so the victim's environment secret would still reach
the link's `base_url`. Same leak, merely deferred. The message says so, and the
test sets the probe variable so that restoring the resolution turns it red.
* fix(security): scrub credentials already leaked into the Gemini shared snippet
Fixing the extractor only prevents new contamination. A Gemini snippet is never
re-extracted once it exists — startup auto-extract and post-import extraction
both require `snippet.is_none()`, and the on-switch rewrite only covers Claude
and Codex — so existing users keep injecting the leaked key into live config,
the proxy upstream, and the new-provider form, where another account's key is
plainly visible.
Removing it from the snippet alone would make things worse. Merge and strip
cancel out by *value equality*: on switch-away, `remove_common_config_from_settings`
deletes the injected keys using the snippet as its only record of what to look
for. Once the key is gone from the snippet, the residue left in live config is
backfilled verbatim into the victim provider's `settings_config`, turning a
transient leak into a permanent one. So the cleanup covers all four locations at
once, and step order is itself a safety property: every fallible step runs before
the irreversible one, and a failure returns an error so the next start retries
from an intact state.
Deletion is by key *and* value, never by key name alone, so a provider's own
same-named key with a different value survives. The `~/.gemini/.env` edit
preserves layout rather than round-tripping through
`parse_env_file`/`serialize_env_file`: that pair drops comments, blank lines and
unparseable lines, collapses duplicate keys and re-sorts the file. Acceptable
when re-projecting everything, destructive for a targeted removal the user never
asked for — in testing it reduced a six-line fixture to one line, taking the
user's own key with it.
An audit record is written before any provider row changes, listing key names and
affected provider ids but *no values*: `settings` is not in `SYNC_SKIP_TABLES`,
so it is uploaded by WebDAV/S3 sync, and these are precisely the credentials that
must be destroyed. Keeping values would trade one deletion for a plaintext copy
that spreads across devices, has no UI to reach it, and never expires. It is
written only when absent, so a partially-applied earlier run cannot overwrite the
original pre-cleanup state.
Consequence, intentional: some Gemini providers will now report a missing API key
and need one entered. That key was never theirs. (The victim's own value was
already overwritten at merge time and cannot be recovered — worth noting in the
release notes, along with a recommendation to rotate the leaked key.) The snippet
row is deleted rather than set to `{}` when nothing shareable remains, and the
`cleared` flag is deliberately not set — either would disable auto-extract
permanently and prevent the user's legitimate shared config from ever coming back.
The frontend snippet validator is aligned with the backend matcher. It had the
same two hardcoded key names, so a hand-edited snippet could put `GOOGLE_API_KEY`
straight back with no error.
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Jason <farion1231@gmail.com>
714 lines
23 KiB
Rust
714 lines
23 KiB
Rust
use crate::config::{get_home_dir, write_text_file};
|
||
use crate::error::AppError;
|
||
use serde_json::Value;
|
||
use std::collections::HashMap;
|
||
use std::fs;
|
||
use std::path::PathBuf;
|
||
|
||
/// 获取 Gemini 配置目录路径(支持设置覆盖)
|
||
pub fn get_gemini_dir() -> PathBuf {
|
||
if let Some(custom) = crate::settings::get_gemini_override_dir() {
|
||
return custom;
|
||
}
|
||
|
||
get_home_dir().join(".gemini")
|
||
}
|
||
|
||
/// 获取 Gemini .env 文件路径
|
||
pub fn get_gemini_env_path() -> PathBuf {
|
||
get_gemini_dir().join(".env")
|
||
}
|
||
|
||
/// 解析 .env 文件内容为键值对
|
||
///
|
||
/// 此函数宽松地解析 .env 文件,跳过无效行。
|
||
/// 对于需要严格验证的场景,请使用 `parse_env_file_strict`。
|
||
pub fn parse_env_file(content: &str) -> HashMap<String, String> {
|
||
let mut map = HashMap::new();
|
||
|
||
for line in content.lines() {
|
||
let line = line.trim();
|
||
|
||
// 跳过空行和注释
|
||
if line.is_empty() || line.starts_with('#') {
|
||
continue;
|
||
}
|
||
|
||
// 解析 KEY=VALUE
|
||
if let Some((key, value)) = line.split_once('=') {
|
||
let key = key.trim().to_string();
|
||
let value = value.trim().to_string();
|
||
|
||
// 验证 key 是否有效(不为空,只包含字母、数字和下划线)
|
||
if !key.is_empty() && key.chars().all(|c| c.is_alphanumeric() || c == '_') {
|
||
map.insert(key, value);
|
||
}
|
||
}
|
||
}
|
||
|
||
map
|
||
}
|
||
|
||
/// 严格解析 .env 文件内容,返回详细的错误信息
|
||
///
|
||
/// 与 `parse_env_file` 不同,此函数在遇到无效行时会返回错误,
|
||
/// 包含行号和详细的错误信息。
|
||
///
|
||
/// # 错误
|
||
///
|
||
/// 返回 `AppError` 如果遇到以下情况:
|
||
/// - 行不包含 `=` 分隔符
|
||
/// - Key 为空或包含无效字符
|
||
/// - Key 不符合环境变量命名规范
|
||
///
|
||
/// # 使用场景
|
||
///
|
||
/// 此函数为未来的严格验证场景预留,当前运行时使用宽松的 `parse_env_file`。
|
||
/// 可用于:
|
||
/// - 配置导入验证
|
||
/// - CLI 工具的严格模式
|
||
/// - 配置文件错误诊断
|
||
///
|
||
/// 已有完整的测试覆盖,可直接使用。
|
||
#[allow(dead_code)]
|
||
pub fn parse_env_file_strict(content: &str) -> Result<HashMap<String, String>, AppError> {
|
||
let mut map = HashMap::new();
|
||
|
||
for (line_num, line) in content.lines().enumerate() {
|
||
let line = line.trim();
|
||
let line_number = line_num + 1; // 行号从 1 开始
|
||
|
||
// 跳过空行和注释
|
||
if line.is_empty() || line.starts_with('#') {
|
||
continue;
|
||
}
|
||
|
||
// 检查是否包含 =
|
||
if !line.contains('=') {
|
||
return Err(AppError::localized(
|
||
"gemini.env.parse_error.no_equals",
|
||
format!("Gemini .env 文件格式错误(第 {line_number} 行):缺少 '=' 分隔符\n行内容: {line}"),
|
||
format!("Invalid Gemini .env format (line {line_number}): missing '=' separator\nLine: {line}"),
|
||
));
|
||
}
|
||
|
||
// 解析 KEY=VALUE
|
||
if let Some((key, value)) = line.split_once('=') {
|
||
let key = key.trim();
|
||
let value = value.trim();
|
||
|
||
// 验证 key 不为空
|
||
if key.is_empty() {
|
||
return Err(AppError::localized(
|
||
"gemini.env.parse_error.empty_key",
|
||
format!("Gemini .env 文件格式错误(第 {line_number} 行):环境变量名不能为空\n行内容: {line}"),
|
||
format!("Invalid Gemini .env format (line {line_number}): variable name cannot be empty\nLine: {line}"),
|
||
));
|
||
}
|
||
|
||
// 验证 key 只包含字母、数字和下划线
|
||
if !key.chars().all(|c| c.is_alphanumeric() || c == '_') {
|
||
return Err(AppError::localized(
|
||
"gemini.env.parse_error.invalid_key",
|
||
format!("Gemini .env 文件格式错误(第 {line_number} 行):环境变量名只能包含字母、数字和下划线\n变量名: {key}"),
|
||
format!("Invalid Gemini .env format (line {line_number}): variable name can only contain letters, numbers, and underscores\nVariable: {key}"),
|
||
));
|
||
}
|
||
|
||
map.insert(key.to_string(), value.to_string());
|
||
}
|
||
}
|
||
|
||
Ok(map)
|
||
}
|
||
|
||
/// 将键值对序列化为 .env 格式
|
||
pub fn serialize_env_file(map: &HashMap<String, String>) -> String {
|
||
let mut lines = Vec::new();
|
||
|
||
// 按键排序以保证输出稳定
|
||
let mut keys: Vec<_> = map.keys().collect();
|
||
keys.sort();
|
||
|
||
for key in keys {
|
||
if let Some(value) = map.get(key) {
|
||
lines.push(format!("{key}={value}"));
|
||
}
|
||
}
|
||
|
||
lines.join("\n")
|
||
}
|
||
|
||
/// 读取 Gemini .env 文件
|
||
pub fn read_gemini_env() -> Result<HashMap<String, String>, AppError> {
|
||
let path = get_gemini_env_path();
|
||
|
||
if !path.exists() {
|
||
return Ok(HashMap::new());
|
||
}
|
||
|
||
let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
||
|
||
Ok(parse_env_file(&content))
|
||
}
|
||
|
||
/// 从 .env 原文中按「键名 + 值」双匹配删除若干行,其余内容逐字保留
|
||
///
|
||
/// 不走 `parse_env_file` → `serialize_env_file` 的往返:那对函数会丢掉注释、空行、
|
||
/// 无法识别的行和重复定义,并按键名重排整个文件。全量投影时这无所谓(本来就要重写
|
||
/// 整份),但用来做**定向**清理就等于顺手把用户手写的东西一起删了。
|
||
///
|
||
/// 按值匹配而非按键名:只清掉扩散出去的那一份,用户自己写的同名不同值的行保留。
|
||
/// 同一个键有重复定义时也只删命中的那条,被它遮住的上一条会重新生效——这正是想要的
|
||
/// 结果,因为遮住它的恰恰是泄漏值。
|
||
///
|
||
/// 返回 `None` 表示没有任何一行命中,调用方据此跳过写盘。
|
||
pub fn remove_env_entries_preserving_layout(
|
||
content: &str,
|
||
doomed: &HashMap<String, String>,
|
||
) -> Option<String> {
|
||
let mut removed = false;
|
||
let mut kept: Vec<&str> = Vec::new();
|
||
|
||
for line in content.split('\n') {
|
||
let trimmed = line.trim();
|
||
let hit = !trimmed.is_empty()
|
||
&& !trimmed.starts_with('#')
|
||
&& trimmed.split_once('=').is_some_and(|(key, value)| {
|
||
doomed
|
||
.get(key.trim())
|
||
.is_some_and(|doomed_value| doomed_value == value.trim())
|
||
});
|
||
|
||
if hit {
|
||
removed = true;
|
||
} else {
|
||
kept.push(line);
|
||
}
|
||
}
|
||
|
||
removed.then(|| kept.join("\n"))
|
||
}
|
||
|
||
/// 从 `~/.gemini/.env` 中定向删除「键=值」完全匹配的行,返回是否真的改了文件
|
||
pub fn remove_gemini_env_entries(doomed: &HashMap<String, String>) -> Result<bool, AppError> {
|
||
let path = get_gemini_env_path();
|
||
if !path.exists() {
|
||
return Ok(false);
|
||
}
|
||
|
||
let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
||
match remove_env_entries_preserving_layout(&content, doomed) {
|
||
Some(cleaned) => {
|
||
write_gemini_env_text_atomic(&cleaned)?;
|
||
Ok(true)
|
||
}
|
||
None => Ok(false),
|
||
}
|
||
}
|
||
|
||
/// 写入 Gemini .env 文件(原子操作)
|
||
pub fn write_gemini_env_atomic(map: &HashMap<String, String>) -> Result<(), AppError> {
|
||
write_gemini_env_text_atomic(&serialize_env_file(map))
|
||
}
|
||
|
||
/// 写入 Gemini .env 文件(原子操作,内容逐字落盘)
|
||
///
|
||
/// 与 `write_gemini_env_atomic` 共用目录/文件权限处理,区别只在于内容不经
|
||
/// `serialize_env_file` 归一化——供保序的定向删除使用。
|
||
pub fn write_gemini_env_text_atomic(content: &str) -> Result<(), AppError> {
|
||
let path = get_gemini_env_path();
|
||
|
||
// 确保目录存在
|
||
if let Some(parent) = path.parent() {
|
||
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||
|
||
// 设置目录权限为 700(仅所有者可读写执行)
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::PermissionsExt;
|
||
let mut perms = fs::metadata(parent)
|
||
.map_err(|e| AppError::io(parent, e))?
|
||
.permissions();
|
||
perms.set_mode(0o700);
|
||
fs::set_permissions(parent, perms).map_err(|e| AppError::io(parent, e))?;
|
||
}
|
||
}
|
||
|
||
write_text_file(&path, content)?;
|
||
|
||
// 设置文件权限为 600(仅所有者可读写)
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::PermissionsExt;
|
||
let mut perms = fs::metadata(&path)
|
||
.map_err(|e| AppError::io(&path, e))?
|
||
.permissions();
|
||
perms.set_mode(0o600);
|
||
fs::set_permissions(&path, perms).map_err(|e| AppError::io(&path, e))?;
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// 从 .env 格式转换为 Provider.settings_config (JSON Value)
|
||
pub fn env_to_json(env_map: &HashMap<String, String>) -> Value {
|
||
let mut json_map = serde_json::Map::new();
|
||
|
||
for (key, value) in env_map {
|
||
json_map.insert(key.clone(), Value::String(value.clone()));
|
||
}
|
||
|
||
serde_json::json!({ "env": json_map })
|
||
}
|
||
|
||
/// 从 Provider.settings_config (JSON Value) 提取 .env 格式
|
||
pub fn json_to_env(settings: &Value) -> Result<HashMap<String, String>, AppError> {
|
||
let mut env_map = HashMap::new();
|
||
|
||
if let Some(env_obj) = settings.get("env").and_then(|v| v.as_object()) {
|
||
for (key, value) in env_obj {
|
||
if let Some(val_str) = value.as_str() {
|
||
env_map.insert(key.clone(), val_str.to_string());
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(env_map)
|
||
}
|
||
|
||
/// 验证 Gemini 配置的基本结构
|
||
///
|
||
/// 此函数只验证配置的基本格式,不强制要求 GEMINI_API_KEY。
|
||
/// 这允许用户先创建供应商配置,稍后再填写 API Key。
|
||
///
|
||
/// API Key 的验证会在切换供应商时进行(通过 `validate_gemini_settings_strict`)。
|
||
pub fn validate_gemini_settings(settings: &Value) -> Result<(), AppError> {
|
||
// 只验证基本结构,不强制要求 GEMINI_API_KEY
|
||
// 如果有 env 字段,验证它是一个对象
|
||
if let Some(env) = settings.get("env") {
|
||
if !env.is_object() {
|
||
return Err(AppError::localized(
|
||
"gemini.validation.invalid_env",
|
||
"Gemini 配置格式错误: env 必须是对象",
|
||
"Gemini config invalid: env must be an object",
|
||
));
|
||
}
|
||
}
|
||
|
||
// 如果有 config 字段,验证它是对象或 null
|
||
if let Some(config) = settings.get("config") {
|
||
if !(config.is_object() || config.is_null()) {
|
||
return Err(AppError::localized(
|
||
"gemini.validation.invalid_config",
|
||
"Gemini 配置格式错误: config 必须是对象",
|
||
"Gemini config invalid: config must be an object",
|
||
));
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// 严格验证 Gemini 配置(要求必需字段)
|
||
///
|
||
/// 此函数在切换供应商时使用,确保配置包含所有必需的字段。
|
||
/// 对于需要 API Key 的供应商(如 PackyCode),会验证 GEMINI_API_KEY 字段。
|
||
pub fn validate_gemini_settings_strict(settings: &Value) -> Result<(), AppError> {
|
||
// 先做基础格式验证(包含 env/config 类型)
|
||
validate_gemini_settings(settings)?;
|
||
|
||
let env_map = json_to_env(settings)?;
|
||
|
||
// 如果 env 为空,表示使用 OAuth(如 Google 官方),跳过验证
|
||
if env_map.is_empty() {
|
||
return Ok(());
|
||
}
|
||
|
||
// 如果 env 不为空,检查必需字段 GEMINI_API_KEY
|
||
if !env_map.contains_key("GEMINI_API_KEY") {
|
||
return Err(AppError::localized(
|
||
"gemini.validation.missing_api_key",
|
||
"Gemini 配置缺少必需字段: GEMINI_API_KEY",
|
||
"Gemini config missing required field: GEMINI_API_KEY",
|
||
));
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// 获取 Gemini settings.json 文件路径
|
||
///
|
||
/// 返回路径:`~/.gemini/settings.json`(与 `.env` 文件同级)
|
||
pub fn get_gemini_settings_path() -> PathBuf {
|
||
get_gemini_dir().join("settings.json")
|
||
}
|
||
|
||
/// 更新 Gemini 目录 settings.json 中的 security.auth.selectedType 字段
|
||
///
|
||
/// 此函数会:
|
||
/// 1. 读取现有的 settings.json(如果存在)
|
||
/// 2. 只更新 `security.auth.selectedType` 字段,保留其他所有字段
|
||
/// 3. 原子性写入文件
|
||
///
|
||
/// # 参数
|
||
/// - `selected_type`: 要设置的 selectedType 值(如 "gemini-api-key" 或 "oauth-personal")
|
||
fn update_selected_type(selected_type: &str) -> Result<(), AppError> {
|
||
let settings_path = get_gemini_settings_path();
|
||
|
||
// 确保目录存在
|
||
if let Some(parent) = settings_path.parent() {
|
||
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||
}
|
||
|
||
// 读取现有的 settings.json(如果存在)
|
||
let mut settings_content = if settings_path.exists() {
|
||
let content =
|
||
fs::read_to_string(&settings_path).map_err(|e| AppError::io(&settings_path, e))?;
|
||
serde_json::from_str::<Value>(&content).unwrap_or_else(|_| serde_json::json!({}))
|
||
} else {
|
||
serde_json::json!({})
|
||
};
|
||
|
||
// 只更新 security.auth.selectedType 字段
|
||
if let Some(obj) = settings_content.as_object_mut() {
|
||
let security = obj
|
||
.entry("security")
|
||
.or_insert_with(|| serde_json::json!({}));
|
||
|
||
if let Some(security_obj) = security.as_object_mut() {
|
||
let auth = security_obj
|
||
.entry("auth")
|
||
.or_insert_with(|| serde_json::json!({}));
|
||
|
||
if let Some(auth_obj) = auth.as_object_mut() {
|
||
auth_obj.insert(
|
||
"selectedType".to_string(),
|
||
Value::String(selected_type.to_string()),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 写入文件
|
||
crate::config::write_json_file(&settings_path, &settings_content)?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// 为 Packycode Gemini 供应商写入 settings.json
|
||
///
|
||
/// 设置 `~/.gemini/settings.json` 中的:
|
||
/// ```json
|
||
/// {
|
||
/// "security": {
|
||
/// "auth": {
|
||
/// "selectedType": "gemini-api-key"
|
||
/// }
|
||
/// }
|
||
/// }
|
||
/// ```
|
||
///
|
||
/// 保留文件中的其他所有字段。
|
||
pub fn write_packycode_settings() -> Result<(), AppError> {
|
||
update_selected_type("gemini-api-key")
|
||
}
|
||
|
||
/// 为 Google 官方 Gemini 供应商写入 settings.json(OAuth 模式)
|
||
///
|
||
/// 设置 `~/.gemini/settings.json` 中的:
|
||
/// ```json
|
||
/// {
|
||
/// "security": {
|
||
/// "auth": {
|
||
/// "selectedType": "oauth-personal"
|
||
/// }
|
||
/// }
|
||
/// }
|
||
/// ```
|
||
///
|
||
/// 保留文件中的其他所有字段。
|
||
pub fn write_google_oauth_settings() -> Result<(), AppError> {
|
||
update_selected_type("oauth-personal")
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_parse_env_file() {
|
||
let content = r#"
|
||
# Comment line
|
||
GOOGLE_GEMINI_BASE_URL=https://example.com
|
||
GEMINI_API_KEY=sk-test123
|
||
GEMINI_MODEL=gemini-3.5-flash
|
||
|
||
# Another comment
|
||
"#;
|
||
|
||
let map = parse_env_file(content);
|
||
|
||
assert_eq!(map.len(), 3);
|
||
assert_eq!(
|
||
map.get("GOOGLE_GEMINI_BASE_URL"),
|
||
Some(&"https://example.com".to_string())
|
||
);
|
||
assert_eq!(map.get("GEMINI_API_KEY"), Some(&"sk-test123".to_string()));
|
||
assert_eq!(
|
||
map.get("GEMINI_MODEL"),
|
||
Some(&"gemini-3.5-flash".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_serialize_env_file() {
|
||
let mut map = HashMap::new();
|
||
map.insert("GEMINI_API_KEY".to_string(), "sk-test".to_string());
|
||
map.insert("GEMINI_MODEL".to_string(), "gemini-3.5-flash".to_string());
|
||
|
||
let content = serialize_env_file(&map);
|
||
|
||
assert!(content.contains("GEMINI_API_KEY=sk-test"));
|
||
assert!(content.contains("GEMINI_MODEL=gemini-3.5-flash"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_env_json_conversion() {
|
||
let mut env_map = HashMap::new();
|
||
env_map.insert("GEMINI_API_KEY".to_string(), "test-key".to_string());
|
||
|
||
let json = env_to_json(&env_map);
|
||
let converted = json_to_env(&json).unwrap();
|
||
|
||
assert_eq!(
|
||
converted.get("GEMINI_API_KEY"),
|
||
Some(&"test-key".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_env_file_strict_success() {
|
||
// 测试严格模式下正常解析
|
||
let content = r#"
|
||
# Comment line
|
||
GOOGLE_GEMINI_BASE_URL=https://example.com
|
||
GEMINI_API_KEY=sk-test123
|
||
GEMINI_MODEL=gemini-3.5-flash
|
||
|
||
# Another comment
|
||
"#;
|
||
|
||
let result = parse_env_file_strict(content);
|
||
assert!(result.is_ok());
|
||
|
||
let map = result.unwrap();
|
||
assert_eq!(map.len(), 3);
|
||
assert_eq!(
|
||
map.get("GOOGLE_GEMINI_BASE_URL"),
|
||
Some(&"https://example.com".to_string())
|
||
);
|
||
assert_eq!(map.get("GEMINI_API_KEY"), Some(&"sk-test123".to_string()));
|
||
assert_eq!(
|
||
map.get("GEMINI_MODEL"),
|
||
Some(&"gemini-3.5-flash".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_env_file_strict_missing_equals() {
|
||
// 测试严格模式下检测缺少 = 的行
|
||
let content = "GOOGLE_GEMINI_BASE_URL=https://example.com
|
||
INVALID_LINE_WITHOUT_EQUALS
|
||
GEMINI_API_KEY=sk-test123";
|
||
|
||
let result = parse_env_file_strict(content);
|
||
assert!(result.is_err());
|
||
|
||
let err = result.unwrap_err();
|
||
let err_msg = format!("{err:?}");
|
||
assert!(err_msg.contains("第 2 行") || err_msg.contains("line 2"));
|
||
assert!(err_msg.contains("INVALID_LINE_WITHOUT_EQUALS"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_env_file_strict_empty_key() {
|
||
// 测试严格模式下检测空 key
|
||
let content = "GOOGLE_GEMINI_BASE_URL=https://example.com
|
||
=value_without_key
|
||
GEMINI_API_KEY=sk-test123";
|
||
|
||
let result = parse_env_file_strict(content);
|
||
assert!(result.is_err());
|
||
|
||
let err = result.unwrap_err();
|
||
let err_msg = format!("{err:?}");
|
||
assert!(err_msg.contains("第 2 行") || err_msg.contains("line 2"));
|
||
assert!(err_msg.contains("empty") || err_msg.contains("空"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_env_file_strict_invalid_key_characters() {
|
||
// 测试严格模式下检测无效字符(如空格、特殊符号)
|
||
let content = "GOOGLE_GEMINI_BASE_URL=https://example.com
|
||
INVALID KEY WITH SPACES=value
|
||
GEMINI_API_KEY=sk-test123";
|
||
|
||
let result = parse_env_file_strict(content);
|
||
assert!(result.is_err());
|
||
|
||
let err = result.unwrap_err();
|
||
let err_msg = format!("{err:?}");
|
||
assert!(err_msg.contains("第 2 行") || err_msg.contains("line 2"));
|
||
assert!(err_msg.contains("INVALID KEY WITH SPACES"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_env_file_lax_vs_strict() {
|
||
// 测试宽松模式和严格模式的差异
|
||
let content = "VALID_KEY=value
|
||
INVALID LINE
|
||
KEY_WITH-DASH=value";
|
||
|
||
// 宽松模式:跳过无效行,继续解析
|
||
let lax_result = parse_env_file(content);
|
||
assert_eq!(lax_result.len(), 1); // 只有 VALID_KEY
|
||
assert_eq!(lax_result.get("VALID_KEY"), Some(&"value".to_string()));
|
||
|
||
// 严格模式:遇到无效行立即返回错误
|
||
let strict_result = parse_env_file_strict(content);
|
||
assert!(strict_result.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_packycode_settings_structure() {
|
||
// 验证 Packycode settings.json 的结构正确
|
||
let settings_content = serde_json::json!({
|
||
"security": {
|
||
"auth": {
|
||
"selectedType": "gemini-api-key"
|
||
}
|
||
}
|
||
});
|
||
|
||
assert_eq!(
|
||
settings_content["security"]["auth"]["selectedType"],
|
||
"gemini-api-key"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_packycode_settings_merge() {
|
||
// 测试合并逻辑:应该保留其他字段
|
||
let mut existing_settings = serde_json::json!({
|
||
"otherField": "should-be-kept",
|
||
"security": {
|
||
"otherSetting": "also-kept",
|
||
"auth": {
|
||
"otherAuth": "preserved"
|
||
}
|
||
}
|
||
});
|
||
|
||
// 模拟更新 selectedType
|
||
if let Some(obj) = existing_settings.as_object_mut() {
|
||
let security = obj
|
||
.entry("security")
|
||
.or_insert_with(|| serde_json::json!({}));
|
||
|
||
if let Some(security_obj) = security.as_object_mut() {
|
||
let auth = security_obj
|
||
.entry("auth")
|
||
.or_insert_with(|| serde_json::json!({}));
|
||
|
||
if let Some(auth_obj) = auth.as_object_mut() {
|
||
auth_obj.insert(
|
||
"selectedType".to_string(),
|
||
Value::String("gemini-api-key".to_string()),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 验证所有字段都被保留
|
||
assert_eq!(existing_settings["otherField"], "should-be-kept");
|
||
assert_eq!(existing_settings["security"]["otherSetting"], "also-kept");
|
||
assert_eq!(
|
||
existing_settings["security"]["auth"]["otherAuth"],
|
||
"preserved"
|
||
);
|
||
assert_eq!(
|
||
existing_settings["security"]["auth"]["selectedType"],
|
||
"gemini-api-key"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_google_oauth_settings_structure() {
|
||
// 验证 Google OAuth settings.json 的结构正确
|
||
let settings_content = serde_json::json!({
|
||
"security": {
|
||
"auth": {
|
||
"selectedType": "oauth-personal"
|
||
}
|
||
}
|
||
});
|
||
|
||
assert_eq!(
|
||
settings_content["security"]["auth"]["selectedType"],
|
||
"oauth-personal"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_validate_empty_env_for_oauth() {
|
||
// 测试空 env(Google 官方 OAuth)可以通过基本验证
|
||
let settings = serde_json::json!({
|
||
"env": {}
|
||
});
|
||
|
||
assert!(validate_gemini_settings(&settings).is_ok());
|
||
// 严格验证也应该通过(空 env 表示 OAuth)
|
||
assert!(validate_gemini_settings_strict(&settings).is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_validate_env_with_api_key() {
|
||
// 测试有 API Key 的配置可以通过验证
|
||
let settings = serde_json::json!({
|
||
"env": {
|
||
"GEMINI_API_KEY": "sk-test123",
|
||
"GEMINI_MODEL": "gemini-3.5-flash"
|
||
}
|
||
});
|
||
|
||
assert!(validate_gemini_settings(&settings).is_ok());
|
||
assert!(validate_gemini_settings_strict(&settings).is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_validate_env_without_api_key_relaxed() {
|
||
// 测试缺少 API Key 的非空配置在基本验证中可以通过(用户稍后填写)
|
||
let settings = serde_json::json!({
|
||
"env": {
|
||
"GEMINI_MODEL": "gemini-3.5-flash"
|
||
}
|
||
});
|
||
|
||
// 基本验证应该通过(允许稍后填写 API Key)
|
||
assert!(validate_gemini_settings(&settings).is_ok());
|
||
// 严格验证应该失败(切换时要求完整配置)
|
||
assert!(validate_gemini_settings_strict(&settings).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_validate_invalid_env_type() {
|
||
// 测试 env 不是对象时会失败
|
||
let settings = serde_json::json!({
|
||
"env": "invalid_string"
|
||
});
|
||
|
||
assert!(validate_gemini_settings(&settings).is_err());
|
||
}
|
||
}
|