quantrs2_device/security/
credentials.rs1use std::collections::HashMap;
7
8pub struct SecretString {
13 inner: Vec<u8>,
14}
15
16impl SecretString {
17 pub fn new(s: impl Into<String>) -> Self {
19 Self {
20 inner: s.into().into_bytes(),
21 }
22 }
23
24 pub fn expose_secret(&self) -> &str {
28 std::str::from_utf8(&self.inner).unwrap_or("")
30 }
31
32 pub fn len(&self) -> usize {
34 self.inner.len()
35 }
36
37 pub fn is_empty(&self) -> bool {
39 self.inner.is_empty()
40 }
41}
42
43impl Drop for SecretString {
44 fn drop(&mut self) {
45 for b in self.inner.iter_mut() {
47 unsafe {
49 std::ptr::write_volatile(b, 0u8);
50 }
51 }
52 }
53}
54
55impl std::fmt::Debug for SecretString {
56 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57 f.write_str("[REDACTED]")
58 }
59}
60
61impl std::fmt::Display for SecretString {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 f.write_str("[REDACTED]")
64 }
65}
66
67#[derive(Debug)]
69#[non_exhaustive]
70pub enum CredentialError {
71 NotFound(String),
73 PermissionDenied(String),
75 IoError(std::io::Error),
77 ParseError(String),
79}
80
81impl std::fmt::Display for CredentialError {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 match self {
84 CredentialError::NotFound(k) => write!(f, "credential not found: {}", k),
85 CredentialError::PermissionDenied(p) => write!(f, "permission denied: {}", p),
86 CredentialError::IoError(e) => write!(f, "credential IO error: {}", e),
87 CredentialError::ParseError(s) => write!(f, "credential parse error: {}", s),
88 }
89 }
90}
91
92impl std::error::Error for CredentialError {
93 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
94 match self {
95 CredentialError::IoError(e) => Some(e),
96 _ => None,
97 }
98 }
99}
100
101impl From<std::io::Error> for CredentialError {
102 fn from(e: std::io::Error) -> Self {
103 CredentialError::IoError(e)
104 }
105}
106
107pub trait CredentialProvider: Send + Sync {
109 fn get_credential(&self, key: &str) -> Result<SecretString, CredentialError>;
111
112 fn available_keys(&self) -> Vec<String>;
117}
118
119pub struct EnvVarCredentialProvider {
133 prefix: String,
134}
135
136impl EnvVarCredentialProvider {
137 pub fn new(prefix: impl Into<String>) -> Self {
139 Self {
140 prefix: prefix.into().to_uppercase(),
141 }
142 }
143
144 fn env_key(&self, key: &str) -> String {
145 if self.prefix.is_empty() {
146 key.to_uppercase()
147 } else {
148 format!("{}_{}", self.prefix, key.to_uppercase())
149 }
150 }
151}
152
153impl CredentialProvider for EnvVarCredentialProvider {
154 fn get_credential(&self, key: &str) -> Result<SecretString, CredentialError> {
155 let env_key = self.env_key(key);
156 std::env::var(&env_key)
157 .map(SecretString::new)
158 .map_err(|_| CredentialError::NotFound(env_key))
159 }
160
161 fn available_keys(&self) -> Vec<String> {
162 vec![]
164 }
165}
166
167pub struct FileCredentialProvider {
181 path: std::path::PathBuf,
182}
183
184impl FileCredentialProvider {
185 pub fn new(path: impl Into<std::path::PathBuf>) -> Self {
187 Self { path: path.into() }
188 }
189
190 fn load(&self) -> Result<HashMap<String, String>, CredentialError> {
191 #[cfg(unix)]
192 {
193 use std::os::unix::fs::MetadataExt;
194 let meta = std::fs::metadata(&self.path).map_err(CredentialError::IoError)?;
195 if meta.mode() & 0o077 != 0 {
196 return Err(CredentialError::PermissionDenied(format!(
197 "credentials file {:?} must have mode 0600",
198 self.path
199 )));
200 }
201 }
202
203 let content = std::fs::read_to_string(&self.path).map_err(CredentialError::IoError)?;
204 serde_json::from_str::<HashMap<String, String>>(&content)
205 .map_err(|e| CredentialError::ParseError(e.to_string()))
206 }
207}
208
209impl CredentialProvider for FileCredentialProvider {
210 fn get_credential(&self, key: &str) -> Result<SecretString, CredentialError> {
211 let map = self.load()?;
212 map.get(key)
213 .map(|v| SecretString::new(v.clone()))
214 .ok_or_else(|| CredentialError::NotFound(key.to_string()))
215 }
216
217 fn available_keys(&self) -> Vec<String> {
218 self.load()
219 .map(|m| m.into_keys().collect())
220 .unwrap_or_default()
221 }
222}
223
224pub struct CompositeCredentialProvider {
240 providers: Vec<Box<dyn CredentialProvider>>,
241}
242
243impl CompositeCredentialProvider {
244 pub fn new() -> Self {
246 Self { providers: vec![] }
247 }
248
249 pub fn with_provider(mut self, p: impl CredentialProvider + 'static) -> Self {
251 self.providers.push(Box::new(p));
252 self
253 }
254}
255
256impl Default for CompositeCredentialProvider {
257 fn default() -> Self {
258 Self::new()
259 }
260}
261
262impl CredentialProvider for CompositeCredentialProvider {
263 fn get_credential(&self, key: &str) -> Result<SecretString, CredentialError> {
264 for p in &self.providers {
265 if let Ok(s) = p.get_credential(key) {
266 return Ok(s);
267 }
268 }
269 Err(CredentialError::NotFound(key.to_string()))
270 }
271
272 fn available_keys(&self) -> Vec<String> {
273 self.providers
274 .iter()
275 .flat_map(|p| p.available_keys())
276 .collect()
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283 use std::env;
284 use std::io::Write;
285
286 #[test]
287 fn test_secret_string_expose() {
288 let secret = SecretString::new("my-api-key");
289 assert_eq!(secret.expose_secret(), "my-api-key");
290 }
291
292 #[test]
293 fn test_secret_string_debug_redacted() {
294 let secret = SecretString::new("super-secret");
295 assert_eq!(format!("{:?}", secret), "[REDACTED]");
296 assert_eq!(format!("{}", secret), "[REDACTED]");
297 }
298
299 #[test]
300 fn test_secret_string_len() {
301 let secret = SecretString::new("hello");
302 assert_eq!(secret.len(), 5);
303 assert!(!secret.is_empty());
304 }
305
306 #[test]
307 fn test_secret_string_empty() {
308 let secret = SecretString::new("");
309 assert!(secret.is_empty());
310 assert_eq!(secret.len(), 0);
311 }
312
313 #[test]
314 fn test_env_var_provider_found() {
315 let key = format!("QUANTRS_TEST_KEY_{}", fastrand::u64(..));
316 env::set_var(&key, "test-token");
317
318 let provider = EnvVarCredentialProvider::new("");
319 let secret = provider.get_credential(&key).expect("should find env var");
320 assert_eq!(secret.expose_secret(), "test-token");
321
322 env::remove_var(&key);
323 }
324
325 #[test]
326 fn test_env_var_provider_with_prefix() {
327 let suffix = fastrand::u64(..);
328 let env_var = format!("QUANTRS_TEST_{}", suffix);
329 env::set_var(&env_var, "prefixed-value");
330
331 let provider = EnvVarCredentialProvider::new("QUANTRS");
332 let secret = provider
333 .get_credential(&format!("TEST_{}", suffix))
334 .expect("should find prefixed env var");
335 assert_eq!(secret.expose_secret(), "prefixed-value");
336
337 env::remove_var(&env_var);
338 }
339
340 #[test]
341 fn test_env_var_provider_not_found() {
342 let provider = EnvVarCredentialProvider::new("QUANTRS");
343 let result = provider.get_credential("DEFINITELY_NONEXISTENT_KEY_12345");
344 assert!(matches!(result, Err(CredentialError::NotFound(_))));
345 }
346
347 #[test]
348 fn test_env_var_provider_available_keys_empty() {
349 let provider = EnvVarCredentialProvider::new("QUANTRS");
350 assert!(provider.available_keys().is_empty());
351 }
352
353 #[test]
354 fn test_file_credential_provider() {
355 let dir = env::temp_dir();
356 let path = dir.join(format!("quantrs_creds_{}.json", fastrand::u64(..)));
357
358 let mut file = std::fs::File::create(&path).expect("create file");
359 write!(
360 file,
361 r#"{{"IBM_TOKEN":"ibm-secret","AWS_KEY":"aws-secret"}}"#
362 )
363 .expect("write credentials");
364 drop(file);
365
366 #[cfg(unix)]
368 {
369 use std::os::unix::fs::PermissionsExt;
370 let perms = std::fs::Permissions::from_mode(0o600);
371 std::fs::set_permissions(&path, perms).expect("set permissions");
372 }
373
374 let provider = FileCredentialProvider::new(&path);
375 let secret = provider
376 .get_credential("IBM_TOKEN")
377 .expect("should find IBM_TOKEN");
378 assert_eq!(secret.expose_secret(), "ibm-secret");
379
380 let keys = provider.available_keys();
381 assert!(keys.contains(&"IBM_TOKEN".to_string()) || keys.contains(&"AWS_KEY".to_string()));
382
383 let _ = std::fs::remove_file(&path);
384 }
385
386 #[test]
387 fn test_file_credential_provider_not_found() {
388 let dir = env::temp_dir();
389 let path = dir.join(format!("quantrs_creds_nf_{}.json", fastrand::u64(..)));
390
391 let mut file = std::fs::File::create(&path).expect("create file");
392 write!(file, r#"{{"IBM_TOKEN":"ibm-secret"}}"#).expect("write credentials");
393 drop(file);
394
395 #[cfg(unix)]
396 {
397 use std::os::unix::fs::PermissionsExt;
398 let perms = std::fs::Permissions::from_mode(0o600);
399 std::fs::set_permissions(&path, perms).expect("set permissions");
400 }
401
402 let provider = FileCredentialProvider::new(&path);
403 let result = provider.get_credential("NONEXISTENT");
404 assert!(matches!(result, Err(CredentialError::NotFound(_))));
405
406 let _ = std::fs::remove_file(&path);
407 }
408
409 #[test]
410 fn test_composite_provider_fallthrough() {
411 let suffix = fastrand::u64(..);
413 let env_var = format!("QUANTRS_COMPOSITE_{}", suffix);
414 env::set_var(&env_var, "composite-value");
415
416 let missing_provider = EnvVarCredentialProvider::new("WRONG_PREFIX");
418 let found_provider = EnvVarCredentialProvider::new("");
420
421 let composite = CompositeCredentialProvider::new()
422 .with_provider(missing_provider)
423 .with_provider(found_provider);
424
425 let secret = composite
426 .get_credential(&env_var)
427 .expect("composite should find via second provider");
428 assert_eq!(secret.expose_secret(), "composite-value");
429
430 env::remove_var(&env_var);
431 }
432
433 #[test]
434 fn test_composite_provider_all_fail() {
435 let composite = CompositeCredentialProvider::new().with_provider(
436 EnvVarCredentialProvider::new("DEFINITELY_MISSING_PREFIX_XYZ"),
437 );
438
439 let result = composite.get_credential("NONEXISTENT");
440 assert!(matches!(result, Err(CredentialError::NotFound(_))));
441 }
442
443 #[test]
444 fn test_composite_provider_empty() {
445 let composite = CompositeCredentialProvider::new();
446 let result = composite.get_credential("any_key");
447 assert!(matches!(result, Err(CredentialError::NotFound(_))));
448 assert!(composite.available_keys().is_empty());
449 }
450
451 #[test]
452 fn test_credential_error_display() {
453 let e = CredentialError::NotFound("MY_KEY".to_string());
454 assert!(e.to_string().contains("MY_KEY"));
455
456 let e = CredentialError::PermissionDenied("/path/to/file".to_string());
457 assert!(e.to_string().contains("permission denied"));
458
459 let e = CredentialError::ParseError("invalid json".to_string());
460 assert!(e.to_string().contains("parse error"));
461 }
462}