1pub 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
31const 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#[derive(Debug, Clone)]
51pub struct Config {
52 pub server: String,
54}
55
56impl Config {
57 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 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 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
105fn 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
134fn 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
158fn 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 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 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 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 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 #[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 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 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 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 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}