dev-manager-tauri/src-tauri/src/credential_store.rs
lanrtop 479f4ac2cd fix(onboard/parity): 处置 P8c 独立审查——Codex 信任改进 next_actions、存在即跳过只判存在、token 步区分 needs_repair(P8c)
- Codex 未信任不再并入 blocked:计划新增 codex_trust 与 next_actions 字段,未信任或读不出
  Codex 配置只进 next_actions,不阻断、不进 digest;「本机步骤 → 阻断项」抽成纯函数 machine_step_blocker。
- WriteSink 新增 exists():dispatch_one 只判存在,已存在的非 UTF-8 文件(UTF-16 lefthook.yml 等)
  恢复为 skipped(修复 apply_onboarding_pack 回归);全托管文件读不出仍记 failed。
- token 步改用只读 mcp_auth_token_state 区分 Present / Missing / NeedsRepair,needs_repair 记 needs_human,
  与 get_or_create_mcp_auth_token 的分支一一对应;is_project_injected 仍只作「已接入」判据。
- Codex 信任去掉未验证的祖先目录规则,候选为项目根 + git 主仓库根(worktree 继承主仓库信任);
  键解析按 TOML 规则处理字面量键、基本字符串转义、[projects] 内联表与点号键;删掉非法 TOML 的假阳性用例。
- 写入序列只留一份:抽出 write_project_identity_files_with,inject 与 plan 共用;删除 write_project_mcp_files_with。
- digest 绑定 update 文件的写入前哈希(before_sha256),模块文档写明准确语义。
- 新增 WriteFailure,阻断项指向真正出错的文件;本机 codex 步指向 .codex/config.toml,blocked 按(target, reason)去重。

cargo test 318 passed / 0 failed / 5 ignored(新增 7 条),cargo check 0 warning;两轮变异验证均被对应用例捕获。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:53:41 +09:00

1029 lines
39 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 本机用户级凭证存储。生产 Windows 构建使用 Credential Manager;其他平台 fail closed。
#[cfg(test)]
use std::collections::HashMap;
#[cfg(test)]
use std::sync::Mutex;
fn ensure_legacy_backup(
conn: &rusqlite::Connection,
table: &str,
id_column: &str,
secret_column: &str,
id: &str,
legacy: &str,
) -> Result<(), String> {
let database_path: String = conn
.query_row("PRAGMA database_list", [], |row| row.get(2))
.map_err(|_| "凭证状态 needs_repair:无法确定数据库备份位置".to_string())?;
if database_path.is_empty() {
// 仅测试用的内存数据库无需磁盘备份。
return Ok(());
}
let path = std::path::Path::new(&database_path);
let backup = path.with_extension("pre-credential-migration.db");
if !backup.exists() {
conn.execute("VACUUM main INTO ?1", [backup.to_string_lossy().as_ref()])
.map_err(|_| "凭证状态 needs_repair:迁移前备份失败".to_string())?;
}
let backup_conn =
rusqlite::Connection::open_with_flags(&backup, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
.map_err(|_| "凭证状态 needs_repair:迁移前备份不可读取".to_string())?;
let check: String = backup_conn
.query_row("PRAGMA integrity_check", [], |row| row.get(0))
.map_err(|_| "凭证状态 needs_repair:迁移前备份校验失败".to_string())?;
if check != "ok" {
return Err("凭证状态 needs_repair:迁移前备份完整性检查失败".into());
}
// 防止恢复过旧数据库后误把一份不含当前旧值的备份当作可恢复证据。
let check_sql = format!(
"SELECT EXISTS(SELECT 1 FROM {table} WHERE {id_column} = ?1 AND {secret_column} = ?2)"
);
let contains_value: bool = backup_conn
.query_row(&check_sql, rusqlite::params![id, legacy], |row| row.get(0))
.map_err(|_| "凭证状态 needs_repair:迁移前备份内容无法验证".to_string())?;
if !contains_value {
return Err("凭证状态 needs_repair:现有备份不含当前旧值;请先安排新的可恢复备份".into());
}
Ok(())
}
pub trait CredentialStore {
fn put(&self, reference: &str, value: &[u8]) -> Result<(), String>;
fn get(&self, reference: &str) -> Result<Option<Vec<u8>>, String>;
fn delete(&self, reference: &str) -> Result<(), String>;
}
/// 项目认证材料只用于 MCP 客户端的本机 HTTP 认证;digest 可写入项目配置,token 不可。
pub fn mcp_auth_digest(project_id: &str) -> Result<String, String> {
if project_id.trim().is_empty() {
return Err("项目 ID 不能为空".into());
}
use sha2::Digest;
let digest = sha2::Sha256::digest(project_id.as_bytes());
Ok(digest.iter().map(|byte| format!("{byte:02x}")).collect())
}
fn mcp_auth_reference_for_digest(digest: &str) -> Result<String, String> {
if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err("MCP 认证引用格式无效".into());
}
Ok(format!("mcp-client-auth/{digest}"))
}
fn decode_mcp_auth_token(bytes: Vec<u8>) -> Result<String, String> {
let token = String::from_utf8(bytes).map_err(|_| "MCP 认证材料 needs_repair".to_string())?;
if token.len() != 64 || !token.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err("MCP 认证材料 needs_repair".into());
}
Ok(token)
}
pub fn get_or_create_mcp_auth_token(
store: &impl CredentialStore,
project_id: &str,
) -> Result<String, String> {
let digest = mcp_auth_digest(project_id)?;
let reference = mcp_auth_reference_for_digest(&digest)?;
if let Some(bytes) = store
.get(&reference)
.map_err(|_| "MCP 认证材料 needs_repair:受保护存储不可用".to_string())?
{
return decode_mcp_auth_token(bytes);
}
let token = format!(
"{}{}",
uuid::Uuid::new_v4().simple(),
uuid::Uuid::new_v4().simple()
);
put_verified(store, &reference, token.as_bytes())?;
Ok(token)
}
pub fn read_mcp_auth_token_for_digest(
store: &impl CredentialStore,
digest: &str,
) -> Result<String, String> {
let reference = mcp_auth_reference_for_digest(digest)?;
let bytes = store
.get(&reference)
.map_err(|_| "MCP 认证材料 needs_repair:受保护存储不可用".to_string())?
.ok_or_else(|| "MCP 认证材料未配置".to_string())?;
decode_mcp_auth_token(bytes)
}
/// 项目 MCP token 在受保护存储中的状态(只读,不生成)。分支与 get_or_create_mcp_auth_token 一一对应:
/// 读存储失败或 token 损坏时后者返回 needs_repair(不会重新生成),只有「未配置」才会生成。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum McpAuthTokenState {
Present,
Missing,
NeedsRepair(String),
}
pub fn mcp_auth_token_state(store: &impl CredentialStore, project_id: &str) -> McpAuthTokenState {
let reference = match mcp_auth_digest(project_id)
.and_then(|digest| mcp_auth_reference_for_digest(&digest))
{
Ok(reference) => reference,
Err(reason) => return McpAuthTokenState::NeedsRepair(reason),
};
match store.get(&reference) {
Err(_) => {
McpAuthTokenState::NeedsRepair("MCP 认证材料 needs_repair:受保护存储不可用".into())
}
Ok(None) => McpAuthTokenState::Missing,
Ok(Some(bytes)) => match decode_mcp_auth_token(bytes) {
Ok(_) => McpAuthTokenState::Present,
Err(reason) => McpAuthTokenState::NeedsRepair(reason),
},
}
}
pub fn delete_mcp_auth_token_for_project(
store: &impl CredentialStore,
project_id: &str,
) -> Result<(), String> {
let digest = mcp_auth_digest(project_id)?;
let reference = mcp_auth_reference_for_digest(&digest)?;
store
.delete(&reference)
.map_err(|_| "MCP 认证材料 needs_repair:删除失败".to_string())
}
#[derive(Debug, Clone, Copy)]
pub enum LegacyCredentialKind {
GiteaAccessToken,
GiteaWebhookSecret,
ApiKey,
}
pub fn reference_for(kind: LegacyCredentialKind, id: &str) -> String {
match kind {
LegacyCredentialKind::GiteaAccessToken => format!("gitea/access-token/{id}"),
LegacyCredentialKind::GiteaWebhookSecret => format!("gitea/webhook-secret/{id}"),
LegacyCredentialKind::ApiKey => format!("api-key/{id}"),
}
}
pub fn versioned_reference(kind: LegacyCredentialKind, id: &str) -> String {
format!("{}/{}", reference_for(kind, id), uuid::Uuid::new_v4())
}
pub fn put_verified(
store: &impl CredentialStore,
reference: &str,
value: &[u8],
) -> Result<(), String> {
store
.put(reference, value)
.map_err(|_| "凭证状态 needs_repair:受保护存储写入失败".to_string())?;
let verified = store.get(reference);
match verified {
Ok(Some(bytes)) if bytes == value => Ok(()),
other => {
let _ = store.delete(reference);
match other {
Err(_) => Err("凭证状态 needs_repair:写入后读取校验失败".into()),
Ok(None) => Err("凭证状态 needs_repair:写入后无法读取校验".into()),
Ok(Some(_)) => Err("凭证状态 needs_repair:写入后校验不匹配".into()),
}
}
}
}
/// 在数据库删除事务提交前移除凭证;若中途失败,用快照恢复已删除的引用。
pub fn delete_references(
store: &impl CredentialStore,
references: &[String],
) -> Result<Vec<(String, Option<Vec<u8>>)>, String> {
let mut snapshots = Vec::new();
for reference in references {
if !snapshots.iter().any(|(saved, _)| saved == reference) {
let value = store
.get(reference)
.map_err(|_| "凭证状态 needs_repair:删除前无法读取受保护存储".to_string())?;
snapshots.push((reference.clone(), value));
}
}
for index in 0..snapshots.len() {
if store.delete(&snapshots[index].0).is_err() {
restore_references(store, &snapshots[..=index])?;
return Err("凭证状态 needs_repair:受保护存储删除失败,数据库记录已保留".into());
}
}
Ok(snapshots)
}
pub fn restore_references(
store: &impl CredentialStore,
snapshots: &[(String, Option<Vec<u8>>)],
) -> Result<(), String> {
for (reference, value) in snapshots {
if let Some(value) = value {
put_verified(store, reference, value)
.map_err(|_| "凭证状态 needs_repair:受保护存储凭证恢复失败".to_string())?;
}
}
Ok(())
}
impl LegacyCredentialKind {
fn columns(self) -> (&'static str, &'static str, &'static str, &'static str, bool) {
match self {
Self::GiteaAccessToken => (
"gitea_instances",
"id",
"access_token",
"access_token_ref",
false,
),
Self::GiteaWebhookSecret => (
"gitea_repos",
"project_id",
"webhook_secret",
"webhook_secret_ref",
true,
),
Self::ApiKey => ("api_keys", "id", "api_key", "credential_ref", false),
}
}
}
/// 读取已迁移凭证,或以 write→verify→transactional switch→clear 顺序升级旧记录。
/// 仅接受枚举指定的表/列;错误文本固定为非秘密描述。
pub fn get_or_migrate_legacy(
conn: &rusqlite::Connection,
store: &impl CredentialStore,
kind: LegacyCredentialKind,
id: &str,
) -> Result<Vec<u8>, String> {
let (table, id_column, secret_column, reference_column, nullable) = kind.columns();
let query =
format!("SELECT {secret_column}, {reference_column} FROM {table} WHERE {id_column} = ?1");
let (legacy, reference): (Option<String>, Option<String>) = conn
.query_row(&query, [id], |row| Ok((row.get(0)?, row.get(1)?)))
.map_err(|_| "凭证记录不存在或无法读取".to_string())?;
if let Some(reference) = reference.filter(|value| !value.is_empty()) {
let value = store
.get(&reference)
.map_err(|_| "凭证状态 needs_repair:受保护存储不可用".to_string())?
.ok_or_else(|| "凭证状态 needs_repair:受保护存储中缺少引用值".to_string())?;
return if value.is_empty() {
Err("凭证状态 needs_repair:受保护存储中的凭证为空".into())
} else {
Ok(value)
};
}
let legacy = legacy
.filter(|value| !value.is_empty())
.ok_or_else(|| "凭证状态 not_configured:没有可迁移的凭证".to_string())?;
ensure_legacy_backup(conn, table, id_column, secret_column, id, &legacy)?;
let reference = versioned_reference(kind, id);
let bytes = legacy.as_bytes();
put_verified(store, &reference, bytes)?;
let result = (|| {
let tx = conn
.unchecked_transaction()
.map_err(|_| "凭证状态 needs_repair:数据库切换失败".to_string())?;
let clear = if nullable { "NULL" } else { "''" };
let update = format!(
"UPDATE {table} SET {reference_column} = ?1, {secret_column} = {clear} \
WHERE {id_column} = ?2 AND {secret_column} = ?3 AND \
({reference_column} IS NULL OR {reference_column} = '')"
);
let changed = tx
.execute(&update, rusqlite::params![reference, id, legacy])
.map_err(|_| "凭证状态 needs_repair:数据库切换失败".to_string())?;
if changed != 1 {
return Err("凭证状态 needs_repair:记录在迁移期间发生变化".into());
}
tx.commit()
.map_err(|_| "凭证状态 needs_repair:数据库切换失败".to_string())
})();
if result.is_err() {
let _ = store.delete(&reference);
}
result?;
Ok(bytes.to_vec())
}
#[cfg(windows)]
#[derive(Debug, Default, Clone, Copy)]
pub struct WindowsCredentialStore;
#[cfg(windows)]
impl CredentialStore for WindowsCredentialStore {
fn put(&self, reference: &str, value: &[u8]) -> Result<(), String> {
windows_store::put(reference, value)
}
fn get(&self, reference: &str) -> Result<Option<Vec<u8>>, String> {
windows_store::get(reference)
}
fn delete(&self, reference: &str) -> Result<(), String> {
windows_store::delete(reference)
}
}
#[cfg(not(windows))]
#[derive(Debug, Default, Clone, Copy)]
pub struct WindowsCredentialStore;
#[cfg(not(windows))]
impl CredentialStore for WindowsCredentialStore {
fn put(&self, _reference: &str, _value: &[u8]) -> Result<(), String> {
Err("Windows Credential Manager 不可用".into())
}
fn get(&self, _reference: &str) -> Result<Option<Vec<u8>>, String> {
Err("Windows Credential Manager 不可用".into())
}
fn delete(&self, _reference: &str) -> Result<(), String> {
Err("Windows Credential Manager 不可用".into())
}
}
#[cfg(windows)]
mod windows_store {
use std::ffi::c_void;
use std::ptr::null_mut;
const CRED_TYPE_GENERIC: u32 = 1;
const CRED_PERSIST_LOCAL_MACHINE: u32 = 2;
const CRED_MAX_CREDENTIAL_BLOB_SIZE: usize = 5 * 512;
const ERROR_NOT_FOUND: i32 = 1168;
/// 同进程内并发 CredWrite/CredDelete 实测会丢更新:写入成功后立即读回 NOT_FOUND,
/// 删除成功的条目又被并发写回(2026-10-02 并行 cargo test 偶发 needs_repair 的根因)。
/// 所有 Credential Manager 调用经此锁串行;跨进程并发不在此保护范围内。
static CREDENTIAL_MANAGER_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn serialized() -> std::sync::MutexGuard<'static, ()> {
CREDENTIAL_MANAGER_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[repr(C)]
struct FileTime {
low: u32,
high: u32,
}
#[repr(C)]
struct CredentialAttributeW {
keyword: *mut u16,
flags: u32,
value_size: u32,
value: *mut u8,
}
#[repr(C)]
struct CredentialW {
flags: u32,
kind: u32,
target_name: *mut u16,
comment: *mut u16,
last_written: FileTime,
credential_blob_size: u32,
credential_blob: *mut u8,
persist: u32,
attribute_count: u32,
attributes: *mut CredentialAttributeW,
target_alias: *mut u16,
user_name: *mut u16,
}
#[link(name = "Advapi32")]
unsafe extern "system" {
fn CredWriteW(credential: *const CredentialW, flags: u32) -> i32;
fn CredReadW(
target: *const u16,
kind: u32,
flags: u32,
output: *mut *mut CredentialW,
) -> i32;
fn CredDeleteW(target: *const u16, kind: u32, flags: u32) -> i32;
fn CredFree(buffer: *mut c_void);
}
fn target(reference: &str) -> Result<Vec<u16>, String> {
if reference.is_empty() || reference.len() > 240 || reference.chars().any(char::is_control)
{
return Err("凭证引用格式无效".into());
}
Ok(format!("dev-manager-tauri/{reference}")
.encode_utf16()
.chain(Some(0))
.collect())
}
pub fn put(reference: &str, value: &[u8]) -> Result<(), String> {
if value.is_empty() || value.len() > CRED_MAX_CREDENTIAL_BLOB_SIZE {
return Err("凭证值长度超出 Credential Manager 限制".into());
}
let target = target(reference)?;
let mut blob = value.to_vec();
let credential = CredentialW {
flags: 0,
kind: CRED_TYPE_GENERIC,
target_name: target.as_ptr() as *mut u16,
comment: null_mut(),
last_written: FileTime { low: 0, high: 0 },
credential_blob_size: blob.len() as u32,
credential_blob: blob.as_mut_ptr(),
persist: CRED_PERSIST_LOCAL_MACHINE,
attribute_count: 0,
attributes: null_mut(),
target_alias: null_mut(),
user_name: null_mut(),
};
let ok = {
let _guard = serialized();
unsafe { CredWriteW(&credential, 0) }
};
blob.fill(0);
if ok == 0 {
Err("Credential Manager 写入失败".into())
} else {
Ok(())
}
}
pub fn get(reference: &str) -> Result<Option<Vec<u8>>, String> {
let target = target(reference)?;
let mut raw = null_mut();
let _guard = serialized();
let ok = unsafe { CredReadW(target.as_ptr(), CRED_TYPE_GENERIC, 0, &mut raw) };
if ok == 0 {
let error = std::io::Error::last_os_error();
return if error.raw_os_error() == Some(ERROR_NOT_FOUND) {
Ok(None)
} else {
Err("Credential Manager 读取失败".into())
};
}
if raw.is_null() {
return Err("Credential Manager 返回无效凭证".into());
}
let credential = unsafe { &*raw };
let result = if credential.credential_blob_size as usize > CRED_MAX_CREDENTIAL_BLOB_SIZE {
Err("Credential Manager 凭证长度无效".into())
} else if credential.credential_blob_size > 0 && credential.credential_blob.is_null() {
Err("Credential Manager 返回无效凭证".into())
} else if credential.credential_blob_size == 0 {
Ok(Some(Vec::new()))
} else {
let bytes = unsafe {
std::slice::from_raw_parts(
credential.credential_blob,
credential.credential_blob_size as usize,
)
};
Ok(Some(bytes.to_vec()))
};
unsafe {
CredFree(raw.cast());
}
result
}
pub fn delete(reference: &str) -> Result<(), String> {
let target = target(reference)?;
let _guard = serialized();
let ok = unsafe { CredDeleteW(target.as_ptr(), CRED_TYPE_GENERIC, 0) };
if ok != 0 {
return Ok(());
}
if std::io::Error::last_os_error().raw_os_error() == Some(ERROR_NOT_FOUND) {
Ok(())
} else {
Err("Credential Manager 删除失败".into())
}
}
}
#[cfg(test)]
pub(crate) struct MemoryCredentialStore {
values: Mutex<HashMap<String, Vec<u8>>>,
fail_writes: bool,
fail_reads: bool,
fail_deletes: bool,
fail_delete_reference: Option<String>,
}
#[cfg(test)]
impl MemoryCredentialStore {
pub(crate) fn new() -> Self {
Self {
values: Mutex::new(HashMap::new()),
fail_writes: false,
fail_reads: false,
fail_deletes: false,
fail_delete_reference: None,
}
}
pub(crate) fn failing() -> Self {
Self {
values: Mutex::new(HashMap::new()),
fail_writes: true,
fail_reads: true,
fail_deletes: true,
fail_delete_reference: None,
}
}
pub(crate) fn failing_deletes() -> Self {
Self {
values: Mutex::new(HashMap::new()),
fail_writes: false,
fail_reads: false,
fail_deletes: true,
fail_delete_reference: None,
}
}
pub(crate) fn failing_delete_for(reference: &str) -> Self {
Self {
values: Mutex::new(HashMap::new()),
fail_writes: false,
fail_reads: false,
fail_deletes: false,
fail_delete_reference: Some(reference.into()),
}
}
pub(crate) fn contains_value(&self, value: &[u8]) -> bool {
self.values
.lock()
.map(|values| values.values().any(|stored| stored == value))
.unwrap_or(false)
}
}
#[cfg(test)]
impl CredentialStore for MemoryCredentialStore {
fn put(&self, reference: &str, value: &[u8]) -> Result<(), String> {
if self.fail_writes {
return Err("模拟凭证存储写入失败".into());
}
self.values
.lock()
.map_err(|_| "凭证存储锁不可用".to_string())?
.insert(reference.into(), value.into());
Ok(())
}
fn get(&self, reference: &str) -> Result<Option<Vec<u8>>, String> {
if self.fail_reads {
return Err("模拟凭证存储读取失败".into());
}
Ok(self
.values
.lock()
.map_err(|_| "凭证存储锁不可用".to_string())?
.get(reference)
.cloned())
}
fn delete(&self, reference: &str) -> Result<(), String> {
if self.fail_deletes || self.fail_delete_reference.as_deref() == Some(reference) {
return Err("模拟凭证存储删除失败".into());
}
self.values
.lock()
.map_err(|_| "凭证存储锁不可用".to_string())?
.remove(reference);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::{
get_or_create_mcp_auth_token, get_or_migrate_legacy, mcp_auth_digest,
read_mcp_auth_token_for_digest, CredentialStore, LegacyCredentialKind,
MemoryCredentialStore,
};
use rusqlite::Connection;
fn migration_db() -> Connection {
let conn = crate::db::conn_with_schema();
conn.execute("INSERT INTO gitea_instances (id, name, url, access_token) VALUES ('instance-test', 'fake', 'https://example.invalid', 'synthetic-test-token')", []).unwrap();
conn.execute(
"INSERT INTO projects (id, name) VALUES ('project-test', 'fake')",
[],
)
.unwrap();
conn.execute("INSERT INTO gitea_repos (id, instance_id, project_id, gitea_owner, gitea_repo_name, webhook_secret) VALUES ('repo-test', 'instance-test', 'project-test', 'owner', 'repo', 'synthetic-webhook-secret')", []).unwrap();
conn.execute("INSERT INTO api_keys (id, name, provider, api_key) VALUES ('key-test', 'fake', 'test', 'synthetic-api-key')", []).unwrap();
conn
}
#[test]
fn memory_adapter_supports_put_replace_get_delete_and_failures() {
let store = MemoryCredentialStore::new();
store.put("test/ref", b"first").unwrap();
assert_eq!(
store.get("test/ref").unwrap().as_deref(),
Some(b"first".as_slice())
);
store.put("test/ref", b"second").unwrap();
assert_eq!(
store.get("test/ref").unwrap().as_deref(),
Some(b"second".as_slice())
);
store.delete("test/ref").unwrap();
assert_eq!(store.get("test/ref").unwrap(), None);
let failing = MemoryCredentialStore::failing();
let error = failing.put("test/ref", b"do-not-print-this").unwrap_err();
assert!(!error.contains("do-not-print-this"));
}
#[test]
fn mcp_auth_token_is_stable_per_project_and_never_in_reference() {
let store = MemoryCredentialStore::new();
let p1 = get_or_create_mcp_auth_token(&store, "p1").unwrap();
let p2 = get_or_create_mcp_auth_token(&store, "p2").unwrap();
let digest = mcp_auth_digest("p1").unwrap();
assert_eq!(p1.len(), 64);
assert_ne!(p1, p2);
assert_eq!(get_or_create_mcp_auth_token(&store, "p1").unwrap(), p1);
assert_eq!(read_mcp_auth_token_for_digest(&store, &digest).unwrap(), p1);
assert!(!digest.contains(&p1));
assert!(read_mcp_auth_token_for_digest(&store, "invalid").is_err());
assert!(
get_or_create_mcp_auth_token(&MemoryCredentialStore::failing(), "p1")
.unwrap_err()
.contains("受保护存储")
);
}
/// 一键接入计划的 token 步据此预告 apply:只有 Missing 会生成,NeedsRepair 时 apply 报错、不重新生成。
#[test]
fn mcp_auth_token_state_matches_get_or_create_branches() {
use super::{mcp_auth_reference_for_digest, mcp_auth_token_state, McpAuthTokenState};
let store = MemoryCredentialStore::new();
assert_eq!(
mcp_auth_token_state(&store, "p1"),
McpAuthTokenState::Missing
);
get_or_create_mcp_auth_token(&store, "p1").unwrap();
assert_eq!(
mcp_auth_token_state(&store, "p1"),
McpAuthTokenState::Present
);
let corrupt = mcp_auth_reference_for_digest(&mcp_auth_digest("p2").unwrap()).unwrap();
store.put(&corrupt, b"not-a-64-hex-token").unwrap();
assert!(matches!(
mcp_auth_token_state(&store, "p2"),
McpAuthTokenState::NeedsRepair(reason) if reason.contains("needs_repair")
));
assert!(
get_or_create_mcp_auth_token(&store, "p2")
.unwrap_err()
.contains("needs_repair"),
"token 损坏时 apply 不会重新生成"
);
let failing = MemoryCredentialStore::failing();
assert!(matches!(
mcp_auth_token_state(&failing, "p1"),
McpAuthTokenState::NeedsRepair(reason) if reason.contains("受保护存储不可用")
));
assert!(get_or_create_mcp_auth_token(&failing, "p1").is_err());
}
#[test]
fn legacy_migration_verifies_then_switches_reference_and_clears_old_value() {
let conn = migration_db();
let store = MemoryCredentialStore::new();
let value = get_or_migrate_legacy(
&conn,
&store,
LegacyCredentialKind::GiteaAccessToken,
"instance-test",
)
.unwrap();
assert_eq!(value, b"synthetic-test-token");
let (legacy, reference): (String, String) = conn.query_row("SELECT access_token, access_token_ref FROM gitea_instances WHERE id='instance-test'", [], |row| Ok((row.get(0)?, row.get(1)?))).unwrap();
assert!(legacy.is_empty());
assert_eq!(
store.get(&reference).unwrap().as_deref(),
Some(b"synthetic-test-token".as_slice())
);
let webhook = get_or_migrate_legacy(
&conn,
&store,
LegacyCredentialKind::GiteaWebhookSecret,
"project-test",
)
.unwrap();
assert_eq!(webhook, b"synthetic-webhook-secret");
let old_webhook: Option<String> = conn
.query_row(
"SELECT webhook_secret FROM gitea_repos WHERE project_id='project-test'",
[],
|row| row.get(0),
)
.unwrap();
assert!(old_webhook.is_none());
let key =
get_or_migrate_legacy(&conn, &store, LegacyCredentialKind::ApiKey, "key-test").unwrap();
assert_eq!(key, b"synthetic-api-key");
}
#[test]
fn failed_legacy_migration_keeps_old_value_and_returns_repair_state_without_secret() {
let conn = migration_db();
let store = MemoryCredentialStore::failing();
let error = get_or_migrate_legacy(
&conn,
&store,
LegacyCredentialKind::GiteaAccessToken,
"instance-test",
)
.unwrap_err();
assert!(error.contains("needs_repair"));
assert!(!error.contains("synthetic-test-token"));
let (legacy, reference): (String, Option<String>) = conn.query_row("SELECT access_token, access_token_ref FROM gitea_instances WHERE id='instance-test'", [], |row| Ok((row.get(0)?, row.get(1)?))).unwrap();
assert_eq!(legacy, "synthetic-test-token");
assert!(reference.is_none());
}
#[test]
fn migration_database_failure_keeps_legacy_secret_and_allows_retry() {
let conn = migration_db();
let store = MemoryCredentialStore::new();
conn.execute_batch("CREATE TRIGGER fail_credential_switch BEFORE UPDATE OF access_token_ref ON gitea_instances BEGIN SELECT RAISE(ABORT, 'injected database failure'); END;").unwrap();
let error = get_or_migrate_legacy(
&conn,
&store,
LegacyCredentialKind::GiteaAccessToken,
"instance-test",
)
.unwrap_err();
assert!(error.contains("needs_repair"));
let (legacy, reference): (String, Option<String>) = conn.query_row("SELECT access_token, access_token_ref FROM gitea_instances WHERE id='instance-test'", [], |row| Ok((row.get(0)?, row.get(1)?))).unwrap();
assert_eq!(legacy, "synthetic-test-token");
assert!(reference.is_none());
conn.execute_batch("DROP TRIGGER fail_credential_switch;")
.unwrap();
assert_eq!(
get_or_migrate_legacy(
&conn,
&store,
LegacyCredentialKind::GiteaAccessToken,
"instance-test"
)
.unwrap(),
b"synthetic-test-token"
);
let (legacy, reference): (String, String) = conn.query_row("SELECT access_token, access_token_ref FROM gitea_instances WHERE id='instance-test'", [], |row| Ok((row.get(0)?, row.get(1)?))).unwrap();
assert!(legacy.is_empty());
assert_eq!(
store.get(&reference).unwrap().as_deref(),
Some(b"synthetic-test-token".as_slice())
);
}
#[test]
fn disk_migration_requires_verified_backup_and_backup_restores_old_value() {
let directory = std::env::temp_dir().join(format!(
"lian-jing-credential-backup-test-{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir(&directory).unwrap();
let path = directory.join("synthetic.db");
let backup = path.with_extension("pre-credential-migration.db");
let conn = Connection::open(&path).unwrap();
conn.execute_batch("CREATE TABLE api_keys (id TEXT PRIMARY KEY, api_key TEXT NOT NULL, credential_ref TEXT);").unwrap();
conn.execute(
"INSERT INTO api_keys (id, api_key) VALUES ('k', 'synthetic-backup-key')",
[],
)
.unwrap();
let store = MemoryCredentialStore::new();
std::fs::create_dir(&backup).unwrap();
let error =
get_or_migrate_legacy(&conn, &store, LegacyCredentialKind::ApiKey, "k").unwrap_err();
assert!(error.contains("needs_repair"));
let old: String = conn
.query_row("SELECT api_key FROM api_keys WHERE id='k'", [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(old, "synthetic-backup-key");
std::fs::remove_dir(&backup).unwrap();
assert_eq!(
get_or_migrate_legacy(&conn, &store, LegacyCredentialKind::ApiKey, "k").unwrap(),
b"synthetic-backup-key"
);
let live: String = conn
.query_row("SELECT api_key FROM api_keys WHERE id='k'", [], |row| {
row.get(0)
})
.unwrap();
assert!(live.is_empty());
let restored =
Connection::open_with_flags(&backup, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
.unwrap();
let backup_value: String = restored
.query_row("SELECT api_key FROM api_keys WHERE id='k'", [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(backup_value, "synthetic-backup-key");
conn.execute(
"INSERT INTO api_keys (id, api_key) VALUES ('later', 'synthetic-later-key')",
[],
)
.unwrap();
let stale = get_or_migrate_legacy(&conn, &store, LegacyCredentialKind::ApiKey, "later")
.unwrap_err();
assert!(stale.contains("needs_repair"));
let still_legacy: String = conn
.query_row("SELECT api_key FROM api_keys WHERE id='later'", [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(still_legacy, "synthetic-later-key");
drop(restored);
drop(conn);
std::fs::remove_file(&backup).unwrap();
std::fs::remove_file(&path).unwrap();
std::fs::remove_dir(&directory).unwrap();
}
}
#[cfg(all(test, windows))]
mod windows_tests {
use super::{
get_or_create_mcp_auth_token, mcp_auth_digest, mcp_auth_reference_for_digest,
windows_store, CredentialStore, WindowsCredentialStore,
};
use std::{
io::{Read, Write},
net::TcpListener,
process::Command,
sync::{
atomic::{AtomicBool, AtomicUsize, Ordering},
Arc,
},
time::{Duration, Instant},
};
const CHILD_ENV: &str = "DEV_MANAGER_CREDENTIAL_STORE_CHILD_TEST";
#[test]
fn windows_credential_manager_persists_across_processes() {
let reference = format!("isolated-test/{}", uuid::Uuid::new_v4());
let value = format!("credential-store-test-{}", uuid::Uuid::new_v4()).into_bytes();
let store = WindowsCredentialStore;
store.put(&reference, &value).unwrap();
let child = Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"credential_store::windows_tests::credential_child_probe",
"--ignored",
"--nocapture",
])
.env(CHILD_ENV, &reference)
.env(
"DEV_MANAGER_CREDENTIAL_STORE_EXPECTED",
String::from_utf8(value.clone()).unwrap(),
)
.status();
let cleanup = store.delete(&reference);
assert!(cleanup.is_ok(), "测试凭证清理失败");
assert!(
child.is_ok_and(|status| status.success()),
"Credential Manager 子进程读取测试失败"
);
}
#[test]
#[ignore = "先运行 cargo build 构建同分支的真实可执行文件"]
fn mcp_auth_header_helper_reads_windows_store_and_fails_closed() {
let project_id = format!("qa-only-mcp-auth-{}", uuid::Uuid::new_v4());
let digest = mcp_auth_digest(&project_id).unwrap();
let store = WindowsCredentialStore;
let token = get_or_create_mcp_auth_token(&store, &project_id).unwrap();
let executable = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("target/debug/dev-manager-tauri.exe");
let configured = Command::new(&executable)
.args(["--lianjing-mcp-header", &digest])
.output();
let missing_digest = mcp_auth_digest("qa-only-missing-project").unwrap();
let missing = Command::new(&executable)
.args(["--lianjing-mcp-header", &missing_digest])
.output();
let cleanup = store.delete(&mcp_auth_reference_for_digest(&digest).unwrap());
assert!(cleanup.is_ok(), "测试认证材料清理失败");
let configured = configured.expect("无法启动真实 helper 可执行文件");
assert!(configured.status.success());
let headers: serde_json::Value = serde_json::from_slice(&configured.stdout).unwrap();
let expected = format!("Bearer {token}");
assert!(headers["Authorization"].as_str() == Some(expected.as_str()));
let missing = missing.expect("无法启动真实 helper 可执行文件");
assert!(!missing.status.success());
assert!(missing.stdout.is_empty());
}
#[test]
#[ignore = "需已登录 Codex CLI,先 cargo build;只使用随机合成认证材料"]
fn codex_client_reads_windows_helper_header_across_sessions() {
let project_id = format!("qa-only-codex-auth-{}", uuid::Uuid::new_v4());
let digest = mcp_auth_digest(&project_id).unwrap();
let reference = mcp_auth_reference_for_digest(&digest).unwrap();
let store = WindowsCredentialStore;
let token = get_or_create_mcp_auth_token(&store, &project_id).unwrap();
let executable = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("target/debug/dev-manager-tauri.exe");
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let port = listener.local_addr().unwrap().port();
let observed = Arc::new(AtomicUsize::new(0));
let stop = Arc::new(AtomicBool::new(false));
let observed_for_server = Arc::clone(&observed);
let stop_for_server = Arc::clone(&stop);
let server = std::thread::spawn(move || {
let deadline = Instant::now() + Duration::from_secs(120);
while !stop_for_server.load(Ordering::SeqCst) && Instant::now() < deadline {
match listener.accept() {
Ok((mut stream, _)) => {
stream
.set_read_timeout(Some(Duration::from_secs(3)))
.unwrap();
let mut bytes = [0u8; 8192];
let count = stream.read(&mut bytes).unwrap_or(0);
let request = String::from_utf8_lossy(&bytes[..count]);
let expected = format!("authorization: bearer {token}");
if request.to_ascii_lowercase().contains(&expected) {
observed_for_server.fetch_add(1, Ordering::SeqCst);
}
let _ = stream.write_all(b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(20));
}
Err(_) => break,
}
}
});
let helper = format!(
"\"{}\" --lianjing-mcp-header {digest}",
executable.display()
);
let url_config = format!("mcp_servers.qa_only_auth.url=\"http://127.0.0.1:{port}/mcp\"");
let helper_config = format!(
"mcp_servers.qa_only_auth.http_headers_helper={}",
serde_json::to_string(&helper).unwrap()
);
let mut client_statuses = Vec::new();
for _ in 0..2 {
let status = Command::new("codex")
.args([
"exec",
"--skip-git-repo-check",
"--sandbox",
"read-only",
"-c",
&url_config,
"-c",
&helper_config,
"Reply OK.",
])
.output();
client_statuses.push(status.map(|result| result.status));
}
stop.store(true, Ordering::SeqCst);
server.join().unwrap();
let cleanup = store.delete(&reference);
assert!(cleanup.is_ok(), "测试认证材料清理失败");
assert!(
client_statuses.iter().all(Result::is_ok),
"Codex CLI 未能启动"
);
assert!(
observed.load(Ordering::SeqCst) >= 2,
"两次 Codex 会话未分别通过真实 helper 发送认证头"
);
}
#[test]
#[ignore]
fn credential_child_probe() {
let Ok(reference) = std::env::var(CHILD_ENV) else {
return;
};
let expected = std::env::var("DEV_MANAGER_CREDENTIAL_STORE_EXPECTED").unwrap();
assert_eq!(
windows_store::get(&reference).unwrap().as_deref(),
Some(expected.as_bytes())
);
}
}