mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
966 lines
31 KiB
Rust
966 lines
31 KiB
Rust
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
use std::{env, fs, path::Path};
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub enum GeoipSourceConfig {
|
|
Filesystem {
|
|
maxmind_db_path: Option<String>,
|
|
},
|
|
S3 {
|
|
maxmind_db_path: String,
|
|
s3_bucket: String,
|
|
s3_key: String,
|
|
},
|
|
}
|
|
|
|
impl GeoipSourceConfig {
|
|
pub fn maxmind_db_path(&self) -> Option<String> {
|
|
match self {
|
|
Self::Filesystem { maxmind_db_path } => maxmind_db_path.clone(),
|
|
Self::S3 {
|
|
maxmind_db_path, ..
|
|
} => Some(maxmind_db_path.clone()),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct GeoipS3Config {
|
|
pub endpoint: String,
|
|
pub region: String,
|
|
pub access_key_id: String,
|
|
pub secret_access_key: String,
|
|
}
|
|
|
|
pub fn read_geoip_s3_config_from_env(source: &GeoipSourceConfig) -> Option<GeoipS3Config> {
|
|
read_geoip_s3_config(source, env_value)
|
|
}
|
|
|
|
fn read_geoip_s3_config<F>(source: &GeoipSourceConfig, mut read_var: F) -> Option<GeoipS3Config>
|
|
where
|
|
F: FnMut(&str) -> Option<String>,
|
|
{
|
|
match source {
|
|
GeoipSourceConfig::S3 { .. } => Some(GeoipS3Config {
|
|
endpoint: read_var("FLUXER_S3_ENDPOINT").unwrap_or_default(),
|
|
region: read_var("FLUXER_S3_REGION").unwrap_or_default(),
|
|
access_key_id: read_var("FLUXER_S3_ACCESS_KEY_ID").unwrap_or_default(),
|
|
secret_access_key: read_var("FLUXER_S3_SECRET_ACCESS_KEY").unwrap_or_default(),
|
|
}),
|
|
GeoipSourceConfig::Filesystem { .. } => None,
|
|
}
|
|
}
|
|
|
|
pub fn parse_geoip_source_config(raw_value: &str, service_name: &str) -> GeoipSourceConfig {
|
|
let trimmed = raw_value.trim();
|
|
if trimmed.is_empty() {
|
|
return GeoipSourceConfig::Filesystem {
|
|
maxmind_db_path: None,
|
|
};
|
|
}
|
|
if !trimmed.starts_with("s3://") {
|
|
return GeoipSourceConfig::Filesystem {
|
|
maxmind_db_path: Some(trimmed.to_owned()),
|
|
};
|
|
}
|
|
parse_geoip_s3_source_config(trimmed, service_name)
|
|
}
|
|
|
|
fn parse_geoip_s3_source_config(raw_value: &str, service_name: &str) -> GeoipSourceConfig {
|
|
let url = reqwest::Url::parse(raw_value)
|
|
.unwrap_or_else(|err| panic!("invalid GeoIP S3 URL {}: {}", raw_value, err));
|
|
let s3_bucket = url.host_str().unwrap_or("").to_owned();
|
|
if s3_bucket.is_empty() {
|
|
panic!("invalid GeoIP S3 URL (missing bucket): {raw_value}");
|
|
}
|
|
let s3_key = percent_decode(url.path().trim_start_matches('/'));
|
|
if s3_key.is_empty() {
|
|
panic!("invalid GeoIP S3 URL (missing object key): {raw_value}");
|
|
}
|
|
let maxmind_db_path =
|
|
geoip_runtime_path(&resolve_geoip_download_path(&url, raw_value), service_name);
|
|
GeoipSourceConfig::S3 {
|
|
maxmind_db_path,
|
|
s3_bucket,
|
|
s3_key,
|
|
}
|
|
}
|
|
|
|
fn resolve_geoip_download_path(url: &reqwest::Url, raw_value: &str) -> String {
|
|
let Some(download_path) = url
|
|
.query_pairs()
|
|
.find(|(key, _)| key == "download_path")
|
|
.map(|(_, value)| value.into_owned())
|
|
else {
|
|
panic!("invalid GeoIP S3 URL (missing query parameter \"download_path\"): {raw_value}");
|
|
};
|
|
require_absolute_path(&download_path, "download_path", raw_value)
|
|
}
|
|
|
|
fn require_absolute_path(value: &str, param: &str, raw_value: &str) -> String {
|
|
if !Path::new(value).is_absolute() {
|
|
panic!("GeoIP S3 URL query parameter \"{param}\" must be an absolute path: {raw_value}");
|
|
}
|
|
value.to_owned()
|
|
}
|
|
|
|
fn geoip_runtime_path(configured_path: &str, service_name: &str) -> String {
|
|
let basename = Path::new(configured_path)
|
|
.file_name()
|
|
.and_then(|value| value.to_str())
|
|
.unwrap_or("GeoLite2-City.mmdb");
|
|
Path::new("/tmp/fluxer/geoip")
|
|
.join(service_name)
|
|
.join(basename)
|
|
.to_string_lossy()
|
|
.into_owned()
|
|
}
|
|
|
|
fn percent_decode(value: &str) -> String {
|
|
urlencoding::decode(value)
|
|
.map(|value| value.into_owned())
|
|
.unwrap_or_else(|_| value.to_owned())
|
|
}
|
|
|
|
pub fn env_value(name: &str) -> Option<String> {
|
|
resolve_env_value(name, |key| env::var(key).ok()).unwrap_or_else(|error| panic!("{error}"))
|
|
}
|
|
|
|
pub fn resolve_env_value<F>(name: &str, get: F) -> Result<Option<String>, String>
|
|
where
|
|
F: Fn(&str) -> Option<String>,
|
|
{
|
|
let non_blank = |value: Option<String>| value.filter(|value| !value.trim().is_empty());
|
|
let value = non_blank(get(name));
|
|
let Some(path) = non_blank(get(&format!("{name}_FILE"))) else {
|
|
return Ok(value);
|
|
};
|
|
if value.is_some() {
|
|
return Err(format!("{name} and {name}_FILE are both set, set only one"));
|
|
}
|
|
let contents = fs::read_to_string(&path)
|
|
.map_err(|error| format!("{name}_FILE could not read {path} ({error})"))?;
|
|
let contents = contents
|
|
.strip_suffix('\n')
|
|
.map_or(contents.as_str(), |rest| {
|
|
rest.strip_suffix('\r').unwrap_or(rest)
|
|
});
|
|
Ok(non_blank(Some(contents.to_owned())))
|
|
}
|
|
|
|
pub fn resolve_env_files<I>(vars: I) -> Result<Vec<(String, String)>, String>
|
|
where
|
|
I: IntoIterator<Item = (String, String)>,
|
|
{
|
|
let vars: Vec<(String, String)> = vars.into_iter().collect();
|
|
let get = |key: &str| {
|
|
vars.iter()
|
|
.find_map(|(name, value)| (name == key).then(|| value.clone()))
|
|
};
|
|
let mut resolved = Vec::new();
|
|
for (key, _) in &vars {
|
|
let Some(name) = key
|
|
.strip_suffix("_FILE")
|
|
.filter(|name| name.starts_with("FLUXER_"))
|
|
else {
|
|
continue;
|
|
};
|
|
if let Some(value) = resolve_env_value(name, get)? {
|
|
resolved.push((name.to_owned(), value));
|
|
}
|
|
}
|
|
let rest: Vec<(String, String)> = vars
|
|
.iter()
|
|
.filter(|(key, _)| !resolved.iter().any(|(name, _)| name == key))
|
|
.cloned()
|
|
.collect();
|
|
resolved.extend(rest);
|
|
Ok(resolved)
|
|
}
|
|
|
|
pub fn read_env(name: &str, fallback: &str) -> String {
|
|
env_value(name).unwrap_or_else(|| fallback.to_owned())
|
|
}
|
|
|
|
pub fn read_first_env(names: &[&str], fallback: &str) -> String {
|
|
names
|
|
.iter()
|
|
.find_map(|name| env_value(name))
|
|
.unwrap_or_else(|| fallback.to_owned())
|
|
}
|
|
|
|
pub fn read_bool_env(name: &str, fallback: bool) -> bool {
|
|
let Some(value) = env_value(name) else {
|
|
return fallback;
|
|
};
|
|
matches!(
|
|
value.trim().to_ascii_lowercase().as_str(),
|
|
"1" | "true" | "yes" | "on"
|
|
)
|
|
}
|
|
|
|
pub fn env_filter(default: &str) -> tracing_subscriber::EnvFilter {
|
|
env_value("RUST_LOG")
|
|
.and_then(|filter| tracing_subscriber::EnvFilter::try_new(filter).ok())
|
|
.unwrap_or_else(|| tracing_subscriber::EnvFilter::new(default))
|
|
}
|
|
|
|
pub fn normalize_base_path(value: &str) -> String {
|
|
let trimmed = value.trim().trim_matches('/');
|
|
if trimmed.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!("/{trimmed}")
|
|
}
|
|
}
|
|
|
|
pub fn trim_trailing_slash(value: &str) -> String {
|
|
value.trim_end_matches('/').to_owned()
|
|
}
|
|
|
|
fn is_default_port(scheme: &str, port: u16) -> bool {
|
|
matches!(
|
|
(scheme, port),
|
|
("http", 80) | ("https", 443) | ("ws", 80) | ("wss", 443)
|
|
)
|
|
}
|
|
|
|
fn canonicalize_domain(value: &str) -> String {
|
|
let mut domain = value.trim().to_lowercase();
|
|
if domain.ends_with('.') {
|
|
domain.pop();
|
|
}
|
|
domain
|
|
}
|
|
|
|
fn default_port(scheme: &str) -> u16 {
|
|
if scheme == "https" { 443 } else { 80 }
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct PublicOrigin {
|
|
pub scheme: String,
|
|
pub domain: String,
|
|
pub port: u16,
|
|
}
|
|
|
|
pub fn parse_public_origin(origin: &str) -> Option<PublicOrigin> {
|
|
let trimmed = origin.trim();
|
|
if trimmed.is_empty() {
|
|
return None;
|
|
}
|
|
let parsed = reqwest::Url::parse(trimmed).ok()?;
|
|
let scheme = parsed.scheme();
|
|
if scheme != "http" && scheme != "https" {
|
|
return None;
|
|
}
|
|
if parsed.path() != "/" || parsed.query().is_some() || parsed.fragment().is_some() {
|
|
return None;
|
|
}
|
|
if !parsed.username().is_empty() || parsed.password().is_some() {
|
|
return None;
|
|
}
|
|
let domain = canonicalize_domain(parsed.host_str()?);
|
|
if domain.is_empty() {
|
|
return None;
|
|
}
|
|
Some(PublicOrigin {
|
|
scheme: scheme.to_owned(),
|
|
domain,
|
|
port: parsed.port().unwrap_or_else(|| default_port(scheme)),
|
|
})
|
|
}
|
|
|
|
pub fn resolve_public_domain_and_port<F>(mut read_var: F) -> anyhow::Result<(String, Option<u16>)>
|
|
where
|
|
F: FnMut(&str) -> Option<String>,
|
|
{
|
|
let mut non_empty_var = |name: &str| {
|
|
read_var(name)
|
|
.map(|value| value.trim().to_owned())
|
|
.filter(|value| !value.is_empty())
|
|
};
|
|
let configured_port = match non_empty_var("FLUXER_PUBLIC_PORT") {
|
|
None => None,
|
|
Some(raw) => Some(
|
|
raw.parse::<u16>()
|
|
.ok()
|
|
.filter(|port| *port != 0)
|
|
.ok_or_else(|| {
|
|
anyhow::anyhow!(
|
|
"FLUXER_PUBLIC_PORT must be a port between 1 and 65535, got {raw}"
|
|
)
|
|
})?,
|
|
),
|
|
};
|
|
let configured_domain =
|
|
non_empty_var("FLUXER_BASE_DOMAIN").map(|value| canonicalize_domain(&value));
|
|
let Some(origin) = non_empty_var("FLUXER_PUBLIC_ORIGIN") else {
|
|
return Ok((configured_domain.unwrap_or_default(), configured_port));
|
|
};
|
|
let parsed = parse_public_origin(&origin).ok_or_else(|| {
|
|
anyhow::anyhow!(
|
|
"FLUXER_PUBLIC_ORIGIN must be a scheme, host and optional port such as https://chat.example.com:8443, got {origin}"
|
|
)
|
|
})?;
|
|
Ok((parsed.domain, Some(parsed.port)))
|
|
}
|
|
|
|
pub fn normalize_public_endpoint(url: &str, base_domain: &str, public_port: Option<u16>) -> String {
|
|
let Some(port) = public_port.filter(|port| *port != 0) else {
|
|
return url.to_owned();
|
|
};
|
|
let domain = canonicalize_domain(base_domain);
|
|
if domain.is_empty() {
|
|
return url.to_owned();
|
|
}
|
|
let Ok(parsed) = reqwest::Url::parse(url) else {
|
|
return url.to_owned();
|
|
};
|
|
if canonicalize_domain(parsed.host_str().unwrap_or_default()) != domain {
|
|
return url.to_owned();
|
|
}
|
|
if is_default_port(parsed.scheme(), port) {
|
|
return url.to_owned();
|
|
}
|
|
let Some(scheme_end) = url.find("://") else {
|
|
return url.to_owned();
|
|
};
|
|
let authority_start = scheme_end + 3;
|
|
if url[authority_start..].starts_with('/') {
|
|
return url.to_owned();
|
|
}
|
|
let authority_end = url[authority_start..]
|
|
.find(['/', '\\', '?', '#'])
|
|
.map_or(url.len(), |index| authority_start + index);
|
|
let authority = &url[authority_start..authority_end];
|
|
let host = authority.rsplit('@').next().unwrap_or_default();
|
|
if host[host.rfind(']').map_or(0, |index| index + 1)..].contains(':') {
|
|
return url.to_owned();
|
|
}
|
|
format!("{}:{port}{}", &url[..authority_end], &url[authority_end..])
|
|
}
|
|
|
|
pub fn try_normalize_public_endpoint_from_env(url: &str) -> anyhow::Result<String> {
|
|
let (base_domain, public_port) = resolve_public_domain_and_port(env_value)?;
|
|
Ok(normalize_public_endpoint(url, &base_domain, public_port))
|
|
}
|
|
|
|
pub fn normalize_public_endpoint_from_env(url: &str) -> String {
|
|
try_normalize_public_endpoint_from_env(url).unwrap_or_else(|error| panic!("{error}"))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parses_filesystem_geoip_source() {
|
|
let source = parse_geoip_source_config("/data/GeoLite2-City.mmdb", "test");
|
|
assert_eq!(
|
|
source,
|
|
GeoipSourceConfig::Filesystem {
|
|
maxmind_db_path: Some("/data/GeoLite2-City.mmdb".to_owned()),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_empty_geoip_source() {
|
|
let source = parse_geoip_source_config("", "test");
|
|
assert_eq!(
|
|
source,
|
|
GeoipSourceConfig::Filesystem {
|
|
maxmind_db_path: None,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_s3_geoip_source() {
|
|
let source = parse_geoip_source_config(
|
|
"s3://geoip/GeoLite2-City.mmdb?download_path=/tmp/city.mmdb",
|
|
"test_svc",
|
|
);
|
|
assert_eq!(
|
|
source,
|
|
GeoipSourceConfig::S3 {
|
|
maxmind_db_path: "/tmp/fluxer/geoip/test_svc/city.mmdb".to_owned(),
|
|
s3_bucket: "geoip".to_owned(),
|
|
s3_key: "GeoLite2-City.mmdb".to_owned(),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reads_geoip_s3_config_only_for_s3_source() {
|
|
let source = parse_geoip_source_config(
|
|
"s3://geoip/GeoLite2-City.mmdb?download_path=/tmp/city.mmdb",
|
|
"test_svc",
|
|
);
|
|
let config = read_geoip_s3_config(&source, |name| {
|
|
Some(
|
|
match name {
|
|
"FLUXER_S3_ENDPOINT" => "https://s3.example.test",
|
|
"FLUXER_S3_REGION" => "ewr1",
|
|
"FLUXER_S3_ACCESS_KEY_ID" => "access",
|
|
"FLUXER_S3_SECRET_ACCESS_KEY" => "secret",
|
|
_ => "",
|
|
}
|
|
.to_owned(),
|
|
)
|
|
})
|
|
.expect("s3 source should read s3 config");
|
|
|
|
assert_eq!(
|
|
config,
|
|
GeoipS3Config {
|
|
endpoint: "https://s3.example.test".to_owned(),
|
|
region: "ewr1".to_owned(),
|
|
access_key_id: "access".to_owned(),
|
|
secret_access_key: "secret".to_owned(),
|
|
}
|
|
);
|
|
|
|
let filesystem_source = parse_geoip_source_config("/tmp/city.mmdb", "test_svc");
|
|
assert!(read_geoip_s3_config(&filesystem_source, |_| None).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_base_path_strips_slashes() {
|
|
assert_eq!(normalize_base_path("/foo/bar/"), "/foo/bar");
|
|
assert_eq!(normalize_base_path("foo"), "/foo");
|
|
assert_eq!(normalize_base_path(""), "");
|
|
assert_eq!(normalize_base_path("/"), "");
|
|
}
|
|
|
|
#[test]
|
|
fn default_https_install_is_untouched() {
|
|
for url in [
|
|
"https://fluxer.example",
|
|
"https://fluxer.example/media",
|
|
"https://fluxer.example/admin/oauth2_callback",
|
|
"wss://fluxer.example/gateway",
|
|
] {
|
|
assert_eq!(
|
|
url,
|
|
normalize_public_endpoint(url, "fluxer.example", Some(443))
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn default_http_install_is_untouched() {
|
|
for url in [
|
|
"http://fluxer.example",
|
|
"http://fluxer.example/media",
|
|
"ws://fluxer.example/gateway",
|
|
] {
|
|
assert_eq!(
|
|
url,
|
|
normalize_public_endpoint(url, "fluxer.example", Some(80))
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn inserts_a_non_default_port_for_the_base_domain() {
|
|
assert_eq!(
|
|
"http://fluxer.example:19080/media",
|
|
normalize_public_endpoint("http://fluxer.example/media", "fluxer.example", Some(19080))
|
|
);
|
|
assert_eq!(
|
|
"http://fluxer.example:19080",
|
|
normalize_public_endpoint("http://fluxer.example", "fluxer.example", Some(19080))
|
|
);
|
|
assert_eq!(
|
|
"https://fluxer.example:8443/admin/oauth2_callback",
|
|
normalize_public_endpoint(
|
|
"https://fluxer.example/admin/oauth2_callback",
|
|
"fluxer.example",
|
|
Some(8443)
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_default_port_for_the_urls_own_scheme_is_never_inserted() {
|
|
assert_eq!(
|
|
"ws://fluxer.example/gateway",
|
|
normalize_public_endpoint("ws://fluxer.example/gateway", "fluxer.example", Some(80))
|
|
);
|
|
assert_eq!(
|
|
"wss://fluxer.example/gateway",
|
|
normalize_public_endpoint("wss://fluxer.example/gateway", "fluxer.example", Some(443))
|
|
);
|
|
assert_eq!(
|
|
"http://fluxer.example:443/media",
|
|
normalize_public_endpoint("http://fluxer.example/media", "fluxer.example", Some(443))
|
|
);
|
|
assert_eq!(
|
|
"https://fluxer.example:80/media",
|
|
normalize_public_endpoint("https://fluxer.example/media", "fluxer.example", Some(80))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn another_host_is_never_touched() {
|
|
for url in [
|
|
"https://cdn.example.net/assets",
|
|
"https://media.example.net",
|
|
"http://api:8080",
|
|
"http://media-proxy:8080",
|
|
] {
|
|
assert_eq!(
|
|
url,
|
|
normalize_public_endpoint(url, "fluxer.example", Some(19080))
|
|
);
|
|
}
|
|
assert_eq!(
|
|
"https://sub.fluxer.example/media",
|
|
normalize_public_endpoint(
|
|
"https://sub.fluxer.example/media",
|
|
"fluxer.example",
|
|
Some(19080)
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_explicit_port_is_never_touched() {
|
|
for url in [
|
|
"http://fluxer.example:19080/media",
|
|
"http://fluxer.example:8080/media",
|
|
"https://fluxer.example:443/media",
|
|
"http://fluxer.example:80/media",
|
|
"http://user:[email protected]:19080/media",
|
|
] {
|
|
assert_eq!(
|
|
url,
|
|
normalize_public_endpoint(url, "fluxer.example", Some(19080))
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn is_idempotent() {
|
|
let once =
|
|
normalize_public_endpoint("http://fluxer.example/media", "fluxer.example", Some(19080));
|
|
let twice = normalize_public_endpoint(&once, "fluxer.example", Some(19080));
|
|
assert_eq!("http://fluxer.example:19080/media", once);
|
|
assert_eq!(once, twice);
|
|
}
|
|
|
|
#[test]
|
|
fn an_unset_port_leaves_every_url_alone() {
|
|
assert_eq!(
|
|
"http://fluxer.example/media",
|
|
normalize_public_endpoint("http://fluxer.example/media", "fluxer.example", None)
|
|
);
|
|
assert_eq!(
|
|
"http://fluxer.example/media",
|
|
normalize_public_endpoint("http://fluxer.example/media", "fluxer.example", Some(0))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_base_domain_leaves_every_url_alone() {
|
|
assert_eq!(
|
|
"http://fluxer.example/media",
|
|
normalize_public_endpoint("http://fluxer.example/media", "", Some(19080))
|
|
);
|
|
assert_eq!(
|
|
"http://fluxer.example/media",
|
|
normalize_public_endpoint("http://fluxer.example/media", " ", Some(19080))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_url_that_does_not_parse_is_returned_unchanged() {
|
|
for url in ["", "/api", "fluxer.example/media", "not a url", "://x"] {
|
|
assert_eq!(
|
|
url,
|
|
normalize_public_endpoint(url, "fluxer.example", Some(19080))
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn matches_the_host_case_insensitively_and_ignores_a_trailing_dot() {
|
|
assert_eq!(
|
|
"http://FLUXER.example:19080/Media",
|
|
normalize_public_endpoint("http://FLUXER.example/Media", "Fluxer.Example", Some(19080))
|
|
);
|
|
assert_eq!(
|
|
"http://fluxer.example.:19080/media",
|
|
normalize_public_endpoint(
|
|
"http://fluxer.example./media",
|
|
"fluxer.example",
|
|
Some(19080)
|
|
)
|
|
);
|
|
assert_eq!(
|
|
"http://fluxer.example:19080/media",
|
|
normalize_public_endpoint(
|
|
"http://fluxer.example/media",
|
|
"fluxer.example.",
|
|
Some(19080)
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn preserves_path_query_fragment_and_trailing_slash() {
|
|
assert_eq!(
|
|
"http://fluxer.example:19080/",
|
|
normalize_public_endpoint("http://fluxer.example/", "fluxer.example", Some(19080))
|
|
);
|
|
assert_eq!(
|
|
"http://fluxer.example:19080?a=1",
|
|
normalize_public_endpoint("http://fluxer.example?a=1", "fluxer.example", Some(19080))
|
|
);
|
|
assert_eq!(
|
|
"http://fluxer.example:19080#top",
|
|
normalize_public_endpoint("http://fluxer.example#top", "fluxer.example", Some(19080))
|
|
);
|
|
assert_eq!(
|
|
"http://fluxer.example:19080/media/x.png?v=1#frag",
|
|
normalize_public_endpoint(
|
|
"http://fluxer.example/media/x.png?v=1#frag",
|
|
"fluxer.example",
|
|
Some(19080)
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn keeps_credentials_and_ipv6_literals_intact() {
|
|
assert_eq!(
|
|
"http://user:[email protected]:19080/media",
|
|
normalize_public_endpoint(
|
|
"http://user:[email protected]/media",
|
|
"fluxer.example",
|
|
Some(19080)
|
|
)
|
|
);
|
|
assert_eq!(
|
|
"http://[::1]:19080/media",
|
|
normalize_public_endpoint("http://[::1]/media", "[::1]", Some(19080))
|
|
);
|
|
assert_eq!(
|
|
"http://[::1]:8080/media",
|
|
normalize_public_endpoint("http://[::1]:8080/media", "[::1]", Some(19080))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn matches_the_typescript_normalizer_on_the_shared_vectors() {
|
|
let raw = include_str!("testdata/public_endpoint_vectors.json");
|
|
let vectors: serde_json::Value = serde_json::from_str(raw).expect("vectors parse as json");
|
|
let vectors = vectors.as_array().expect("vectors are an array");
|
|
assert!(!vectors.is_empty());
|
|
for vector in vectors {
|
|
let url = vector["url"].as_str().expect("vector has a url");
|
|
let base_domain = vector["base_domain"]
|
|
.as_str()
|
|
.expect("vector has a base domain");
|
|
let public_port = vector["public_port"].as_u64().map(|port| port as u16);
|
|
let expected = vector["normalized"]
|
|
.as_str()
|
|
.expect("vector has a normalized url");
|
|
assert_eq!(
|
|
expected,
|
|
normalize_public_endpoint(url, base_domain, public_port),
|
|
"vector {url} @ {base_domain} port {public_port:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
fn reader(vars: &[(&str, &str)]) -> impl FnMut(&str) -> Option<String> {
|
|
let vars: Vec<(String, String)> = vars
|
|
.iter()
|
|
.map(|(name, value)| ((*name).to_owned(), (*value).to_owned()))
|
|
.collect();
|
|
move |name: &str| {
|
|
vars.iter()
|
|
.find_map(|(key, value)| (key == name).then(|| value.clone()))
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parses_a_public_origin() {
|
|
assert_eq!(
|
|
Some(PublicOrigin {
|
|
scheme: "https".to_owned(),
|
|
domain: "chat.example.com".to_owned(),
|
|
port: 8443,
|
|
}),
|
|
parse_public_origin("https://chat.example.com:8443")
|
|
);
|
|
assert_eq!(
|
|
Some(PublicOrigin {
|
|
scheme: "http".to_owned(),
|
|
domain: "chat.example.com".to_owned(),
|
|
port: 80,
|
|
}),
|
|
parse_public_origin("http://chat.example.com")
|
|
);
|
|
assert_eq!(
|
|
Some(PublicOrigin {
|
|
scheme: "http".to_owned(),
|
|
domain: "[::1]".to_owned(),
|
|
port: 19080,
|
|
}),
|
|
parse_public_origin("http://[::1]:19080")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_explicit_default_port_normalizes_to_the_portless_form() {
|
|
let origin = parse_public_origin("https://chat.example.com:443").expect("origin parses");
|
|
assert_eq!(443, origin.port);
|
|
assert_eq!(
|
|
"https://chat.example.com/admin/oauth2_callback",
|
|
normalize_public_endpoint(
|
|
"https://chat.example.com/admin/oauth2_callback",
|
|
&origin.domain,
|
|
Some(origin.port)
|
|
)
|
|
);
|
|
assert_eq!(
|
|
80,
|
|
parse_public_origin("http://chat.example.com:80")
|
|
.expect("origin parses")
|
|
.port
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_anything_that_is_not_a_bare_origin() {
|
|
for origin in [
|
|
"",
|
|
" ",
|
|
"not a url",
|
|
"chat.example.com:8443",
|
|
"wss://chat.example.com",
|
|
"https://chat.example.com/media",
|
|
"https://chat.example.com?a=1",
|
|
"https://user:[email protected]",
|
|
] {
|
|
assert_eq!(None, parse_public_origin(origin), "origin {origin}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_origin_supplies_the_domain_and_the_port() {
|
|
let (domain, port) = resolve_public_domain_and_port(reader(&[(
|
|
"FLUXER_PUBLIC_ORIGIN",
|
|
"https://chat.example.com:29080",
|
|
)]))
|
|
.expect("origin resolves");
|
|
assert_eq!("chat.example.com", domain);
|
|
assert_eq!(Some(29080), port);
|
|
}
|
|
|
|
#[test]
|
|
fn the_origin_wins_over_the_named_variables() {
|
|
let (domain, port) = resolve_public_domain_and_port(reader(&[
|
|
("FLUXER_PUBLIC_ORIGIN", "https://chat.example.com:29080"),
|
|
("FLUXER_BASE_DOMAIN", "other.example.com"),
|
|
("FLUXER_PUBLIC_SCHEME", "http"),
|
|
("FLUXER_PUBLIC_PORT", "443"),
|
|
]))
|
|
.expect("the origin resolves");
|
|
assert_eq!("chat.example.com", domain);
|
|
assert_eq!(Some(29080), port);
|
|
}
|
|
|
|
#[test]
|
|
fn a_malformed_public_port_is_loud() {
|
|
for raw in ["abc", "0", "70000", "-1"] {
|
|
let error = resolve_public_domain_and_port(reader(&[
|
|
("FLUXER_BASE_DOMAIN", "chat.example.com"),
|
|
("FLUXER_PUBLIC_PORT", raw),
|
|
]))
|
|
.expect_err("a malformed port is refused");
|
|
assert!(
|
|
error.to_string().contains("FLUXER_PUBLIC_PORT"),
|
|
"port {raw}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_malformed_public_origin_is_loud() {
|
|
let error = resolve_public_domain_and_port(reader(&[(
|
|
"FLUXER_PUBLIC_ORIGIN",
|
|
"https://chat.example.com/app",
|
|
)]))
|
|
.expect_err("a malformed origin is refused");
|
|
assert!(error.to_string().contains("FLUXER_PUBLIC_ORIGIN"));
|
|
}
|
|
|
|
#[test]
|
|
fn without_an_origin_the_named_variables_are_used_as_they_are() {
|
|
let (domain, port) = resolve_public_domain_and_port(reader(&[
|
|
("FLUXER_BASE_DOMAIN", "Chat.Example.com."),
|
|
("FLUXER_PUBLIC_PORT", "19080"),
|
|
]))
|
|
.expect("named variables resolve");
|
|
assert_eq!("chat.example.com", domain);
|
|
assert_eq!(Some(19080), port);
|
|
let (domain, port) = resolve_public_domain_and_port(reader(&[])).expect("empty resolves");
|
|
assert_eq!("", domain);
|
|
assert_eq!(None, port);
|
|
}
|
|
|
|
fn secret_file(dir: &tempfile::TempDir, name: &str, contents: &str) -> String {
|
|
let path = dir.path().join(name);
|
|
fs::write(&path, contents).expect("write secret file");
|
|
path.to_string_lossy().into_owned()
|
|
}
|
|
|
|
fn pairs_reader(pairs: Vec<(String, String)>) -> impl Fn(&str) -> Option<String> {
|
|
move |key| {
|
|
pairs
|
|
.iter()
|
|
.find_map(|(name, value)| (name == key).then(|| value.clone()))
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn env_value_reads_name_file_when_name_is_blank() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let path = secret_file(&dir, "secret", "from-file\r\n");
|
|
let unset = pairs_reader(vec![("X_FILE".to_owned(), path.clone())]);
|
|
let blank = pairs_reader(vec![
|
|
("X".to_owned(), " ".to_owned()),
|
|
("X_FILE".to_owned(), path),
|
|
]);
|
|
assert_eq!(
|
|
Ok(Some("from-file".to_owned())),
|
|
resolve_env_value("X", unset)
|
|
);
|
|
assert_eq!(
|
|
Ok(Some("from-file".to_owned())),
|
|
resolve_env_value("X", blank)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn env_value_trims_only_one_trailing_newline() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let pem = secret_file(&dir, "pem", "-----BEGIN-----\nabc\n-----END-----\n\n");
|
|
let empty = secret_file(&dir, "empty", "\n");
|
|
assert_eq!(
|
|
Ok(Some("-----BEGIN-----\nabc\n-----END-----\n".to_owned())),
|
|
resolve_env_value("X", pairs_reader(vec![("X_FILE".to_owned(), pem)]))
|
|
);
|
|
assert_eq!(
|
|
Ok(None),
|
|
resolve_env_value("X", pairs_reader(vec![("X_FILE".to_owned(), empty)]))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn env_value_rejects_name_and_name_file_together() {
|
|
let reader = pairs_reader(vec![
|
|
("X".to_owned(), "direct".to_owned()),
|
|
("X_FILE".to_owned(), "/run/secrets/x".to_owned()),
|
|
]);
|
|
assert_eq!(
|
|
Err("X and X_FILE are both set, set only one".to_owned()),
|
|
resolve_env_value("X", reader)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn env_value_names_the_file_when_it_is_missing() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let path = dir.path().join("missing").to_string_lossy().into_owned();
|
|
let error = resolve_env_value("X", pairs_reader(vec![("X_FILE".to_owned(), path.clone())]))
|
|
.expect_err("missing file fails");
|
|
assert!(error.starts_with(&format!("X_FILE could not read {path} (")));
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_env_files_replaces_fluxer_names_with_file_values() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let path = secret_file(&dir, "secret", "from-file\n");
|
|
let missing = dir.path().join("missing").to_string_lossy().into_owned();
|
|
let resolved = resolve_env_files(vec![
|
|
("FLUXER_X".to_owned(), String::new()),
|
|
("FLUXER_X_FILE".to_owned(), path),
|
|
("FLUXER_Y".to_owned(), "plain".to_owned()),
|
|
("FLUXER_Y_FILE".to_owned(), String::new()),
|
|
("SSL_CERT_FILE".to_owned(), missing),
|
|
])
|
|
.expect("resolves");
|
|
let values = |key: &str| {
|
|
resolved
|
|
.iter()
|
|
.filter(|(name, _)| name == key)
|
|
.map(|(_, value)| value.as_str())
|
|
.collect::<Vec<_>>()
|
|
};
|
|
assert_eq!(vec!["from-file"], values("FLUXER_X"));
|
|
assert_eq!(vec!["plain"], values("FLUXER_Y"));
|
|
assert!(
|
|
resolve_env_files(vec![
|
|
("FLUXER_X".to_owned(), "direct".to_owned()),
|
|
("FLUXER_X_FILE".to_owned(), "/run/secrets/x".to_owned()),
|
|
])
|
|
.is_err()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_blank_value_reads_as_unset() {
|
|
unsafe {
|
|
env::set_var("FLUXER_COMMON_TEST_BLANK_EMPTY", "");
|
|
env::set_var("FLUXER_COMMON_TEST_BLANK_SPACES", " \t");
|
|
env::set_var("FLUXER_COMMON_TEST_BLANK_SET", "value");
|
|
env::set_var("FLUXER_COMMON_TEST_BLANK_TRUE", "true");
|
|
}
|
|
assert_eq!(None, env_value("FLUXER_COMMON_TEST_BLANK_EMPTY"));
|
|
assert_eq!(None, env_value("FLUXER_COMMON_TEST_BLANK_SPACES"));
|
|
assert_eq!(None, env_value("FLUXER_COMMON_TEST_BLANK_MISSING"));
|
|
assert_eq!(
|
|
"fallback",
|
|
read_env("FLUXER_COMMON_TEST_BLANK_EMPTY", "fallback")
|
|
);
|
|
assert_eq!(
|
|
"fallback",
|
|
read_env("FLUXER_COMMON_TEST_BLANK_SPACES", "fallback")
|
|
);
|
|
assert_eq!(
|
|
"value",
|
|
read_env("FLUXER_COMMON_TEST_BLANK_SET", "fallback")
|
|
);
|
|
assert_eq!(
|
|
"value",
|
|
read_first_env(
|
|
&[
|
|
"FLUXER_COMMON_TEST_BLANK_EMPTY",
|
|
"FLUXER_COMMON_TEST_BLANK_SPACES",
|
|
"FLUXER_COMMON_TEST_BLANK_SET",
|
|
],
|
|
"fallback"
|
|
)
|
|
);
|
|
assert!(read_bool_env("FLUXER_COMMON_TEST_BLANK_EMPTY", true));
|
|
assert!(read_bool_env("FLUXER_COMMON_TEST_BLANK_SPACES", true));
|
|
assert!(read_bool_env("FLUXER_COMMON_TEST_BLANK_TRUE", false));
|
|
}
|
|
|
|
#[test]
|
|
fn trim_trailing_slash_works() {
|
|
assert_eq!(
|
|
trim_trailing_slash("https://example.com/"),
|
|
"https://example.com"
|
|
);
|
|
assert_eq!(trim_trailing_slash(""), "");
|
|
}
|
|
}
|