use std::fmt;
use std::path::Path;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use subtle::ConstantTimeEq;
use crate::error::{ModelError, ModelResult};
const GENERATED_PREFIX: &str = "solti_agt_";
const GENERATED_ENTROPY_BYTES: usize = 32;
#[derive(Clone)]
pub struct Token(String);
impl Token {
pub fn new(token: impl Into<String>) -> Self {
Self(token.into().trim().to_string())
}
pub fn generate() -> Self {
let mut buf = [0u8; GENERATED_ENTROPY_BYTES];
getrandom::fill(&mut buf).expect("getrandom: OS entropy source unavailable");
Self(format!("{GENERATED_PREFIX}{}", URL_SAFE_NO_PAD.encode(buf)))
}
pub fn from_env(var: &str) -> ModelResult<Self> {
let raw = std::env::var(var)
.map_err(|_| ModelError::Invalid(format!("token env var `{var}` is not set").into()))?;
Self::checked(raw)
}
pub fn from_file(path: impl AsRef<Path>) -> ModelResult<Self> {
let path = path.as_ref();
let raw = std::fs::read_to_string(path).map_err(|e| {
ModelError::Invalid(format!("read token file `{}`: {e}", path.display()).into())
})?;
Self::checked(raw)
}
fn checked(raw: String) -> ModelResult<Self> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(ModelError::Invalid("token must not be empty".into()));
}
Ok(Self(trimmed.to_string()))
}
pub fn expose(&self) -> &str {
&self.0
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn verify(&self, candidate: &str) -> bool {
self.0.as_bytes().ct_eq(candidate.as_bytes()).into()
}
}
impl fmt::Debug for Token {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("Token(***redacted***)")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_trims_whitespace() {
assert_eq!(Token::new(" abc\n").expose(), "abc");
}
#[test]
fn checked_rejects_empty() {
assert!(Token::from_env("__definitely_unset_var__").is_err());
}
#[test]
fn verify_matches_only_exact() {
let t = Token::new("s3cr3t-value");
assert!(t.verify("s3cr3t-value"));
assert!(!t.verify("s3cr3t-valuE"));
assert!(!t.verify("s3cr3t"));
assert!(!t.verify("s3cr3t-value-extra"));
}
#[test]
fn debug_is_redacted() {
let t = Token::new("super-secret");
assert_eq!(format!("{t:?}"), "Token(***redacted***)");
assert!(!format!("{t:?}").contains("super-secret"));
}
#[test]
fn generate_is_prefixed_unique_and_self_verifying() {
let a = Token::generate();
let b = Token::generate();
assert!(a.expose().starts_with("solti_agt_"));
assert_ne!(a.expose(), b.expose(), "two generated tokens must differ");
assert!(a.verify(a.expose()));
assert!(!a.verify(b.expose()));
assert_eq!(a.expose().len(), "solti_agt_".len() + 43);
}
}