use std::borrow::Cow;
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use super::host_os::HostOs;
use super::text_newtype::{contains_nul, impl_text_newtype};
#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum InvalidEnvKey {
#[error("environment variable name cannot be empty")]
Empty,
#[error("environment variable name cannot contain '=' or NUL")]
ForbiddenChar,
}
const fn env_key_fault(key: &str) -> Option<InvalidEnvKey> {
if key.is_empty() {
return Some(InvalidEnvKey::Empty);
}
let mut rest = key.as_bytes();
while let [byte, tail @ ..] = rest {
if matches!(byte, b'=' | 0) {
return Some(InvalidEnvKey::ForbiddenChar);
}
rest = tail;
}
None
}
#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum InvalidEnvValue {
#[error("environment variable value cannot contain NUL")]
ContainsNul,
}
const fn env_value_fault(value: &str) -> Option<InvalidEnvValue> {
if contains_nul(value) {
Some(InvalidEnvValue::ContainsNul)
} else {
None
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct EnvKey(Cow<'static, str>);
impl AsRef<std::ffi::OsStr> for EnvKey {
fn as_ref(&self) -> &std::ffi::OsStr {
self.as_str().as_ref()
}
}
impl_text_newtype!(
EnvKey,
InvalidEnvKey,
fault = env_key_fault,
"environment variable name"
);
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct EnvValue(Cow<'static, str>);
impl std::fmt::Debug for EnvValue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("EnvValue(<redacted>)")
}
}
impl AsRef<std::ffi::OsStr> for EnvValue {
fn as_ref(&self) -> &std::ffi::OsStr {
self.as_str().as_ref()
}
}
impl_text_newtype!(
EnvValue,
InvalidEnvValue,
fault = env_value_fault,
"environment variable value"
);
pub const MAX_ENV_TOTAL_BYTES: usize = 128 * 1024;
const ENTRY_OVERHEAD_BYTES: usize = 2;
fn entry_bytes(key: &EnvKey, value: &EnvValue) -> usize {
key.as_str()
.len()
.saturating_add(value.as_str().len())
.saturating_add(ENTRY_OVERHEAD_BYTES)
}
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum EnvTooLarge {
#[error(
"environment variable '{key}' is {bytes} bytes as NAME=value and a terminator, over the {limit} byte limit"
)]
Entry {
key: EnvKey,
bytes: usize,
limit: usize,
},
#[error("environment is {bytes} bytes in total, over the {MAX_ENV_TOTAL_BYTES} byte limit")]
Total {
bytes: usize,
},
}
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum InvalidServerEnv {
#[error(transparent)]
Duplicate(DuplicateEnvKey),
#[error(transparent)]
TooLarge(EnvTooLarge),
}
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
#[error(
"environment variable '{first}' is set twice ('{second}'): names compare case-insensitively on Windows"
)]
pub struct DuplicateEnvKey {
pub first: EnvKey,
pub second: EnvKey,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
struct HostKey(String);
impl HostKey {
fn of(name: &str, host: HostOs) -> Self {
Self(match host {
HostOs::Windows => name.chars().map(windows_fold).collect(),
HostOs::Other => name.to_owned(),
})
}
}
fn windows_fold(c: char) -> char {
let mut upper = c.to_uppercase();
match (upper.next(), upper.next()) {
(Some(folded), None) if c.len_utf16() == 1 && folded.len_utf16() == 1 => folded,
_ => c,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServerEnv {
host: HostOs,
vars: BTreeMap<HostKey, (EnvKey, EnvValue)>,
total_bytes: usize,
}
impl Default for ServerEnv {
fn default() -> Self {
Self::new(HostOs::CURRENT)
}
}
impl ServerEnv {
#[must_use]
pub const fn new(host: HostOs) -> Self {
Self {
host,
vars: BTreeMap::new(),
total_bytes: 0,
}
}
pub fn from_entries(
entries: impl IntoIterator<Item = (EnvKey, EnvValue)>,
host: HostOs,
) -> Result<Self, InvalidServerEnv> {
let mut env = Self::new(host);
for (key, value) in entries {
if let Some((first, _)) = env.vars.get(&HostKey::of(key.as_str(), host)) {
return Err(InvalidServerEnv::Duplicate(DuplicateEnvKey {
first: first.clone(),
second: key,
}));
}
env.insert(key, value).map_err(InvalidServerEnv::TooLarge)?;
}
Ok(env)
}
#[must_use]
pub const fn host(&self) -> HostOs {
self.host
}
#[must_use]
pub fn get(&self, key: &str) -> Option<&str> {
self.vars
.get(&HostKey::of(key, self.host))
.map(|(_, value)| value.as_str())
}
#[must_use]
pub fn contains_key(&self, key: &str) -> bool {
self.vars.contains_key(&HostKey::of(key, self.host))
}
pub fn insert(&mut self, key: EnvKey, value: EnvValue) -> Result<(), EnvTooLarge> {
let bytes = entry_bytes(&key, &value);
let limit = self.host.max_env_entry_bytes();
if bytes > limit {
return Err(EnvTooLarge::Entry { key, bytes, limit });
}
let host_key = HostKey::of(key.as_str(), self.host);
let replaced = self
.vars
.get(&host_key)
.map_or_default(|(old_key, old_value)| entry_bytes(old_key, old_value));
let total = self
.total_bytes
.saturating_sub(replaced)
.saturating_add(bytes);
if total > MAX_ENV_TOTAL_BYTES {
return Err(EnvTooLarge::Total { bytes: total });
}
self.total_bytes = total;
self.vars.insert(host_key, (key, value));
Ok(())
}
pub fn iter(&self) -> impl Iterator<Item = (&EnvKey, &EnvValue)> {
self.vars.values().map(|(key, value)| (key, value))
}
#[must_use]
pub fn len(&self) -> usize {
self.vars.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.vars.is_empty()
}
}
impl From<ServerEnv> for BTreeMap<EnvKey, String> {
fn from(env: ServerEnv) -> Self {
env.vars
.into_values()
.map(|(key, value)| (key, String::from(value)))
.collect()
}
}
#[cfg(test)]
mod tests {
use std::assert_matches;
use super::*;
fn key(name: &str) -> EnvKey {
EnvKey::new(name).unwrap()
}
fn value(text: &str) -> EnvValue {
EnvValue::new(text).unwrap()
}
fn entries(pairs: &[(&str, &str)]) -> Vec<(EnvKey, EnvValue)> {
pairs
.iter()
.map(|(name, text)| (key(name), value(text)))
.collect()
}
#[test]
fn insert_bounds_each_entry_by_the_tables_host() {
for host in [HostOs::Other, HostOs::Windows] {
let max = host.max_env_entry_bytes();
let mut env = ServerEnv::new(host);
assert_eq!(env.insert(key("A"), value(&"x".repeat(max - 3))), Ok(()));
assert_eq!(
env.insert(key("A"), value(&"x".repeat(max - 2))),
Err(EnvTooLarge::Entry {
key: key("A"),
bytes: max + 1,
limit: max,
})
);
assert_eq!(env.get("A").map(str::len), Some(max - 3));
}
}
#[test]
fn insert_bounds_the_total_and_counts_a_replacement_once() {
let mut env = ServerEnv::new(HostOs::Other);
for index in 0..6 {
env.insert(key(&format!("K{index}")), value(&"x".repeat(20_000)))
.unwrap();
}
assert_matches!(
env.insert(key("K6"), value(&"x".repeat(20_000))),
Err(EnvTooLarge::Total { .. })
);
assert_eq!(env.len(), 6);
env.insert(key("K0"), value(&"y".repeat(20_000))).unwrap();
assert_eq!(env.len(), 6);
}
#[test]
fn from_entries_refuses_a_table_that_is_too_large_or_has_a_duplicate() {
let big = |name: &str| (key(name), value(&"x".repeat(20_000)));
let too_large =
ServerEnv::from_entries((0..8).map(|n| big(&format!("K{n}"))), HostOs::Other);
assert_matches!(
too_large,
Err(InvalidServerEnv::TooLarge(EnvTooLarge::Total { .. }))
);
let duplicate =
ServerEnv::from_entries(entries(&[("Path", "a"), ("PATH", "b")]), HostOs::Windows);
assert_matches!(duplicate, Err(InvalidServerEnv::Duplicate(_)));
}
#[test]
fn env_key_rejects_what_the_os_would() {
assert_eq!(EnvKey::new(""), Err(InvalidEnvKey::Empty));
assert_eq!(EnvKey::new("A=B"), Err(InvalidEnvKey::ForbiddenChar));
assert_eq!(EnvKey::new("A\0B"), Err(InvalidEnvKey::ForbiddenChar));
assert!(EnvKey::new("Mixed_Case1").is_ok());
assert_eq!(EnvKey::from_static("PATH"), "PATH");
}
#[test]
fn env_value_rejects_nul_and_accepts_empty_text() {
assert_eq!(EnvValue::new("a\0b"), Err(InvalidEnvValue::ContainsNul));
assert!(EnvValue::new("").is_ok());
assert_eq!(EnvValue::from_static("1"), "1");
}
#[test]
fn debug_output_never_holds_a_value() {
assert_eq!(
format!("{:?}", value("SECRETVALUE")),
"EnvValue(<redacted>)"
);
let env =
ServerEnv::from_entries(entries(&[("TOKEN", "SECRETVALUE")]), HostOs::Other).unwrap();
let rendered = format!("{env:?}");
assert!(!rendered.contains("SECRETVALUE"));
assert!(rendered.contains("TOKEN"));
}
#[test]
fn env_key_is_checked_when_a_config_is_deserialized() {
#[derive(Debug, Deserialize)]
struct Holder {
env: BTreeMap<EnvKey, String>,
}
let holder: Holder = toml::from_str("[env]\nA = \"1\"").unwrap();
assert_eq!(holder.env.len(), 1);
assert!(toml::from_str::<Holder>("[env]\n\"A=B\" = \"1\"").is_err());
assert!(toml::from_str::<Holder>("[env]\n\"\" = \"1\"").is_err());
}
#[test]
fn get_compares_names_as_the_host_does() {
for name in [
"PATH",
"HOME",
"USERPROFILE",
"NoDefaultCurrentDirectoryInExePath",
] {
let lowered = entries(&[(&name.to_ascii_lowercase(), "v")]);
let windows = ServerEnv::from_entries(lowered.clone(), HostOs::Windows).unwrap();
assert_eq!(windows.get(name), Some("v"), "{name}");
assert!(windows.contains_key(name), "{name}");
let other = ServerEnv::from_entries(lowered, HostOs::Other).unwrap();
assert_eq!(other.get(name), None, "{name}");
assert!(!other.contains_key(name), "{name}");
}
}
#[test]
fn insert_replaces_every_alias_on_windows_and_only_the_exact_key_elsewhere() {
let mut windows =
ServerEnv::from_entries(entries(&[("Path", "a")]), HostOs::Windows).unwrap();
windows.insert(key("PATH"), value("b")).unwrap();
assert_eq!(
windows.iter().collect::<Vec<_>>(),
[(&key("PATH"), &value("b"))]
);
let mut other = ServerEnv::from_entries(entries(&[("Path", "a")]), HostOs::Other).unwrap();
other.insert(key("PATH"), value("b")).unwrap();
assert_eq!(other.len(), 2);
assert_eq!(other.get("Path"), Some("a"));
}
#[test]
fn from_entries_rejects_case_variants_only_on_windows() {
let err =
ServerEnv::from_entries(entries(&[("Path", "a"), ("PATH", "b")]), HostOs::Windows)
.unwrap_err();
let InvalidServerEnv::Duplicate(err) = err else {
panic!("expected a duplicate, got {err:?}");
};
assert_eq!(err.first, "Path");
assert_eq!(err.second, "PATH");
let other =
ServerEnv::from_entries(entries(&[("Path", "a"), ("PATH", "b")]), HostOs::Other)
.unwrap();
assert_eq!(other.len(), 2);
}
#[test]
fn get_prefers_an_exact_spelling() {
let env = ServerEnv::from_entries(entries(&[("Path", "a"), ("PATH", "b")]), HostOs::Other)
.unwrap();
assert_eq!(env.get("PATH"), Some("b"));
}
#[test]
fn windows_folds_letters_in_the_basic_plane_only() {
let key = |name: &str| HostKey::of(name, HostOs::Windows);
assert_eq!(key("\u{e9}"), key("\u{c9}"));
assert_eq!(key("Path"), key("PATH"));
assert_ne!(key("\u{10428}"), key("\u{10400}"));
assert_ne!(key("\u{df}"), key("SS"));
assert_eq!(key("\u{df}"), key("\u{df}"));
}
#[test]
fn other_hosts_compare_names_exactly() {
let key = |name: &str| HostKey::of(name, HostOs::Other);
assert_ne!(key("\u{e9}"), key("\u{c9}"));
}
#[test]
fn the_host_is_fixed_at_construction() {
assert_eq!(ServerEnv::new(HostOs::Windows).host(), HostOs::Windows);
assert_eq!(ServerEnv::default().host(), HostOs::CURRENT);
}
}