Skip to main content

pipedash_core/infrastructure/config/
token_ref.rs

1use 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}