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::impl_text_newtype;
10
11/// Why a string is not a valid [`EnvKey`].
12#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
13pub enum InvalidEnvKey {
14    /// The name was empty.
15    #[error("environment variable name cannot be empty")]
16    Empty,
17    /// The name contained `=`, which separates a name from its value, or a NUL.
18    #[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/// The name of an environment variable: non-empty, with no `=` and no NUL, so
47/// the operating system accepts it as one.
48///
49/// Deserializes from a TOML string and rejects any other name at load time.
50///
51/// # Examples
52///
53/// ```
54/// use mcpls_core::config::EnvKey;
55///
56/// assert_eq!(EnvKey::new("RUST_LOG").unwrap(), "RUST_LOG");
57/// assert!(EnvKey::new("").is_err());
58/// assert!(EnvKey::new("A=B").is_err());
59/// ```
60#[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/// Two keys of one `env` table name the same variable on the host.
79#[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    /// The key seen first.
85    pub first: EnvKey,
86    /// The key that collides with it.
87    pub second: EnvKey,
88}
89
90/// A variable name as a host compares it: the key a table is indexed by.
91#[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/// Environment variable overrides for a server process.
104///
105/// Holds at most one key per variable as the host compares names (exactly on
106/// Unix, ASCII-case-insensitively on Windows): the table is indexed by the
107/// host's comparison of the name, so a lookup or a write cannot meet two
108/// spellings of one variable and mcpls resolves the same value the child
109/// process sees. The host is fixed at construction, so a table built for one
110/// host cannot be read or extended as another. Keys come in through
111/// [`Self::from_entries`], which rejects collisions, or [`Self::insert`],
112/// which replaces them.
113///
114/// # Examples
115///
116/// ```
117/// use mcpls_core::config::{EnvKey, ServerEnv};
118/// use mcpls_core::lsp::HostOs;
119///
120/// let mut env = ServerEnv::new(HostOs::Windows);
121/// env.insert(EnvKey::new("Path").unwrap(), "/a".to_owned());
122/// assert_eq!(env.get("PATH"), Some("/a"));
123///
124/// env.insert(EnvKey::new("PATH").unwrap(), "/b".to_owned());
125/// assert_eq!(env.len(), 1);
126///
127/// let mut other = ServerEnv::new(HostOs::Other);
128/// other.insert(EnvKey::new("Path").unwrap(), "/a".to_owned());
129/// assert_eq!(other.get("PATH"), None);
130/// ```
131#[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    /// An empty table whose names compare as `host` compares them.
145    #[must_use]
146    pub const fn new(host: HostOs) -> Self {
147        Self {
148            host,
149            vars: BTreeMap::new(),
150        }
151    }
152
153    /// Builds the table from `entries`, as `host` compares names.
154    ///
155    /// # Errors
156    ///
157    /// [`DuplicateEnvKey`] when two keys name one variable on `host`.
158    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    /// The host whose name comparison this table follows.
176    #[must_use]
177    pub const fn host(&self) -> HostOs {
178        self.host
179    }
180
181    /// The value of `key`, looked up as the table's host compares names.
182    #[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    /// Whether `key` is set, as the table's host compares names.
190    #[must_use]
191    pub fn contains_key(&self, key: &str) -> bool {
192        self.vars.contains_key(&HostKey::of(key, self.host))
193    }
194
195    /// Sets `key`, replacing the spelling of it that the table's host treats
196    /// as the same variable.
197    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    /// The entries, ordered by the host's comparison of the name.
203    pub fn iter(&self) -> impl Iterator<Item = (&EnvKey, &String)> {
204        self.vars.values().map(|(key, value)| (key, value))
205    }
206
207    /// The number of variables set.
208    #[must_use]
209    pub fn len(&self) -> usize {
210        self.vars.len()
211    }
212
213    /// Whether no variable is set.
214    #[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    /// #691: lookups compare names as the host does, for every variable the
264    /// resolution and hardening code asks for.
265    #[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}