Skip to main content

scrollcase_consumer/
environment.rs

1//! Environment resolution shared by every place a box interpreter is run.
2//!
3//! Inheritance is intentionally preserved: this is a provenance and precedence mechanism, not a
4//! sandbox. Layers merge in order with the signed release last, so a release value wins over a
5//! caller's and a caller's wins over the host's.
6//!
7//! Windows names are compared case-insensitively, because passing both `Path` and `PATH` to a child
8//! would leave the winning value to whatever serialises the process environment rather than to this
9//! contract.
10//!
11//! Host values are masked in the report by default. A variable inherited from the machine running
12//! the box can hold a token or a path that identifies someone, and a diagnostic that leaks it by
13//! default would be a worse failure than the one it was printed to diagnose.
14
15use std::collections::BTreeMap;
16
17use crate::error::{fail, Result};
18
19const MASKED_VALUE: &str = "<masked>";
20
21/// Where a value came from, in precedence order.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
23pub enum EnvironmentSource {
24    /// Inherited from the process running the box.
25    Host,
26    /// Supplied by the caller for this run.
27    Caller,
28    /// Forced by a validation run onto one accelerator.
29    Validation,
30    /// Declared by the signed release. Always wins.
31    Release,
32}
33
34impl EnvironmentSource {
35    /// The name this source carries in a report.
36    #[must_use]
37    pub fn as_str(self) -> &'static str {
38        match self {
39            Self::Host => "host",
40            Self::Caller => "caller",
41            Self::Validation => "validation",
42            Self::Release => "release",
43        }
44    }
45}
46
47/// One source's contribution to a variable.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct EnvironmentSourceValue {
50    /// Which layer supplied it.
51    pub source: EnvironmentSource,
52    /// The exact spelling that layer used.
53    pub name: String,
54    /// The value, masked for inherited host values unless explicitly revealed.
55    pub value: String,
56}
57
58/// What the report says about one variable.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct EnvironmentVariableReport {
61    /// Exact spelling of the winning variable.
62    pub name: String,
63    /// The layer that won.
64    pub source: EnvironmentSource,
65    /// The winning value, subject to host-value masking.
66    pub value: String,
67    /// Whether an inherited host variable can change which code the interpreter loads.
68    pub execution_affecting: bool,
69    /// Whether the sources supplied different values.
70    pub conflict: bool,
71    /// Every contribution, in precedence order.
72    pub sources: Vec<EnvironmentSourceValue>,
73}
74
75/// How much of the environment the report shows.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum ReportMode {
78    /// Only what is actionable: release values, conflicts, and dangerous inherited variables.
79    Summary,
80    /// Every variable.
81    Full,
82}
83
84/// A diagnostic snapshot of the environment a box would run with.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct EnvironmentReport {
87    /// Whether every variable is listed, or only the actionable ones.
88    pub mode: ReportMode,
89    /// Whether inherited host values are shown rather than masked.
90    pub host_values_revealed: bool,
91    /// How many variables the signed release declares.
92    pub release_variable_count: usize,
93    /// How many variables more than one source supplied a different value for.
94    pub conflict_count: usize,
95    /// Inherited variables that can change which code the interpreter loads.
96    pub dangerous_host_variables: Vec<String>,
97    /// Variables omitted from a compact summary.
98    pub remaining_variable_count: usize,
99    /// The variables the report lists.
100    pub variables: Vec<EnvironmentVariableReport>,
101}
102
103impl EnvironmentReport {
104    /// Whether the compact default has anything actionable to say.
105    #[must_use]
106    pub fn is_worth_reporting(&self) -> bool {
107        self.mode == ReportMode::Full
108            || self.release_variable_count > 0
109            || self.conflict_count > 0
110            || !self.dangerous_host_variables.is_empty()
111    }
112}
113
114/// One layer of the environment.
115pub struct EnvironmentLayer<'a> {
116    /// Where these values came from.
117    pub source: EnvironmentSource,
118    /// The values themselves.
119    pub values: Vec<(&'a str, &'a str)>,
120}
121
122/// Everything needed to resolve an environment and describe it.
123pub struct ResolveOptions<'a> {
124    /// Target platform, deciding whether names are case-insensitive.
125    pub platform: &'a str,
126    /// The layers, in precedence order.
127    pub layers: Vec<EnvironmentLayer<'a>>,
128    /// Inherited variables that can change which code the interpreter loads.
129    pub execution_affecting_variables: &'a [&'a str],
130    /// Whether to list every variable rather than only the actionable ones.
131    pub expanded: bool,
132    /// Whether to show inherited host values rather than masking them.
133    pub reveal_host_values: bool,
134}
135
136/// The resolved environment, and the diagnostic describing how it was reached.
137pub struct ResolvedEnvironment {
138    /// What the child process receives.
139    pub environment: BTreeMap<String, String>,
140    /// What a caller is told about it.
141    pub report: EnvironmentReport,
142}
143
144fn is_case_insensitive(platform: &str) -> bool {
145    platform == "windows"
146}
147
148fn normalized_name(name: &str, platform: &str) -> String {
149    if is_case_insensitive(platform) {
150        name.to_uppercase()
151    } else {
152        name.to_string()
153    }
154}
155
156/// Refuses a name or value a process environment cannot carry.
157fn check_entry(source: EnvironmentSource, name: &str, value: &str) -> Result<()> {
158    if name.is_empty() || name.contains('=') || name.contains('\0') || value.contains('\0') {
159        fail!(
160            "Box execution {} environment must map valid names to string values.",
161            source.as_str()
162        );
163    }
164    Ok(())
165}
166
167struct Record {
168    sources: Vec<EnvironmentSourceValue>,
169    winner: EnvironmentSourceValue,
170}
171
172/// Describes one variable, and says whether a compact summary should list it.
173///
174/// A variable earns its place in the summary by being declared by the release, by being an inherited
175/// variable that can change executed code, or by having sources that disagree. Everything else is
176/// counted and left out, which is what keeps the default readable on a machine with two hundred
177/// environment variables.
178fn describe(
179    record: &Record,
180    is_dangerous: bool,
181    reveal_host_values: bool,
182) -> (EnvironmentVariableReport, bool) {
183    let has_host = record
184        .sources
185        .iter()
186        .any(|entry| entry.source == EnvironmentSource::Host);
187    let execution_affecting = is_dangerous && has_host;
188    let distinct: std::collections::BTreeSet<&str> = record
189        .sources
190        .iter()
191        .map(|entry| entry.value.as_str())
192        .collect();
193    let conflict = distinct.len() > 1;
194    let visible = |entry: &EnvironmentSourceValue| {
195        if entry.source == EnvironmentSource::Host && !reveal_host_values {
196            MASKED_VALUE.to_string()
197        } else {
198            entry.value.clone()
199        }
200    };
201    let has_release = record
202        .sources
203        .iter()
204        .any(|entry| entry.source == EnvironmentSource::Release);
205    (
206        EnvironmentVariableReport {
207            name: record.winner.name.clone(),
208            source: record.winner.source,
209            value: visible(&record.winner),
210            execution_affecting,
211            conflict,
212            sources: record
213                .sources
214                .iter()
215                .map(|entry| EnvironmentSourceValue {
216                    source: entry.source,
217                    name: entry.name.clone(),
218                    value: visible(entry),
219                })
220                .collect(),
221        },
222        has_release || execution_affecting || conflict,
223    )
224}
225
226/// Merges the layers in precedence order and produces the masked diagnostic.
227///
228/// # Errors
229///
230/// When a layer holds a name or value a process environment cannot carry.
231pub fn resolve_environment(options: &ResolveOptions<'_>) -> Result<ResolvedEnvironment> {
232    let platform = options.platform;
233    let mut records: BTreeMap<String, Record> = BTreeMap::new();
234    let mut environment: BTreeMap<String, String> = BTreeMap::new();
235    let mut environment_names: BTreeMap<String, String> = BTreeMap::new();
236
237    for layer in &options.layers {
238        for (name, value) in &layer.values {
239            check_entry(layer.source, name, value)?;
240            let normalized = normalized_name(name, platform);
241            let contribution = EnvironmentSourceValue {
242                source: layer.source,
243                name: (*name).to_string(),
244                value: (*value).to_string(),
245            };
246            let record = records.entry(normalized.clone()).or_insert_with(|| Record {
247                sources: Vec::new(),
248                winner: contribution.clone(),
249            });
250            record.sources.push(contribution.clone());
251            record.winner = contribution;
252
253            // On Windows a later layer may spell the name differently. Dropping the earlier spelling
254            // keeps exactly one of them in what the child receives.
255            if let Some(previous) = environment_names.get(&normalized) {
256                if previous != name {
257                    environment.remove(previous);
258                }
259            }
260            environment_names.insert(normalized, (*name).to_string());
261            environment.insert((*name).to_string(), (*value).to_string());
262        }
263    }
264
265    let dangerous: Vec<String> = options
266        .execution_affecting_variables
267        .iter()
268        .map(|name| normalized_name(name, platform))
269        .collect();
270
271    let mut all: Vec<(EnvironmentVariableReport, bool)> = records
272        .iter()
273        .map(|(normalized, record)| {
274            describe(record, dangerous.contains(normalized), options.reveal_host_values)
275        })
276        .collect();
277    all.sort_by(|left, right| left.0.name.cmp(&right.0.name));
278
279    let release_variable_count = records
280        .values()
281        .filter(|record| {
282            record
283                .sources
284                .iter()
285                .any(|entry| entry.source == EnvironmentSource::Release)
286        })
287        .count();
288    let conflict_count = all.iter().filter(|(entry, _)| entry.conflict).count();
289    let dangerous_host_variables: Vec<String> = all
290        .iter()
291        .filter(|(entry, _)| entry.execution_affecting)
292        .filter_map(|(entry, _)| {
293            entry
294                .sources
295                .iter()
296                .find(|source| source.source == EnvironmentSource::Host)
297                .map(|source| source.name.clone())
298        })
299        .collect();
300
301    let total = all.len();
302    let variables: Vec<EnvironmentVariableReport> = all
303        .into_iter()
304        .filter(|(_, selected)| options.expanded || *selected)
305        .map(|(entry, _)| entry)
306        .collect();
307
308    Ok(ResolvedEnvironment {
309        environment,
310        report: EnvironmentReport {
311            mode: if options.expanded {
312                ReportMode::Full
313            } else {
314                ReportMode::Summary
315            },
316            host_values_revealed: options.reveal_host_values,
317            release_variable_count,
318            conflict_count,
319            dangerous_host_variables,
320            remaining_variable_count: total - variables.len(),
321            variables,
322        },
323    })
324}
325
326#[cfg(test)]
327mod tests {
328    use super::{
329        resolve_environment, EnvironmentLayer, EnvironmentSource, ReportMode, ResolveOptions,
330    };
331
332    fn options<'a>(
333        platform: &'a str,
334        layers: Vec<EnvironmentLayer<'a>>,
335        dangerous: &'a [&'a str],
336        expanded: bool,
337        reveal: bool,
338    ) -> ResolveOptions<'a> {
339        ResolveOptions {
340            platform,
341            layers,
342            execution_affecting_variables: dangerous,
343            expanded,
344            reveal_host_values: reveal,
345        }
346    }
347
348    fn layer(source: EnvironmentSource, values: &[(&'static str, &'static str)]) -> EnvironmentLayer<'static> {
349        EnvironmentLayer {
350            source,
351            values: values.to_vec(),
352        }
353    }
354
355    #[test]
356    fn the_signed_release_wins_over_the_caller_and_the_host() {
357        let resolved = resolve_environment(&options(
358            "linux",
359            vec![
360                layer(EnvironmentSource::Host, &[("SC_VAR", "host")]),
361                layer(EnvironmentSource::Caller, &[("SC_VAR", "caller")]),
362                layer(EnvironmentSource::Release, &[("SC_VAR", "release")]),
363            ],
364            &[],
365            false,
366            false,
367        ))
368        .unwrap();
369
370        assert_eq!(resolved.environment["SC_VAR"], "release");
371        let variable = &resolved.report.variables[0];
372        assert_eq!(variable.source, EnvironmentSource::Release);
373        assert!(variable.conflict);
374        assert_eq!(resolved.report.conflict_count, 1);
375        // The host contribution is listed, but its value is not.
376        assert_eq!(variable.sources[0].source, EnvironmentSource::Host);
377        assert_eq!(variable.sources[0].value, "<masked>");
378    }
379
380    #[test]
381    fn a_host_value_is_shown_only_when_it_is_explicitly_asked_for() {
382        let layers = || {
383            vec![
384                layer(EnvironmentSource::Host, &[("SC_SECRET", "token")]),
385                layer(EnvironmentSource::Release, &[("SC_SECRET", "declared")]),
386            ]
387        };
388        let masked = resolve_environment(&options("linux", layers(), &[], false, false)).unwrap();
389        assert_eq!(masked.report.variables[0].sources[0].value, "<masked>");
390        assert!(!masked.report.host_values_revealed);
391
392        let revealed = resolve_environment(&options("linux", layers(), &[], false, true)).unwrap();
393        assert_eq!(revealed.report.variables[0].sources[0].value, "token");
394        assert!(revealed.report.host_values_revealed);
395    }
396
397    #[test]
398    fn an_inherited_variable_that_can_change_executed_code_is_always_reported() {
399        // PYTHONPATH is not declared by the release and conflicts with nothing, so only the
400        // execution-affecting rule can put it in a compact summary.
401        let resolved = resolve_environment(&options(
402            "linux",
403            vec![layer(
404                EnvironmentSource::Host,
405                &[("PYTHONPATH", "/host/code"), ("HOME", "/home/someone")],
406            )],
407            &["PYTHONPATH", "LD_PRELOAD"],
408            false,
409            false,
410        ))
411        .unwrap();
412
413        assert_eq!(resolved.report.mode, ReportMode::Summary);
414        assert_eq!(resolved.report.variables.len(), 1);
415        assert_eq!(resolved.report.variables[0].name, "PYTHONPATH");
416        assert!(resolved.report.variables[0].execution_affecting);
417        assert_eq!(resolved.report.dangerous_host_variables, ["PYTHONPATH"]);
418        // HOME is ordinary, so it is counted but not listed.
419        assert_eq!(resolved.report.remaining_variable_count, 1);
420    }
421
422    #[test]
423    fn a_release_variable_alone_is_not_a_conflict() {
424        let resolved = resolve_environment(&options(
425            "linux",
426            vec![layer(EnvironmentSource::Release, &[("SC_ONLY", "value")])],
427            &[],
428            false,
429            false,
430        ))
431        .unwrap();
432        assert_eq!(resolved.report.release_variable_count, 1);
433        assert_eq!(resolved.report.conflict_count, 0);
434        assert!(!resolved.report.variables[0].conflict);
435    }
436
437    #[test]
438    fn windows_names_collapse_by_case_so_only_one_reaches_the_child() {
439        let resolved = resolve_environment(&options(
440            "windows",
441            vec![
442                layer(EnvironmentSource::Host, &[("Path", "C:\\host")]),
443                layer(EnvironmentSource::Release, &[("PATH", "C:\\box")]),
444            ],
445            &[],
446            true,
447            false,
448        ))
449        .unwrap();
450
451        assert_eq!(resolved.environment.len(), 1);
452        assert_eq!(resolved.environment["PATH"], "C:\\box");
453        assert_eq!(resolved.report.variables.len(), 1);
454        assert!(resolved.report.variables[0].conflict);
455
456        // The same two names stay separate where case matters.
457        let posix = resolve_environment(&options(
458            "linux",
459            vec![
460                layer(EnvironmentSource::Host, &[("Path", "/host")]),
461                layer(EnvironmentSource::Release, &[("PATH", "/box")]),
462            ],
463            &[],
464            true,
465            false,
466        ))
467        .unwrap();
468        assert_eq!(posix.environment.len(), 2);
469    }
470
471    #[test]
472    fn a_name_a_process_environment_cannot_carry_is_refused() {
473        for (name, value) in [("", "v"), ("A=B", "v"), ("A\0B", "v"), ("A", "v\0")] {
474            let result = resolve_environment(&options(
475                "linux",
476                vec![EnvironmentLayer {
477                    source: EnvironmentSource::Release,
478                    values: vec![(name, value)],
479                }],
480                &[],
481                false,
482                false,
483            ));
484            assert!(result.is_err(), "{name}={value} was accepted");
485        }
486    }
487
488    #[test]
489    fn the_full_report_lists_everything_the_summary_counts() {
490        let layers = || {
491            vec![layer(
492                EnvironmentSource::Host,
493                &[("A", "1"), ("B", "2"), ("C", "3")],
494            )]
495        };
496        let summary = resolve_environment(&options("linux", layers(), &[], false, false)).unwrap();
497        assert!(summary.report.variables.is_empty());
498        assert_eq!(summary.report.remaining_variable_count, 3);
499        assert!(!summary.report.is_worth_reporting());
500
501        let full = resolve_environment(&options("linux", layers(), &[], true, false)).unwrap();
502        assert_eq!(full.report.variables.len(), 3);
503        assert_eq!(full.report.remaining_variable_count, 0);
504        assert!(full.report.is_worth_reporting());
505    }
506}