Skip to content

Commit a054ec7

Browse files
committed
fix(ai): 移除默认凭据槽作为合法槽位的路径
1 parent 9b1e8aa commit a054ec7

5 files changed

Lines changed: 285 additions & 48 deletions

File tree

‎app/src-tauri/src/ai_credentials.rs‎

Lines changed: 71 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,53 @@
44
//! Windows 用 DPAPI CryptProtectData 加密后写入数据目录
55
//! ai_credentials.bin(当前用户作用域,管理员与其他用户不可解;
66
//! CRYPTPROTECT_UI_FORBIDDEN 禁弹窗,服务化场景不卡 UI)。
7-
//! @ai-context: scope 化(v0.11.6 M1):默认条目 scope="default" 保持旧文件名
8-
//! ai_credentials.bin 向后兼容(已存用户凭据不失效);per-provider
9-
//! 隔离用 "provider:<id>" → ai_credentials_<safe_id>.bin,各自独立
10-
//! DPAPI 加密。scope 字符白名单:字母/数字/-/_,其余映射为 '_'。
7+
//! @ai-context: scope 化(v0.11.6 M1):per-provider 隔离用 "provider:<id>" →
8+
//! ai_credentials_<safe_id>.bin,各自独立 DPAPI 加密。scope 字符
9+
//! 白名单:字母/数字/-/_,其余映射为 '_'。旧默认条目 scope="default"
10+
//! 的物理文件名(ai_credentials.bin)仍被 scoped_path 指认,但该槽
11+
//! 已废弃(见下方遗留槽移除条)。
1112
//! @ai-context: keyring crate spike 因本机 TLS 拦截(crates.io 新依赖下载
1213
//! 失败)跳过,直接走 v0.8.0 规划裁决的 DPAPI 直写 fallback
1314
//! 路径(裁决见 ADR-016);环境变量 SILICONFLOW_API_KEY 保留
1415
//! 为开发路径,优先级:环境变量 > 凭据库(command 层解析)。
1516
//! @ai-context: 凭据库 roundtrip 单测走内存桩(M5 契约测试口径)——DPAPI
1617
//! 为系统调用不单测(与 model_downloader 网络路径同口径)。
18+
//! @ai-context: 遗留槽移除(2026-09-13 批 8 T25 · 用户裁决 U2 = c):单 Provider
19+
//! 时代的 scope="default" **不再是合法槽位** —— save/load/clear
20+
//! 三条路径一律显式拒绝(守卫 ensure_slot_usable)。🔴 只移除
21+
//! **代码路径**:用户已存的旧数据(DPAPI 文件与既有条目)**不删、
22+
//! 不改、不迁移**(物理抹除是另一件事,用户裁决未授权);仅有旧槽
23+
//! 的真机用户需重填密钥(风险已登记)。
1724
1825
use std::path::{Path, PathBuf};
1926
use std::sync::Mutex;
2027

21-
/// 凭据存储抽象(scope:默认条目 "default" 或 per-provider "provider:<id>";
22-
/// 测试注入内存桩;平台存储 Windows=DPAPI 加密文件)。
28+
/// 遗留单 Provider 凭据槽名(v0.11.6 scope 化之前的唯一槽位)。
29+
///
30+
/// @ai-context: 2026-09-13 批 8 T25(U2 = c「删除」):本槽**不再是合法槽位**,
31+
/// 写/读/清三条路径全部显式拒绝(见 `ensure_slot_usable`)。旧数据
32+
/// 仍在物理存储里(**不做**迁移、**不做**抹除),仅在应用内不再可达
33+
/// ⇒ 名字保留是为了「旧数据在哪」这件事仍可被指认与取证。
34+
pub const LEGACY_DEFAULT_SCOPE: &str = "default";
35+
36+
/// 槽位守卫:拒绝遗留槽(per-provider `provider:<id>` 是唯一合法形态)。
37+
///
38+
/// @ai-context: 为什么**读**也拒:只要旧槽还算「合法槽位」,任何调用点都能把它
39+
/// 当兜底读(批 1 删掉旧 IPC 后,`resolve_default_provider_key` 与
40+
/// 启动迁移正是这样留着的)——把合法性收在存储抽象这一层,调用点
41+
/// 无从绕过;拒绝是显式的(返回具名错误),不是静默返回 None。
42+
fn ensure_slot_usable(scope: &str) -> Result<(), String> {
43+
if scope == LEGACY_DEFAULT_SCOPE {
44+
return Err(format!(
45+
"凭据槽 \"{}\" 已废弃:遗留单 Provider 槽位不再可用,请用 provider:<id> 保存密钥",
46+
LEGACY_DEFAULT_SCOPE
47+
));
48+
}
49+
Ok(())
50+
}
51+
52+
/// 凭据存储抽象(scope:per-provider "provider:<id>";遗留 "default" 已废弃
53+
/// ⇒ 三条方法一律拒绝;测试注入内存桩;平台存储 Windows=DPAPI 加密文件)。
2354
pub trait CredentialStore: Send + Sync {
2455
fn save_key(&self, scope: &str, api_key: &str) -> Result<(), String>;
2556
fn load_key(&self, scope: &str) -> Result<Option<String>, String>;
@@ -56,9 +87,14 @@ pub struct DpapiCredentialStore {
5687

5788
#[cfg(target_os = "windows")]
5889
impl DpapiCredentialStore {
59-
/// scope → 凭据文件路径(default=旧文件名兼容;provider:<id> 按 id 隔离)
90+
/// scope → 凭据文件路径(provider:<id> 按 id 隔离)。
91+
///
92+
/// 🔴 遗留 "default" 的**物理键映射保留**(它指向旧文件 ai_credentials.bin):
93+
/// 删掉会改变旧数据的物理归属、让「旧数据在哪」无从指认(U2 的 (ii) 不做
94+
/// 抹除 ⇒ 映射必须留着)。⚠️ 该分支经槽位 API **不可达**——三条方法已在入口
95+
/// 拒绝遗留槽(U2 = c 的 (i))。
6096
fn scoped_path(&self, scope: &str) -> PathBuf {
61-
if scope == "default" {
97+
if scope == LEGACY_DEFAULT_SCOPE {
6298
return self.path.clone();
6399
}
64100
let safe: String = scope
@@ -72,6 +108,7 @@ impl DpapiCredentialStore {
72108
#[cfg(target_os = "windows")]
73109
impl CredentialStore for DpapiCredentialStore {
74110
fn save_key(&self, scope: &str, api_key: &str) -> Result<(), String> {
111+
ensure_slot_usable(scope)?;
75112
if api_key.trim().is_empty() {
76113
return Err("密钥不能为空".to_string());
77114
}
@@ -81,6 +118,7 @@ impl CredentialStore for DpapiCredentialStore {
81118
}
82119

83120
fn load_key(&self, scope: &str) -> Result<Option<String>, String> {
121+
ensure_slot_usable(scope)?;
84122
let raw = match std::fs::read(self.scoped_path(scope)) {
85123
Ok(b) => b,
86124
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
@@ -95,6 +133,7 @@ impl CredentialStore for DpapiCredentialStore {
95133
}
96134

97135
fn clear_key(&self, scope: &str) -> Result<(), String> {
136+
ensure_slot_usable(scope)?;
98137
match std::fs::remove_file(self.scoped_path(scope)) {
99138
Ok(()) => Ok(()),
100139
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
@@ -176,6 +215,7 @@ pub struct MemoryCredentialStore {
176215

177216
impl CredentialStore for MemoryCredentialStore {
178217
fn save_key(&self, scope: &str, api_key: &str) -> Result<(), String> {
218+
ensure_slot_usable(scope)?;
179219
if api_key.trim().is_empty() {
180220
return Err("密钥不能为空".to_string());
181221
}
@@ -187,10 +227,12 @@ impl CredentialStore for MemoryCredentialStore {
187227
}
188228

189229
fn load_key(&self, scope: &str) -> Result<Option<String>, String> {
230+
ensure_slot_usable(scope)?;
190231
Ok(self.inner.lock().map_err(|_| "凭据锁中毒".to_string())?.get(scope).cloned())
191232
}
192233

193234
fn clear_key(&self, scope: &str) -> Result<(), String> {
235+
ensure_slot_usable(scope)?;
194236
self.inner
195237
.lock()
196238
.map_err(|_| "凭据锁中毒".to_string())?
@@ -199,6 +241,27 @@ impl CredentialStore for MemoryCredentialStore {
199241
}
200242
}
201243

244+
/// 测试专用:越过槽位 API 直接落**物理层**(模拟旧版本写下的遗留槽数据)。
245+
///
246+
/// @ai-context: 2026-09-13 批 8 T25:槽位 API 已拒绝遗留槽 ⇒ 单测要造「旧数据
247+
/// 仍在」的前置状态,只能落物理层——这正是旧数据的真实成因。
248+
/// `#[cfg(test)]` ⇒ 生产二进制零足迹。
249+
#[cfg(test)]
250+
impl MemoryCredentialStore {
251+
/// 直接写入遗留槽条目(不经过守卫)。
252+
pub(crate) fn seed_legacy_default_for_tests(&self, value: &str) {
253+
self.inner
254+
.lock()
255+
.expect("凭据锁")
256+
.insert(LEGACY_DEFAULT_SCOPE.to_string(), value.to_string());
257+
}
258+
259+
/// 直接读回遗留槽条目(键与值都可读 = 「数据仍在」的物理层证据)。
260+
pub(crate) fn read_legacy_default_for_tests(&self) -> Option<String> {
261+
self.inner.lock().expect("凭据锁").get(LEGACY_DEFAULT_SCOPE).cloned()
262+
}
263+
}
264+
202265
/// 单测独立文件(保持本文件 ≤300 行,AGENTS.md §3)。
203266
#[cfg(test)]
204267
#[path = "ai_credentials_tests.rs"]
Lines changed: 111 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,154 @@
11
//! ai_credentials.rs 单测(AAA 模式;凭据库 roundtrip 走内存桩——
22
//! DPAPI 为系统调用不单测,与 model_downloader 网络路径同口径)。
3+
//!
4+
//! @ai-context: 2026-09-13 批 8 T25(用户裁决 U2 = c):遗留槽 "default" 不再是
5+
//! 合法槽位 ⇒ 原「全部走 default 条目」的用例改用**合法** per-provider
6+
//! 槽(语义不变:roundtrip/覆盖/幂等/空值拒绝),并新增两组判据:
7+
//! ① **写/读/清三路全拒**(V1);② **旧数据仍在**(V2——物理层键与值
8+
//! 可读 + 旧 blob 文件字节不变)。测试串全为**虚构值**,不含任何真密钥。
39
4-
use crate::ai_credentials::{CredentialStore, MemoryCredentialStore};
10+
#[cfg(target_os = "windows")]
11+
use std::path::PathBuf;
12+
13+
use crate::ai_credentials::{CredentialStore, MemoryCredentialStore, LEGACY_DEFAULT_SCOPE};
514

615
fn store() -> MemoryCredentialStore {
716
MemoryCredentialStore::default()
817
}
918

10-
/// 默认 scope(与 command 层调用一致——旧测试全部走 "default" 条目)。
11-
const DEFAULT_SCOPE: &str = "default";
19+
/// 合法槽位(per-provider;旧测试走 "default" 条目,U2 = c 后该槽已废弃)。
20+
const TEST_SCOPE: &str = "provider:test";
1221

1322
#[test]
1423
fn roundtrip_save_load() {
1524
let s = store();
16-
s.save_key(DEFAULT_SCOPE, "sk-test-123").expect("保存成功");
17-
assert_eq!(s.load_key(DEFAULT_SCOPE).expect("读取成功").as_deref(), Some("sk-test-123"));
25+
s.save_key(TEST_SCOPE, "sk-test-123").expect("保存成功");
26+
assert_eq!(s.load_key(TEST_SCOPE).expect("读取成功").as_deref(), Some("sk-test-123"));
1827
}
1928

2029
#[test]
2130
fn load_empty_is_none() {
2231
// 未保存 → None(与 DPAPI 文件不存在语义一致)
2332
let s = store();
24-
assert_eq!(s.load_key(DEFAULT_SCOPE).expect("读取成功"), None);
33+
assert_eq!(s.load_key(TEST_SCOPE).expect("读取成功"), None);
2534
}
2635

2736
#[test]
2837
fn clear_removes_key() {
2938
let s = store();
30-
s.save_key(DEFAULT_SCOPE, "sk-abc").expect("保存成功");
31-
s.clear_key(DEFAULT_SCOPE).expect("清除成功");
32-
assert_eq!(s.load_key(DEFAULT_SCOPE).expect("读取成功"), None);
39+
s.save_key(TEST_SCOPE, "sk-abc").expect("保存成功");
40+
s.clear_key(TEST_SCOPE).expect("清除成功");
41+
assert_eq!(s.load_key(TEST_SCOPE).expect("读取成功"), None);
3342
// 幂等:重复清除不报错(文件不存在视为已清除)
34-
s.clear_key(DEFAULT_SCOPE).expect("重复清除成功");
43+
s.clear_key(TEST_SCOPE).expect("重复清除成功");
3544
}
3645

3746
#[test]
3847
fn empty_key_rejected() {
3948
// 空/空白密钥拒绝保存(防御:不写坏凭据)
4049
let s = store();
41-
assert!(s.save_key(DEFAULT_SCOPE, "").is_err());
42-
assert!(s.save_key(DEFAULT_SCOPE, " ").is_err());
43-
assert_eq!(s.load_key(DEFAULT_SCOPE).expect("读取成功"), None);
50+
assert!(s.save_key(TEST_SCOPE, "").is_err());
51+
assert!(s.save_key(TEST_SCOPE, " ").is_err());
52+
assert_eq!(s.load_key(TEST_SCOPE).expect("读取成功"), None);
4453
}
4554

4655
#[test]
4756
fn save_overwrites_previous() {
4857
let s = store();
49-
s.save_key(DEFAULT_SCOPE, "sk-old").expect("保存成功");
50-
s.save_key(DEFAULT_SCOPE, "sk-new").expect("覆盖保存成功");
51-
assert_eq!(s.load_key(DEFAULT_SCOPE).expect("读取成功").as_deref(), Some("sk-new"));
58+
s.save_key(TEST_SCOPE, "sk-old").expect("保存成功");
59+
s.save_key(TEST_SCOPE, "sk-new").expect("覆盖保存成功");
60+
assert_eq!(s.load_key(TEST_SCOPE).expect("读取成功").as_deref(), Some("sk-new"));
5261
}
5362

5463
#[test]
5564
fn scoped_store_isolates_keys_by_scope() {
5665
let store = crate::ai_credentials::MemoryCredentialStore::default();
57-
store.save_key("default", "sk-a").unwrap();
58-
store.save_key("provider:p1", "sk-b").unwrap();
59-
assert_eq!(store.load_key("default").unwrap().as_deref(), Some("sk-a"));
60-
assert_eq!(store.load_key("provider:p1").unwrap().as_deref(), Some("sk-b"));
66+
store.save_key("provider:p1", "sk-a").unwrap();
67+
store.save_key("provider:p2", "sk-b").unwrap();
68+
assert_eq!(store.load_key("provider:p1").unwrap().as_deref(), Some("sk-a"));
69+
assert_eq!(store.load_key("provider:p2").unwrap().as_deref(), Some("sk-b"));
6170
store.clear_key("provider:p1").unwrap();
6271
assert_eq!(store.load_key("provider:p1").unwrap(), None);
63-
assert_eq!(store.load_key("default").unwrap().as_deref(), Some("sk-a"));
72+
assert_eq!(store.load_key("provider:p2").unwrap().as_deref(), Some("sk-b"), "清一个槽不得动另一个");
73+
}
74+
75+
// ────────────────────────────────────────────────────────────
76+
// U2 = c 判据(V1:遗留槽不再是合法槽位——写/读/清三路全拒)
77+
// ────────────────────────────────────────────────────────────
78+
79+
#[test]
80+
fn legacy_default_slot_write_is_rejected() {
81+
// Arrange:合法槽位先写一条(证明拒绝不是"整个库坏了")
82+
let s = store();
83+
s.save_key(TEST_SCOPE, "sk-live-fixture").expect("合法槽位可写");
84+
// Act
85+
let err = s.save_key(LEGACY_DEFAULT_SCOPE, "sk-legacy-fixture").expect_err("遗留槽必须拒绝写入");
86+
// Assert:具名断言——拒绝**且**报的是遗留槽
87+
assert!(
88+
err.contains(LEGACY_DEFAULT_SCOPE),
89+
"拒绝理由必须点名遗留槽,实得: {}",
90+
err
91+
);
92+
assert_eq!(s.read_legacy_default_for_tests(), None, "被拒的写入不得落进存储");
93+
}
94+
95+
#[test]
96+
fn legacy_default_slot_read_is_rejected() {
97+
// V1 的「读取不再把它当合法值」:连原始读取都拒绝(显式 Err,不是静默 None)
98+
let s = store();
99+
s.seed_legacy_default_for_tests("sk-legacy-fixture");
100+
assert!(s.load_key(LEGACY_DEFAULT_SCOPE).is_err(), "遗留槽读取必须显式拒绝");
101+
}
102+
103+
#[test]
104+
fn legacy_default_slot_clear_is_rejected() {
105+
// (ii) 的代码级防线:删不掉 ⇒ 没有任何 API 能抹除旧数据
106+
let s = store();
107+
s.seed_legacy_default_for_tests("sk-legacy-fixture");
108+
assert!(s.clear_key(LEGACY_DEFAULT_SCOPE).is_err(), "遗留槽清除必须显式拒绝");
109+
}
110+
111+
// ────────────────────────────────────────────────────────────
112+
// U2 = c 判据(V2:旧数据仍在——物理层键与值可读 / 旧 blob 字节不变)
113+
// ────────────────────────────────────────────────────────────
114+
115+
#[test]
116+
fn legacy_default_slot_value_survives_code_removal() {
117+
// Arrange:旧版本写下的遗留槽数据(只能落物理层——槽位 API 已关门)
118+
const LEGACY_VALUE: &str = "sk-legacy-fixture-value";
119+
let s = store();
120+
s.seed_legacy_default_for_tests(LEGACY_VALUE);
121+
// Act:走一圈现存**可达**的操作(合法槽位读写清 + 尝试抹除旧槽)
122+
s.save_key("provider:p1", "sk-p1-fixture").expect("合法槽位可写");
123+
assert!(s.clear_key(LEGACY_DEFAULT_SCOPE).is_err(), "旧槽不可通过 API 抹除");
124+
s.clear_key("provider:p1").expect("合法槽位可清");
125+
// Assert:旧键与旧值**都还在**(键与值都可读 ⇒ 没有 DELETE / 覆盖写动过它)
126+
assert_eq!(
127+
s.read_legacy_default_for_tests().as_deref(),
128+
Some(LEGACY_VALUE),
129+
"已存数据必须仍在(本次不抹除任何已存数据)"
130+
);
131+
}
132+
133+
/// 旧凭据 blob 的**物理文件**:合法槽位操作一律不得碰它(字节级对拍)。
134+
#[cfg(target_os = "windows")]
135+
#[test]
136+
fn legacy_blob_file_untouched_by_legal_slot_ops() {
137+
// Arrange:造一个"数据目录",旧 blob 落哨兵字节(不调 DPAPI——只做文件 I/O)
138+
const SENTINEL: &[u8] = b"legacy-blob-fixture-bytes";
139+
let dir = std::env::temp_dir().join(format!("ed-t25-legacy-{}", std::process::id()));
140+
std::fs::create_dir_all(&dir).expect("建临时目录");
141+
let legacy_path: PathBuf = dir.join("ai_credentials.bin");
142+
std::fs::write(&legacy_path, SENTINEL).expect("写旧 blob 哨兵");
143+
let store = crate::ai_credentials::DpapiCredentialStore { path: legacy_path.clone() };
144+
// Act:合法槽位操作 + 对旧槽的三种尝试(全拒)
145+
assert_eq!(store.load_key("provider:p1").expect("合法槽位读取"), None);
146+
store.clear_key("provider:p1").expect("合法槽位清除幂等");
147+
assert!(store.save_key(LEGACY_DEFAULT_SCOPE, "x").is_err(), "旧槽写入被拒");
148+
assert!(store.load_key(LEGACY_DEFAULT_SCOPE).is_err(), "旧槽读取被拒");
149+
assert!(store.clear_key(LEGACY_DEFAULT_SCOPE).is_err(), "旧槽清除被拒");
150+
// Assert:旧 blob 文件仍在且**字节不变**(物理键未被删/未被覆写)
151+
let after = std::fs::read(&legacy_path).expect("旧 blob 文件必须仍在");
152+
assert_eq!(after, SENTINEL, "旧凭据文件字节必须原样保留(不删、不覆写)");
153+
std::fs::remove_dir_all(&dir).ok();
64154
}

‎app/src-tauri/src/app_setup.rs‎

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -162,8 +162,13 @@ pub fn setup_app_state(app: &mut tauri::App) -> Result<(), String> {
162162
));
163163
let ai_credentials = crate::ai_credentials::platform_store(&data_dir.join("ai_credentials.bin"));
164164
// v0.11.6 M1:AI Provider 装配——providers 为空即迁移(新用户亦生成
165-
// 无密钥预设 Provider,行为自洽);密钥迁移同处完成(旧凭据条目 →
166-
// provider:legacy-deepseek 新条目,v0.12.0 M4 迁移目标改 DeepSeek)。
165+
// 无密钥预设 Provider,行为自洽)。
166+
// 🔴 2026-09-13 批 8 T25(用户裁决 U2 = c):原「旧凭据条目 "default" →
167+
// provider:legacy-deepseek」的**启动迁移读路径已移除**——遗留槽不再是合法
168+
// 槽位(存储抽象三路全拒),留着只会静默拿 Err。🔴 **不抹除旧数据**:
169+
// ai_credentials.bin 原样保留,不删、不迁移、不覆写(物理抹除是另一件事,
170+
// 用户裁决未授权)。仅有旧槽的真机用户在应用内不再可达该密钥 ⇒ 需在
171+
// Provider 面板重填(风险已登记)。
167172
// 锁序契约:启动单线程装配、锁序(providers→settings)与运行时调用点
168173
// (settings→providers)相反,禁止在命令层持 providers 锁读 settings。
169174
// save 决策:保存失败阻断启动是有意 fail-fast(数据目录不可写时后续
@@ -179,12 +184,6 @@ pub fn setup_app_state(app: &mut tauri::App) -> Result<(), String> {
179184
let (providers, default_id) = crate::ai_provider::migrate_from_legacy(&legacy);
180185
store.providers = providers;
181186
store.default_provider_id = default_id;
182-
// 密钥迁移:旧凭据存在且新 scope 不存在 → 复制(旧条目保留供回退)
183-
if let Ok(Some(key)) = ai_credentials.load_key("default") {
184-
if ai_credentials.load_key("provider:legacy-deepseek").ok().flatten().is_none() {
185-
let _ = ai_credentials.save_key("provider:legacy-deepseek", &key);
186-
}
187-
}
188187
store.save(&ai_providers_path)?;
189188
} else {
190189
let mut changed = crate::ai_provider::upgrade_existing_default_to_deepseek(&mut store);

0 commit comments

Comments
 (0)