Skip to main content

mcpls_core/config/
server_env.rs

1//! The environment overrides of a configured server.
2
3use 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/// Why a string is not a valid [`EnvKey`].
12#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
13#[non_exhaustive]
14pub enum InvalidEnvKey {
15    /// The name was empty.
16    #[error("environment variable name cannot be empty")]
17    Empty,
18    /// The name contained `=`, which separates a name from its value, or a NUL.
19    #[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/// Why a string is not a valid [`EnvValue`].
38#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
39#[non_exhaustive]
40pub enum InvalidEnvValue {
41    /// The value contained a NUL, which the operating system cannot pass in
42    /// an environment.
43    #[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/// The name of an environment variable: non-empty, with no `=` and no NUL, so
56/// the operating system accepts it as one.
57///
58/// Deserializes from a TOML string and rejects any other name at load time.
59///
60/// # Examples
61///
62/// ```
63/// use mcpls_core::config::EnvKey;
64///
65/// assert_eq!(EnvKey::new("RUST_LOG").unwrap(), "RUST_LOG");
66/// assert!(EnvKey::new("").is_err());
67/// assert!(EnvKey::new("A=B").is_err());
68/// ```
69#[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/// The value of an environment variable: any text without a NUL, so the
87/// operating system accepts it as one.
88///
89/// # Examples
90///
91/// ```
92/// use mcpls_core::config::EnvValue;
93///
94/// assert_eq!(EnvValue::new("debug").unwrap(), "debug");
95/// assert!(EnvValue::new("").is_ok());
96/// assert!(EnvValue::new("a\0b").is_err());
97/// ```
98#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
99pub struct EnvValue(Cow<'static, str>);
100
101/// Hides the value: environment values commonly carry credentials.
102impl 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
121/// The largest total size, in bytes, of a server's `env` table, counting each
122/// entry as `NAME=value` and a terminator.
123///
124/// Well under the 1 MiB macOS allows for arguments and environment together,
125/// so the table leaves room for the inherited environment and the arguments;
126/// past the platform limit a spawn fails with `E2BIG`.
127pub const MAX_ENV_TOTAL_BYTES: usize = 128 * 1024;
128
129/// The `=` between name and value and the terminator the OS adds to an entry.
130const 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/// Why a [`ServerEnv`] is too large to pass to a process.
140#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
141#[non_exhaustive]
142pub enum EnvTooLarge {
143    /// One entry is over [`HostOs::max_env_entry_bytes`] of the table's host.
144    #[error(
145        "environment variable '{key}' is {bytes} bytes as NAME=value and a terminator, over the {limit} byte limit"
146    )]
147    Entry {
148        /// The variable.
149        key: EnvKey,
150        /// Its size as `NAME=value`.
151        bytes: usize,
152        /// The most one entry may take on the table's host.
153        limit: usize,
154    },
155    /// The entries together are over [`MAX_ENV_TOTAL_BYTES`].
156    #[error("environment is {bytes} bytes in total, over the {MAX_ENV_TOTAL_BYTES} byte limit")]
157    Total {
158        /// The size of all entries.
159        bytes: usize,
160    },
161}
162
163/// Why entries do not make a valid [`ServerEnv`].
164#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
165#[non_exhaustive]
166pub enum InvalidServerEnv {
167    /// Two keys name one variable on the host.
168    #[error(transparent)]
169    Duplicate(DuplicateEnvKey),
170    /// The table is too large to pass to a process.
171    #[error(transparent)]
172    TooLarge(EnvTooLarge),
173}
174
175/// Two keys of one `env` table name the same variable on the host.
176#[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    /// The key seen first.
182    pub first: EnvKey,
183    /// The key that collides with it.
184    pub second: EnvKey,
185}
186
187/// A variable name as a host compares it: the key a table is indexed by.
188#[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
200/// The character Windows compares `c` as, approximately: its Unicode
201/// uppercase (the full mapping, not the simple one) when that is a single
202/// character and both stay in the Basic Multilingual Plane, `c` itself
203/// otherwise. Windows folds names with a per-UTF-16-unit table, so a
204/// supplementary-plane letter or a mapping that expands (`ß` to `SS`) does not
205/// fold. A character whose full mapping expands but whose simple mapping is one
206/// character (U+1F80) is left unfolded here although Windows folds it.
207fn 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/// Environment variable overrides for a server process.
216///
217/// Holds at most one key per variable as the host compares names (exactly on
218/// Unix, case-insensitively as Windows compares names): the table is indexed by the
219/// host's comparison of the name, so a lookup or a write cannot meet two
220/// spellings of one variable and mcpls resolves the same value the child
221/// process sees. The host is fixed at construction, so a table built for one
222/// host cannot be read or extended as another. Keys come in through
223/// [`Self::from_entries`], which rejects collisions, or [`Self::insert`],
224/// which replaces them. Neither lets the table outgrow what the host can pass
225/// to a process: [`HostOs::max_env_entry_bytes`] for one entry and
226/// [`MAX_ENV_TOTAL_BYTES`] for all.
227///
228/// The host is not serialized: a deserialized table is always for
229/// [`HostOs::CURRENT`], and converting into a map drops it. Other hosts exist
230/// so tests can model them (Windows name folding on any build).
231///
232/// # Examples
233///
234/// ```
235/// use mcpls_core::config::{EnvKey, EnvValue, HostOs, ServerEnv};
236///
237/// let mut env = ServerEnv::new(HostOs::Windows);
238/// env.insert(EnvKey::new("Path").unwrap(), EnvValue::new("/a").unwrap())?;
239/// assert_eq!(env.get("PATH"), Some("/a"));
240///
241/// env.insert(EnvKey::new("PATH").unwrap(), EnvValue::new("/b").unwrap())?;
242/// assert_eq!(env.len(), 1);
243///
244/// let mut other = ServerEnv::new(HostOs::Other);
245/// other.insert(EnvKey::new("Path").unwrap(), EnvValue::new("/a").unwrap())?;
246/// assert_eq!(other.get("PATH"), None);
247/// # Ok::<(), mcpls_core::config::EnvTooLarge>(())
248/// ```
249#[derive(Debug, Clone, PartialEq, Eq)]
250pub struct ServerEnv {
251    host: HostOs,
252    vars: BTreeMap<HostKey, (EnvKey, EnvValue)>,
253    /// The size of all entries as `NAME=value` and a terminator.
254    total_bytes: usize,
255}
256
257/// An empty table for [`HostOs::CURRENT`].
258impl Default for ServerEnv {
259    fn default() -> Self {
260        Self::new(HostOs::CURRENT)
261    }
262}
263
264impl ServerEnv {
265    /// An empty table whose names compare as `host` compares them.
266    #[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    /// Builds the table from `entries`, as `host` compares names.
276    ///
277    /// # Errors
278    ///
279    /// [`InvalidServerEnv::Duplicate`] when two keys name one variable on
280    /// `host`, [`InvalidServerEnv::TooLarge`] when an entry or the table is
281    /// too large to pass to a process there.
282    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    /// The host whose name comparison this table follows.
300    #[must_use]
301    pub const fn host(&self) -> HostOs {
302        self.host
303    }
304
305    /// The value of `key`, looked up as the table's host compares names.
306    #[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    /// Whether `key` is set, as the table's host compares names.
314    #[must_use]
315    pub fn contains_key(&self, key: &str) -> bool {
316        self.vars.contains_key(&HostKey::of(key, self.host))
317    }
318
319    /// Sets `key`, replacing the spelling of it that the table's host treats
320    /// as the same variable.
321    ///
322    /// # Errors
323    ///
324    /// [`EnvTooLarge`] when the entry is over [`HostOs::max_env_entry_bytes`]
325    /// or the table would be over [`MAX_ENV_TOTAL_BYTES`], the sizes past which
326    /// a spawn fails with `E2BIG`; the table is left as it was.
327    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    /// The entries, ordered by the host's comparison of the name.
351    pub fn iter(&self) -> impl Iterator<Item = (&EnvKey, &EnvValue)> {
352        self.vars.values().map(|(key, value)| (key, value))
353    }
354
355    /// The number of variables set.
356    #[must_use]
357    pub fn len(&self) -> usize {
358        self.vars.len()
359    }
360
361    /// Whether no variable is set.
362    #[must_use]
363    pub fn is_empty(&self) -> bool {
364        self.vars.is_empty()
365    }
366}
367
368/// The entries without the table's host, which is not serialized.
369impl 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    /// #691: lookups compare names as the host does, for every variable the
493    /// resolution and hardening code asks for.
494    #[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}