Skip to main content

tix/sync/
token.rs

1//! Token resolution with the contract's precedence: env var, then
2//! `tix.toml` `[export]`, then the CLI's native auth.
3//!
4//! `contracts/external-tools.md` §Token resolution: `GITLAB_TOKEN` /
5//! `LINEAR_TOKEN` beat a `tix.toml` token (which itself never reaches disk),
6//! and tokens are injected only as an environment pair — never in argv. The
7//! [`EnvReader`] seam keeps precedence unit-testable without touching process
8//! state; a token found in `tix.toml` triggers the shared "prefer the env
9//! var" warning once per run.
10
11use std::env;
12
13use crate::config::Config;
14use crate::sync::Tool;
15
16/// Where the secret ultimately came from.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum TokenResolution {
19    /// `GITLAB_TOKEN` / `LINEAR_TOKEN` was present in the process env.
20    Env(String),
21    /// `token` was read from the `[export]` section of `tix.toml`.
22    Config(String),
23    /// Neither — the CLI's native auth (glab keyring / linear-cli auth) is
24    /// used and no token is injected.
25    Native,
26}
27
28impl TokenResolution {
29    /// The secret to scrub from error/report surfaces, if any.
30    pub fn secret(&self) -> Option<&str> {
31        match self {
32            TokenResolution::Env(s) | TokenResolution::Config(s) => Some(s),
33            TokenResolution::Native => None,
34        }
35    }
36
37    /// The `(VAR_NAME, secret)` pair injected into the tool's environment,
38    /// or `None` for native auth.
39    pub fn env_pair(&self, tool: Tool) -> Option<(String, String)> {
40        self.secret()
41            .map(|s| (tool.token_var().to_string(), s.to_string()))
42    }
43}
44
45impl std::fmt::Display for TokenResolution {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        match self {
48            TokenResolution::Env(_) => write!(f, "from GITLAB_TOKEN/LINEAR_TOKEN env var"),
49            TokenResolution::Config(_) => write!(f, "from tix.toml"),
50            TokenResolution::Native => write!(f, "native CLI auth"),
51        }
52    }
53}
54
55/// Read access to the process environment, injectable in tests.
56pub trait EnvReader {
57    fn get(&self, key: &str) -> Option<String>;
58}
59
60/// The real process environment.
61pub struct RealEnv;
62
63impl EnvReader for RealEnv {
64    fn get(&self, key: &str) -> Option<String> {
65        env::var(key).ok()
66    }
67}
68
69/// Resolve the token for `tool` per `contracts/external-tools.md`
70/// §Token resolution. Emits a warning through `warn` exactly once when a
71/// cleartext `tix.toml` token is used (the env var is always preferred and
72/// the file token is never written back out).
73pub fn resolve(
74    tool: Tool,
75    config: &Config,
76    env: &dyn EnvReader,
77    warn: &mut dyn FnMut(&str),
78) -> TokenResolution {
79    if let Some(value) = env.get(tool.token_var()).filter(|v| !v.trim().is_empty()) {
80        return TokenResolution::Env(value);
81    }
82    let file_token = match tool {
83        Tool::GitLab => config
84            .export
85            .gitlab
86            .as_ref()
87            .and_then(|r| r.token.as_deref()),
88        Tool::Linear => config
89            .export
90            .linear
91            .as_ref()
92            .and_then(|r| r.token.as_deref()),
93    };
94    match file_token {
95        Some(value) if !value.trim().is_empty() => {
96            warn(&format!(
97                "warning: reading {} token from tix.toml — prefer the {} environment variable to avoid committing a secret",
98                tool.token_var().trim_end_matches("_TOKEN"),
99                tool.token_var()
100            ));
101            TokenResolution::Config(value.to_string())
102        }
103        _ => TokenResolution::Native,
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::config::RemoteConfig;
111
112    struct TestEnv<'a>(&'a [(&'a str, &'a str)]);
113
114    impl EnvReader for TestEnv<'_> {
115        fn get(&self, key: &str) -> Option<String> {
116            self.0
117                .iter()
118                .find(|(k, _)| *k == key)
119                .map(|(_, v)| v.to_string())
120        }
121    }
122
123    fn config_with_file_token(tool: Tool) -> Config {
124        let mut config = Config::default();
125        match tool {
126            Tool::GitLab => {
127                config.export.gitlab = Some(RemoteConfig {
128                    token: Some("gl-secret".into()),
129                    ..RemoteConfig::default()
130                });
131            }
132            Tool::Linear => {
133                config.export.linear = Some(RemoteConfig {
134                    token: Some("lin-secret".into()),
135                    ..RemoteConfig::default()
136                });
137            }
138        }
139        config
140    }
141
142    #[test]
143    fn env_var_wins_over_file_token_without_warning() {
144        let config = config_with_file_token(Tool::GitLab);
145        let env = TestEnv(&[("GITLAB_TOKEN", "env-secret")]);
146        let mut warned = false;
147        let resolved = resolve(Tool::GitLab, &config, &env, &mut |_| warned = true);
148        assert_eq!(resolved, TokenResolution::Env("env-secret".into()));
149        assert!(!warned, "env token must not warn");
150    }
151
152    #[test]
153    fn empty_env_var_falls_back_to_file_token_and_warns() {
154        let config = config_with_file_token(Tool::Linear);
155        let env = TestEnv(&[("LINEAR_TOKEN", "")]);
156        let mut warnings: Vec<String> = Vec::new();
157        let resolved = resolve(Tool::Linear, &config, &env, &mut |w| {
158            warnings.push(w.to_string())
159        });
160        assert_eq!(resolved, TokenResolution::Config("lin-secret".into()));
161        assert_eq!(warnings.len(), 1);
162        assert_eq!(
163            warnings[0],
164            "warning: reading LINEAR token from tix.toml — prefer the LINEAR_TOKEN environment variable to avoid committing a secret"
165        );
166        assert!(
167            !warnings[0].contains("lin-secret"),
168            "warning must not echo the secret: {}",
169            warnings[0]
170        );
171    }
172
173    #[test]
174    fn file_token_warns_once_and_not_for_other_tool() {
175        let config = config_with_file_token(Tool::GitLab);
176        let env = TestEnv(&[]);
177        let mut warnings: Vec<String> = Vec::new();
178        let resolved = resolve(Tool::GitLab, &config, &env, &mut |w| {
179            warnings.push(w.to_string())
180        });
181        assert_eq!(resolved, TokenResolution::Config("gl-secret".into()));
182        assert_eq!(warnings.len(), 1);
183        assert_eq!(
184            warnings[0],
185            "warning: reading GITLAB token from tix.toml — prefer the GITLAB_TOKEN environment variable to avoid committing a secret"
186        );
187    }
188
189    #[test]
190    fn native_auth_when_neither_source_configured() {
191        let config = Config::default();
192        let env = TestEnv(&[]);
193        let resolved = resolve(Tool::GitLab, &config, &env, &mut |_| {});
194        assert_eq!(resolved, TokenResolution::Native);
195        assert_eq!(resolved.secret(), None);
196        assert_eq!(resolved.env_pair(Tool::GitLab), None);
197    }
198
199    #[test]
200    fn env_pair_uses_tool_token_var() {
201        let resolved = TokenResolution::Env("s".into());
202        assert_eq!(
203            resolved.env_pair(Tool::GitLab),
204            Some(("GITLAB_TOKEN".to_string(), "s".to_string()))
205        );
206        assert_eq!(
207            resolved.env_pair(Tool::Linear),
208            Some(("LINEAR_TOKEN".to_string(), "s".to_string()))
209        );
210    }
211
212    #[test]
213    fn display_never_echoes_the_secret() {
214        let resolved = TokenResolution::Env("sekrit".into());
215        assert!(!resolved.to_string().contains("sekrit"));
216        let config_resolved = TokenResolution::Config("sekrit".into());
217        assert!(!config_resolved.to_string().contains("sekrit"));
218    }
219}