修复+重构: 全库走查真bug+架构+P1/P2 后端 crate

- df-nodes: schema required 对齐 + docker POSIX 注入防御 + HumanNode timeout 1800 + parse_review_json(verdict规范/score clamp/正则兜底)
- df-mcp: update 实体校验(防跨实体 B-260801-01)
- df-storage: keyring 迁移失败达阈值清除明文
- df-ai: router estimated_context+tier tiebreak+DataReadOnly 兜底 + sanitize step4 显式不制造 orphan
- df-ideas: adversarial tier:None 对齐
This commit is contained in:
lxy
2026-08-02 10:44:09 +08:00
parent f1773dde4f
commit ea309c607b
11 changed files with 1512 additions and 96 deletions
+222 -12
View File
@@ -11,7 +11,8 @@
//! 设计:
//! - keyring entry: service=`devflow-ai-provider`, username=provider_id
//! - DB `api_key` 列恒空(迁移后/新建均空),真实密钥唯一源 = OS keyring
//! - 启动一次性迁移:`migrate_secrets_to_keyring` 读老明文 → keyring → DB 置空(失败保留明文下次重试)
//! - 启动一次性迁移:`migrate_secrets_to_keyring` 读老明文 → keyring → DB 置空
//! (失败累计达阈值前保留明文下次重试;达阈值后清除明文 + 强制重配,防长期滞留)
//! - 消费点(build_provider)经 `resolve_provider_secret` 取:DB 优先,fallback keyring(兼容未迁移)
//! - 跨平台:Windows Credential Manager / macOS Keychain / Linux Secret Service
@@ -25,8 +26,9 @@ use keyring::Entry;
const KEYRING_SERVICE: &str = "devflow-ai-provider";
/// 迁移失败计数器阈值:同一 provider 累计失败到此次数 → 升级为 warn 提示明文密钥长期滞留风险
/// 跨启动持久化(sidecar 文件),计数仅用于告警,不影响兼容时序(不强制迁移、不删明文)。
/// 迁移失败计数器阈值:同一 provider 累计失败到此次数 → **清除 DB 明文 + error 告警**(强制重配)
/// 跨启动持久化(sidecar 文件)。阈值前仅累计 + warn + 保留明文下次重试(兼容临时性 keyring 故障);
/// 达阈值后清明文防长期滞留(devflow.db 无文件级加密,明文 = 持续暴露)。
const MIGRATION_FAIL_THRESHOLD: u32 = 3;
/// 迁移失败计数 sidecar 文件(<cwd>/.devflow-keyring-failcount):逐行 `provider_id=count`。
@@ -191,7 +193,10 @@ pub async fn delete_provider_secret_async(id: String) -> anyhow::Result<()> {
.map_err(|e| anyhow::anyhow!("delete_provider_secret join 失败: {}", e))?
}
/// 启动一次性迁移:DB 明文 → keyring → DB 置空(失败保留明文下次重试,非阻断)
/// 启动一次性迁移:DB 明文 → keyring → DB 置空
/// 失败累计达阈值前:warn + 保留明文下次重试(兼容临时性 keyring 后端故障);
/// 达阈值后:清除 DB 明文 + error 告警(防明文长期滞留无加密 SQLite,强制用户重配走即时迁移)。
/// 非阻断:整个迁移函数本身不因单条失败而 Err。
pub async fn migrate_secrets_to_keyring(repo: &AiProviderRepo) -> anyhow::Result<usize> {
let providers = repo.list_all().await?;
let mut migrated = 0;
@@ -200,16 +205,37 @@ pub async fn migrate_secrets_to_keyring(repo: &AiProviderRepo) -> anyhow::Result
continue; // 已迁移或无密钥
}
if let Err(e) = set_provider_secret(&p.id, &p.api_key) {
// 累计失败次数:达阈值(默认 3)升级告警,提示明文 api_key 长期滞留 SQLite(无加密)风险。
// 计数仅告警用,不改兼容时序——仍保留明文下次重试,不强制迁移、不删明文
// 累计失败次数:达阈值 → warn + 保留明文下次重试(给临时性 keyring 后端故障恢复机会);
// 达阈值 → **安全兜底:清除 DB 明文 api_key + error 告警**
//
// 安全考量:devflow.db 落在用户 AppData 目录无文件级加密,OS keyring 长期不可用时
// 明文 api_key 无限滞留 = 持续暴露风险(P1)。项目无内置加密栈(无 aes/chacha/argon2 依赖),
// 引入需解决密钥派生 + 密钥存储位置(又会回到 keyring,自相矛盾)——成本/收益不划算。
// 故达阈值后选「清除明文 + 强制用户重配」:DB 不再保留明文,用户下次进设置保存时
// 走 ai_save_provider 即时迁移路径(provider.rs set_provider_secret_async)重新写入 keyring。
// 阈值 3 次已足够覆盖临时性故障(单次启动 keyring 后端未就绪/COM 未初始化等)。
let n = record_migration_fail(&p.id);
if n >= MIGRATION_FAIL_THRESHOLD {
tracing::warn!(
"[密钥迁移] provider {} keyring 迁移已连续失败 {} 次,明文 api_key 长期滞留 SQLite 文件(无加密)。\
建议:1) 确认 OS 钥匙串可用(Win Credential Manager / macOS Keychain);\
2) keyring 后端异常时排查对应平台后端;3) 必要时手动在设置中重新保存密钥触发写入",
p.id, n
);
let pid = p.id.clone();
let pname = p.name.clone();
// 清除 DB 明文:复用下方成功路径同款 clear + insert 模式。
p.api_key.clear();
if let Err(clear_err) = repo.insert(p).await {
tracing::error!(
"[密钥迁移] provider {} ({}) keyring 连续失败 {} 次后清除 DB 明文失败({}) —— \
明文仍滞留 SQLite!请立即手动处理:进设置删除该 provider 或修复 OS 钥匙串后重启",
pname, pid, n, clear_err
);
} else {
tracing::error!(
"[密钥迁移] provider {} ({}) keyring 连续失败 {} 次,已清除 DB 明文 api_key 防长期滞留。\
该 provider 密钥需重新配置:进设置 → 编辑该提供商 → 重新填写 API Key 并保存\
(走即时迁移写入系统钥匙串)。失败原因: {}",
pname, pid, n, e
);
}
// 已清除明文 → 不再计入「待迁移」,清零失败计数(下次若重新出现明文从 1 起算)。
clear_migration_failcount(&pid);
} else {
tracing::warn!(
"[密钥迁移] keyring 迁移失败 {} (累计 {}/{},保留明文下次重试): {}",
@@ -350,4 +376,188 @@ mod tests {
assert!(delete_provider_secret(&id).is_ok());
assert_eq!(get_provider_secret(&id), None, "删除后应读不到");
}
// ============================================================
// failcount sidecar 逻辑测试 + 迁移达阈值后清明文(DB 安全保证)测试
// ============================================================
//
// 涉及 cwd(failcount_path 读 current_dir)的测试用全局 Mutex 串行化,
// 避免并发测试互相污染 sidecar 文件。不引入 serial_test 依赖(零新依赖)。
use std::sync::Mutex as StdMutex;
static CWD_GUARD: StdMutex<()> = StdMutex::new(());
/// RAII 守卫:持有全局锁 + 切到唯一临时 cwd,Drop 时恢复原 cwd 并清理临时目录。
/// 即使持锁期间 panic 也能恢复(PoisonError 用 into_inner 兜底)。
/// 用纳秒戳造唯一临时目录,测后清理,不残留 sidecar 文件污染其他测试。
struct IsolatedCwd {
// 持有 MutexGuard 直到 IsolatedCwd drop → 跨整个测试作用域串行化 cwd 操作。
// CWD_GUARD 是 static,guard 借用 'static,可存入 struct 字段。
_guard: std::sync::MutexGuard<'static, ()>,
orig: PathBuf,
tmp: PathBuf,
}
impl IsolatedCwd {
fn new() -> Self {
let guard = CWD_GUARD.lock().unwrap_or_else(|e| e.into_inner());
let orig = std::env::current_dir().expect("读 cwd");
let nano = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let tmp = std::env::temp_dir().join(format!("df-secret-test-{}", nano));
std::fs::create_dir_all(&tmp).expect("建临时目录");
std::env::set_current_dir(&tmp).expect("切到临时 cwd");
IsolatedCwd { _guard: guard, orig, tmp }
}
}
impl Drop for IsolatedCwd {
fn drop(&mut self) {
let _ = std::env::set_current_dir(&self.orig);
let _ = std::fs::remove_dir_all(&self.tmp);
}
}
#[test]
fn failcount_read_write_record_clear_roundtrip() {
let _cwd = IsolatedCwd::new();
// 空文件 → 空 map
assert!(read_failcounts().is_empty());
// record 累加
assert_eq!(record_migration_fail("p1"), 1);
assert_eq!(record_migration_fail("p1"), 2);
assert_eq!(record_migration_fail("p2"), 1);
let map = read_failcounts();
assert_eq!(map.get("p1"), Some(&2));
assert_eq!(map.get("p2"), Some(&1));
// clear 清零指定 id,不影响其他
clear_migration_failcount("p1");
let map = read_failcounts();
assert!(!map.contains_key("p1"), "clear 后 p1 应不存在");
assert_eq!(map.get("p2"), Some(&1), "p2 不受影响");
// clear 不存在的 id → 无副作用
clear_migration_failcount("nope");
assert_eq!(read_failcounts().get("p2"), Some(&1));
}
#[test]
fn failcount_threshold_constant_is_3() {
// 锁定阈值常量值(测试依赖此值构造「阈值-1」预置场景)。若将来调整需同步更新测试。
assert_eq!(MIGRATION_FAIL_THRESHOLD, 3);
}
#[test]
fn failcount_persists_across_reads() {
let _cwd = IsolatedCwd::new();
// 验证 sidecar 真落盘(跨 read 实例持久化)——这是「跨启动累计失败」的语义基础。
record_migration_fail("pp");
record_migration_fail("pp");
// 模拟「重启」:重新读一次(新 HashMap 实例),计数应保留
assert_eq!(read_failcounts().get("pp"), Some(&2));
}
/// **核心安全保证**:迁移函数跑完后,DB 中 provider 的明文 api_key 必须被清除。
///
/// 覆盖两条路径(都断言同一不变量):
/// - keyring 可用 → 走迁移成功路径,api_key.clear() + insert
/// - keyring 不可用 → 走失败路径:
/// · 预置 failcount 到 THRESHOLD-1(=2),本次失败恰好达阈值 → 清明文分支
/// · 若 keyring 在 CI 上恰好成功,则走成功路径,断言同样成立
///
/// 无论哪条路径,DB api_key 最终必为空 = 安全保证(P1:不保留明文)。
#[tokio::test]
async fn migrate_clears_db_plaintext_after_threshold() {
use crate::crud::AiProviderRepo;
use crate::db::Database;
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = AiProviderRepo::new(&db);
// 构造带明文 api_key 的 provider。id 用唯一纳秒戳避与 keyring 真实 provider 冲突。
let nano = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let pid = format!("df-migrate-test-{}", nano);
let rec = AiProviderRecord {
id: pid.clone(), name: "迁移测试".into(), provider_type: "openai_compat".into(),
api_key: "sk-plaintext-secret".into(), base_url: "https://x".into(),
default_model: "m".into(), models: None, model_configs: Vec::new(), is_default: false,
config: None, created_at: "0".into(), updated_at: "0".into(),
enabled: true, weight: 50,
};
repo.insert(rec).await.expect("insert provider");
// 关键:迁移函数内部读 sidecar(failcount_path 用 cwd),所以整个测试逻辑必须
// 在临时 cwd 下执行(IsolatedCwd 存活期间 + async 迁移在同一作用域)。
let _cwd = IsolatedCwd::new();
// 预置 failcount 到 THRESHOLD-1(=2),使本次失败恰好达阈值触发清除分支。
// (若 keyring 在此 CI 环境恰好可用,迁移直接成功清明文,断言同样成立。)
let mut m = std::collections::HashMap::new();
m.insert(pid.clone(), MIGRATION_FAIL_THRESHOLD - 1);
write_failcounts(&m);
assert_eq!(
read_failcounts().get(&pid),
Some(&(MIGRATION_FAIL_THRESHOLD - 1)),
"预置 failcount 应写入"
);
// 跑迁移(非阻断,内部已处理失败)。
let _ = migrate_secrets_to_keyring(&repo).await;
// 核心断言:无论 keyring 成败,迁移后 DB 不应保留明文 api_key。
let got = repo.get_by_id(&pid).await.expect("get").expect("row exists");
assert!(
got.api_key.is_empty(),
"[P1 安全] 迁移后 DB api_key 必须为空(成功迁移清空 / 失败达阈值清明文),\
实际残留: {:?}。provider={}",
got.api_key, pid
);
// 测后清理 keyring(若迁移成功写入了测试 provider 的密钥)
let _ = delete_provider_secret(&pid);
}
/// 阈值前(keyring 失败 + 未达阈值):计数递增但保留明文下次重试。
/// 此测试只在 keyring 实际失败时验证「保留明文」分支;keyring 可用时跳过(不算失败)。
/// 用 cfg-gate 避 CI 不可控 keyring 后端导致断言不稳。
#[cfg(not(target_os = "linux"))]
#[tokio::test]
async fn migrate_keeps_plaintext_below_threshold_when_keyring_fails() {
use crate::crud::AiProviderRepo;
use crate::db::Database;
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = AiProviderRepo::new(&db);
let nano = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
// 用极长 id 让 Entry::new 在多数平台失败(keyring 后端对超长 username 行为不一,常报错)。
// 若恰好成功则视为 keyring 可用,跳过断言(不算回归)。
let pid = format!("df-migrate-fail-{}-{}", nano, "x".repeat(200));
let rec = AiProviderRecord {
id: pid.clone(), name: "阈值前保留".into(), provider_type: "openai_compat".into(),
api_key: "sk-keep-me".into(), base_url: "https://x".into(),
default_model: "m".into(), models: None, model_configs: Vec::new(), is_default: false,
config: None, created_at: "0".into(), updated_at: "0".into(),
enabled: true, weight: 50,
};
repo.insert(rec).await.expect("insert");
// 隔离 cwd + failcount 从 0 开始(无预置)。迁移函数内部读 sidecar 用 cwd,必须同作用域。
let _cwd = IsolatedCwd::new();
let _ = migrate_secrets_to_keyring(&repo).await;
let got = repo.get_by_id(&pid).await.expect("get").expect("row exists");
// 仅在 keyring 真的失败(Entry::new/set_password 报错)时才能验证「保留明文」。
// keyring 可用时此测试无意义(走成功清明文),跳过。
if get_provider_secret(&pid).is_none() {
// keyring 无值 = 本次迁移失败 → 阈值前(首次失败,count=1<3)应保留明文
assert_eq!(
got.api_key, "sk-keep-me",
"[阈值前] 首次失败未达阈值,应保留明文下次重试(兼容临时性故障)"
);
}
let _ = delete_provider_secret(&pid);
}
}