pipedash_core/infrastructure/config/
token_ref.rs1use std::fmt;
2
3use serde::{
4 Deserialize,
5 Serialize,
6};
7use thiserror::Error;
8
9use crate::infrastructure::TokenStore;
10
11#[derive(Debug, Error)]
12pub enum TokenRefError {
13 #[error("Plain-text token detected: {0}")]
14 PlainTextToken(String),
15
16 #[error("Invalid token reference format: {0}")]
17 InvalidFormat(String),
18
19 #[error("Failed to parse storage ID: {0}")]
20 InvalidStorageId(#[from] std::num::ParseIntError),
21
22 #[error("Environment variable not found: {0}")]
23 EnvVarNotFound(String),
24
25 #[error("Token not found in secure storage for provider: {0}")]
26 NotFoundInStorage(i64),
27
28 #[error("Token not configured")]
29 NotConfigured,
30
31 #[error("Keyring lookup failed: {0}")]
32 KeyringError(String),
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
36#[serde(tag = "type", content = "value")]
37pub enum TokenReference {
38 EnvVar(String),
39 SecureStorage(i64),
40 Keyring(String),
41 #[default]
42 None,
43}
44
45impl fmt::Display for TokenReference {
46 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47 match self {
48 Self::EnvVar(name) => write!(f, "${{{}}} ", name),
49 Self::SecureStorage(id) => write!(f, "storage:{}", id),
50 Self::Keyring(name) => write!(f, "keyring:{}", name),
51 Self::None => write!(f, "(not configured)"),
52 }
53 }
54}
55
56impl TokenReference {
57 pub fn parse(value: &str) -> Result<Self, TokenRefError> {
58 let value = value.trim();
59
60 if value.is_empty() {
61 return Ok(Self::None);
62 }
63
64 if value.starts_with("${") && value.ends_with('}') {
65 let inner = &value[2..value.len() - 1];
66 let var_name = inner.split(":-").next().unwrap_or(inner);
67 if var_name.is_empty() {
68 return Err(TokenRefError::InvalidFormat(
69 "Empty variable name in ${}".to_string(),
70 ));
71 }
72 return Ok(Self::EnvVar(var_name.to_string()));
73 }
74
75 if let Some(var_name) = value.strip_prefix("env:") {
76 if var_name.is_empty() {
77 return Err(TokenRefError::InvalidFormat(
78 "Empty variable name after env:".to_string(),
79 ));
80 }
81 return Ok(Self::EnvVar(var_name.to_string()));
82 }
83
84 if let Some(name) = value.strip_prefix("keyring:") {
85 if name.is_empty() {
86 return Err(TokenRefError::InvalidFormat(
87 "Empty name after keyring:".to_string(),
88 ));
89 }
90 return Ok(Self::Keyring(name.to_string()));
91 }
92
93 if let Some(id_str) = value.strip_prefix("storage:") {
94 let id: i64 = id_str.parse()?;
95 return Ok(Self::SecureStorage(id));
96 }
97
98 if Self::looks_like_token(value) {
99 return Err(TokenRefError::PlainTextToken(
100 "Plain-text tokens are not allowed in config. Use ${ENV_VAR} or leave empty for keyring.".to_string(),
101 ));
102 }
103
104 if Self::looks_like_env_var_name(value) {
105 return Ok(Self::EnvVar(value.to_string()));
106 }
107
108 Err(TokenRefError::InvalidFormat(format!(
109 "Unknown token reference format: '{}'. Use ${{ENV_VAR}}, env:VAR, keyring:name, or storage:id",
110 value
111 )))
112 }
113
114 pub fn to_toml_string(&self) -> String {
115 match self {
116 Self::EnvVar(name) => format!("${{{}}}", name),
117 Self::SecureStorage(id) => format!("storage:{}", id),
118 Self::Keyring(name) => format!("keyring:{}", name),
119 Self::None => String::new(),
120 }
121 }
122
123 pub fn is_plain_text(&self) -> bool {
124 false
125 }
126
127 pub fn is_configured(&self) -> bool {
128 !matches!(self, Self::None)
129 }
130
131 pub async fn resolve(
132 &self, token_store: &dyn TokenStore, provider_id: Option<i64>,
133 ) -> Result<String, TokenRefError> {
134 match self {
135 Self::EnvVar(name) => {
136 std::env::var(name).map_err(|_| TokenRefError::EnvVarNotFound(name.clone()))
137 }
138 Self::SecureStorage(id) => token_store
139 .get_token(*id)
140 .await
141 .map_err(|_| TokenRefError::NotFoundInStorage(*id)),
142 Self::Keyring(name) => token_store
143 .get_token_by_name(name)
144 .await
145 .map_err(|e| TokenRefError::KeyringError(e.to_string())),
146 Self::None => {
147 if let Some(id) = provider_id {
148 token_store
149 .get_token(id)
150 .await
151 .map_err(|_| TokenRefError::NotFoundInStorage(id))
152 } else {
153 Err(TokenRefError::NotConfigured)
154 }
155 }
156 }
157 }
158
159 fn looks_like_token(value: &str) -> bool {
160 if value.starts_with("ghp_")
161 || value.starts_with("gho_")
162 || value.starts_with("ghu_")
163 || value.starts_with("ghs_")
164 || value.starts_with("github_pat_")
165 {
166 return true;
167 }
168
169 if value.starts_with("glpat-") || value.starts_with("gldt-") {
170 return true;
171 }
172
173 if value.starts_with("ATBB") {
174 return true;
175 }
176
177 if value.len() >= 32
178 && value
179 .chars()
180 .all(|c| c.is_ascii_hexdigit() || c == '-' || c == '_')
181 {
182 return true;
183 }
184
185 if value.len() > 30
186 && value
187 .chars()
188 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
189 {
190 let has_upper = value.chars().any(|c| c.is_ascii_uppercase());
191 let has_lower = value.chars().any(|c| c.is_ascii_lowercase());
192 let has_digit = value.chars().any(|c| c.is_ascii_digit());
193 if (has_digit || has_upper) && has_lower || has_upper && has_digit {
194 return true;
195 }
196 }
197
198 false
199 }
200
201 fn looks_like_env_var_name(value: &str) -> bool {
202 !value.is_empty()
203 && value
204 .chars()
205 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
206 && value.chars().next().is_some_and(|c| c.is_ascii_uppercase())
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn test_parse_env_var_dollar_brace() {
216 let result = TokenReference::parse("${GITHUB_TOKEN}").unwrap();
217 assert_eq!(result, TokenReference::EnvVar("GITHUB_TOKEN".to_string()));
218 }
219
220 #[test]
221 fn test_parse_env_var_with_default() {
222 let result = TokenReference::parse("${GITHUB_TOKEN:-}").unwrap();
223 assert_eq!(result, TokenReference::EnvVar("GITHUB_TOKEN".to_string()));
224
225 let result = TokenReference::parse("${GITHUB_TOKEN:-default_value}").unwrap();
226 assert_eq!(result, TokenReference::EnvVar("GITHUB_TOKEN".to_string()));
227 }
228
229 #[test]
230 fn test_parse_env_prefix() {
231 let result = TokenReference::parse("env:MY_TOKEN").unwrap();
232 assert_eq!(result, TokenReference::EnvVar("MY_TOKEN".to_string()));
233 }
234
235 #[test]
236 fn test_parse_keyring() {
237 let result = TokenReference::parse("keyring:my-provider").unwrap();
238 assert_eq!(result, TokenReference::Keyring("my-provider".to_string()));
239 }
240
241 #[test]
242 fn test_parse_storage() {
243 let result = TokenReference::parse("storage:123").unwrap();
244 assert_eq!(result, TokenReference::SecureStorage(123));
245 }
246
247 #[test]
248 fn test_parse_empty() {
249 let result = TokenReference::parse("").unwrap();
250 assert_eq!(result, TokenReference::None);
251 }
252
253 #[test]
254 fn test_parse_uppercase_as_env() {
255 let result = TokenReference::parse("GITHUB_TOKEN").unwrap();
256 assert_eq!(result, TokenReference::EnvVar("GITHUB_TOKEN".to_string()));
257 }
258
259 #[test]
260 fn test_reject_github_token() {
261 let result = TokenReference::parse("ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx");
262 assert!(matches!(result, Err(TokenRefError::PlainTextToken(_))));
263 }
264
265 #[test]
266 fn test_reject_gitlab_token() {
267 let result = TokenReference::parse("glpat-xxxxxxxxxxxxxxxxxxxx");
268 assert!(matches!(result, Err(TokenRefError::PlainTextToken(_))));
269 }
270
271 #[test]
272 fn test_to_toml_string() {
273 assert_eq!(
274 TokenReference::EnvVar("TOKEN".to_string()).to_toml_string(),
275 "${TOKEN}"
276 );
277 assert_eq!(
278 TokenReference::SecureStorage(42).to_toml_string(),
279 "storage:42"
280 );
281 assert_eq!(
282 TokenReference::Keyring("my-key".to_string()).to_toml_string(),
283 "keyring:my-key"
284 );
285 assert_eq!(TokenReference::None.to_toml_string(), "");
286 }
287}