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