// 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, }, S3 { maxmind_db_path: String, s3_bucket: String, s3_key: String, }, } impl GeoipSourceConfig { pub fn maxmind_db_path(&self) -> Option { 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 { read_geoip_s3_config(source, env_value) } fn read_geoip_s3_config(source: &GeoipSourceConfig, mut read_var: F) -> Option where F: FnMut(&str) -> Option, { 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 { resolve_env_value(name, |key| env::var(key).ok()).unwrap_or_else(|error| panic!("{error}")) } pub fn resolve_env_value(name: &str, get: F) -> Result, String> where F: Fn(&str) -> Option, { let non_blank = |value: Option| 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(vars: I) -> Result, String> where I: IntoIterator, { 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 { 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(mut read_var: F) -> anyhow::Result<(String, Option)> where F: FnMut(&str) -> Option, { 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::() .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) -> 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 { 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:pass@fluxer.example: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:pass@fluxer.example:19080/media", normalize_public_endpoint( "http://user:pass@fluxer.example/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 { 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:pw@chat.example.com", ] { 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 { 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::>() }; 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(""), ""); } }