Skip to main content

ati/core/
token.rs

1//! Session token resolution with file-backed fallback for hot-rotation.
2//!
3//! Long-lived agent processes that embed `ati` as subprocesses inherit
4//! `ATI_SESSION_TOKEN` at start and never see env updates. JWTs expire after
5//! ~3h, leaving the agent unable to call the proxy without a restart. The
6//! file-backed path lets an external supervisor rotate the token atomically;
7//! the next `ati` invocation picks up the new value transparently.
8//!
9//! For a given env-var name `<NAME>`, resolution order (first non-empty wins):
10//! 1. `<NAME>` env var
11//! 2. `<NAME>_FILE` env var → file path
12//! 3. Default file path derived from `<NAME>` (see [`default_token_file`])
13//!
14//! Each call re-reads the file from disk — no in-process caching.
15//!
16//! [`resolve_session_token`] is a thin wrapper for the default
17//! `ATI_SESSION_TOKEN`. [`resolve_token`] takes an arbitrary env-var name so
18//! per-provider tokens (e.g. `PARCHA_TOOLS_SESSION_TOKEN`) get the same
19//! file-fallback + hot-rotation semantics — see issue #121.
20
21use std::io::ErrorKind;
22
23const DEFAULT_SESSION_TOKEN_FILE: &str = "/run/ati/session_token";
24
25/// Compute the default file path for a given session-token env var name.
26///
27/// Convention: strip a trailing `_SESSION_TOKEN` suffix (either uppercase or
28/// lowercase — POSIX env var names are always uppercase in practice, so we
29/// don't bother with mixed-case forms like `Parcha_Tools_Session_Token`),
30/// lowercase the rest, prefix with `/run/ati/`. `ATI_SESSION_TOKEN` is a
31/// hardcoded exception that resolves to `/run/ati/session_token` to preserve
32/// the v0.7.x deployed path (the slugify rule alone would produce
33/// `/run/ati/ati`, breaking existing supervisors).
34pub(crate) fn default_token_file(env_name: &str) -> String {
35    if env_name == "ATI_SESSION_TOKEN" {
36        return DEFAULT_SESSION_TOKEN_FILE.to_string();
37    }
38    let trimmed = env_name
39        .strip_suffix("_SESSION_TOKEN")
40        .or_else(|| env_name.strip_suffix("_session_token"))
41        .unwrap_or(env_name);
42    format!("/run/ati/{}", trimmed.to_lowercase())
43}
44
45/// Resolve a session token from env or a token file, for an arbitrary env
46/// var name. See module docs for the resolution order.
47///
48/// `env_name` is e.g. `"ATI_SESSION_TOKEN"` or `"PARCHA_TOOLS_SESSION_TOKEN"`.
49/// The associated file path defaults to [`default_token_file`]`(env_name)`
50/// but can be overridden by `<env_name>_FILE`.
51///
52/// Returns:
53/// - `Ok(Some(token))` if a non-empty token was found
54/// - `Ok(None)` if no source supplied a token (env unset/empty, file missing or empty)
55/// - `Err(msg)` only if a configured file path exists but cannot be read
56///   (e.g., permission denied)
57pub fn resolve_token(env_name: &str) -> Result<Option<String>, String> {
58    if let Ok(raw) = std::env::var(env_name) {
59        let trimmed = raw.trim();
60        if !trimmed.is_empty() {
61            return Ok(Some(trimmed.to_string()));
62        }
63    }
64
65    let file_env = format!("{env_name}_FILE");
66    let path = std::env::var(&file_env)
67        .ok()
68        .map(|s| s.trim().to_string())
69        .filter(|s| !s.is_empty())
70        .unwrap_or_else(|| default_token_file(env_name));
71
72    match std::fs::read_to_string(&path) {
73        Ok(contents) => {
74            let trimmed = contents.trim();
75            if trimmed.is_empty() {
76                Ok(None)
77            } else {
78                Ok(Some(trimmed.to_string()))
79            }
80        }
81        Err(e) if e.kind() == ErrorKind::NotFound => Ok(None),
82        Err(e) => Err(format!("Cannot read {path}: {e}")),
83    }
84}
85
86/// Resolve the default session token (`ATI_SESSION_TOKEN`). Thin wrapper
87/// around [`resolve_token`] preserved for callers that don't care about
88/// per-provider token selection (issue #121).
89pub fn resolve_session_token() -> Result<Option<String>, String> {
90    resolve_token("ATI_SESSION_TOKEN")
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use std::sync::Mutex;
97
98    // The helper reads process-wide env vars. Tests must serialize to avoid
99    // clobbering each other.
100    static ENV_LOCK: Mutex<()> = Mutex::new(());
101
102    struct EnvGuard {
103        keys: Vec<&'static str>,
104        prev: Vec<(String, Option<String>)>,
105    }
106
107    impl EnvGuard {
108        fn set(pairs: &[(&'static str, Option<&str>)]) -> Self {
109            let mut prev = Vec::new();
110            let mut keys = Vec::new();
111            for (k, v) in pairs {
112                prev.push(((*k).to_string(), std::env::var(k).ok()));
113                keys.push(*k);
114                match v {
115                    Some(val) => std::env::set_var(k, val),
116                    None => std::env::remove_var(k),
117                }
118            }
119            Self { keys, prev }
120        }
121    }
122
123    impl Drop for EnvGuard {
124        fn drop(&mut self) {
125            for (k, v) in &self.prev {
126                match v {
127                    Some(val) => std::env::set_var(k, val),
128                    None => std::env::remove_var(k),
129                }
130            }
131            // belt-and-suspenders: ensure nothing leaks
132            let _ = &self.keys;
133        }
134    }
135
136    #[test]
137    fn env_var_wins_over_file() {
138        let _g = ENV_LOCK.lock().unwrap();
139        let dir = tempfile::tempdir().unwrap();
140        let path = dir.path().join("tok");
141        std::fs::write(&path, "from-file").unwrap();
142        let _e = EnvGuard::set(&[
143            ("ATI_SESSION_TOKEN", Some("from-env")),
144            ("ATI_SESSION_TOKEN_FILE", Some(path.to_str().unwrap())),
145        ]);
146        assert_eq!(
147            resolve_session_token().unwrap(),
148            Some("from-env".to_string())
149        );
150    }
151
152    #[test]
153    fn empty_env_falls_through_to_file_and_rereads() {
154        let _g = ENV_LOCK.lock().unwrap();
155        let dir = tempfile::tempdir().unwrap();
156        let path = dir.path().join("tok");
157        std::fs::write(&path, "tok-v1").unwrap();
158        let _e = EnvGuard::set(&[
159            ("ATI_SESSION_TOKEN", Some("")),
160            ("ATI_SESSION_TOKEN_FILE", Some(path.to_str().unwrap())),
161        ]);
162        assert_eq!(resolve_session_token().unwrap(), Some("tok-v1".to_string()));
163
164        // Overwrite the file; next call must see the new value (no caching).
165        std::fs::write(&path, "tok-v2").unwrap();
166        assert_eq!(resolve_session_token().unwrap(), Some("tok-v2".to_string()));
167    }
168
169    #[test]
170    fn trims_whitespace_in_file_contents() {
171        let _g = ENV_LOCK.lock().unwrap();
172        let dir = tempfile::tempdir().unwrap();
173        let path = dir.path().join("tok");
174        std::fs::write(&path, "  hello-tok\n\n").unwrap();
175        let _e = EnvGuard::set(&[
176            ("ATI_SESSION_TOKEN", None),
177            ("ATI_SESSION_TOKEN_FILE", Some(path.to_str().unwrap())),
178        ]);
179        assert_eq!(
180            resolve_session_token().unwrap(),
181            Some("hello-tok".to_string())
182        );
183    }
184
185    #[test]
186    fn empty_file_returns_none() {
187        let _g = ENV_LOCK.lock().unwrap();
188        let dir = tempfile::tempdir().unwrap();
189        let path = dir.path().join("tok");
190        std::fs::write(&path, "   \n\t").unwrap();
191        let _e = EnvGuard::set(&[
192            ("ATI_SESSION_TOKEN", None),
193            ("ATI_SESSION_TOKEN_FILE", Some(path.to_str().unwrap())),
194        ]);
195        assert_eq!(resolve_session_token().unwrap(), None);
196    }
197
198    #[test]
199    fn missing_file_no_env_returns_none() {
200        let _g = ENV_LOCK.lock().unwrap();
201        let _e = EnvGuard::set(&[
202            ("ATI_SESSION_TOKEN", None),
203            (
204                "ATI_SESSION_TOKEN_FILE",
205                Some("/nonexistent/path/never/exists/session_token"),
206            ),
207        ]);
208        assert_eq!(resolve_session_token().unwrap(), None);
209    }
210
211    #[cfg(unix)]
212    #[test]
213    fn unreadable_file_returns_err_with_path() {
214        use std::os::unix::fs::PermissionsExt;
215
216        // Skip when running as root — root bypasses unix permission bits, so we
217        // can't simulate "permission denied" reliably.
218        if unsafe { libc::geteuid() } == 0 {
219            eprintln!("skipping unreadable_file_returns_err_with_path: running as root");
220            return;
221        }
222
223        let _g = ENV_LOCK.lock().unwrap();
224        let dir = tempfile::tempdir().unwrap();
225        let path = dir.path().join("tok");
226        std::fs::write(&path, "secret").unwrap();
227        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
228
229        let _e = EnvGuard::set(&[
230            ("ATI_SESSION_TOKEN", None),
231            ("ATI_SESSION_TOKEN_FILE", Some(path.to_str().unwrap())),
232        ]);
233        let err = resolve_session_token().unwrap_err();
234        assert!(err.contains("Cannot read"), "unexpected error: {err}");
235        assert!(
236            err.contains(path.to_str().unwrap()),
237            "error should mention path: {err}"
238        );
239
240        // Restore perms so tempdir can clean up.
241        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
242    }
243
244    // -----------------------------------------------------------------------
245    // Per-provider token resolution (issue #121).
246    //
247    // `resolve_token(env_name)` generalizes the session-token resolver so
248    // that a manifest's `auth_session_token_env` can name any env var (e.g.
249    // `PARCHA_TOOLS_SESSION_TOKEN`) and get the same env → file → default
250    // semantics that `ATI_SESSION_TOKEN` enjoys. Tests below cover:
251    //   - arbitrary env name short-circuits
252    //   - `<NAME>_FILE` fallback fires
253    //   - `default_token_file` slugify rule
254    //   - `resolve_session_token()` wrapper is still semantically identical
255    // -----------------------------------------------------------------------
256
257    #[test]
258    fn resolve_token_reads_arbitrary_env_var() {
259        let _g = ENV_LOCK.lock().unwrap();
260        let _e = EnvGuard::set(&[("PARCHA_TOOLS_SESSION_TOKEN", Some("parcha-tok"))]);
261        assert_eq!(
262            resolve_token("PARCHA_TOOLS_SESSION_TOKEN").unwrap(),
263            Some("parcha-tok".to_string())
264        );
265    }
266
267    #[test]
268    fn resolve_token_falls_back_to_named_file_env() {
269        let _g = ENV_LOCK.lock().unwrap();
270        let dir = tempfile::tempdir().unwrap();
271        let path = dir.path().join("ptok");
272        std::fs::write(&path, "file-tok").unwrap();
273        let _e = EnvGuard::set(&[
274            ("PARCHA_TOOLS_SESSION_TOKEN", None),
275            (
276                "PARCHA_TOOLS_SESSION_TOKEN_FILE",
277                Some(path.to_str().unwrap()),
278            ),
279        ]);
280        assert_eq!(
281            resolve_token("PARCHA_TOOLS_SESSION_TOKEN").unwrap(),
282            Some("file-tok".to_string())
283        );
284    }
285
286    #[test]
287    fn resolve_token_empty_env_falls_through_to_file() {
288        let _g = ENV_LOCK.lock().unwrap();
289        let dir = tempfile::tempdir().unwrap();
290        let path = dir.path().join("ptok");
291        std::fs::write(&path, "from-file").unwrap();
292        let _e = EnvGuard::set(&[
293            ("PARCHA_TOOLS_SESSION_TOKEN", Some("")),
294            (
295                "PARCHA_TOOLS_SESSION_TOKEN_FILE",
296                Some(path.to_str().unwrap()),
297            ),
298        ]);
299        assert_eq!(
300            resolve_token("PARCHA_TOOLS_SESSION_TOKEN").unwrap(),
301            Some("from-file".to_string())
302        );
303    }
304
305    #[test]
306    fn resolve_session_token_wrapper_back_compat() {
307        // The wrapper must behave identically to calling
308        // resolve_token("ATI_SESSION_TOKEN") — that's the contract a v0.7.11
309        // user relies on (no caller changes).
310        let _g = ENV_LOCK.lock().unwrap();
311        let _e = EnvGuard::set(&[("ATI_SESSION_TOKEN", Some("wrapped-tok"))]);
312        assert_eq!(
313            resolve_session_token().unwrap(),
314            resolve_token("ATI_SESSION_TOKEN").unwrap(),
315        );
316        assert_eq!(
317            resolve_session_token().unwrap(),
318            Some("wrapped-tok".to_string())
319        );
320    }
321
322    #[test]
323    fn default_token_file_hardcoded_ati_session_token() {
324        // Preserves the v0.7.x deployed path. Hardcoded because the slugify
325        // rule would otherwise produce `/run/ati/ati`, breaking supervisors.
326        assert_eq!(
327            default_token_file("ATI_SESSION_TOKEN"),
328            "/run/ati/session_token"
329        );
330    }
331
332    #[test]
333    fn default_token_file_strips_session_token_suffix() {
334        assert_eq!(
335            default_token_file("PARCHA_TOOLS_SESSION_TOKEN"),
336            "/run/ati/parcha_tools"
337        );
338        assert_eq!(
339            default_token_file("FOO_BAR_SESSION_TOKEN"),
340            "/run/ati/foo_bar"
341        );
342    }
343
344    #[test]
345    fn default_token_file_lowercases_when_no_suffix_to_strip() {
346        assert_eq!(default_token_file("CUSTOM_TOKEN"), "/run/ati/custom_token");
347        assert_eq!(default_token_file("FOO"), "/run/ati/foo");
348    }
349}