1use std::fmt;
10use std::path::Path;
11
12use base64::Engine;
13use base64::engine::general_purpose::URL_SAFE_NO_PAD;
14use subtle::ConstantTimeEq;
15
16use crate::error::{ModelError, ModelResult};
17
18const GENERATED_PREFIX: &str = "solti_agt_";
21
22const GENERATED_ENTROPY_BYTES: usize = 32;
24
25#[derive(Clone)]
27pub struct Token(String);
28
29impl Token {
30 pub fn new(token: impl Into<String>) -> Self {
34 Self(token.into().trim().to_string())
35 }
36
37 pub fn generate() -> Self {
42 let mut buf = [0u8; GENERATED_ENTROPY_BYTES];
43 getrandom::fill(&mut buf).expect("getrandom: OS entropy source unavailable");
44 Self(format!("{GENERATED_PREFIX}{}", URL_SAFE_NO_PAD.encode(buf)))
45 }
46
47 pub fn from_env(var: &str) -> ModelResult<Self> {
51 let raw = std::env::var(var)
52 .map_err(|_| ModelError::Invalid(format!("token env var `{var}` is not set").into()))?;
53 Self::checked(raw)
54 }
55
56 pub fn from_file(path: impl AsRef<Path>) -> ModelResult<Self> {
60 let path = path.as_ref();
61 let raw = std::fs::read_to_string(path).map_err(|e| {
62 ModelError::Invalid(format!("read token file `{}`: {e}", path.display()).into())
63 })?;
64 Self::checked(raw)
65 }
66
67 fn checked(raw: String) -> ModelResult<Self> {
68 let trimmed = raw.trim();
69 if trimmed.is_empty() {
70 return Err(ModelError::Invalid("token must not be empty".into()));
71 }
72 Ok(Self(trimmed.to_string()))
73 }
74
75 pub fn expose(&self) -> &str {
78 &self.0
79 }
80
81 pub fn is_empty(&self) -> bool {
83 self.0.is_empty()
84 }
85
86 pub fn verify(&self, candidate: &str) -> bool {
92 self.0.as_bytes().ct_eq(candidate.as_bytes()).into()
93 }
94}
95
96impl fmt::Debug for Token {
97 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98 f.write_str("Token(***redacted***)")
99 }
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105
106 #[test]
107 fn new_trims_whitespace() {
108 assert_eq!(Token::new(" abc\n").expose(), "abc");
109 }
110
111 #[test]
112 fn checked_rejects_empty() {
113 assert!(Token::from_env("__definitely_unset_var__").is_err());
114 }
115
116 #[test]
117 fn verify_matches_only_exact() {
118 let t = Token::new("s3cr3t-value");
119 assert!(t.verify("s3cr3t-value"));
120 assert!(!t.verify("s3cr3t-valuE"));
121 assert!(!t.verify("s3cr3t"));
122 assert!(!t.verify("s3cr3t-value-extra"));
123 }
124
125 #[test]
126 fn debug_is_redacted() {
127 let t = Token::new("super-secret");
128 assert_eq!(format!("{t:?}"), "Token(***redacted***)");
129 assert!(!format!("{t:?}").contains("super-secret"));
130 }
131
132 #[test]
133 fn generate_is_prefixed_unique_and_self_verifying() {
134 let a = Token::generate();
135 let b = Token::generate();
136 assert!(a.expose().starts_with("solti_agt_"));
137 assert_ne!(a.expose(), b.expose(), "two generated tokens must differ");
138 assert!(a.verify(a.expose()));
139 assert!(!a.verify(b.expose()));
140 assert_eq!(a.expose().len(), "solti_agt_".len() + 43);
141 }
142}