Keep values in Windows Credential Manager, require a project bearer for single-value reads, and preserve existing non-secret MCP behavior. Feature-Confirmed: true Rules-Applied: R01,R03,R05,R06,R07,R10,R12,R13
949 lines
36 KiB
Rust
949 lines
36 KiB
Rust
//! 本机用户级凭证存储。生产 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)
|
||
}
|
||
|
||
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;
|
||
|
||
#[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 = 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 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 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("受保护存储")
|
||
);
|
||
}
|
||
|
||
#[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())
|
||
);
|
||
}
|
||
}
|