1use std::borrow::Cow;
4use std::collections::BTreeMap;
5
6use serde::{Deserialize, Serialize};
7
8use super::host_os::HostOs;
9use super::text_newtype::{contains_nul, impl_text_newtype};
10
11#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
13#[non_exhaustive]
14pub enum InvalidEnvKey {
15 #[error("environment variable name cannot be empty")]
17 Empty,
18 #[error("environment variable name cannot contain '=' or NUL")]
20 ForbiddenChar,
21}
22
23const fn env_key_fault(key: &str) -> Option<InvalidEnvKey> {
24 if key.is_empty() {
25 return Some(InvalidEnvKey::Empty);
26 }
27 let mut rest = key.as_bytes();
28 while let [byte, tail @ ..] = rest {
29 if matches!(byte, b'=' | 0) {
30 return Some(InvalidEnvKey::ForbiddenChar);
31 }
32 rest = tail;
33 }
34 None
35}
36
37#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
39#[non_exhaustive]
40pub enum InvalidEnvValue {
41 #[error("environment variable value cannot contain NUL")]
44 ContainsNul,
45}
46
47const fn env_value_fault(value: &str) -> Option<InvalidEnvValue> {
48 if contains_nul(value) {
49 Some(InvalidEnvValue::ContainsNul)
50 } else {
51 None
52 }
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
70#[serde(try_from = "String", into = "String")]
71pub struct EnvKey(Cow<'static, str>);
72
73impl AsRef<std::ffi::OsStr> for EnvKey {
74 fn as_ref(&self) -> &std::ffi::OsStr {
75 self.as_str().as_ref()
76 }
77}
78
79impl_text_newtype!(
80 EnvKey,
81 InvalidEnvKey,
82 fault = env_key_fault,
83 "environment variable name"
84);
85
86#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
99pub struct EnvValue(Cow<'static, str>);
100
101impl std::fmt::Debug for EnvValue {
103 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104 f.write_str("EnvValue(<redacted>)")
105 }
106}
107
108impl AsRef<std::ffi::OsStr> for EnvValue {
109 fn as_ref(&self) -> &std::ffi::OsStr {
110 self.as_str().as_ref()
111 }
112}
113
114impl_text_newtype!(
115 EnvValue,
116 InvalidEnvValue,
117 fault = env_value_fault,
118 "environment variable value"
119);
120
121pub const MAX_ENV_TOTAL_BYTES: usize = 128 * 1024;
128
129const ENTRY_OVERHEAD_BYTES: usize = 2;
131
132fn entry_bytes(key: &EnvKey, value: &EnvValue) -> usize {
133 key.as_str()
134 .len()
135 .saturating_add(value.as_str().len())
136 .saturating_add(ENTRY_OVERHEAD_BYTES)
137}
138
139#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
141#[non_exhaustive]
142pub enum EnvTooLarge {
143 #[error(
145 "environment variable '{key}' is {bytes} bytes as NAME=value and a terminator, over the {limit} byte limit"
146 )]
147 Entry {
148 key: EnvKey,
150 bytes: usize,
152 limit: usize,
154 },
155 #[error("environment is {bytes} bytes in total, over the {MAX_ENV_TOTAL_BYTES} byte limit")]
157 Total {
158 bytes: usize,
160 },
161}
162
163#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
165#[non_exhaustive]
166pub enum InvalidServerEnv {
167 #[error(transparent)]
169 Duplicate(DuplicateEnvKey),
170 #[error(transparent)]
172 TooLarge(EnvTooLarge),
173}
174
175#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
177#[error(
178 "environment variable '{first}' is set twice ('{second}'): names compare case-insensitively on Windows"
179)]
180pub struct DuplicateEnvKey {
181 pub first: EnvKey,
183 pub second: EnvKey,
185}
186
187#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
189struct HostKey(String);
190
191impl HostKey {
192 fn of(name: &str, host: HostOs) -> Self {
193 Self(match host {
194 HostOs::Windows => name.chars().map(windows_fold).collect(),
195 HostOs::Other => name.to_owned(),
196 })
197 }
198}
199
200fn windows_fold(c: char) -> char {
208 let mut upper = c.to_uppercase();
209 match (upper.next(), upper.next()) {
210 (Some(folded), None) if c.len_utf16() == 1 && folded.len_utf16() == 1 => folded,
211 _ => c,
212 }
213}
214
215#[derive(Debug, Clone, PartialEq, Eq)]
250pub struct ServerEnv {
251 host: HostOs,
252 vars: BTreeMap<HostKey, (EnvKey, EnvValue)>,
253 total_bytes: usize,
255}
256
257impl Default for ServerEnv {
259 fn default() -> Self {
260 Self::new(HostOs::CURRENT)
261 }
262}
263
264impl ServerEnv {
265 #[must_use]
267 pub const fn new(host: HostOs) -> Self {
268 Self {
269 host,
270 vars: BTreeMap::new(),
271 total_bytes: 0,
272 }
273 }
274
275 pub fn from_entries(
283 entries: impl IntoIterator<Item = (EnvKey, EnvValue)>,
284 host: HostOs,
285 ) -> Result<Self, InvalidServerEnv> {
286 let mut env = Self::new(host);
287 for (key, value) in entries {
288 if let Some((first, _)) = env.vars.get(&HostKey::of(key.as_str(), host)) {
289 return Err(InvalidServerEnv::Duplicate(DuplicateEnvKey {
290 first: first.clone(),
291 second: key,
292 }));
293 }
294 env.insert(key, value).map_err(InvalidServerEnv::TooLarge)?;
295 }
296 Ok(env)
297 }
298
299 #[must_use]
301 pub const fn host(&self) -> HostOs {
302 self.host
303 }
304
305 #[must_use]
307 pub fn get(&self, key: &str) -> Option<&str> {
308 self.vars
309 .get(&HostKey::of(key, self.host))
310 .map(|(_, value)| value.as_str())
311 }
312
313 #[must_use]
315 pub fn contains_key(&self, key: &str) -> bool {
316 self.vars.contains_key(&HostKey::of(key, self.host))
317 }
318
319 pub fn insert(&mut self, key: EnvKey, value: EnvValue) -> Result<(), EnvTooLarge> {
328 let bytes = entry_bytes(&key, &value);
329 let limit = self.host.max_env_entry_bytes();
330 if bytes > limit {
331 return Err(EnvTooLarge::Entry { key, bytes, limit });
332 }
333 let host_key = HostKey::of(key.as_str(), self.host);
334 let replaced = self
335 .vars
336 .get(&host_key)
337 .map_or_default(|(old_key, old_value)| entry_bytes(old_key, old_value));
338 let total = self
339 .total_bytes
340 .saturating_sub(replaced)
341 .saturating_add(bytes);
342 if total > MAX_ENV_TOTAL_BYTES {
343 return Err(EnvTooLarge::Total { bytes: total });
344 }
345 self.total_bytes = total;
346 self.vars.insert(host_key, (key, value));
347 Ok(())
348 }
349
350 pub fn iter(&self) -> impl Iterator<Item = (&EnvKey, &EnvValue)> {
352 self.vars.values().map(|(key, value)| (key, value))
353 }
354
355 #[must_use]
357 pub fn len(&self) -> usize {
358 self.vars.len()
359 }
360
361 #[must_use]
363 pub fn is_empty(&self) -> bool {
364 self.vars.is_empty()
365 }
366}
367
368impl From<ServerEnv> for BTreeMap<EnvKey, String> {
370 fn from(env: ServerEnv) -> Self {
371 env.vars
372 .into_values()
373 .map(|(key, value)| (key, String::from(value)))
374 .collect()
375 }
376}
377
378#[cfg(test)]
379mod tests {
380 use std::assert_matches;
381
382 use super::*;
383
384 fn key(name: &str) -> EnvKey {
385 EnvKey::new(name).unwrap()
386 }
387
388 fn value(text: &str) -> EnvValue {
389 EnvValue::new(text).unwrap()
390 }
391
392 fn entries(pairs: &[(&str, &str)]) -> Vec<(EnvKey, EnvValue)> {
393 pairs
394 .iter()
395 .map(|(name, text)| (key(name), value(text)))
396 .collect()
397 }
398
399 #[test]
400 fn insert_bounds_each_entry_by_the_tables_host() {
401 for host in [HostOs::Other, HostOs::Windows] {
402 let max = host.max_env_entry_bytes();
403 let mut env = ServerEnv::new(host);
404 assert_eq!(env.insert(key("A"), value(&"x".repeat(max - 3))), Ok(()));
405
406 assert_eq!(
407 env.insert(key("A"), value(&"x".repeat(max - 2))),
408 Err(EnvTooLarge::Entry {
409 key: key("A"),
410 bytes: max + 1,
411 limit: max,
412 })
413 );
414 assert_eq!(env.get("A").map(str::len), Some(max - 3));
415 }
416 }
417
418 #[test]
419 fn insert_bounds_the_total_and_counts_a_replacement_once() {
420 let mut env = ServerEnv::new(HostOs::Other);
421 for index in 0..6 {
422 env.insert(key(&format!("K{index}")), value(&"x".repeat(20_000)))
423 .unwrap();
424 }
425 assert_matches!(
426 env.insert(key("K6"), value(&"x".repeat(20_000))),
427 Err(EnvTooLarge::Total { .. })
428 );
429 assert_eq!(env.len(), 6);
430
431 env.insert(key("K0"), value(&"y".repeat(20_000))).unwrap();
432 assert_eq!(env.len(), 6);
433 }
434
435 #[test]
436 fn from_entries_refuses_a_table_that_is_too_large_or_has_a_duplicate() {
437 let big = |name: &str| (key(name), value(&"x".repeat(20_000)));
438 let too_large =
439 ServerEnv::from_entries((0..8).map(|n| big(&format!("K{n}"))), HostOs::Other);
440 assert_matches!(
441 too_large,
442 Err(InvalidServerEnv::TooLarge(EnvTooLarge::Total { .. }))
443 );
444 let duplicate =
445 ServerEnv::from_entries(entries(&[("Path", "a"), ("PATH", "b")]), HostOs::Windows);
446 assert_matches!(duplicate, Err(InvalidServerEnv::Duplicate(_)));
447 }
448
449 #[test]
450 fn env_key_rejects_what_the_os_would() {
451 assert_eq!(EnvKey::new(""), Err(InvalidEnvKey::Empty));
452 assert_eq!(EnvKey::new("A=B"), Err(InvalidEnvKey::ForbiddenChar));
453 assert_eq!(EnvKey::new("A\0B"), Err(InvalidEnvKey::ForbiddenChar));
454 assert!(EnvKey::new("Mixed_Case1").is_ok());
455 assert_eq!(EnvKey::from_static("PATH"), "PATH");
456 }
457
458 #[test]
459 fn env_value_rejects_nul_and_accepts_empty_text() {
460 assert_eq!(EnvValue::new("a\0b"), Err(InvalidEnvValue::ContainsNul));
461 assert!(EnvValue::new("").is_ok());
462 assert_eq!(EnvValue::from_static("1"), "1");
463 }
464
465 #[test]
466 fn debug_output_never_holds_a_value() {
467 assert_eq!(
468 format!("{:?}", value("SECRETVALUE")),
469 "EnvValue(<redacted>)"
470 );
471
472 let env =
473 ServerEnv::from_entries(entries(&[("TOKEN", "SECRETVALUE")]), HostOs::Other).unwrap();
474 let rendered = format!("{env:?}");
475 assert!(!rendered.contains("SECRETVALUE"));
476 assert!(rendered.contains("TOKEN"));
477 }
478
479 #[test]
480 fn env_key_is_checked_when_a_config_is_deserialized() {
481 #[derive(Debug, Deserialize)]
482 struct Holder {
483 env: BTreeMap<EnvKey, String>,
484 }
485
486 let holder: Holder = toml::from_str("[env]\nA = \"1\"").unwrap();
487 assert_eq!(holder.env.len(), 1);
488 assert!(toml::from_str::<Holder>("[env]\n\"A=B\" = \"1\"").is_err());
489 assert!(toml::from_str::<Holder>("[env]\n\"\" = \"1\"").is_err());
490 }
491
492 #[test]
495 fn get_compares_names_as_the_host_does() {
496 for name in [
497 "PATH",
498 "HOME",
499 "USERPROFILE",
500 "NoDefaultCurrentDirectoryInExePath",
501 ] {
502 let lowered = entries(&[(&name.to_ascii_lowercase(), "v")]);
503 let windows = ServerEnv::from_entries(lowered.clone(), HostOs::Windows).unwrap();
504 assert_eq!(windows.get(name), Some("v"), "{name}");
505 assert!(windows.contains_key(name), "{name}");
506
507 let other = ServerEnv::from_entries(lowered, HostOs::Other).unwrap();
508 assert_eq!(other.get(name), None, "{name}");
509 assert!(!other.contains_key(name), "{name}");
510 }
511 }
512
513 #[test]
514 fn insert_replaces_every_alias_on_windows_and_only_the_exact_key_elsewhere() {
515 let mut windows =
516 ServerEnv::from_entries(entries(&[("Path", "a")]), HostOs::Windows).unwrap();
517 windows.insert(key("PATH"), value("b")).unwrap();
518 assert_eq!(
519 windows.iter().collect::<Vec<_>>(),
520 [(&key("PATH"), &value("b"))]
521 );
522
523 let mut other = ServerEnv::from_entries(entries(&[("Path", "a")]), HostOs::Other).unwrap();
524 other.insert(key("PATH"), value("b")).unwrap();
525 assert_eq!(other.len(), 2);
526 assert_eq!(other.get("Path"), Some("a"));
527 }
528
529 #[test]
530 fn from_entries_rejects_case_variants_only_on_windows() {
531 let err =
532 ServerEnv::from_entries(entries(&[("Path", "a"), ("PATH", "b")]), HostOs::Windows)
533 .unwrap_err();
534 let InvalidServerEnv::Duplicate(err) = err else {
535 panic!("expected a duplicate, got {err:?}");
536 };
537 assert_eq!(err.first, "Path");
538 assert_eq!(err.second, "PATH");
539 let other =
540 ServerEnv::from_entries(entries(&[("Path", "a"), ("PATH", "b")]), HostOs::Other)
541 .unwrap();
542 assert_eq!(other.len(), 2);
543 }
544
545 #[test]
546 fn get_prefers_an_exact_spelling() {
547 let env = ServerEnv::from_entries(entries(&[("Path", "a"), ("PATH", "b")]), HostOs::Other)
548 .unwrap();
549 assert_eq!(env.get("PATH"), Some("b"));
550 }
551
552 #[test]
553 fn windows_folds_letters_in_the_basic_plane_only() {
554 let key = |name: &str| HostKey::of(name, HostOs::Windows);
555 assert_eq!(key("\u{e9}"), key("\u{c9}"));
556 assert_eq!(key("Path"), key("PATH"));
557 assert_ne!(key("\u{10428}"), key("\u{10400}"));
558 assert_ne!(key("\u{df}"), key("SS"));
559 assert_eq!(key("\u{df}"), key("\u{df}"));
560 }
561
562 #[test]
563 fn other_hosts_compare_names_exactly() {
564 let key = |name: &str| HostKey::of(name, HostOs::Other);
565 assert_ne!(key("\u{e9}"), key("\u{c9}"));
566 }
567
568 #[test]
569 fn the_host_is_fixed_at_construction() {
570 assert_eq!(ServerEnv::new(HostOs::Windows).host(), HostOs::Windows);
571 assert_eq!(ServerEnv::default().host(), HostOs::CURRENT);
572 }
573}