use std::path::PathBuf;
use crate::core::error::{OlError, ERR_PROXY_CONFIG_INVALID};
use super::no_proxy::NoProxyMatcher;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ProxyMode {
#[default]
Auto,
Manual,
Direct,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ProxyAuth {
#[default]
Auto,
None,
Basic,
Negotiate,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ProxySource {
Manual,
Env,
Windows,
Macos,
Gnome,
Pac,
Wpad,
}
impl ProxySource {
pub fn as_str(self) -> &'static str {
match self {
Self::Manual => "manual",
Self::Env => "env",
Self::Windows => "windows",
Self::Macos => "macos",
Self::Gnome => "gnome",
Self::Pac => "pac",
Self::Wpad => "wpad",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EgressWarning {
EnvCaseMismatch {
lower: String,
upper: String,
},
UnsupportedNoProxyEntry(String),
}
impl std::fmt::Display for EgressWarning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::EnvCaseMismatch { lower, upper } => {
write!(f, "{lower} and {upper} disagree; {lower} wins")
}
Self::UnsupportedNoProxyEntry(entry) => write!(
f,
"no_proxy entry \"{entry}\" could not be parsed and bypasses nothing"
),
}
}
}
#[derive(Debug, Default, Clone, serde::Deserialize)]
pub struct ProxyToml {
pub mode: Option<String>,
pub url: Option<String>,
pub username: Option<String>,
pub auth: Option<String>,
pub no_proxy: Option<String>,
pub pac_url: Option<String>,
pub ca_bundle: Option<String>,
pub allow_direct: Option<bool>,
pub source: Option<String>,
pub spn: Option<String>,
pub http1_only: Option<bool>,
}
pub const PROXY_TOML_KEYS: &[&str] = &[
"mode",
"url",
"username",
"auth",
"no_proxy",
"pac_url",
"ca_bundle",
"allow_direct",
"source",
"spn",
"http1_only",
];
pub trait EnvSource {
fn var(&self, key: &str) -> Option<String>;
}
pub struct ProcessEnv;
impl EnvSource for ProcessEnv {
fn var(&self, key: &str) -> Option<String> {
std::env::var(key).ok().filter(|v| !v.is_empty())
}
}
#[derive(Clone)]
pub struct EgressConfig {
pub mode: ProxyMode,
pub url: Option<String>,
pub username: Option<String>,
pub env_password: Option<String>,
pub resolved_password: Option<String>,
pub auth: ProxyAuth,
pub no_proxy: NoProxyMatcher,
pub pac_url: Option<String>,
pub ca_bundle: Option<PathBuf>,
pub allow_direct: bool,
pub source: Option<ProxySource>,
pub spn: Option<String>,
pub http1_only: bool,
pub warnings: Vec<EgressWarning>,
pub resolved: Option<super::resolve::ResolvedAuth>,
}
impl std::fmt::Debug for EgressConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
struct Redacted<'a>(&'a Option<String>);
impl std::fmt::Debug for Redacted<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.0 {
Some(_) => f.write_str("Some(<redacted>)"),
None => f.write_str("None"),
}
}
}
f.debug_struct("EgressConfig")
.field("mode", &self.mode)
.field("url", &self.masked_url())
.field("username", &self.username)
.field("env_password", &Redacted(&self.env_password))
.field("resolved_password", &Redacted(&self.resolved_password))
.field("auth", &self.auth)
.field("no_proxy", &self.no_proxy)
.field("pac_url", &self.pac_url)
.field("ca_bundle", &self.ca_bundle)
.field("allow_direct", &self.allow_direct)
.field("source", &self.source)
.field("spn", &self.spn)
.field("http1_only", &self.http1_only)
.field("warnings", &self.warnings)
.field("resolved", &self.resolved)
.finish()
}
}
impl Default for EgressConfig {
fn default() -> Self {
Self::direct()
}
}
impl EgressConfig {
pub fn direct() -> Self {
Self {
mode: ProxyMode::Direct,
url: None,
username: None,
env_password: None,
resolved_password: None,
auth: ProxyAuth::None,
no_proxy: NoProxyMatcher::new("").0,
pac_url: None,
ca_bundle: None,
allow_direct: true,
source: None,
spn: None,
http1_only: false,
warnings: Vec::new(),
resolved: None,
}
}
pub fn has_proxy(&self) -> bool {
self.mode != ProxyMode::Direct && self.url.is_some()
}
pub fn proxy_password(&self) -> Option<&str> {
self.resolved_password
.as_deref()
.or(self.env_password.as_deref())
}
pub fn masked_url(&self) -> Option<String> {
self.url.as_deref().map(super::credentials::mask_userinfo)
}
pub fn proxy_authority(&self) -> Option<String> {
self.url
.as_deref()
.and_then(super::credentials::authority_key)
}
pub fn with_candidate(&self, candidate: &super::ProxyCandidate) -> Self {
Self {
mode: ProxyMode::Auto,
url: Some(candidate.url.clone()),
source: Some(candidate.source),
..self.clone()
}
}
pub fn resolve(
toml: Option<&ProxyToml>,
env: &dyn EnvSource,
daemon_port: u16,
boundary_port: u16,
) -> Result<Self, OlError> {
let mut warnings = Vec::new();
let mode = pick(
env.var("OPENLATCH_PROXY_MODE"),
toml.and_then(|t| t.mode.clone()),
);
let mode = match mode.as_deref() {
None => ProxyMode::Auto,
Some("auto") => ProxyMode::Auto,
Some("manual") => ProxyMode::Manual,
Some("direct") => ProxyMode::Direct,
Some(other) => return Err(invalid("mode", other, "auto, manual or direct")),
};
let auth = pick(
env.var("OPENLATCH_PROXY_AUTH"),
toml.and_then(|t| t.auth.clone()),
);
let auth = match auth.as_deref() {
None => ProxyAuth::Auto,
Some("auto") => ProxyAuth::Auto,
Some("none") => ProxyAuth::None,
Some("basic") => ProxyAuth::Basic,
Some("negotiate") => ProxyAuth::Negotiate,
Some(other) => {
return Err(invalid(
"auth",
other,
"auto, none, basic or negotiate (NTLM is not supported)",
))
}
};
let source = match toml.and_then(|t| t.source.clone()).as_deref() {
None => None,
Some("manual") => Some(ProxySource::Manual),
Some("env") => Some(ProxySource::Env),
Some("windows") => Some(ProxySource::Windows),
Some("macos") => Some(ProxySource::Macos),
Some("gnome") => Some(ProxySource::Gnome),
Some("pac") => Some(ProxySource::Pac),
Some("wpad") => Some(ProxySource::Wpad),
Some(other) => {
return Err(invalid(
"source",
other,
"manual, env, windows, macos, gnome, pac or wpad",
))
}
};
let allow_direct = toml.and_then(|t| t.allow_direct).unwrap_or(true);
if mode == ProxyMode::Direct && !allow_direct {
return Err(OlError::new(
ERR_PROXY_CONFIG_INVALID,
"[proxy] mode = \"direct\" contradicts allow_direct = false",
)
.with_suggestion(
"Set a proxy url and mode = \"manual\", or set allow_direct = true.",
));
}
let (env_url, env_password) = match env.var("OPENLATCH_PROXY") {
Some(raw) => {
let (clean, user, pass) = split_userinfo(&raw)?;
(Some((clean, user)), pass)
}
None => (None, None),
};
let toml_url = match toml.and_then(|t| t.url.clone()).filter(|u| !u.is_empty()) {
Some(raw) => {
if has_userinfo(&raw) {
return Err(OlError::new(
ERR_PROXY_CONFIG_INVALID,
"[proxy] url must not contain a username or password",
)
.with_suggestion(
"Credentials belong in OPENLATCH_PROXY or the init prompt, which \
store them in the OS credential store. config.toml is plaintext \
on disk.",
));
}
Some(raw)
}
None => None,
};
let (ambient_url, ambient_warning) = ambient_proxy(env);
if let Some(w) = ambient_warning {
warnings.push(w);
}
let (url, username) = match (env_url, toml_url, ambient_url) {
(Some((u, user)), _, _) => (
Some(u),
user.or_else(|| toml.and_then(|t| t.username.clone())),
),
(None, Some(u), _) => (Some(u), toml.and_then(|t| t.username.clone())),
(None, None, Some(u)) => (Some(u), None),
(None, None, None) => (None, toml.and_then(|t| t.username.clone())),
};
let url = match url.filter(|u| !u.is_empty()) {
Some(raw) => Some(validate_url(&raw, daemon_port, boundary_port)?),
None => None,
};
let no_proxy_raw = pick(
env.var("OPENLATCH_NO_PROXY"),
toml.and_then(|t| t.no_proxy.clone()),
)
.or_else(|| ambient_no_proxy(env))
.unwrap_or_default();
let (no_proxy, unsupported) = NoProxyMatcher::new(&no_proxy_raw);
warnings.extend(
unsupported
.into_iter()
.map(EgressWarning::UnsupportedNoProxyEntry),
);
if !allow_direct && no_proxy.has_non_loopback_entry() {
return Err(OlError::new(
ERR_PROXY_CONFIG_INVALID,
"[proxy] no_proxy has a non-loopback entry while allow_direct = false",
)
.with_suggestion(
"allow_direct = false means no traffic may bypass the proxy. Remove the \
no_proxy entries, or set allow_direct = true. Loopback always bypasses \
and needs no entry.",
));
}
let ca_bundle = match pick(
env.var("OPENLATCH_CA_BUNDLE"),
toml.and_then(|t| t.ca_bundle.clone()),
)
.filter(|p| !p.is_empty())
{
Some(p) => Some(validate_ca_bundle(&p)?),
None => None,
};
Ok(Self {
mode,
url,
username,
env_password,
resolved_password: None,
auth,
no_proxy,
pac_url: pick(
env.var("OPENLATCH_PROXY_PAC_URL"),
toml.and_then(|t| t.pac_url.clone()),
)
.filter(|p| !p.is_empty()),
ca_bundle,
allow_direct,
source,
spn: pick(
env.var("OPENLATCH_PROXY_SPN"),
toml.and_then(|t| t.spn.clone()),
)
.filter(|s| !s.is_empty()),
http1_only: toml.and_then(|t| t.http1_only).unwrap_or(false),
warnings,
resolved: None,
})
}
}
fn pick(tier2: Option<String>, tier3: Option<String>) -> Option<String> {
tier2.filter(|v| !v.is_empty()).or(tier3)
}
fn invalid(key: &str, got: &str, expected: &str) -> OlError {
OlError::new(
ERR_PROXY_CONFIG_INVALID,
format!("[proxy] {key} = \"{got}\" is not a valid value"),
)
.with_suggestion(format!("Expected one of: {expected}."))
}
fn has_userinfo(url: &str) -> bool {
match url.split_once("://") {
Some((_, rest)) => rest.split('/').next().is_some_and(|a| a.contains('@')),
None => false,
}
}
fn split_userinfo(url: &str) -> Result<(String, Option<String>, Option<String>), OlError> {
let Some((scheme, rest)) = url.split_once("://") else {
return Err(invalid_url(url, "no scheme"));
};
let (authority, path) = match rest.split_once('/') {
Some((a, p)) => (a, Some(p)),
None => (rest, None),
};
let Some((userinfo, host)) = authority.rsplit_once('@') else {
return Ok((url.to_string(), None, None));
};
let (user, pass) = match userinfo.split_once(':') {
Some((u, p)) => (u.to_string(), Some(percent_decode(p))),
None => (userinfo.to_string(), None),
};
let clean = match path {
Some(p) => format!("{scheme}://{host}/{p}"),
None => format!("{scheme}://{host}"),
};
Ok((clean, Some(percent_decode(&user)), pass))
}
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hi = (bytes[i + 1] as char).to_digit(16);
let lo = (bytes[i + 2] as char).to_digit(16);
if let (Some(hi), Some(lo)) = (hi, lo) {
out.push((hi * 16 + lo) as u8);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
fn invalid_url(url: &str, why: &str) -> OlError {
let url = super::credentials::mask_userinfo(url);
OlError::new(
ERR_PROXY_CONFIG_INVALID,
format!("proxy url \"{url}\" is not usable: {why}"),
)
.with_suggestion(
"Use http://host:port, https://host:port, socks5://host:port or \
socks5h://host:port.",
)
}
fn validate_url(raw: &str, daemon_port: u16, boundary_port: u16) -> Result<String, OlError> {
let Some((scheme, rest)) = raw.split_once("://") else {
return Err(invalid_url(raw, "no scheme"));
};
if !matches!(scheme, "http" | "https" | "socks5" | "socks5h") {
return Err(invalid_url(
raw,
&format!("unsupported scheme \"{scheme}\""),
));
}
let authority = rest.split('/').next().unwrap_or(rest);
if authority.is_empty() {
return Err(invalid_url(raw, "no host"));
}
let (host, port) = split_host_port(authority);
let is_loopback = matches!(host, "127.0.0.1" | "localhost" | "::1" | "[::1]");
if is_loopback && port.is_some_and(|p| p == daemon_port || p == boundary_port) {
return Err(OlError::new(
ERR_PROXY_CONFIG_INVALID,
format!(
"proxy url \"{}\" points at this client's own listener",
super::credentials::mask_userinfo(raw)
),
)
.with_suggestion(
"The daemon and boundary ports cannot also be the proxy — that would forward \
traffic to ourselves. Point [proxy] url at the corporate proxy instead.",
));
}
Ok(raw.to_string())
}
fn split_host_port(authority: &str) -> (&str, Option<u16>) {
if let Some(rest) = authority.strip_prefix('[') {
if let Some((host, tail)) = rest.split_once(']') {
let port = tail.strip_prefix(':').and_then(|p| p.parse().ok());
return (host, port);
}
}
match authority.rsplit_once(':') {
Some((h, p)) => (h, p.parse().ok()),
None => (authority, None),
}
}
fn validate_ca_bundle(path: &str) -> Result<PathBuf, OlError> {
let p = PathBuf::from(path);
let bytes = std::fs::read(&p).map_err(|e| {
OlError::new(
ERR_PROXY_CONFIG_INVALID,
format!("[proxy] ca_bundle \"{path}\" cannot be read: {e}"),
)
.with_suggestion("Point ca_bundle at a readable PEM file, or remove the key.")
})?;
let text = String::from_utf8_lossy(&bytes);
if !text.contains("-----BEGIN CERTIFICATE-----") {
return Err(OlError::new(
ERR_PROXY_CONFIG_INVALID,
format!("[proxy] ca_bundle \"{path}\" contains no PEM certificate"),
)
.with_suggestion(
"The file must be PEM, not DER. Convert with: \
openssl x509 -inform der -in cert.der -out cert.pem",
));
}
Ok(p)
}
fn ambient_proxy(env: &dyn EnvSource) -> (Option<String>, Option<EgressWarning>) {
for (lower, upper) in [
("https_proxy", "HTTPS_PROXY"),
("http_proxy", "HTTP_PROXY"),
("all_proxy", "ALL_PROXY"),
] {
let lo = env.var(lower);
let up = env.var(upper);
match (lo, up) {
(Some(l), Some(u)) => {
let warning = if cfg!(unix) && l != u {
Some(EgressWarning::EnvCaseMismatch {
lower: lower.to_string(),
upper: upper.to_string(),
})
} else {
None
};
return (Some(l), warning);
}
(Some(l), None) => return (Some(l), None),
(None, Some(u)) => return (Some(u), None),
(None, None) => continue,
}
}
(None, None)
}
fn ambient_no_proxy(env: &dyn EnvSource) -> Option<String> {
env.var("no_proxy").or_else(|| env.var("NO_PROXY"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[derive(Default)]
struct MapEnv(HashMap<String, String>);
impl MapEnv {
fn with(pairs: &[(&str, &str)]) -> Self {
Self(
pairs
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect(),
)
}
}
impl EnvSource for MapEnv {
fn var(&self, key: &str) -> Option<String> {
self.0.get(key).cloned().filter(|v| !v.is_empty())
}
}
fn resolve(toml: Option<&ProxyToml>, env: &dyn EnvSource) -> Result<EgressConfig, OlError> {
EgressConfig::resolve(toml, env, 7443, 7444)
}
#[test]
fn nothing_configured_is_direct_with_no_proxy() {
let cfg = resolve(None, &MapEnv::default()).expect("resolve");
assert!(cfg.url.is_none());
assert!(cfg.allow_direct);
assert_eq!(cfg.mode, ProxyMode::Auto);
}
#[test]
fn openlatch_env_beats_config_per_key() {
let toml = ProxyToml {
mode: Some("manual".into()),
url: Some("http://from-config:3128".into()),
..Default::default()
};
let env = MapEnv::with(&[("OPENLATCH_PROXY", "http://from-env:8080")]);
let cfg = resolve(Some(&toml), &env).expect("resolve");
assert_eq!(cfg.url.as_deref(), Some("http://from-env:8080"));
assert_eq!(cfg.mode, ProxyMode::Manual);
}
#[test]
fn config_beats_ambient_env() {
let toml = ProxyToml {
url: Some("http://from-config:3128".into()),
..Default::default()
};
let env = MapEnv::with(&[("https_proxy", "http://ambient:8080")]);
let cfg = resolve(Some(&toml), &env).expect("resolve");
assert_eq!(cfg.url.as_deref(), Some("http://from-config:3128"));
}
#[test]
fn ambient_lowercase_wins_over_uppercase() {
let env = MapEnv::with(&[
("https_proxy", "http://lower:8080"),
("HTTPS_PROXY", "http://upper:8080"),
]);
let cfg = resolve(None, &env).expect("resolve");
assert_eq!(cfg.url.as_deref(), Some("http://lower:8080"));
if cfg!(unix) {
assert!(cfg.warnings.iter().any(|w| matches!(
w,
EgressWarning::EnvCaseMismatch { lower, .. } if lower == "https_proxy"
)));
}
}
#[test]
fn agreeing_case_pair_produces_no_warning() {
let env = MapEnv::with(&[
("https_proxy", "http://same:8080"),
("HTTPS_PROXY", "http://same:8080"),
]);
let cfg = resolve(None, &env).expect("resolve");
assert!(cfg.warnings.is_empty());
}
#[test]
fn env_userinfo_is_lifted_out_of_the_url() {
let env = MapEnv::with(&[("OPENLATCH_PROXY", "http://alice:s3cr3t@proxy:8080")]);
let cfg = resolve(None, &env).expect("resolve");
assert_eq!(cfg.url.as_deref(), Some("http://proxy:8080"));
assert_eq!(cfg.username.as_deref(), Some("alice"));
assert_eq!(cfg.env_password.as_deref(), Some("s3cr3t"));
}
#[test]
fn env_userinfo_is_percent_decoded() {
let env = MapEnv::with(&[("OPENLATCH_PROXY", "http://dom%5Calice:p%40ss@proxy:8080")]);
let cfg = resolve(None, &env).expect("resolve");
assert_eq!(cfg.username.as_deref(), Some("dom\\alice"));
assert_eq!(cfg.env_password.as_deref(), Some("p@ss"));
}
#[test]
fn config_userinfo_is_rejected() {
let toml = ProxyToml {
url: Some("http://alice:s3cr3t@proxy:8080".into()),
..Default::default()
};
let err = resolve(Some(&toml), &MapEnv::default()).expect_err("must reject");
assert_eq!(err.code, ERR_PROXY_CONFIG_INVALID);
assert!(err
.suggestion
.unwrap_or_default()
.contains("OPENLATCH_PROXY"));
}
#[test]
fn unsupported_scheme_is_rejected() {
let toml = ProxyToml {
url: Some("ftp://proxy:21".into()),
..Default::default()
};
let err = resolve(Some(&toml), &MapEnv::default()).expect_err("must reject");
assert_eq!(err.code, ERR_PROXY_CONFIG_INVALID);
}
#[test]
fn direct_mode_with_allow_direct_false_is_a_contradiction() {
let toml = ProxyToml {
mode: Some("direct".into()),
allow_direct: Some(false),
..Default::default()
};
let err = resolve(Some(&toml), &MapEnv::default()).expect_err("must reject");
assert_eq!(err.code, ERR_PROXY_CONFIG_INVALID);
}
#[test]
fn our_own_listener_cannot_be_the_proxy() {
for port in [7443u16, 7444] {
let toml = ProxyToml {
url: Some(format!("http://127.0.0.1:{port}")),
..Default::default()
};
let err = resolve(Some(&toml), &MapEnv::default()).expect_err("must reject");
assert_eq!(err.code, ERR_PROXY_CONFIG_INVALID);
}
}
#[test]
fn another_loopback_port_is_a_legal_proxy() {
let toml = ProxyToml {
url: Some("http://127.0.0.1:9999".into()),
..Default::default()
};
let cfg = resolve(Some(&toml), &MapEnv::default()).expect("resolve");
assert_eq!(cfg.url.as_deref(), Some("http://127.0.0.1:9999"));
}
#[test]
fn no_proxy_with_allow_direct_false_is_rejected() {
let toml = ProxyToml {
url: Some("http://proxy:8080".into()),
no_proxy: Some("internal.example".into()),
allow_direct: Some(false),
..Default::default()
};
let err = resolve(Some(&toml), &MapEnv::default()).expect_err("must reject");
assert_eq!(err.code, ERR_PROXY_CONFIG_INVALID);
}
#[test]
fn loopback_only_no_proxy_survives_allow_direct_false() {
let toml = ProxyToml {
url: Some("http://proxy:8080".into()),
allow_direct: Some(false),
..Default::default()
};
let cfg = resolve(Some(&toml), &MapEnv::default()).expect("resolve");
assert!(!cfg.allow_direct);
assert!(cfg.no_proxy.matches("127.0.0.1", 1234));
}
#[test]
fn missing_ca_bundle_fails_at_parse() {
let toml = ProxyToml {
ca_bundle: Some("/definitely/not/here.pem".into()),
..Default::default()
};
let err = resolve(Some(&toml), &MapEnv::default()).expect_err("must reject");
assert_eq!(err.code, ERR_PROXY_CONFIG_INVALID);
}
#[test]
fn malformed_ca_bundle_fails_at_parse() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("bad.pem");
std::fs::write(&path, b"this is not a certificate").expect("write");
let toml = ProxyToml {
ca_bundle: Some(path.to_string_lossy().into_owned()),
..Default::default()
};
let err = resolve(Some(&toml), &MapEnv::default()).expect_err("must reject");
assert_eq!(err.code, ERR_PROXY_CONFIG_INVALID);
assert!(err.message.contains("no PEM certificate"));
}
#[test]
fn bad_enum_values_name_the_key_and_the_alternatives() {
for (toml, key) in [
(
ProxyToml {
mode: Some("sometimes".into()),
..Default::default()
},
"mode",
),
(
ProxyToml {
auth: Some("ntlm".into()),
..Default::default()
},
"auth",
),
(
ProxyToml {
source: Some("telepathy".into()),
..Default::default()
},
"source",
),
] {
let err = resolve(Some(&toml), &MapEnv::default()).expect_err("must reject");
assert_eq!(err.code, ERR_PROXY_CONFIG_INVALID);
assert!(err.message.contains(key), "message must name {key}");
}
}
#[test]
fn ntlm_is_refused_by_name() {
let toml = ProxyToml {
auth: Some("ntlm".into()),
..Default::default()
};
let err = resolve(Some(&toml), &MapEnv::default()).expect_err("must reject");
assert!(err.suggestion.unwrap_or_default().contains("NTLM"));
}
#[test]
fn debug_output_never_renders_a_password() {
let env = MapEnv::with(&[("OPENLATCH_PROXY", "http://alice:hunter2@proxy.corp:8080")]);
let mut cfg = resolve(None, &env).expect("resolve");
cfg.resolved_password = Some("from-the-keychain".to_string());
let rendered = format!("{cfg:?}");
assert!(
!rendered.contains("hunter2"),
"env_password leaked into Debug: {rendered}"
);
assert!(
!rendered.contains("from-the-keychain"),
"resolved_password leaked into Debug: {rendered}"
);
assert!(
rendered.contains("alice"),
"expected the username: {rendered}"
);
assert!(rendered.contains("proxy.corp:8080"));
}
#[test]
fn a_malformed_env_url_is_reported_without_its_password() {
let env = MapEnv::with(&[("OPENLATCH_PROXY", "alice:hunter2@proxy.corp:8080")]);
let err = resolve(None, &env).expect_err("no scheme must be refused");
assert_eq!(err.code, ERR_PROXY_CONFIG_INVALID);
let rendered = format!("{err:?} {} {:?}", err.message, err.suggestion);
assert!(
!rendered.contains("hunter2"),
"the password leaked into the parse error: {rendered}"
);
}
#[test]
fn the_resolved_password_beats_the_env_one() {
let env = MapEnv::with(&[("OPENLATCH_PROXY", "http://alice:from-env@proxy.corp:8080")]);
let mut cfg = resolve(None, &env).expect("resolve");
assert_eq!(cfg.proxy_password(), Some("from-env"));
cfg.resolved_password = Some("from-the-ladder".to_string());
assert_eq!(cfg.proxy_password(), Some("from-the-ladder"));
}
#[test]
fn the_proxy_authority_is_the_credential_key() {
let toml = ProxyToml {
url: Some("http://Proxy.Corp:8080".into()),
..Default::default()
};
let cfg = resolve(Some(&toml), &MapEnv::default()).expect("resolve");
assert_eq!(cfg.proxy_authority().as_deref(), Some("proxy.corp:8080"));
assert_eq!(cfg.masked_url().as_deref(), Some("http://Proxy.Corp:8080"));
}
}