use std::fs;
use std::path::PathBuf;
use crate::credentials::{CredentialStore, Credentials};
use crate::crypto;
use crate::error::AuthError;
#[derive(Debug, Clone)]
pub struct EncryptedFileCredentialStore {
dir: PathBuf,
use_keyring: bool,
}
impl EncryptedFileCredentialStore {
pub fn new(dir: impl Into<PathBuf>) -> Self {
Self {
dir: dir.into(),
use_keyring: true,
}
}
pub fn dir(&self) -> &std::path::Path {
&self.dir
}
pub fn keyring_enabled(&self) -> bool {
self.use_keyring
}
#[must_use]
pub fn with_keyring(mut self, enabled: bool) -> Self {
self.use_keyring = enabled;
self
}
pub fn credentials_path(&self) -> PathBuf {
self.dir.join("credentials.enc")
}
pub fn legacy_paths(&self) -> [PathBuf; 2] {
[self.dir.join("bot.enc"), self.dir.join("token.enc")]
}
pub fn encryption_key_path(&self) -> PathBuf {
crypto::encryption_key_path(&self.dir)
}
pub fn generate_key(&self) -> [u8; 32] {
crypto::generate_random_key()
}
}
impl CredentialStore for EncryptedFileCredentialStore {
#[allow(clippy::disallowed_methods)]
fn load(&self) -> Result<Option<Credentials>, AuthError> {
let path = self.credentials_path();
let data = match fs::read(&path) {
Ok(data) => data,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(AuthError::Storage(format!(
"读取凭据文件 {} 失败: {e}",
path.display()
)));
}
};
let creds = crypto::try_decrypt_data(&self.dir, self.use_keyring, &data)
.inspect_err(|e| {
tracing::warn!(path = %path.display(), error = %e, "failed to decrypt credentials");
})
.ok();
Ok(creds)
}
fn save(&self, creds: &Credentials) -> Result<(), AuthError> {
if creds.bot.is_none() && creds.token.is_none() {
return self.clear();
}
let key = crypto::load_key_from_file(&self.dir)
.or_else(|| crypto::load_key_from_keyring(&self.dir).filter(|_| self.use_keyring))
.unwrap_or_else(|| {
let k = crypto::generate_random_key();
tracing::info!("generated a new encryption key");
k
});
crypto::save_key(&self.dir, &key, self.use_keyring)?;
let encrypted = crypto::encrypt_data(creds, &key)?;
crypto::atomic_write(&self.credentials_path(), &encrypted, 0o600)?;
tracing::info!("credentials saved");
Ok(())
}
#[allow(clippy::disallowed_methods)]
fn clear(&self) -> Result<(), AuthError> {
let path = self.credentials_path();
if path.exists() {
fs::remove_file(&path).map_err(|e| {
AuthError::Storage(format!("删除凭据文件 {} 失败: {e}", path.display()))
})?;
tracing::info!("credentials file removed: {}", path.display());
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use base64::Engine as _;
use super::*;
fn store(dir: &std::path::Path) -> EncryptedFileCredentialStore {
EncryptedFileCredentialStore::new(dir).with_keyring(false)
}
fn write_key(dir: &std::path::Path, key: &[u8; 32]) {
#[allow(clippy::disallowed_methods)] std::fs::write(
dir.join(".encryption_key"),
base64::prelude::BASE64_STANDARD.encode(key),
)
.unwrap();
}
fn bot(id: &str) -> crate::bot::BotCredential {
crate::bot::BotCredential::new(id.to_string(), "secret".into())
}
#[test]
fn save_load_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let store = store(dir.path());
write_key(dir.path(), &store.generate_key());
let creds = Credentials {
bot: Some(bot("bot1")),
token: Some("tok-1".into()),
};
store.save(&creds).unwrap();
let loaded = store.load().unwrap().unwrap();
assert_eq!(loaded.bot.as_ref().map(|b| b.id.as_str()), Some("bot1"));
assert_eq!(loaded.token.as_deref(), Some("tok-1"));
}
#[test]
fn save_empty_creds_deletes_file() {
let dir = tempfile::tempdir().unwrap();
let store = store(dir.path());
write_key(dir.path(), &store.generate_key());
store.save(&Credentials::default()).unwrap();
assert!(!dir.path().join("credentials.enc").exists());
}
#[test]
fn bot_and_token_independent() {
let dir = tempfile::tempdir().unwrap();
let store = store(dir.path());
write_key(dir.path(), &store.generate_key());
let mut c = store.load().unwrap().unwrap_or_default();
c.bot = Some(bot("bot2"));
store.save(&c).unwrap();
let mut c = store.load().unwrap().unwrap_or_default();
c.token = Some("tok-2".into());
store.save(&c).unwrap();
let loaded = store.load().unwrap().unwrap();
assert_eq!(loaded.bot.as_ref().map(|b| b.id.as_str()), Some("bot2"));
assert_eq!(loaded.token.as_deref(), Some("tok-2"));
}
#[test]
fn clear_deletes_file_and_is_noop_when_missing() {
let dir = tempfile::tempdir().unwrap();
let store = store(dir.path());
write_key(dir.path(), &store.generate_key());
let c = Credentials {
bot: Some(bot("bot4")),
token: Some("tok-4".into()),
};
store.save(&c).unwrap();
store.clear().unwrap();
assert!(!dir.path().join("credentials.enc").exists());
store.clear().unwrap();
}
#[test]
fn legacy_files_kept_when_no_credentials() {
let dir = tempfile::tempdir().unwrap();
let store = store(dir.path());
write_key(dir.path(), &store.generate_key());
#[allow(clippy::disallowed_methods)] std::fs::write(dir.path().join("bot.enc"), b"legacy-bot").unwrap();
#[allow(clippy::disallowed_methods)] std::fs::write(dir.path().join("token.enc"), b"legacy-token").unwrap();
assert!(store.load().unwrap().is_none());
assert!(dir.path().join("bot.enc").exists(), "legacy must be kept");
assert!(dir.path().join("token.enc").exists(), "legacy must be kept");
}
#[test]
fn legacy_files_kept_even_with_credentials() {
let dir = tempfile::tempdir().unwrap();
let store = store(dir.path());
write_key(dir.path(), &store.generate_key());
let c = Credentials {
bot: Some(bot("bot1")),
token: Some("tok-1".into()),
};
store.save(&c).unwrap();
#[allow(clippy::disallowed_methods)] std::fs::write(dir.path().join("bot.enc"), b"legacy-bot").unwrap();
assert!(store.load().unwrap().is_some());
assert!(dir.path().join("bot.enc").exists(), "legacy must be kept");
}
#[test]
fn load_missing_returns_none() {
let dir = tempfile::tempdir().unwrap();
let store = store(dir.path());
assert!(store.load().unwrap().is_none());
}
#[test]
fn load_corrupted_returns_none() {
let dir = tempfile::tempdir().unwrap();
let store = store(dir.path());
write_key(dir.path(), &store.generate_key());
#[allow(clippy::disallowed_methods)] std::fs::write(dir.path().join("credentials.enc"), b"garbage").unwrap();
assert!(store.load().unwrap().is_none());
}
#[test]
fn load_wrong_key_returns_none() {
let dir = tempfile::tempdir().unwrap();
let store = store(dir.path());
write_key(dir.path(), &store.generate_key());
let c = Credentials {
bot: Some(bot("bot1")),
token: Some("tok-secret".into()),
};
store.save(&c).unwrap();
write_key(dir.path(), &store.generate_key()); assert!(store.load().unwrap().is_none());
}
}