use zeroize::Zeroizing;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ConfigTier {
Project,
User,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EnvVarSyntax {
Shell,
Windows,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum EnvExpansionError {
#[error("environment variable {name:?} referenced in config value is not set")]
UndefinedVar {
name: String,
},
#[error("env-var expansion is not permitted in a project-tier config value")]
NotAllowedInProjectTier,
}
const SHELL_PLACEHOLDER_START: &str = "${";
enum Segment<'a> {
Literal(&'a str),
Value(Zeroizing<String>),
}
fn assemble(segments: &[Segment<'_>]) -> Zeroizing<String> {
let total_len: usize = segments
.iter()
.map(|segment| match segment {
Segment::Literal(s) => s.len(),
Segment::Value(v) => v.len(),
})
.sum();
let mut out = Zeroizing::new(String::with_capacity(total_len));
for segment in segments {
match segment {
Segment::Literal(s) => out.push_str(s),
Segment::Value(v) => out.push_str(v.as_str()),
}
}
out
}
#[allow(clippy::string_slice)]
fn scan_shell(
raw: &str,
lookup: &impl Fn(&str) -> Option<Zeroizing<String>>,
) -> Result<Zeroizing<String>, EnvExpansionError> {
let mut segments = Vec::new();
let mut rest = raw;
while let Some(start) = rest.find(SHELL_PLACEHOLDER_START) {
if start > 0 {
segments.push(Segment::Literal(&rest[..start]));
}
let after = &rest[start + SHELL_PLACEHOLDER_START.len()..];
let Some(end) = after.find('}') else {
segments.push(Segment::Literal(&rest[start..]));
rest = "";
break;
};
let var_name = &after[..end];
let value = lookup(var_name).ok_or_else(|| EnvExpansionError::UndefinedVar {
name: var_name.to_string(),
})?;
segments.push(Segment::Value(value));
rest = &after[end + 1..];
}
if !rest.is_empty() {
segments.push(Segment::Literal(rest));
}
Ok(assemble(&segments))
}
#[allow(clippy::string_slice)]
fn scan_windows(
raw: &str,
lookup: &impl Fn(&str) -> Option<Zeroizing<String>>,
) -> Result<Zeroizing<String>, EnvExpansionError> {
let mut segments = Vec::new();
let mut rest = raw;
while let Some(pct) = rest.find('%') {
let literal = &rest[..pct];
let after = &rest[pct + 1..];
if let Some(end) = after.find('%') {
let name = &after[..end];
if !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
if !literal.is_empty() {
segments.push(Segment::Literal(literal));
}
let value = lookup(name).ok_or_else(|| EnvExpansionError::UndefinedVar {
name: name.to_string(),
})?;
segments.push(Segment::Value(value));
rest = &after[end + 1..];
continue;
}
}
segments.push(Segment::Literal(&rest[..=pct]));
rest = &rest[pct + 1..];
}
if !rest.is_empty() {
segments.push(Segment::Literal(rest));
}
Ok(assemble(&segments))
}
fn windows_has_placeholder(raw: &str) -> bool {
let detected = std::cell::Cell::new(false);
let _: Result<_, _> = scan_windows(raw, &|_name| {
detected.set(true);
None
});
detected.get()
}
pub fn expand_env_vars(
raw: &str,
syntax: EnvVarSyntax,
tier: ConfigTier,
lookup: impl Fn(&str) -> Option<Zeroizing<String>>,
) -> Result<Zeroizing<String>, EnvExpansionError> {
if tier == ConfigTier::Project {
let has_placeholder = match syntax {
EnvVarSyntax::Shell => raw.contains(SHELL_PLACEHOLDER_START),
EnvVarSyntax::Windows => windows_has_placeholder(raw),
};
if has_placeholder {
return Err(EnvExpansionError::NotAllowedInProjectTier);
}
return Ok(Zeroizing::new(raw.to_owned()));
}
match syntax {
EnvVarSyntax::Shell => scan_shell(raw, &lookup),
EnvVarSyntax::Windows => scan_windows(raw, &lookup),
}
}
#[must_use]
pub fn process_env(name: &str) -> Option<Zeroizing<String>> {
std::env::var(name).ok().map(Zeroizing::new)
}
#[cfg(test)]
mod tests {
use super::*;
fn z(s: &str) -> Zeroizing<String> {
Zeroizing::new(s.to_string())
}
#[test]
fn shell_no_placeholder_passthrough() {
assert_eq!(
expand_env_vars(
"https://npm.example/",
EnvVarSyntax::Shell,
ConfigTier::User,
|_| None
)
.unwrap()
.as_str(),
"https://npm.example/"
);
}
#[test]
fn shell_defined_var_expands() {
let result = expand_env_vars(
"${NPM_REGISTRY}/",
EnvVarSyntax::Shell,
ConfigTier::User,
|name| (name == "NPM_REGISTRY").then(|| z("https://npm.mycorp.example")),
);
assert_eq!(result.unwrap().as_str(), "https://npm.mycorp.example/");
}
#[test]
fn shell_undefined_var_errors() {
let result = expand_env_vars(
"${UNDEFINED_VAR}",
EnvVarSyntax::Shell,
ConfigTier::User,
|_| None,
);
assert_eq!(
result,
Err(EnvExpansionError::UndefinedVar {
name: "UNDEFINED_VAR".to_string()
})
);
}
#[test]
fn shell_unterminated_placeholder_is_literal() {
assert_eq!(
expand_env_vars("a${B", EnvVarSyntax::Shell, ConfigTier::User, |_| {
panic!("lookup must not be called")
})
.unwrap()
.as_str(),
"a${B"
);
}
#[test]
fn shell_empty_name_is_undefined() {
let result = expand_env_vars("${}", EnvVarSyntax::Shell, ConfigTier::User, |name| {
assert_eq!(name, "");
None
});
assert_eq!(
result,
Err(EnvExpansionError::UndefinedVar {
name: String::new()
})
);
}
#[test]
fn shell_adjacent_placeholders() {
let lookup = |name: &str| match name {
"A" => Some(z("1")),
"B" => Some(z("2")),
_ => None,
};
assert_eq!(
expand_env_vars("${A}${B}", EnvVarSyntax::Shell, ConfigTier::User, lookup)
.unwrap()
.as_str(),
"12"
);
}
#[test]
fn shell_dollar_before_placeholder() {
let lookup = |name: &str| (name == "A").then(|| z("1"));
assert_eq!(
expand_env_vars("$${A}", EnvVarSyntax::Shell, ConfigTier::User, lookup)
.unwrap()
.as_str(),
"$1"
);
}
#[test]
fn shell_trailing_brace_is_literal() {
let lookup = |name: &str| (name == "A").then(|| z("1"));
assert_eq!(
expand_env_vars("${A}}", EnvVarSyntax::Shell, ConfigTier::User, lookup)
.unwrap()
.as_str(),
"1}"
);
}
#[test]
fn shell_surrounding_literal_text() {
let lookup = |name: &str| (name == "A").then(|| z("1"));
assert_eq!(
expand_env_vars("pre${A}post", EnvVarSyntax::Shell, ConfigTier::User, lookup)
.unwrap()
.as_str(),
"pre1post"
);
}
#[test]
fn shell_looked_up_value_is_not_recursively_expanded() {
let lookup = |name: &str| (name == "A").then(|| z("${B}"));
assert_eq!(
expand_env_vars("${A}", EnvVarSyntax::Shell, ConfigTier::User, lookup)
.unwrap()
.as_str(),
"${B}"
);
}
#[test]
fn shell_first_unset_var_short_circuits() {
let calls = std::cell::RefCell::new(Vec::new());
let lookup = |name: &str| {
calls.borrow_mut().push(name.to_string());
None
};
let result = expand_env_vars("${A}${B}", EnvVarSyntax::Shell, ConfigTier::User, lookup);
assert_eq!(
result,
Err(EnvExpansionError::UndefinedVar {
name: "A".to_string()
})
);
assert_eq!(*calls.borrow(), vec!["A".to_string()]);
}
#[test]
fn windows_set_and_unset() {
let set = expand_env_vars(
"%CORP_FEED_PAT%",
EnvVarSyntax::Windows,
ConfigTier::User,
|name| (name == "CORP_FEED_PAT").then(|| z("secret-pat")),
);
assert_eq!(set.unwrap().as_str(), "secret-pat");
let unset = expand_env_vars(
"%CORP_FEED_PAT%",
EnvVarSyntax::Windows,
ConfigTier::User,
|_| None,
);
assert_eq!(
unset,
Err(EnvExpansionError::UndefinedVar {
name: "CORP_FEED_PAT".to_string()
})
);
}
#[test]
fn windows_edge_cases() {
let lookup = |name: &str| match name {
"A" => Some(z("1")),
"B" => Some(z("2")),
_ => None,
};
assert_eq!(
expand_env_vars(
"pre-%A%-post",
EnvVarSyntax::Windows,
ConfigTier::User,
lookup
)
.unwrap()
.as_str(),
"pre-1-post"
);
assert_eq!(
expand_env_vars("%A%%B%", EnvVarSyntax::Windows, ConfigTier::User, lookup)
.unwrap()
.as_str(),
"12"
);
assert_eq!(
expand_env_vars(
"abc%1bad!name%def",
EnvVarSyntax::Windows,
ConfigTier::User,
lookup
)
.unwrap()
.as_str(),
"abc%1bad!name%def"
);
assert_eq!(
expand_env_vars("abc%A", EnvVarSyntax::Windows, ConfigTier::User, lookup)
.unwrap()
.as_str(),
"abc%A"
);
assert_eq!(
expand_env_vars("%%", EnvVarSyntax::Windows, ConfigTier::User, lookup)
.unwrap()
.as_str(),
"%%"
);
assert_eq!(
expand_env_vars(
"pre-%UNSET%-post",
EnvVarSyntax::Windows,
ConfigTier::User,
lookup
),
Err(EnvExpansionError::UndefinedVar {
name: "UNSET".to_string()
})
);
}
#[test]
fn windows_malformed_then_well_formed() {
let lookup = |name: &str| (name == "A").then(|| z("1"));
assert_eq!(
expand_env_vars("%x!%A%", EnvVarSyntax::Windows, ConfigTier::User, lookup)
.unwrap()
.as_str(),
"%x!1"
);
}
#[test]
fn windows_looked_up_value_is_not_recursively_expanded() {
let lookup = |name: &str| (name == "A").then(|| z("%B%"));
assert_eq!(
expand_env_vars("%A%", EnvVarSyntax::Windows, ConfigTier::User, lookup)
.unwrap()
.as_str(),
"%B%"
);
}
#[test]
fn windows_first_unset_var_short_circuits() {
let calls = std::cell::RefCell::new(Vec::new());
let lookup = |name: &str| {
calls.borrow_mut().push(name.to_string());
None
};
let result = expand_env_vars("%A%%B%", EnvVarSyntax::Windows, ConfigTier::User, lookup);
assert_eq!(
result,
Err(EnvExpansionError::UndefinedVar {
name: "A".to_string()
})
);
assert_eq!(*calls.borrow(), vec!["A".to_string()]);
}
#[test]
fn shell_project_tier_rejects_unterminated_placeholder() {
let result = expand_env_vars("a${B", EnvVarSyntax::Shell, ConfigTier::Project, |_| {
panic!("lookup must not be called for a project-tier value")
});
assert_eq!(result, Err(EnvExpansionError::NotAllowedInProjectTier));
}
#[test]
fn shell_project_tier_rejects_defined_var() {
let result = expand_env_vars("${PATH}", EnvVarSyntax::Shell, ConfigTier::Project, |_| {
panic!("lookup must not be called for a project-tier value")
});
assert_eq!(result, Err(EnvExpansionError::NotAllowedInProjectTier));
}
#[test]
fn shell_project_tier_no_placeholder_is_passthrough() {
let result = expand_env_vars(
"https://npm.example/",
EnvVarSyntax::Shell,
ConfigTier::Project,
|_| panic!("lookup must not be called"),
);
assert_eq!(result.unwrap().as_str(), "https://npm.example/");
}
#[test]
fn windows_project_tier_rejects_well_formed_placeholder() {
let result = expand_env_vars("%A%", EnvVarSyntax::Windows, ConfigTier::Project, |_| {
panic!("lookup must not be called for a project-tier value")
});
assert_eq!(result, Err(EnvExpansionError::NotAllowedInProjectTier));
}
#[test]
fn windows_project_tier_rejects_malformed_then_well_formed() {
let result = expand_env_vars("%x!%A%", EnvVarSyntax::Windows, ConfigTier::Project, |_| {
panic!("lookup must not be called for a project-tier value")
});
assert_eq!(result, Err(EnvExpansionError::NotAllowedInProjectTier));
}
#[test]
fn windows_project_tier_percent_encoding_not_rejected() {
let result = expand_env_vars("%20", EnvVarSyntax::Windows, ConfigTier::Project, |_| {
panic!("lookup must not be called: %20 is not a well-formed placeholder")
});
assert_eq!(result.unwrap().as_str(), "%20");
}
#[test]
fn process_env_unset_var_is_none() {
assert!(process_env("DEPS_LSP_CONFIG_TRUST_TEST_UNSET_VAR_XYZ").is_none());
}
}