Skip to main content

xurl/config/
mod.rs

1//! Application configuration resolved from environment variables.
2//!
3//! `Config` mirrors the Go `config.Config` struct. All fields come from
4//! env vars with sensible defaults for the X API; the redirect URI is
5//! resolved per app at construction.
6
7use std::path::Path;
8
9use serde::Serialize;
10use url::Url;
11
12use crate::error::XurlError;
13
14/// Application configuration resolved from environment variables.
15///
16/// Mirrors the Go `config.Config` struct — all fields come from env vars
17/// with sensible defaults for the X API.
18///
19/// Holds the application configuration.
20#[derive(Debug, Clone)]
21pub struct Config {
22    /// `OAuth2` client ID (may come from env or the active app in `.xurl`).
23    pub client_id: String,
24    /// `OAuth2` client secret.
25    pub client_secret: String,
26    /// `OAuth2` PKCE redirect URI.
27    pub redirect_uri: String,
28    /// `OAuth2` authorization URL.
29    pub auth_url: String,
30    /// `OAuth2` token exchange URL.
31    pub token_url: String,
32    /// API base URL.
33    pub api_base_url: String,
34    /// User info endpoint URL.
35    pub info_url: String,
36    /// Explicit `--app` override; empty means "use default".
37    pub app_name: String,
38    /// Precedence level that produced the current [`Self::redirect_uri`].
39    ///
40    /// `Config::new()` cannot consult any token store, so it only ever
41    /// emits [`ResolveSource::EnvVar`] or [`ResolveSource::BuiltInDefault`].
42    /// [`Auth::new_with_store_path`](crate::auth::Auth::new_with_store_path)
43    /// overwrites this with the full three-level resolution.
44    pub(crate) redirect_uri_source: ResolveSource,
45    /// Convenience predicate mirroring `redirect_uri_source.is_env_var()`.
46    ///
47    /// Stored separately to keep `Auth`-consuming hot paths free of the
48    /// `match` on the enum variant.
49    pub(crate) redirect_uri_from_env: bool,
50    /// Per-request HTTP timeout in seconds for all reqwest-backed paths
51    /// (API client, OAuth2 token exchange/refresh, `fetch_username`).
52    ///
53    /// Sourced from `--timeout` / `XURL_TIMEOUT` via the CLI runner;
54    /// `Config::new()` defaults to [`crate::api::DEFAULT_TIMEOUT_SECS`].
55    pub http_timeout_secs: u64,
56}
57
58/// Built-in default `OAuth2` redirect URI used when neither the
59/// `REDIRECT_URI` env var nor a stored per-app value is set.
60pub const DEFAULT_REDIRECT_URI: &str = "http://localhost:8080/callback";
61
62impl Config {
63    /// Creates a new `Config` from environment variables, falling back to defaults.
64    ///
65    /// `redirect_uri` resolution is env-only here: `REDIRECT_URI` if set,
66    /// otherwise [`DEFAULT_REDIRECT_URI`]. The token-store-aware three-level
67    /// precedence (env > app-stored > default) is run by
68    /// [`Auth::new_with_store_path`](crate::auth::Auth::new_with_store_path),
69    /// which overwrites `redirect_uri`, `redirect_uri_source`, and
70    /// `redirect_uri_from_env` on the owned `Config`. The R12 audit confirms
71    /// no consumer reads `redirect_uri` from a pre-resolution `Config`.
72    #[must_use]
73    pub fn new() -> Self {
74        let client_id = env_or_default("CLIENT_ID", "");
75        let client_secret = env_or_default("CLIENT_SECRET", "");
76        let redirect_uri_env = std::env::var("REDIRECT_URI").ok();
77        let redirect_uri_from_env = redirect_uri_env.is_some();
78        let redirect_uri = redirect_uri_env.unwrap_or_else(|| DEFAULT_REDIRECT_URI.to_string());
79        let redirect_uri_source = if redirect_uri_from_env {
80            ResolveSource::EnvVar
81        } else {
82            ResolveSource::BuiltInDefault
83        };
84        let auth_url = env_or_default("AUTH_URL", "https://x.com/i/oauth2/authorize");
85        let token_url = env_or_default("TOKEN_URL", "https://api.x.com/2/oauth2/token");
86        let api_base_url = env_or_default("API_BASE_URL", "https://api.x.com");
87        let info_url = env_or_default("INFO_URL", &format!("{api_base_url}/2/users/me"));
88
89        Self {
90            client_id,
91            client_secret,
92            redirect_uri,
93            auth_url,
94            token_url,
95            api_base_url,
96            info_url,
97            app_name: String::new(),
98            redirect_uri_source,
99            redirect_uri_from_env,
100            http_timeout_secs: crate::api::DEFAULT_TIMEOUT_SECS,
101        }
102    }
103}
104
105impl Default for Config {
106    fn default() -> Self {
107        Self::new()
108    }
109}
110
111impl Config {
112    /// Returns the legacy default token-store path: `~/.xurl`.
113    ///
114    /// Falls back to `./.xurl` when the home directory cannot be resolved.
115    /// This is the canonical legacy path resolver — the binary uses it; tests
116    /// pass explicit tempdir paths to `Auth::new_with_store_path` instead.
117    #[must_use]
118    pub fn default_store_path() -> std::path::PathBuf {
119        dirs::home_dir()
120            .unwrap_or_else(|| std::path::PathBuf::from("."))
121            .join(".xurl")
122    }
123
124    /// Validates an `OAuth2` redirect URI.
125    ///
126    /// Enforces the project's https-or-loopback policy: accept any `https`
127    /// URL, or `http` only when the host is one of `localhost`, `127.0.0.1`,
128    /// or `::1`. All other schemes (including `ftp`, `file`) and `http`
129    /// against a non-loopback host are rejected.
130    ///
131    /// Returns the parsed [`Url`] on success so callers that already need it
132    /// (e.g., the listener bind logic) can avoid a second parse.
133    ///
134    /// # Errors
135    ///
136    /// Returns [`XurlError::Validation`] when parsing fails or the URI does
137    /// not satisfy the https-or-loopback rule.
138    pub fn validate_redirect_uri(uri: &str) -> crate::error::Result<Url> {
139        let parsed = Url::parse(uri)
140            .map_err(|e| XurlError::validation(format!("invalid redirect URI: {e}")))?;
141
142        let scheme = parsed.scheme();
143        if scheme == "https" {
144            return Ok(parsed);
145        }
146
147        if scheme == "http"
148            && let Some(host) = parsed.host_str()
149            && matches!(host, "localhost" | "127.0.0.1" | "::1" | "[::1]")
150        {
151            return Ok(parsed);
152        }
153
154        Err(XurlError::validation(format!(
155            "redirect URI must be https, or http on loopback (localhost / 127.0.0.1 / [::1]); got: {uri}"
156        )))
157    }
158}
159
160// ── Resolver ─────────────────────────────────────────────────────────────────
161
162/// Origin of a resolved redirect URI.
163///
164/// Mirrors the upstream Go xurl labels at `config/config.go:62-72`.
165/// The `#[serde(rename_all = "kebab-case")]` directive produces
166/// `"env-var"`, `"app-config"`, and `"built-in-default"` in JSON output
167/// (the machine-readable shape consumed by `--output json`); the
168/// human-readable text rendering uses [`ResolveSource::as_text_label`].
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
170#[serde(rename_all = "kebab-case")]
171pub(crate) enum ResolveSource {
172    /// Resolved from the `REDIRECT_URI` environment variable.
173    EnvVar,
174    /// Resolved from the per-app value stored in `~/.xurl`.
175    AppConfig,
176    /// Fell through to [`DEFAULT_REDIRECT_URI`].
177    BuiltInDefault,
178}
179
180#[allow(dead_code)] // Consumed by U3 (Auth integration) and U5 (status rendering)
181impl ResolveSource {
182    /// Returns the upstream-verbatim human label for this source.
183    ///
184    /// Used by `auth status` and `auth apps redirect-uri get` text mode.
185    pub(crate) fn as_text_label(&self) -> &'static str {
186        match self {
187            Self::EnvVar => "REDIRECT_URI environment variable",
188            Self::AppConfig => "app config",
189            Self::BuiltInDefault => "built-in default",
190        }
191    }
192
193    /// Convenience predicate: was the URI resolved from the env var?
194    pub(crate) fn is_env_var(&self) -> bool {
195        matches!(self, Self::EnvVar)
196    }
197}
198
199/// A resolved redirect URI plus the precedence level that produced it.
200#[allow(dead_code)] // Consumed by U3 (Auth integration) and U4 (CLI redirect-uri get handler)
201pub(crate) struct ResolvedRedirectUri {
202    /// The effective URI to use for the `OAuth2` flow.
203    pub uri: String,
204    /// The precedence level that produced [`Self::uri`].
205    pub source: ResolveSource,
206}
207
208/// Pure precedence helper: `REDIRECT_URI` env var > stored app value > built-in default.
209///
210/// `env_value` is the raw `Option<String>` produced by `std::env::var("REDIRECT_URI").ok()`.
211/// `stored` is the per-app value from `TokenStore::get_app_redirect_uri`.
212///
213/// When `env_value` is set but fails [`Config::validate_redirect_uri`](Config::validate_redirect_uri), the helper
214/// emits a one-line warning to stderr (via [`crate::output::warn_stderr`])
215/// and falls through to the next precedence level. The pure helper has no
216/// `OutputConfig` available, so the warning shape is intentionally minimal;
217/// the binary's `OutputConfig::print_message` equivalent would be redundant
218/// here since callers cannot suppress the env-var rejection in any meaningful
219/// way.
220///
221/// Stored values are assumed valid — validation is enforced at `set_app_redirect_uri`
222/// write time per R2.
223pub(crate) fn resolve_redirect_uri_from(
224    env_value: Option<String>,
225    stored: Option<&str>,
226) -> ResolvedRedirectUri {
227    if let Some(v) = env_value {
228        if Config::validate_redirect_uri(&v).is_ok() {
229            return ResolvedRedirectUri {
230                uri: v,
231                source: ResolveSource::EnvVar,
232            };
233        }
234        crate::output::warn_stderr(
235            "REDIRECT_URI env value rejected by validation; falling through to next precedence level",
236        );
237    }
238
239    if let Some(s) = stored
240        && !s.is_empty()
241    {
242        return ResolvedRedirectUri {
243            uri: s.to_string(),
244            source: ResolveSource::AppConfig,
245        };
246    }
247
248    ResolvedRedirectUri {
249        uri: DEFAULT_REDIRECT_URI.to_string(),
250        source: ResolveSource::BuiltInDefault,
251    }
252}
253
254/// Thin wrapper around the pure precedence helper that opens the token
255/// store at `store_path` and looks up the per-app stored URI for `app_name`.
256///
257/// Callers that already hold a `TokenStore` should call the pure helper
258/// directly with the env var and the result of
259/// `store.get_app_redirect_uri(app_name)` to avoid a second disk read.
260#[must_use]
261#[allow(private_interfaces)] // ResolvedRedirectUri is pub(crate) per KTD9; the plan keeps this resolver pub
262pub fn resolve_redirect_uri(store_path: &Path, app_name: &str) -> ResolvedRedirectUri {
263    let env = std::env::var("REDIRECT_URI").ok();
264    let store = crate::store::TokenStore::new_with_path(store_path.to_str().unwrap_or("."));
265    let stored = store.get_app_redirect_uri(app_name).map(str::to_string);
266    resolve_redirect_uri_from(env, stored.as_deref())
267}
268
269/// Returns an environment variable's value, or `default` if unset.
270fn env_or_default(key: &str, default: &str) -> String {
271    std::env::var(key).unwrap_or_else(|_| default.to_string())
272}
273
274// In-source unit tests cover the `pub(crate)` resolver internals that the
275// external integration-test crate cannot reach without breaking visibility.
276// Tests touching only the public API (`resolve_redirect_uri`,
277// `validate_redirect_uri`, `DEFAULT_REDIRECT_URI`) live in
278// `tests/config_tests.rs`.
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn resolve_redirect_uri_from_env_wins_over_stored() {
285        let resolved = resolve_redirect_uri_from(
286            Some("https://example.com/cb".to_string()),
287            Some("http://stored.example.com/cb"),
288        );
289        assert_eq!(resolved.source, ResolveSource::EnvVar);
290        assert_eq!(resolved.uri, "https://example.com/cb");
291    }
292
293    #[test]
294    fn resolve_redirect_uri_from_stored_wins_over_default() {
295        let resolved = resolve_redirect_uri_from(None, Some("http://localhost:9090/cb"));
296        assert_eq!(resolved.source, ResolveSource::AppConfig);
297        assert_eq!(resolved.uri, "http://localhost:9090/cb");
298    }
299
300    #[test]
301    fn resolve_redirect_uri_from_default_fallback() {
302        let resolved = resolve_redirect_uri_from(None, None);
303        assert_eq!(resolved.source, ResolveSource::BuiltInDefault);
304        assert_eq!(resolved.uri, DEFAULT_REDIRECT_URI);
305    }
306
307    #[test]
308    fn resolve_redirect_uri_from_empty_stored_falls_through_to_default() {
309        let resolved = resolve_redirect_uri_from(None, Some(""));
310        assert_eq!(resolved.source, ResolveSource::BuiltInDefault);
311        assert_eq!(resolved.uri, DEFAULT_REDIRECT_URI);
312    }
313
314    #[test]
315    fn resolve_redirect_uri_from_invalid_env_falls_through_to_stored() {
316        let resolved = resolve_redirect_uri_from(
317            Some("not-a-url".to_string()),
318            Some("http://localhost:9090/cb"),
319        );
320        assert_eq!(resolved.source, ResolveSource::AppConfig);
321        assert_eq!(resolved.uri, "http://localhost:9090/cb");
322    }
323
324    // Exhaustive mapping locks both rendering paths so a new variant trips
325    // the compiler (via the inner match) at the same time as a serde-mapping
326    // assertion failure.
327    #[test]
328    fn resolve_source_as_text_label_exhaustive() {
329        for variant in [
330            ResolveSource::EnvVar,
331            ResolveSource::AppConfig,
332            ResolveSource::BuiltInDefault,
333        ] {
334            let label = variant.as_text_label();
335            match variant {
336                ResolveSource::EnvVar => {
337                    assert_eq!(label, "REDIRECT_URI environment variable");
338                }
339                ResolveSource::AppConfig => {
340                    assert_eq!(label, "app config");
341                }
342                ResolveSource::BuiltInDefault => {
343                    assert_eq!(label, "built-in default");
344                }
345            }
346        }
347    }
348
349    #[test]
350    fn resolve_source_serialize_kebab_case_exhaustive() {
351        for variant in [
352            ResolveSource::EnvVar,
353            ResolveSource::AppConfig,
354            ResolveSource::BuiltInDefault,
355        ] {
356            let json = serde_json::to_string(&variant).expect("serialize ResolveSource");
357            match variant {
358                ResolveSource::EnvVar => assert_eq!(json, "\"env-var\""),
359                ResolveSource::AppConfig => assert_eq!(json, "\"app-config\""),
360                ResolveSource::BuiltInDefault => assert_eq!(json, "\"built-in-default\""),
361            }
362        }
363    }
364
365    #[test]
366    fn resolve_source_is_env_var_predicate() {
367        assert!(ResolveSource::EnvVar.is_env_var());
368        assert!(!ResolveSource::AppConfig.is_env_var());
369        assert!(!ResolveSource::BuiltInDefault.is_env_var());
370    }
371
372    // ── Thin-wrapper resolve_redirect_uri tests ─────────────────────────
373    //
374    // These exercise the disk-I/O wrapper. The env-var leg uses serial_test
375    // to avoid races with other env-mutating tests in the same crate.
376
377    use serial_test::serial;
378    use std::fs;
379    use tempfile::TempDir;
380
381    fn write_store_with_redirect_uri(path: &std::path::Path, app: &str, uri: &str) {
382        let yaml = format!(
383            "apps:\n  {app}:\n    client_id: ''\n    client_secret: ''\n    redirect_uri: '{uri}'\n    oauth2_tokens: {{}}\ndefault_app: {app}\n"
384        );
385        fs::write(path, yaml).expect("write tempdir store");
386    }
387
388    fn write_empty_store(path: &std::path::Path, app: &str) {
389        let yaml = format!(
390            "apps:\n  {app}:\n    client_id: ''\n    client_secret: ''\n    oauth2_tokens: {{}}\ndefault_app: {app}\n"
391        );
392        fs::write(path, yaml).expect("write tempdir store");
393    }
394
395    #[test]
396    #[serial]
397    fn resolve_redirect_uri_env_wins() {
398        let tmp = TempDir::new().expect("create tempdir for redirect_uri test");
399        let store_path = tmp.path().join(".xurl");
400        write_store_with_redirect_uri(&store_path, "app1", "http://localhost:7777/cb");
401
402        unsafe {
403            std::env::set_var("REDIRECT_URI", "https://example.com/cb");
404        }
405        let resolved = resolve_redirect_uri(&store_path, "app1");
406        unsafe {
407            std::env::remove_var("REDIRECT_URI");
408        }
409
410        assert_eq!(resolved.source, ResolveSource::EnvVar);
411        assert_eq!(resolved.uri, "https://example.com/cb");
412    }
413
414    #[test]
415    #[serial]
416    fn resolve_redirect_uri_stored_when_no_env() {
417        let tmp = TempDir::new().expect("create tempdir for redirect_uri test");
418        let store_path = tmp.path().join(".xurl");
419        write_store_with_redirect_uri(&store_path, "app1", "http://localhost:9090/cb");
420
421        unsafe {
422            std::env::remove_var("REDIRECT_URI");
423        }
424        let resolved = resolve_redirect_uri(&store_path, "app1");
425
426        assert_eq!(resolved.source, ResolveSource::AppConfig);
427        assert_eq!(resolved.uri, "http://localhost:9090/cb");
428    }
429
430    #[test]
431    #[serial]
432    fn resolve_redirect_uri_default_fallback() {
433        let tmp = TempDir::new().expect("create tempdir for redirect_uri test");
434        let store_path = tmp.path().join(".xurl");
435        write_empty_store(&store_path, "app1");
436
437        unsafe {
438            std::env::remove_var("REDIRECT_URI");
439        }
440        let resolved = resolve_redirect_uri(&store_path, "app1");
441
442        assert_eq!(resolved.source, ResolveSource::BuiltInDefault);
443        assert_eq!(resolved.uri, DEFAULT_REDIRECT_URI);
444    }
445}