Skip to main content

dsp_cli/config/
mod.rs

1//! Config resolution — layer 5 of dsp-cli/ADR-0008.
2//!
3//! The four-layer stack from dsp-cli/ADR-0007: flag → env var → `.env` (CWD) →
4//! fail. `.env` is loaded via `dotenvy::dotenv()` at startup; no
5//! hard-coded default server.
6//!
7//! By the time [`Config::resolve`] is called, the chain has already collapsed
8//! to a single `Option<&str>`: clap's `env = "DSP_SERVER"` attribute merged
9//! the flag and env-var tiers; `dotenvy::dotenv()` in `main` populated the
10//! env from `.env` before clap ran. This function handles only the final
11//! two cases: expand a shortcut or literal, or fail.
12//!
13//! It also validates the expanded value's scheme: a non-local `http://`
14//! server is refused, because every authenticated command sends a bearer
15//! token that would otherwise cross the network in cleartext. `--server
16//! local` (`http://0.0.0.0:3333`) and other loopback/`localhost` addresses
17//! are exempt — a bearer token never leaves the machine. `--allow-insecure-server`
18//! / `DSP_ALLOW_INSECURE_SERVER` overrides the refusal.
19//!
20//! A value containing a control character is refused on any scheme, and that
21//! refusal is not overridable.
22
23pub mod auth_cache;
24pub use auth_cache::AuthCache;
25
26pub mod token;
27pub use token::{ResolvedToken, TokenOrigin, resolve_token};
28
29use crate::diagnostic::Diagnostic;
30
31/// Built-in shortcut names → canonical server URLs. See dsp-cli/ADR-0007.
32///
33/// Lookup is linear; a handful of entries is too small to justify a `HashMap`.
34/// Matching is case-insensitive (`PROD`, `Prod`, `prod` all resolve); a value
35/// that matches no shortcut passes through unchanged as a literal URL.
36///
37/// Reachability of each host was last verified 2026-05-27 via `GET /health`.
38const SHORTCUTS: &[(&str, &str)] = &[
39    ("prod", "https://api.dasch.swiss"),
40    ("stage", "https://api.stage.dasch.swiss"),
41    ("dev", "https://api.dev.dasch.swiss"),
42    ("demo", "https://api.demo.dasch.swiss"),
43    ("rdu", "https://api.rdu.dasch.swiss"),
44    ("ls-prod", "https://api.ls-prod-server.dasch.swiss"),
45    ("ls-test", "https://api.ls-test-server.dasch.swiss"),
46    ("local", "http://0.0.0.0:3333"),
47];
48
49/// Resolved configuration for a single command invocation.
50#[derive(Debug, Clone)]
51pub struct Config {
52    /// The fully-resolved server URL (or shortcut-expanded URL).
53    pub server: String,
54}
55
56impl Config {
57    /// Resolve the active server from the collapsed `Option<&str>`.
58    ///
59    /// `server` is the value after clap has merged `--server` and
60    /// `DSP_SERVER` (including any `.env` values dotenvy loaded at startup).
61    /// `None` means the user provided nothing — that's a usage error.
62    ///
63    /// `allow_insecure` is the resolved `--allow-insecure-server` /
64    /// `DSP_ALLOW_INSECURE_SERVER` override (flag before env — see
65    /// `Cli::allow_insecure_server` in `src/cli/mod.rs`); when `true`, the
66    /// cleartext-HTTP scheme check below is skipped entirely.
67    pub fn resolve(server: Option<&str>, allow_insecure: bool) -> Result<Self, Diagnostic> {
68        match server {
69            None => Err(Diagnostic::Usage(
70                "no server specified. Provide one via --server <prod|dev|…|URL>, \
71the DSP_SERVER environment variable, or a .env file in the current directory. \
72See `dsp docs connecting` for details."
73                    .to_string(),
74            )),
75            Some(s) => {
76                // Case-insensitive shortcut match; lowercase only for the lookup
77                // so a literal URL passes through with its original casing intact.
78                let lower = s.to_ascii_lowercase();
79                let url = SHORTCUTS
80                    .iter()
81                    .find(|(name, _)| *name == lower)
82                    .map(|(_, url)| *url)
83                    .unwrap_or(s);
84
85                // A URL never legitimately contains a raw control character, so refusing
86                // outright is safer than sanitizing the stored value: sanitizing would change
87                // the string used as the auth-cache key and sent in outgoing requests.
88                if url.chars().any(char::is_control) {
89                    return Err(Diagnostic::Usage(format!(
90                        "refusing server value \"{}\": it contains a control character",
91                        sanitize_for_diagnostic(url)
92                    )));
93                }
94
95                tracing::debug!(server = url, "resolved server");
96
97                validate_scheme(url, allow_insecure)?;
98
99                Ok(Config { server: url.to_string() })
100            }
101        }
102    }
103}
104
105/// Refuses a non-local `http://` server unless overridden.
106///
107/// Only the `http` scheme is checked: `https://` is always accepted, and a
108/// value that is not a parseable absolute URL (e.g. an unrecognised bare
109/// word — see `resolve_with_unknown_word_passes_through`) is left for a
110/// later layer to reject on its own terms, since there is no scheme to
111/// assess a cleartext risk on.
112fn validate_scheme(server: &str, allow_insecure: bool) -> Result<(), Diagnostic> {
113    if allow_insecure {
114        return Ok(());
115    }
116
117    let Ok(parsed) = reqwest::Url::parse(server) else {
118        return Ok(());
119    };
120
121    if parsed.scheme() == "http" && !is_local_host(&parsed) {
122        return Err(Diagnostic::Usage(format!(
123            "refusing to use \"{}\" over plain HTTP: an authenticated command sends a bearer \
124token, which would cross the network in cleartext. Use https://, a local address \
125(loopback or unspecified (127.0.0.0/8, ::1, 0.0.0.0, ::) or localhost), or override \
126with --allow-insecure-server / DSP_ALLOW_INSECURE_SERVER=1.",
127            sanitize_for_diagnostic(server)
128        )));
129    }
130
131    Ok(())
132}
133
134/// Whether `url`'s host is loopback, unspecified (`0.0.0.0`/`::`), or
135/// `localhost` — the set of hosts a bearer token never actually leaves the
136/// machine for, even over plain HTTP.
137///
138/// `reqwest::Url` re-exports `url::Url`, so `.host()` is reachable — but not
139/// the `url::Host` enum it returns, since `url` is not a direct dependency of
140/// this crate to match on. This reads `host_str()` instead. An IPv6 host
141/// comes back bracketed (e.g. `"[::1]"` — `url`'s `Host::Ipv6` `Display` impl
142/// wraps in brackets); strip them before parsing with `std::net::IpAddr`. A
143/// bare IPv4 host or domain has no brackets, so the strip is a no-op for
144/// those.
145fn is_local_host(url: &reqwest::Url) -> bool {
146    let Some(host) = url.host_str() else { return false };
147
148    if host.eq_ignore_ascii_case("localhost") {
149        return true;
150    }
151
152    let bare = host.strip_prefix('[').and_then(|h| h.strip_suffix(']')).unwrap_or(host);
153    bare.parse::<std::net::IpAddr>()
154        .map(|ip| ip.is_loopback() || ip.is_unspecified())
155        .unwrap_or(false)
156}
157
158/// Strips control characters (including ANSI escape, `\x1b`) from a
159/// `--server` value before it is embedded in a diagnostic message, so a
160/// crafted server value cannot inject terminal escapes into an error message.
161fn sanitize_for_diagnostic(s: &str) -> String {
162    s.chars().filter(|c| !c.is_control()).collect()
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use crate::diagnostic::Diagnostic;
169
170    #[test]
171    fn resolve_with_literal_url() {
172        let cfg = Config::resolve(Some("https://api.example.org"), false).unwrap();
173        assert_eq!(cfg.server, "https://api.example.org");
174    }
175
176    #[test]
177    fn resolve_with_known_shortcut_prod() {
178        let cfg = Config::resolve(Some("prod"), false).unwrap();
179        assert_eq!(cfg.server, "https://api.dasch.swiss");
180    }
181
182    #[test]
183    fn resolve_with_known_shortcut_local() {
184        // "local" expands to http://0.0.0.0:3333 — an unspecified address, not
185        // loopback — and must still resolve with no override (CRITICAL case:
186        // this is the developer's primary shortcut, dsp-cli/ADR-0007).
187        let cfg = Config::resolve(Some("local"), false).unwrap();
188        assert_eq!(cfg.server, "http://0.0.0.0:3333");
189    }
190
191    #[test]
192    fn resolve_with_unknown_word_passes_through() {
193        let cfg = Config::resolve(Some("staging-experiment"), false).unwrap();
194        assert_eq!(cfg.server, "staging-experiment");
195    }
196
197    #[test]
198    fn resolve_with_known_shortcut_dev() {
199        let cfg = Config::resolve(Some("dev"), false).unwrap();
200        assert_eq!(cfg.server, "https://api.dev.dasch.swiss");
201    }
202
203    #[test]
204    fn resolve_with_known_shortcut_demo() {
205        let cfg = Config::resolve(Some("demo"), false).unwrap();
206        assert_eq!(cfg.server, "https://api.demo.dasch.swiss");
207    }
208
209    #[test]
210    fn resolve_shortcut_is_case_insensitive() {
211        // Mixed and upper case both resolve to the canonical URL.
212        assert_eq!(Config::resolve(Some("PROD"), false).unwrap().server, "https://api.dasch.swiss");
213        assert_eq!(
214            Config::resolve(Some("Dev"), false).unwrap().server,
215            "https://api.dev.dasch.swiss"
216        );
217    }
218
219    #[test]
220    fn resolve_literal_url_preserves_case() {
221        // A non-shortcut value passes through unchanged — casing is NOT lowered.
222        // (Also proves the scheme-validation pass doesn't normalise the stored
223        // value: it only *parses* a copy of `server` to inspect the scheme.)
224        let cfg = Config::resolve(Some("https://API.Example.ORG/Path"), false).unwrap();
225        assert_eq!(cfg.server, "https://API.Example.ORG/Path");
226    }
227
228    #[test]
229    fn resolve_with_none_returns_usage_diagnostic() {
230        let err = Config::resolve(None, false).unwrap_err();
231        assert!(matches!(err, Diagnostic::Usage(_)));
232    }
233
234    #[test]
235    fn missing_server_message_mentions_all_three_paths() {
236        let err = Config::resolve(None, false).unwrap_err();
237        let msg = err.to_string();
238        assert!(msg.contains("--server"), "missing --server in: {msg}");
239        assert!(msg.contains("DSP_SERVER"), "missing DSP_SERVER in: {msg}");
240        assert!(msg.contains(".env"), "missing .env in: {msg}");
241    }
242
243    #[test]
244    fn shortcut_and_canonical_url_resolve_identically() {
245        // set_entry(server, …) and token(server) both use the resolved URL as the map
246        // key, so "dev" and its canonical expansion must produce the same string.
247        let via_shortcut = Config::resolve(Some("dev"), false).unwrap();
248        let via_url = Config::resolve(Some("https://api.dev.dasch.swiss"), false).unwrap();
249        assert_eq!(
250            via_shortcut.server, via_url.server,
251            "shortcut 'dev' and its URL must resolve to the same string for \
252cache key lookups to work"
253        );
254    }
255
256    // ── --server scheme validation ────────────────────────────────────────────
257
258    #[test]
259    fn https_is_always_accepted() {
260        assert!(Config::resolve(Some("https://api.dasch.swiss"), false).is_ok());
261    }
262
263    #[test]
264    fn every_shortcut_still_resolves_with_scheme_validation() {
265        // SHORTCUTS holds only https:// entries except "local" (0.0.0.0, covered
266        // by resolve_with_known_shortcut_local above) — assert none of them are
267        // rejected now that resolve() validates scheme.
268        for (name, _) in SHORTCUTS {
269            let result = Config::resolve(Some(name), false);
270            assert!(result.is_ok(), "shortcut '{name}' must still resolve, got {result:?}");
271        }
272    }
273
274    #[test]
275    fn http_loopback_ipv6_with_brackets_is_accepted() {
276        // Discriminator for the host_str()-returns-bracketed-IPv6 question.
277        let cfg = Config::resolve(Some("http://[::1]:3333"), false).unwrap();
278        assert_eq!(cfg.server, "http://[::1]:3333");
279    }
280
281    #[test]
282    fn http_unspecified_ipv4_is_accepted() {
283        let cfg = Config::resolve(Some("http://0.0.0.0:3333"), false).unwrap();
284        assert_eq!(cfg.server, "http://0.0.0.0:3333");
285    }
286
287    #[test]
288    fn http_loopback_ipv4_is_accepted() {
289        let cfg = Config::resolve(Some("http://127.0.0.1:3333"), false).unwrap();
290        assert_eq!(cfg.server, "http://127.0.0.1:3333");
291    }
292
293    #[test]
294    fn http_localhost_is_accepted() {
295        let cfg = Config::resolve(Some("http://localhost:3333"), false).unwrap();
296        assert_eq!(cfg.server, "http://localhost:3333");
297    }
298
299    #[test]
300    fn http_non_local_host_is_refused() {
301        let err = Config::resolve(Some("http://api.example.org"), false).unwrap_err();
302        assert!(matches!(err, Diagnostic::Usage(_)), "expected Usage, got {err:?}");
303        let msg = err.to_string();
304        assert!(msg.contains("cleartext") || msg.contains("bearer token"), "message: {msg}");
305        assert!(msg.contains("--allow-insecure-server"), "message: {msg}");
306        assert!(msg.contains("DSP_ALLOW_INSECURE_SERVER"), "message: {msg}");
307    }
308
309    #[test]
310    fn http_non_local_host_passes_with_override() {
311        // Represents both override paths: the CLI collapses --allow-insecure-server
312        // and DSP_ALLOW_INSECURE_SERVER=1 into this single bool before calling
313        // Config::resolve (see Cli::allow_insecure_server in src/cli/mod.rs); the
314        // flag/env parsing itself is covered by CLI-layer tests, not here.
315        let cfg = Config::resolve(Some("http://api.example.org"), true).unwrap();
316        assert_eq!(cfg.server, "http://api.example.org");
317    }
318
319    #[test]
320    fn control_character_in_server_value_is_refused() {
321        // A control character (ESC here) makes a server value outright invalid,
322        // even over https:// — refused before the scheme is ever checked. The
323        // diagnostic text itself must not carry the raw byte back out.
324        let server = "https://api.example.org/\u{1b}[31mFAKE\u{1b}[0m";
325        let err = Config::resolve(Some(server), false).unwrap_err();
326        assert!(matches!(err, Diagnostic::Usage(_)), "expected Usage, got {err:?}");
327        let msg = err.to_string();
328        assert!(
329            msg.bytes().all(|b| b >= 0x20 || b == b'\n'),
330            "control character leaked into diagnostic: {msg:?}"
331        );
332    }
333}