pub mod auth_cache;
pub use auth_cache::AuthCache;
pub mod token;
pub use token::{ResolvedToken, TokenOrigin, resolve_token};
use crate::diagnostic::Diagnostic;
const SHORTCUTS: &[(&str, &str)] = &[
("prod", "https://api.dasch.swiss"),
("stage", "https://api.stage.dasch.swiss"),
("dev", "https://api.dev.dasch.swiss"),
("demo", "https://api.demo.dasch.swiss"),
("rdu", "https://api.rdu.dasch.swiss"),
("ls-prod", "https://api.ls-prod-server.dasch.swiss"),
("ls-test", "https://api.ls-test-server.dasch.swiss"),
("local", "http://0.0.0.0:3333"),
];
#[derive(Debug, Clone)]
pub struct Config {
pub server: String,
}
impl Config {
pub fn resolve(server: Option<&str>, allow_insecure: bool) -> Result<Self, Diagnostic> {
match server {
None => Err(Diagnostic::Usage(
"no server specified. Provide one via --server <prod|dev|…|URL>, \
the DSP_SERVER environment variable, or a .env file in the current directory. \
See `dsp docs connecting` for details."
.to_string(),
)),
Some(s) => {
let lower = s.to_ascii_lowercase();
let url = SHORTCUTS
.iter()
.find(|(name, _)| *name == lower)
.map(|(_, url)| *url)
.unwrap_or(s);
if url.chars().any(char::is_control) {
return Err(Diagnostic::Usage(format!(
"refusing server value \"{}\": it contains a control character",
sanitize_for_diagnostic(url)
)));
}
tracing::debug!(server = url, "resolved server");
validate_scheme(url, allow_insecure)?;
Ok(Config { server: url.to_string() })
}
}
}
}
fn validate_scheme(server: &str, allow_insecure: bool) -> Result<(), Diagnostic> {
if allow_insecure {
return Ok(());
}
let Ok(parsed) = reqwest::Url::parse(server) else {
return Ok(());
};
if parsed.scheme() == "http" && !is_local_host(&parsed) {
return Err(Diagnostic::Usage(format!(
"refusing to use \"{}\" over plain HTTP: an authenticated command sends a bearer \
token, which would cross the network in cleartext. Use https://, a local address \
(loopback or unspecified (127.0.0.0/8, ::1, 0.0.0.0, ::) or localhost), or override \
with --allow-insecure-server / DSP_ALLOW_INSECURE_SERVER=1.",
sanitize_for_diagnostic(server)
)));
}
Ok(())
}
fn is_local_host(url: &reqwest::Url) -> bool {
let Some(host) = url.host_str() else { return false };
if host.eq_ignore_ascii_case("localhost") {
return true;
}
let bare = host.strip_prefix('[').and_then(|h| h.strip_suffix(']')).unwrap_or(host);
bare.parse::<std::net::IpAddr>()
.map(|ip| ip.is_loopback() || ip.is_unspecified())
.unwrap_or(false)
}
fn sanitize_for_diagnostic(s: &str) -> String {
s.chars().filter(|c| !c.is_control()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diagnostic::Diagnostic;
#[test]
fn resolve_with_literal_url() {
let cfg = Config::resolve(Some("https://api.example.org"), false).unwrap();
assert_eq!(cfg.server, "https://api.example.org");
}
#[test]
fn resolve_with_known_shortcut_prod() {
let cfg = Config::resolve(Some("prod"), false).unwrap();
assert_eq!(cfg.server, "https://api.dasch.swiss");
}
#[test]
fn resolve_with_known_shortcut_local() {
let cfg = Config::resolve(Some("local"), false).unwrap();
assert_eq!(cfg.server, "http://0.0.0.0:3333");
}
#[test]
fn resolve_with_unknown_word_passes_through() {
let cfg = Config::resolve(Some("staging-experiment"), false).unwrap();
assert_eq!(cfg.server, "staging-experiment");
}
#[test]
fn resolve_with_known_shortcut_dev() {
let cfg = Config::resolve(Some("dev"), false).unwrap();
assert_eq!(cfg.server, "https://api.dev.dasch.swiss");
}
#[test]
fn resolve_with_known_shortcut_demo() {
let cfg = Config::resolve(Some("demo"), false).unwrap();
assert_eq!(cfg.server, "https://api.demo.dasch.swiss");
}
#[test]
fn resolve_shortcut_is_case_insensitive() {
assert_eq!(Config::resolve(Some("PROD"), false).unwrap().server, "https://api.dasch.swiss");
assert_eq!(
Config::resolve(Some("Dev"), false).unwrap().server,
"https://api.dev.dasch.swiss"
);
}
#[test]
fn resolve_literal_url_preserves_case() {
let cfg = Config::resolve(Some("https://API.Example.ORG/Path"), false).unwrap();
assert_eq!(cfg.server, "https://API.Example.ORG/Path");
}
#[test]
fn resolve_with_none_returns_usage_diagnostic() {
let err = Config::resolve(None, false).unwrap_err();
assert!(matches!(err, Diagnostic::Usage(_)));
}
#[test]
fn missing_server_message_mentions_all_three_paths() {
let err = Config::resolve(None, false).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("--server"), "missing --server in: {msg}");
assert!(msg.contains("DSP_SERVER"), "missing DSP_SERVER in: {msg}");
assert!(msg.contains(".env"), "missing .env in: {msg}");
}
#[test]
fn shortcut_and_canonical_url_resolve_identically() {
let via_shortcut = Config::resolve(Some("dev"), false).unwrap();
let via_url = Config::resolve(Some("https://api.dev.dasch.swiss"), false).unwrap();
assert_eq!(
via_shortcut.server, via_url.server,
"shortcut 'dev' and its URL must resolve to the same string for \
cache key lookups to work"
);
}
#[test]
fn https_is_always_accepted() {
assert!(Config::resolve(Some("https://api.dasch.swiss"), false).is_ok());
}
#[test]
fn every_shortcut_still_resolves_with_scheme_validation() {
for (name, _) in SHORTCUTS {
let result = Config::resolve(Some(name), false);
assert!(result.is_ok(), "shortcut '{name}' must still resolve, got {result:?}");
}
}
#[test]
fn http_loopback_ipv6_with_brackets_is_accepted() {
let cfg = Config::resolve(Some("http://[::1]:3333"), false).unwrap();
assert_eq!(cfg.server, "http://[::1]:3333");
}
#[test]
fn http_unspecified_ipv4_is_accepted() {
let cfg = Config::resolve(Some("http://0.0.0.0:3333"), false).unwrap();
assert_eq!(cfg.server, "http://0.0.0.0:3333");
}
#[test]
fn http_loopback_ipv4_is_accepted() {
let cfg = Config::resolve(Some("http://127.0.0.1:3333"), false).unwrap();
assert_eq!(cfg.server, "http://127.0.0.1:3333");
}
#[test]
fn http_localhost_is_accepted() {
let cfg = Config::resolve(Some("http://localhost:3333"), false).unwrap();
assert_eq!(cfg.server, "http://localhost:3333");
}
#[test]
fn http_non_local_host_is_refused() {
let err = Config::resolve(Some("http://api.example.org"), false).unwrap_err();
assert!(matches!(err, Diagnostic::Usage(_)), "expected Usage, got {err:?}");
let msg = err.to_string();
assert!(msg.contains("cleartext") || msg.contains("bearer token"), "message: {msg}");
assert!(msg.contains("--allow-insecure-server"), "message: {msg}");
assert!(msg.contains("DSP_ALLOW_INSECURE_SERVER"), "message: {msg}");
}
#[test]
fn http_non_local_host_passes_with_override() {
let cfg = Config::resolve(Some("http://api.example.org"), true).unwrap();
assert_eq!(cfg.server, "http://api.example.org");
}
#[test]
fn control_character_in_server_value_is_refused() {
let server = "https://api.example.org/\u{1b}[31mFAKE\u{1b}[0m";
let err = Config::resolve(Some(server), false).unwrap_err();
assert!(matches!(err, Diagnostic::Usage(_)), "expected Usage, got {err:?}");
let msg = err.to_string();
assert!(
msg.bytes().all(|b| b >= 0x20 || b == b'\n'),
"control character leaked into diagnostic: {msg:?}"
);
}
}