wecomx_auth/
file_store.rs1use std::fs;
9use std::path::PathBuf;
10
11use crate::credentials::{CredentialStore, Credentials};
12use crate::crypto;
13use crate::error::AuthError;
14
15#[derive(Debug, Clone)]
21pub struct EncryptedFileCredentialStore {
22 dir: PathBuf,
23 use_keyring: bool,
24}
25
26impl EncryptedFileCredentialStore {
27 pub fn new(dir: impl Into<PathBuf>) -> Self {
29 Self {
30 dir: dir.into(),
31 use_keyring: true,
32 }
33 }
34
35 pub fn dir(&self) -> &std::path::Path {
37 &self.dir
38 }
39
40 pub fn keyring_enabled(&self) -> bool {
42 self.use_keyring
43 }
44
45 #[must_use]
49 pub fn with_keyring(mut self, enabled: bool) -> Self {
50 self.use_keyring = enabled;
51 self
52 }
53
54 pub fn credentials_path(&self) -> PathBuf {
56 self.dir.join("credentials.enc")
57 }
58
59 pub fn legacy_paths(&self) -> [PathBuf; 2] {
64 [self.dir.join("bot.enc"), self.dir.join("token.enc")]
65 }
66
67 pub fn encryption_key_path(&self) -> PathBuf {
69 crypto::encryption_key_path(&self.dir)
70 }
71
72 pub fn generate_key(&self) -> [u8; 32] {
74 crypto::generate_random_key()
75 }
76}
77
78impl CredentialStore for EncryptedFileCredentialStore {
79 #[allow(clippy::disallowed_methods)]
81 fn load(&self) -> Result<Option<Credentials>, AuthError> {
82 let path = self.credentials_path();
83 let data = match fs::read(&path) {
84 Ok(data) => data,
85 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
87 Err(e) => {
88 return Err(AuthError::Storage(format!(
89 "读取凭据文件 {} 失败: {e}",
90 path.display()
91 )));
92 }
93 };
94 let creds = crypto::try_decrypt_data(&self.dir, self.use_keyring, &data)
95 .inspect_err(|e| {
96 tracing::warn!(path = %path.display(), error = %e, "failed to decrypt credentials");
97 })
98 .ok();
99 Ok(creds)
100 }
101
102 fn save(&self, creds: &Credentials) -> Result<(), AuthError> {
104 if creds.bot.is_none() && creds.token.is_none() {
105 return self.clear();
106 }
107 let key = crypto::load_key_from_file(&self.dir)
108 .or_else(|| crypto::load_key_from_keyring(&self.dir).filter(|_| self.use_keyring))
109 .unwrap_or_else(|| {
110 let k = crypto::generate_random_key();
111 tracing::info!("generated a new encryption key");
112 k
113 });
114 crypto::save_key(&self.dir, &key, self.use_keyring)?;
115 let encrypted = crypto::encrypt_data(creds, &key)?;
116 crypto::atomic_write(&self.credentials_path(), &encrypted, 0o600)?;
117 tracing::info!("credentials saved");
118 Ok(())
119 }
120
121 #[allow(clippy::disallowed_methods)]
123 fn clear(&self) -> Result<(), AuthError> {
124 let path = self.credentials_path();
125 if path.exists() {
126 fs::remove_file(&path).map_err(|e| {
127 AuthError::Storage(format!("删除凭据文件 {} 失败: {e}", path.display()))
128 })?;
129 tracing::info!("credentials file removed: {}", path.display());
130 }
131 Ok(())
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use base64::Engine as _;
149
150 use super::*;
151
152 fn store(dir: &std::path::Path) -> EncryptedFileCredentialStore {
154 EncryptedFileCredentialStore::new(dir).with_keyring(false)
155 }
156
157 fn write_key(dir: &std::path::Path, key: &[u8; 32]) {
158 #[allow(clippy::disallowed_methods)] std::fs::write(
160 dir.join(".encryption_key"),
161 base64::prelude::BASE64_STANDARD.encode(key),
162 )
163 .unwrap();
164 }
165
166 fn bot(id: &str) -> crate::bot::BotCredential {
167 crate::bot::BotCredential::new(id.to_string(), "secret".into())
168 }
169
170 #[test]
172 fn save_load_roundtrip() {
173 let dir = tempfile::tempdir().unwrap();
174 let store = store(dir.path());
175 write_key(dir.path(), &store.generate_key());
176
177 let creds = Credentials {
178 bot: Some(bot("bot1")),
179 token: Some("tok-1".into()),
180 };
181 store.save(&creds).unwrap();
182 let loaded = store.load().unwrap().unwrap();
183 assert_eq!(loaded.bot.as_ref().map(|b| b.id.as_str()), Some("bot1"));
184 assert_eq!(loaded.token.as_deref(), Some("tok-1"));
185 }
186
187 #[test]
189 fn save_empty_creds_deletes_file() {
190 let dir = tempfile::tempdir().unwrap();
191 let store = store(dir.path());
192 write_key(dir.path(), &store.generate_key());
193
194 store.save(&Credentials::default()).unwrap();
195 assert!(!dir.path().join("credentials.enc").exists());
196 }
197
198 #[test]
200 fn bot_and_token_independent() {
201 let dir = tempfile::tempdir().unwrap();
202 let store = store(dir.path());
203 write_key(dir.path(), &store.generate_key());
204
205 let mut c = store.load().unwrap().unwrap_or_default();
206 c.bot = Some(bot("bot2"));
207 store.save(&c).unwrap();
208 let mut c = store.load().unwrap().unwrap_or_default();
209 c.token = Some("tok-2".into());
210 store.save(&c).unwrap();
211 let loaded = store.load().unwrap().unwrap();
212 assert_eq!(loaded.bot.as_ref().map(|b| b.id.as_str()), Some("bot2"));
213 assert_eq!(loaded.token.as_deref(), Some("tok-2"));
214 }
215
216 #[test]
218 fn clear_deletes_file_and_is_noop_when_missing() {
219 let dir = tempfile::tempdir().unwrap();
220 let store = store(dir.path());
221 write_key(dir.path(), &store.generate_key());
222
223 let c = Credentials {
224 bot: Some(bot("bot4")),
225 token: Some("tok-4".into()),
226 };
227 store.save(&c).unwrap();
228 store.clear().unwrap();
229 assert!(!dir.path().join("credentials.enc").exists());
230 store.clear().unwrap();
231 }
232
233 #[test]
235 fn legacy_files_kept_when_no_credentials() {
236 let dir = tempfile::tempdir().unwrap();
237 let store = store(dir.path());
238 write_key(dir.path(), &store.generate_key());
239 #[allow(clippy::disallowed_methods)] std::fs::write(dir.path().join("bot.enc"), b"legacy-bot").unwrap();
241 #[allow(clippy::disallowed_methods)] std::fs::write(dir.path().join("token.enc"), b"legacy-token").unwrap();
243
244 assert!(store.load().unwrap().is_none());
245 assert!(dir.path().join("bot.enc").exists(), "legacy must be kept");
246 assert!(dir.path().join("token.enc").exists(), "legacy must be kept");
247 }
248
249 #[test]
251 fn legacy_files_kept_even_with_credentials() {
252 let dir = tempfile::tempdir().unwrap();
253 let store = store(dir.path());
254 write_key(dir.path(), &store.generate_key());
255
256 let c = Credentials {
257 bot: Some(bot("bot1")),
258 token: Some("tok-1".into()),
259 };
260 store.save(&c).unwrap();
261 #[allow(clippy::disallowed_methods)] std::fs::write(dir.path().join("bot.enc"), b"legacy-bot").unwrap();
264
265 assert!(store.load().unwrap().is_some());
266 assert!(dir.path().join("bot.enc").exists(), "legacy must be kept");
267 }
268
269 #[test]
271 fn load_missing_returns_none() {
272 let dir = tempfile::tempdir().unwrap();
273 let store = store(dir.path());
274 assert!(store.load().unwrap().is_none());
275 }
276
277 #[test]
279 fn load_corrupted_returns_none() {
280 let dir = tempfile::tempdir().unwrap();
281 let store = store(dir.path());
282 write_key(dir.path(), &store.generate_key());
283 #[allow(clippy::disallowed_methods)] std::fs::write(dir.path().join("credentials.enc"), b"garbage").unwrap();
285 assert!(store.load().unwrap().is_none());
286 }
287
288 #[test]
290 fn load_wrong_key_returns_none() {
291 let dir = tempfile::tempdir().unwrap();
292 let store = store(dir.path());
293 write_key(dir.path(), &store.generate_key());
294
295 let c = Credentials {
296 bot: Some(bot("bot1")),
297 token: Some("tok-secret".into()),
298 };
299 store.save(&c).unwrap();
300
301 write_key(dir.path(), &store.generate_key()); assert!(store.load().unwrap().is_none());
303 }
304}