Files
fluxer/fluxer_common/src/config.rs
T

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(""), "");
}
}