1use std::collections::BTreeMap;
16
17use crate::error::{fail, Result};
18
19const MASKED_VALUE: &str = "<masked>";
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
23pub enum EnvironmentSource {
24 Host,
26 Caller,
28 Validation,
30 Release,
32}
33
34impl EnvironmentSource {
35 #[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#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct EnvironmentSourceValue {
50 pub source: EnvironmentSource,
52 pub name: String,
54 pub value: String,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct EnvironmentVariableReport {
61 pub name: String,
63 pub source: EnvironmentSource,
65 pub value: String,
67 pub execution_affecting: bool,
69 pub conflict: bool,
71 pub sources: Vec<EnvironmentSourceValue>,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum ReportMode {
78 Summary,
80 Full,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct EnvironmentReport {
87 pub mode: ReportMode,
89 pub host_values_revealed: bool,
91 pub release_variable_count: usize,
93 pub conflict_count: usize,
95 pub dangerous_host_variables: Vec<String>,
97 pub remaining_variable_count: usize,
99 pub variables: Vec<EnvironmentVariableReport>,
101}
102
103impl EnvironmentReport {
104 #[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
114pub struct EnvironmentLayer<'a> {
116 pub source: EnvironmentSource,
118 pub values: Vec<(&'a str, &'a str)>,
120}
121
122pub struct ResolveOptions<'a> {
124 pub platform: &'a str,
126 pub layers: Vec<EnvironmentLayer<'a>>,
128 pub execution_affecting_variables: &'a [&'a str],
130 pub expanded: bool,
132 pub reveal_host_values: bool,
134}
135
136pub struct ResolvedEnvironment {
138 pub environment: BTreeMap<String, String>,
140 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
156fn 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
172fn 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
226pub 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 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 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 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 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 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}