use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, RwLock};
#[derive(Clone)]
pub struct Credentials {
inner: Arc<Inner>,
}
struct Inner {
current: RwLock<(String, String)>,
generation: AtomicU64,
}
impl Credentials {
pub fn new(user: impl Into<String>, password: impl Into<String>) -> Credentials {
Credentials {
inner: Arc::new(Inner {
current: RwLock::new((user.into(), password.into())),
generation: AtomicU64::new(1),
}),
}
}
pub fn current(&self) -> (String, String) {
match self.inner.current.read() {
Ok(current) => current.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
}
}
pub fn user(&self) -> String {
self.current().0
}
pub fn generation(&self) -> u64 {
self.inner.generation.load(Ordering::Acquire)
}
pub fn rotate(&self, user: impl Into<String>, password: impl Into<String>) -> u64 {
let pair = (user.into(), password.into());
match self.inner.current.write() {
Ok(mut current) => *current = pair,
Err(poisoned) => *poisoned.into_inner() = pair,
}
self.inner.generation.fetch_add(1, Ordering::AcqRel) + 1
}
pub fn is_current(&self, generation: u64) -> bool {
generation == self.generation()
}
}
impl std::fmt::Debug for Credentials {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Credentials")
.field("user", &self.user())
.field("password", &"<redacted>")
.field("generation", &self.generation())
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hands_out_what_it_was_given() {
let credentials = Credentials::new("app", "s3cr3t");
assert_eq!(credentials.current(), ("app".to_string(), "s3cr3t".to_string()));
assert_eq!(credentials.user(), "app");
}
#[test]
fn rotating_replaces_the_pair_and_moves_the_generation_on() {
let credentials = Credentials::new("old-user", "old-pass");
let first = credentials.generation();
let second = credentials.rotate("new-user", "new-pass");
assert_eq!(credentials.current(), ("new-user".to_string(), "new-pass".to_string()));
assert_eq!(second, first + 1);
assert!(!credentials.is_current(first), "connections from before must be retired");
assert!(credentials.is_current(second));
}
#[test]
fn every_clone_sees_the_rotation() {
let credentials = Credentials::new("old", "old");
let held_elsewhere = credentials.clone();
credentials.rotate("new", "new");
assert_eq!(held_elsewhere.user(), "new");
assert_eq!(held_elsewhere.generation(), credentials.generation());
}
#[test]
fn rotating_to_the_same_values_still_retires_the_old_connections() {
let credentials = Credentials::new("app", "same");
let before = credentials.generation();
credentials.rotate("app", "same");
assert!(!credentials.is_current(before));
}
#[test]
fn generations_keep_climbing_across_many_rotations() {
let credentials = Credentials::new("a", "a");
let start = credentials.generation();
for round in 1..=100 {
assert_eq!(credentials.rotate("a", "a"), start + round);
}
}
#[test]
fn debug_prints_the_user_but_never_the_password() {
let printed = format!("{:?}", Credentials::new("v-token-app-abc", "s3cr3t"));
assert!(printed.contains("v-token-app-abc"));
assert!(!printed.contains("s3cr3t"), "the password reached a log: {printed}");
}
#[test]
fn a_poisoned_lock_still_yields_credentials() {
let credentials = Credentials::new("app", "s3cr3t");
let clone = credentials.clone();
let _ = std::thread::spawn(move || {
let _guard = clone.inner.current.write().unwrap();
panic!("poisoning the lock");
})
.join();
assert_eq!(credentials.user(), "app");
assert_eq!(credentials.rotate("next", "next"), 2);
assert_eq!(credentials.user(), "next");
}
}