Skip to main content

cageforge_command/
environment.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Environment bases, filters, and overrides for [`crate::EnvironmentSpec`].
4//!
5//! This module describes transformations but does not discover the operating
6//! system's core variables. A backend supplies that base when it applies the
7//! [`crate::EnvironmentBase::Core`] request.
8
9use std::cmp::Ordering;
10use std::collections::{BTreeMap, HashMap};
11use std::ffi::{OsStr, OsString};
12use std::hash::{Hash, Hasher};
13
14#[cfg(unix)]
15use std::os::unix::ffi::OsStrExt;
16#[cfg(windows)]
17use std::os::windows::ffi::OsStrExt;
18
19use wildmatch::WildMatch;
20
21use crate::CommandError;
22use crate::command::contains_nul;
23
24mod model;
25
26use model::{CaseFoldedText, EnvironmentNameIdentity};
27pub use model::{
28    CoreEnvironment, EnvironmentBase, EnvironmentFilterAction, EnvironmentInput,
29    EnvironmentNameKey, EnvironmentOverride, EnvironmentPattern, EnvironmentSpec,
30};
31
32impl PartialEq for EnvironmentPattern {
33    fn eq(&self, other: &Self) -> bool {
34        self.canonical == other.canonical
35    }
36}
37
38impl Eq for EnvironmentPattern {}
39
40impl Hash for EnvironmentPattern {
41    fn hash<H: Hasher>(&self, state: &mut H) {
42        self.canonical.hash(state);
43    }
44}
45
46impl PartialOrd for EnvironmentPattern {
47    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
48        Some(self.cmp(other))
49    }
50}
51
52impl Ord for EnvironmentPattern {
53    fn cmp(&self, other: &Self) -> Ordering {
54        self.canonical.cmp(&other.canonical)
55    }
56}
57
58impl EnvironmentPattern {
59    /// Creates a validated environment-variable pattern.
60    pub fn new(pattern: impl Into<String>) -> Result<Self, CommandError> {
61        let pattern = pattern.into();
62        if pattern.is_empty() {
63            return Err(CommandError::EmptyEnvironmentPattern);
64        }
65        if contains_nul(OsStr::new(&pattern)) {
66            return Err(CommandError::EnvironmentPatternContainsNul);
67        }
68        if pattern.contains('=') {
69            return Err(CommandError::EnvironmentPatternContainsEquals);
70        }
71        Ok(Self {
72            canonical: case_folded_text(&pattern),
73            matcher: WildMatch::new_case_insensitive(&pattern),
74            original: pattern,
75        })
76    }
77
78    /// Returns the original pattern text.
79    ///
80    /// Trait identity is based on the same case-insensitive canonical form as
81    /// [`Self::matches`], while this accessor preserves the caller's spelling
82    /// for diagnostics and serialization.
83    pub fn as_str(&self) -> &str {
84        &self.original
85    }
86
87    /// Returns whether this pattern matches an environment variable name.
88    pub fn matches(&self, name: &str) -> bool {
89        self.matcher.matches(name)
90    }
91}
92
93impl EnvironmentNameKey {
94    /// Creates the policy identity for one native environment-variable name.
95    pub fn new(name: &OsStr) -> Self {
96        Self(environment_name_identity(name))
97    }
98}
99
100impl CoreEnvironment {
101    /// Creates a validated core snapshot from variables selected by the
102    /// process adapter.
103    pub fn from_selected<I>(variables: I) -> Result<Self, CommandError>
104    where
105        I: IntoIterator<Item = (OsString, OsString)>,
106    {
107        Ok(Self {
108            variables: collect_environment(variables)?,
109        })
110    }
111
112    /// Returns the selected core variables.
113    pub fn variables(&self) -> &BTreeMap<OsString, OsString> {
114        &self.variables
115    }
116}
117
118impl EnvironmentInput {
119    /// Creates a validated input containing all inherited variables.
120    pub fn all<I>(variables: I) -> Result<Self, CommandError>
121    where
122        I: IntoIterator<Item = (OsString, OsString)>,
123    {
124        Ok(Self {
125            base: EnvironmentBase::All,
126            variables: collect_environment(variables)?,
127        })
128    }
129
130    /// Creates an input containing a process adapter's selected core set.
131    pub fn core(environment: CoreEnvironment) -> Self {
132        Self {
133            base: EnvironmentBase::Core,
134            variables: environment.variables,
135        }
136    }
137
138    /// Creates an input with no inherited variables.
139    pub fn empty() -> Self {
140        Self {
141            base: EnvironmentBase::None,
142            variables: BTreeMap::new(),
143        }
144    }
145
146    /// Returns the declared base represented by this input.
147    pub const fn base(&self) -> EnvironmentBase {
148        self.base
149    }
150
151    /// Returns the variables represented by this input.
152    pub fn variables(&self) -> &BTreeMap<OsString, OsString> {
153        &self.variables
154    }
155
156    /// Consumes the input and returns its selected variables.
157    pub fn into_variables(self) -> BTreeMap<OsString, OsString> {
158        self.variables
159    }
160}
161
162impl PartialEq for EnvironmentSpec {
163    fn eq(&self, other: &Self) -> bool {
164        self.base == other.base
165            && self.filters == other.filters
166            && self.override_names.len() == other.override_names.len()
167            && self.override_names.iter().all(|(key, name)| {
168                let Some(other_name) = other.override_names.get(key) else {
169                    return false;
170                };
171                self.overrides.get(name) == other.overrides.get(other_name)
172            })
173    }
174}
175
176impl Eq for EnvironmentSpec {}
177
178impl EnvironmentSpec {
179    /// Creates an environment that inherits all parent variables.
180    pub fn inherit_all() -> Self {
181        Self {
182            base: EnvironmentBase::All,
183            overrides: BTreeMap::new(),
184            override_names: HashMap::new(),
185            filters: BTreeMap::new(),
186        }
187    }
188
189    /// Creates an environment that starts empty.
190    pub fn empty() -> Self {
191        Self {
192            base: EnvironmentBase::None,
193            overrides: BTreeMap::new(),
194            override_names: HashMap::new(),
195            filters: BTreeMap::new(),
196        }
197    }
198
199    /// Creates an environment that inherits the platform's core variables.
200    pub fn inherit_core() -> Self {
201        Self {
202            base: EnvironmentBase::Core,
203            overrides: BTreeMap::new(),
204            override_names: HashMap::new(),
205            filters: BTreeMap::new(),
206        }
207    }
208
209    /// Returns the selected base environment behavior.
210    pub fn base(&self) -> EnvironmentBase {
211        self.base
212    }
213
214    /// Returns all explicit variable overrides in deterministic key order.
215    ///
216    /// The spelling of a name is retained for backend diagnostics, but names
217    /// are one case-insensitive logical namespace for lookup and equality.
218    pub fn overrides(&self) -> &BTreeMap<OsString, EnvironmentOverride> {
219        &self.overrides
220    }
221
222    /// Returns canonical environment filters in deterministic pattern order.
223    pub fn filters(&self) -> &BTreeMap<EnvironmentPattern, EnvironmentFilterAction> {
224        &self.filters
225    }
226
227    /// Returns the override for one variable, if present.
228    pub fn override_for(&self, name: &OsStr) -> Option<&EnvironmentOverride> {
229        self.override_names
230            .get(&EnvironmentNameKey::new(name))
231            .and_then(|name| self.overrides.get(name))
232    }
233
234    /// Returns the filter action for a variable name, if a filter matches.
235    ///
236    /// Exclude always wins when both actions match. A backend can use the
237    /// presence of any include filter together with this result to implement
238    /// the complete inherited-environment decision without reimplementing
239    /// wildcard precedence.
240    pub fn filter_action_for(&self, name: &str) -> Option<EnvironmentFilterAction> {
241        let mut include_matches = false;
242        for (pattern, action) in &self.filters {
243            if pattern.matches(name) {
244                match action {
245                    EnvironmentFilterAction::Include => include_matches = true,
246                    EnvironmentFilterAction::Exclude => {
247                        return Some(EnvironmentFilterAction::Exclude);
248                    }
249                }
250            }
251        }
252        include_matches.then_some(EnvironmentFilterAction::Include)
253    }
254
255    /// Applies filters and explicit overrides to an already selected base
256    /// environment.
257    ///
258    /// The caller selects the `All`, `Core`, or `None` base at the backend
259    /// boundary. A broader input is rejected before transformation. This
260    /// method then applies the portable sequence `exclude -> set/remove ->
261    /// include` and keeps the validated base tag on the returned snapshot. A
262    /// variable removed by an exclude is not restored by an include; an
263    /// explicit set is applied after exclusion and can intentionally
264    /// reintroduce that named variable.
265    pub fn apply_to(&self, input: EnvironmentInput) -> Result<EnvironmentInput, CommandError> {
266        if !base_is_at_most(input.base, self.base) {
267            return Err(CommandError::EnvironmentBaseTooPermissive {
268                required: self.base,
269                supplied: input.base,
270            });
271        }
272        let base = input.base;
273        let variables = input.variables;
274        let mut environment = BTreeMap::new();
275        let mut environment_names = HashMap::new();
276        for (name, value) in variables {
277            remove_environment_name(&mut environment, &mut environment_names, &name);
278            environment_names.insert(EnvironmentNameKey::new(&name), name.clone());
279            environment.insert(name, value);
280        }
281        let has_include_filter = self
282            .filters
283            .values()
284            .any(|action| *action == EnvironmentFilterAction::Include);
285
286        environment.retain(|name, _| {
287            self.filters.is_empty()
288                || name.to_str().is_some_and(|name| {
289                    !self.filters.iter().any(|(pattern, action)| {
290                        *action == EnvironmentFilterAction::Exclude && pattern.matches(name)
291                    })
292                })
293        });
294
295        for (name, value) in &self.overrides {
296            match value {
297                EnvironmentOverride::Set(value) => {
298                    remove_environment_name(&mut environment, &mut environment_names, name);
299                    environment_names.insert(EnvironmentNameKey::new(name), name.clone());
300                    environment.insert(name.clone(), value.clone());
301                }
302                EnvironmentOverride::Remove => {
303                    remove_environment_name(&mut environment, &mut environment_names, name);
304                }
305            }
306        }
307
308        if has_include_filter {
309            environment.retain(|name, _| {
310                name.to_str().is_some_and(|name| {
311                    self.filters.iter().any(|(pattern, action)| {
312                        *action == EnvironmentFilterAction::Include && pattern.matches(name)
313                    })
314                })
315            });
316        } else if !self.filters.is_empty() {
317            environment.retain(|name, _| name.to_str().is_some());
318        }
319
320        Ok(EnvironmentInput {
321            base,
322            variables: environment,
323        })
324    }
325
326    /// Adds a variable assignment and returns the updated environment.
327    pub fn with_var(
328        mut self,
329        name: impl Into<OsString>,
330        value: impl Into<OsString>,
331    ) -> Result<Self, CommandError> {
332        let name = name.into();
333        let value = value.into();
334        validate_name(&name)?;
335        if contains_nul(&value) {
336            return Err(CommandError::EnvironmentValueContainsNul);
337        }
338        remove_environment_name(&mut self.overrides, &mut self.override_names, &name);
339        self.override_names
340            .insert(EnvironmentNameKey::new(&name), name.clone());
341        self.overrides.insert(name, EnvironmentOverride::Set(value));
342        Ok(self)
343    }
344
345    /// Adds a variable removal and returns the updated environment.
346    pub fn without_var(mut self, name: impl Into<OsString>) -> Result<Self, CommandError> {
347        let name = name.into();
348        validate_name(&name)?;
349        remove_environment_name(&mut self.overrides, &mut self.override_names, &name);
350        self.override_names
351            .insert(EnvironmentNameKey::new(&name), name.clone());
352        self.overrides.insert(name, EnvironmentOverride::Remove);
353        Ok(self)
354    }
355
356    /// Adds or replaces a canonical environment filter.
357    pub fn with_filter(
358        mut self,
359        pattern: impl Into<String>,
360        action: EnvironmentFilterAction,
361    ) -> Result<Self, CommandError> {
362        let pattern = EnvironmentPattern::new(pattern)?;
363        self.filters.remove(&pattern);
364        self.filters.insert(pattern, action);
365        Ok(self)
366    }
367
368    /// Adds an include filter.
369    pub fn with_include_pattern(self, pattern: impl Into<String>) -> Result<Self, CommandError> {
370        self.with_filter(pattern, EnvironmentFilterAction::Include)
371    }
372
373    /// Adds an exclude filter.
374    pub fn with_exclude_pattern(self, pattern: impl Into<String>) -> Result<Self, CommandError> {
375        self.with_filter(pattern, EnvironmentFilterAction::Exclude)
376    }
377}
378
379impl Default for EnvironmentSpec {
380    fn default() -> Self {
381        Self::inherit_core()
382    }
383}
384
385fn validate_name(name: &OsStr) -> Result<(), CommandError> {
386    if name.is_empty() {
387        return Err(CommandError::EmptyEnvironmentName);
388    }
389    if contains_nul(name) {
390        return Err(CommandError::EnvironmentNameContainsNul);
391    }
392    if name.to_string_lossy().contains('=') {
393        return Err(CommandError::EnvironmentNameContainsEquals);
394    }
395    Ok(())
396}
397
398fn collect_environment<I>(variables: I) -> Result<BTreeMap<OsString, OsString>, CommandError>
399where
400    I: IntoIterator<Item = (OsString, OsString)>,
401{
402    let mut collected = BTreeMap::new();
403    let mut names = HashMap::new();
404    for (name, value) in variables {
405        validate_name(&name)?;
406        if contains_nul(&value) {
407            return Err(CommandError::EnvironmentValueContainsNul);
408        }
409        remove_environment_name(&mut collected, &mut names, &name);
410        names.insert(EnvironmentNameKey::new(&name), name.clone());
411        collected.insert(name, value);
412    }
413    Ok(collected)
414}
415
416fn remove_environment_name<V>(
417    values: &mut BTreeMap<OsString, V>,
418    names: &mut HashMap<EnvironmentNameKey, OsString>,
419    name: &OsStr,
420) {
421    if let Some(existing) = names.remove(&EnvironmentNameKey::new(name)) {
422        values.remove(&existing);
423    }
424}
425
426fn environment_name_identity(name: &OsStr) -> EnvironmentNameIdentity {
427    if let Some(name) = name.to_str() {
428        return EnvironmentNameIdentity::Folded(case_folded_text(name));
429    }
430    #[cfg(unix)]
431    {
432        EnvironmentNameIdentity::NativeBytes(name.as_bytes().to_vec())
433    }
434    #[cfg(windows)]
435    {
436        EnvironmentNameIdentity::NativeWide(name.encode_wide().collect())
437    }
438}
439
440fn case_folded_text(value: &str) -> CaseFoldedText {
441    CaseFoldedText(
442        value
443            .chars()
444            .map(|character| character.to_lowercase().collect())
445            .collect(),
446    )
447}
448
449fn base_is_at_most(supplied: EnvironmentBase, required: EnvironmentBase) -> bool {
450    match required {
451        EnvironmentBase::None => supplied == EnvironmentBase::None,
452        EnvironmentBase::Core => supplied != EnvironmentBase::All,
453        EnvironmentBase::All => true,
454    }
455}