1use std::borrow::Cow;
4use std::collections::BTreeMap;
5
6use serde::{Deserialize, Serialize};
7
8use super::host_os::HostOs;
9use super::text_newtype::impl_text_newtype;
10
11#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
13pub enum InvalidEnvKey {
14 #[error("environment variable name cannot be empty")]
16 Empty,
17 #[error("environment variable name cannot contain '=' or NUL")]
19 ForbiddenChar,
20}
21
22const fn is_valid_env_key(key: &str) -> bool {
23 if key.is_empty() {
24 return false;
25 }
26 let mut rest = key.as_bytes();
27 while let [byte, tail @ ..] = rest {
28 if matches!(byte, b'=' | 0) {
29 return false;
30 }
31 rest = tail;
32 }
33 true
34}
35
36const fn check_env_key(key: &str) -> Result<(), InvalidEnvKey> {
37 if key.is_empty() {
38 Err(InvalidEnvKey::Empty)
39 } else if is_valid_env_key(key) {
40 Ok(())
41 } else {
42 Err(InvalidEnvKey::ForbiddenChar)
43 }
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
61#[serde(try_from = "String", into = "String")]
62pub struct EnvKey(Cow<'static, str>);
63
64impl AsRef<std::ffi::OsStr> for EnvKey {
65 fn as_ref(&self) -> &std::ffi::OsStr {
66 self.as_str().as_ref()
67 }
68}
69
70impl_text_newtype!(
71 EnvKey,
72 InvalidEnvKey,
73 checked = check_env_key,
74 valid = is_valid_env_key,
75 "environment variable name"
76);
77
78#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
80#[error(
81 "environment variable '{first}' is set twice ('{second}'): names compare case-insensitively on Windows"
82)]
83pub struct DuplicateEnvKey {
84 pub first: EnvKey,
86 pub second: EnvKey,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
92struct HostKey(String);
93
94impl HostKey {
95 fn of(name: &str, host: HostOs) -> Self {
96 Self(match host {
97 HostOs::Windows => name.to_ascii_lowercase(),
98 HostOs::Other => name.to_owned(),
99 })
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct ServerEnv {
133 host: HostOs,
134 vars: BTreeMap<HostKey, (EnvKey, String)>,
135}
136
137impl Default for ServerEnv {
138 fn default() -> Self {
139 Self::new(HostOs::CURRENT)
140 }
141}
142
143impl ServerEnv {
144 #[must_use]
146 pub const fn new(host: HostOs) -> Self {
147 Self {
148 host,
149 vars: BTreeMap::new(),
150 }
151 }
152
153 pub fn from_entries(
159 entries: impl IntoIterator<Item = (EnvKey, String)>,
160 host: HostOs,
161 ) -> Result<Self, DuplicateEnvKey> {
162 let mut env = Self::new(host);
163 for (key, value) in entries {
164 if let Some((first, _)) = env.vars.get(&HostKey::of(key.as_str(), host)) {
165 return Err(DuplicateEnvKey {
166 first: first.clone(),
167 second: key,
168 });
169 }
170 env.insert(key, value);
171 }
172 Ok(env)
173 }
174
175 #[must_use]
177 pub const fn host(&self) -> HostOs {
178 self.host
179 }
180
181 #[must_use]
183 pub fn get(&self, key: &str) -> Option<&str> {
184 self.vars
185 .get(&HostKey::of(key, self.host))
186 .map(|(_, value)| value.as_str())
187 }
188
189 #[must_use]
191 pub fn contains_key(&self, key: &str) -> bool {
192 self.vars.contains_key(&HostKey::of(key, self.host))
193 }
194
195 pub fn insert(&mut self, key: EnvKey, value: String) {
198 self.vars
199 .insert(HostKey::of(key.as_str(), self.host), (key, value));
200 }
201
202 pub fn iter(&self) -> impl Iterator<Item = (&EnvKey, &String)> {
204 self.vars.values().map(|(key, value)| (key, value))
205 }
206
207 #[must_use]
209 pub fn len(&self) -> usize {
210 self.vars.len()
211 }
212
213 #[must_use]
215 pub fn is_empty(&self) -> bool {
216 self.vars.is_empty()
217 }
218}
219
220impl From<ServerEnv> for BTreeMap<EnvKey, String> {
221 fn from(env: ServerEnv) -> Self {
222 env.vars.into_values().collect()
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 fn key(name: &str) -> EnvKey {
231 EnvKey::new(name).unwrap()
232 }
233
234 fn entries(pairs: &[(&str, &str)]) -> Vec<(EnvKey, String)> {
235 pairs
236 .iter()
237 .map(|(name, value)| (key(name), (*value).to_owned()))
238 .collect()
239 }
240
241 #[test]
242 fn env_key_rejects_what_the_os_would() {
243 assert_eq!(EnvKey::new(""), Err(InvalidEnvKey::Empty));
244 assert_eq!(EnvKey::new("A=B"), Err(InvalidEnvKey::ForbiddenChar));
245 assert_eq!(EnvKey::new("A\0B"), Err(InvalidEnvKey::ForbiddenChar));
246 assert!(EnvKey::new("Mixed_Case1").is_ok());
247 assert_eq!(EnvKey::from_static("PATH"), "PATH");
248 }
249
250 #[test]
251 fn env_key_is_checked_when_a_config_is_deserialized() {
252 #[derive(Debug, Deserialize)]
253 struct Holder {
254 env: BTreeMap<EnvKey, String>,
255 }
256
257 let holder: Holder = toml::from_str("[env]\nA = \"1\"").unwrap();
258 assert_eq!(holder.env.len(), 1);
259 assert!(toml::from_str::<Holder>("[env]\n\"A=B\" = \"1\"").is_err());
260 assert!(toml::from_str::<Holder>("[env]\n\"\" = \"1\"").is_err());
261 }
262
263 #[test]
266 fn get_compares_names_as_the_host_does() {
267 for name in [
268 "PATH",
269 "HOME",
270 "USERPROFILE",
271 "NoDefaultCurrentDirectoryInExePath",
272 ] {
273 let lowered = entries(&[(&name.to_ascii_lowercase(), "v")]);
274 let windows = ServerEnv::from_entries(lowered.clone(), HostOs::Windows).unwrap();
275 assert_eq!(windows.get(name), Some("v"), "{name}");
276 assert!(windows.contains_key(name), "{name}");
277
278 let other = ServerEnv::from_entries(lowered, HostOs::Other).unwrap();
279 assert_eq!(other.get(name), None, "{name}");
280 assert!(!other.contains_key(name), "{name}");
281 }
282 }
283
284 #[test]
285 fn insert_replaces_every_alias_on_windows_and_only_the_exact_key_elsewhere() {
286 let mut windows =
287 ServerEnv::from_entries(entries(&[("Path", "a")]), HostOs::Windows).unwrap();
288 windows.insert(key("PATH"), "b".to_owned());
289 assert_eq!(
290 windows.iter().collect::<Vec<_>>(),
291 [(&key("PATH"), &"b".to_owned())]
292 );
293
294 let mut other = ServerEnv::from_entries(entries(&[("Path", "a")]), HostOs::Other).unwrap();
295 other.insert(key("PATH"), "b".to_owned());
296 assert_eq!(other.len(), 2);
297 assert_eq!(other.get("Path"), Some("a"));
298 }
299
300 #[test]
301 fn from_entries_rejects_case_variants_only_on_windows() {
302 let err =
303 ServerEnv::from_entries(entries(&[("Path", "a"), ("PATH", "b")]), HostOs::Windows)
304 .unwrap_err();
305 assert_eq!(err.first, "Path");
306 assert_eq!(err.second, "PATH");
307 let other =
308 ServerEnv::from_entries(entries(&[("Path", "a"), ("PATH", "b")]), HostOs::Other)
309 .unwrap();
310 assert_eq!(other.len(), 2);
311 }
312
313 #[test]
314 fn get_prefers_an_exact_spelling() {
315 let env = ServerEnv::from_entries(entries(&[("Path", "a"), ("PATH", "b")]), HostOs::Other)
316 .unwrap();
317 assert_eq!(env.get("PATH"), Some("b"));
318 }
319
320 #[test]
321 fn the_host_is_fixed_at_construction() {
322 assert_eq!(ServerEnv::new(HostOs::Windows).host(), HostOs::Windows);
323 assert_eq!(ServerEnv::default().host(), HostOs::CURRENT);
324 }
325}