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` (and, for Linear, the tool's own `LINEAR_API_KEY`) beat a
6//! `tix.toml` token (which itself never reaches disk),
7//! and tokens are injected only as an environment pair — never in argv. The
8//! [`EnvReader`] seam keeps precedence unit-testable without touching process
9//! state; a token found in `tix.toml` triggers the shared "prefer the env
10//! var" warning once per run.
11
12use std::env;
13
14use crate::config::Config;
15use crate::sync::Tool;
16
17/// Where the secret ultimately came from.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum TokenResolution {
20    /// `GITLAB_TOKEN` / `LINEAR_TOKEN` / `LINEAR_API_KEY` was present in the
21    /// process env.
22    Env(String),
23    /// `token` was read from the `[export]` section of `tix.toml`.
24    Config(String),
25    /// Neither — the CLI's native auth (glab keyring / linear-cli auth) is
26    /// used and no token is injected.
27    Native,
28}
29
30impl TokenResolution {
31    /// The secret to scrub from error/report surfaces, if any.
32    #[must_use]
33    pub fn secret(&self) -> Option<&str> {
34        match self {
35            TokenResolution::Env(s) | TokenResolution::Config(s) => Some(s),
36            TokenResolution::Native => None,
37        }
38    }
39}
40
41impl std::fmt::Display for TokenResolution {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        match self {
44            TokenResolution::Env(_) => {
45                write!(f, "from GITLAB_TOKEN/LINEAR_TOKEN/LINEAR_API_KEY env var")
46            }
47            TokenResolution::Config(_) => write!(f, "from tix.toml"),
48            TokenResolution::Native => write!(f, "native CLI auth"),
49        }
50    }
51}
52
53/// Read access to the process environment, injectable in tests.
54pub trait EnvReader {
55    fn get(&self, key: &str) -> Option<String>;
56}
57
58/// The real process environment.
59pub struct RealEnv;
60
61impl EnvReader for RealEnv {
62    fn get(&self, key: &str) -> Option<String> {
63        env::var(key).ok()
64    }
65}
66
67/// Resolve the token for `tool` per `contracts/external-tools.md`
68/// §Token resolution. Emits a warning through `warn` exactly once when a
69/// cleartext `tix.toml` token is used (the env var is always preferred and
70/// the file token is never written back out).
71pub fn resolve(
72    tool: Tool,
73    config: &Config,
74    env: &dyn EnvReader,
75    warn: &mut dyn FnMut(&str),
76) -> TokenResolution {
77    for var in tool.token_vars() {
78        if let Some(value) = env.get(var).filter(|v| !v.trim().is_empty()) {
79            return TokenResolution::Env(value);
80        }
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    #[allow(clippy::wildcard_imports)]
110    use super::*;
111    use crate::config::RemoteConfig;
112
113    struct TestEnv<'a>(&'a [(&'a str, &'a str)]);
114
115    impl EnvReader for TestEnv<'_> {
116        fn get(&self, key: &str) -> Option<String> {
117            self.0
118                .iter()
119                .find(|(k, _)| *k == key)
120                .map(|(_, v)| v.to_string())
121        }
122    }
123
124    fn config_with_file_token(tool: Tool) -> Config {
125        let mut config = Config::default();
126        match tool {
127            Tool::GitLab => {
128                config.export.gitlab = Some(RemoteConfig {
129                    token: Some("gl-secret".into()),
130                    ..RemoteConfig::default()
131                });
132            }
133            Tool::Linear => {
134                config.export.linear = Some(RemoteConfig {
135                    token: Some("lin-secret".into()),
136                    ..RemoteConfig::default()
137                });
138            }
139        }
140        config
141    }
142
143    #[test]
144    fn env_var_wins_over_file_token_without_warning() {
145        let config = config_with_file_token(Tool::GitLab);
146        let env = TestEnv(&[("GITLAB_TOKEN", "env-secret")]);
147        let mut warned = false;
148        let resolved = resolve(Tool::GitLab, &config, &env, &mut |_| warned = true);
149        assert_eq!(resolved, TokenResolution::Env("env-secret".into()));
150        assert!(!warned, "env token must not warn");
151    }
152
153    #[test]
154    fn linear_token_wins_over_linear_api_key() {
155        let config = Config::default();
156        let env = TestEnv(&[
157            ("LINEAR_TOKEN", "tix-name"),
158            ("LINEAR_API_KEY", "tool-name"),
159        ]);
160        let resolved = resolve(Tool::Linear, &config, &env, &mut |_| {});
161        assert_eq!(resolved, TokenResolution::Env("tix-name".into()));
162    }
163
164    #[test]
165    fn linear_api_key_alone_is_read() {
166        let config = Config::default();
167        let env = TestEnv(&[("LINEAR_API_KEY", "tool-name")]);
168        let resolved = resolve(Tool::Linear, &config, &env, &mut |_| {});
169        assert_eq!(resolved, TokenResolution::Env("tool-name".into()));
170    }
171
172    #[test]
173    fn a_blank_linear_token_falls_through_to_the_api_key() {
174        let config = Config::default();
175        let env = TestEnv(&[("LINEAR_TOKEN", "   "), ("LINEAR_API_KEY", "tool-name")]);
176        let resolved = resolve(Tool::Linear, &config, &env, &mut |_| {});
177        assert_eq!(resolved, TokenResolution::Env("tool-name".into()));
178    }
179
180    #[test]
181    fn a_blank_linear_api_key_still_falls_through_to_the_file_token() {
182        let config = config_with_file_token(Tool::Linear);
183        let env = TestEnv(&[("LINEAR_API_KEY", "")]);
184        let mut warned = false;
185        let resolved = resolve(Tool::Linear, &config, &env, &mut |_| warned = true);
186        assert_eq!(resolved, TokenResolution::Config("lin-secret".into()));
187        assert!(warned, "the file token still warns");
188    }
189
190    #[test]
191    fn gitlab_reads_only_its_own_token_var() {
192        let config = Config::default();
193        // `LINEAR_API_KEY` belongs to Linear and must never be read for GitLab.
194        let env = TestEnv(&[("LINEAR_API_KEY", "tool-name")]);
195        let resolved = resolve(Tool::GitLab, &config, &env, &mut |_| {});
196        assert_eq!(resolved, TokenResolution::Native);
197    }
198
199    #[test]
200    fn empty_env_var_falls_back_to_file_token_and_warns() {
201        let config = config_with_file_token(Tool::Linear);
202        let env = TestEnv(&[("LINEAR_TOKEN", "")]);
203        let mut warnings: Vec<String> = Vec::new();
204        let resolved = resolve(Tool::Linear, &config, &env, &mut |w| {
205            warnings.push(w.to_string())
206        });
207        assert_eq!(resolved, TokenResolution::Config("lin-secret".into()));
208        assert_eq!(warnings.len(), 1);
209        assert_eq!(
210            warnings[0],
211            "warning: reading LINEAR token from tix.toml — prefer the LINEAR_TOKEN environment variable to avoid committing a secret"
212        );
213        assert!(
214            !warnings[0].contains("lin-secret"),
215            "warning must not echo the secret: {}",
216            warnings[0]
217        );
218    }
219
220    #[test]
221    fn file_token_warns_once_and_not_for_other_tool() {
222        let config = config_with_file_token(Tool::GitLab);
223        let env = TestEnv(&[]);
224        let mut warnings: Vec<String> = Vec::new();
225        let resolved = resolve(Tool::GitLab, &config, &env, &mut |w| {
226            warnings.push(w.to_string())
227        });
228        assert_eq!(resolved, TokenResolution::Config("gl-secret".into()));
229        assert_eq!(warnings.len(), 1);
230        assert_eq!(
231            warnings[0],
232            "warning: reading GITLAB token from tix.toml — prefer the GITLAB_TOKEN environment variable to avoid committing a secret"
233        );
234    }
235
236    #[test]
237    fn native_auth_when_neither_source_configured() {
238        let config = Config::default();
239        let env = TestEnv(&[]);
240        let resolved = resolve(Tool::GitLab, &config, &env, &mut |_| {});
241        assert_eq!(resolved, TokenResolution::Native);
242        assert_eq!(resolved.secret(), None);
243    }
244
245    #[test]
246    fn display_never_echoes_the_secret() {
247        let resolved = TokenResolution::Env("sekrit".into());
248        assert!(!resolved.to_string().contains("sekrit"));
249        let config_resolved = TokenResolution::Config("sekrit".into());
250        assert!(!config_resolved.to_string().contains("sekrit"));
251    }
252}