running_process_platform_internal/env.rs
1//! The mechanism for reading declared environment variables (#1101).
2//!
3//! This is *how* a variable is read -- the kinds, the owner, the typed
4//! accessors and the boolean parsers -- with no variable names in it. It lives
5//! here, below `running-process`, so every crate in the workspace can share
6//! one parser instead of growing its own; `running_process::env_vars`
7//! re-exports it and keeps the policy: which variables exist, and what each
8//! means.
9//!
10//! # Why booleans get two accessors rather than one
11//!
12//! "Is this switch on?" has two defensible answers when the value is neither
13//! clearly on nor clearly off, and which one is right depends on who owns the
14//! variable -- not on the call site, which is how a codebase ends up with five
15//! parsers that disagree.
16//!
17//! - [`flag_owned`] is for switches the reader defines. Unknown means **off**.
18//! - [`flag_foreign`] is for values written by someone else. Unknown means
19//! **on**, so a stray `=0` cannot exempt a process from being reaped.
20//! - [`flag_opt_out`] is for an escape hatch that is on until someone turns it
21//! off. Unset means **on**.
22//!
23//! All three trim and lowercase before comparing, so `" True "` and `"TRUE"`
24//! agree.
25
26use std::ffi::OsStr;
27
28/// What kind of value a variable carries, and how it is read.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum EnvKind {
31 /// A switch the reader defines. Unknown values are off; see [`flag_owned`].
32 OwnedFlag,
33 /// A switch whose value space belongs to someone else. Unknown values are
34 /// on; see [`flag_foreign`].
35 ForeignFlag,
36 /// An escape hatch that is on unless explicitly turned off. Unset is on;
37 /// see [`flag_opt_out`].
38 OptOutFlag,
39 /// A switch that is on for exactly one spelling and off for every other,
40 /// including plausible ones. Reserved for guards where honouring a
41 /// misspelling would be the dangerous direction.
42 ExactValue(&'static str),
43 /// A filesystem path.
44 Path,
45 /// Free text -- a name, scope, endpoint, or token.
46 Text,
47 /// A number: a count, a timeout in milliseconds, a port, a descriptor.
48 ///
49 /// `zero_selects_default` records what `0` means for *this* variable,
50 /// because it is not the same answer everywhere. A connect timeout of zero
51 /// makes every connection fail instantly and is never what anyone wants,
52 /// so zero falls back to the default. A drain timeout of zero means "do
53 /// not wait", which is a perfectly reasonable thing to ask for, so zero is
54 /// honoured. Leaving this unstated is how the two ended up parsed
55 /// differently by accident.
56 Number { zero_selects_default: bool },
57}
58
59/// Who decides what values a variable may take.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum Owner {
62 /// Defined by the reader; the value space is ours.
63 Crate,
64 /// Set by a supervising process or a test harness; we only read it.
65 Foreign,
66}
67
68/// One environment variable the reader reads.
69#[derive(Debug, Clone, Copy)]
70pub struct EnvVar {
71 /// The variable name as it appears in the environment.
72 pub name: &'static str,
73 /// What the value means and how it is parsed.
74 pub kind: EnvKind,
75 /// Who owns the value space.
76 pub owner: Owner,
77 /// What happens when the variable is unset.
78 pub default: &'static str,
79 /// One line an embedder can read to know whether they care.
80 pub summary: &'static str,
81}
82
83impl EnvVar {
84 /// Read this variable as a boolean, using the semantics it declares.
85 ///
86 /// # Panics
87 /// If the variable is not declared as a flag. That is a programming error
88 /// in the reader, caught by `an_unset_flag_matches_its_declared_default`,
89 /// not something a value in the environment can cause.
90 pub fn is_set(&self) -> bool {
91 match self.kind {
92 EnvKind::OwnedFlag => flag_owned(self.name),
93 EnvKind::ForeignFlag => flag_foreign(self.name),
94 EnvKind::OptOutFlag => flag_opt_out(self.name),
95 EnvKind::ExactValue(expected) => {
96 std::env::var_os(self.name).is_some_and(|value| value == OsStr::new(expected))
97 }
98 other => panic!("{} is declared as {other:?}, not a flag", self.name),
99 }
100 }
101}
102
103impl EnvVar {
104 /// Read this variable as a count, falling back to `default`.
105 ///
106 /// A value that is not a number is not a smaller number: it is a mistake,
107 /// and the default is a better answer than a silently-wrong one.
108 pub fn count_or(&self, default: usize) -> usize {
109 self.parsed::<usize>().unwrap_or(default)
110 }
111
112 /// Read this variable as a millisecond duration, falling back to `default`.
113 pub fn millis_or(&self, default: std::time::Duration) -> std::time::Duration {
114 self.parsed::<u64>()
115 .map(std::time::Duration::from_millis)
116 .unwrap_or(default)
117 }
118
119 /// Read this variable as a port number, if it names one.
120 pub fn port(&self) -> Option<u16> {
121 self.parsed::<u16>()
122 }
123
124 /// Whether the variable is present at all, whatever its value -- including
125 /// empty or `0`.
126 ///
127 /// Several older switches are read this way, and narrowing them to
128 /// recognised spellings would turn `=0` from "on" into "off" for anyone who
129 /// already relies on it. It is a method rather than an [`EnvKind`] variant
130 /// because `EnvKind` is public and exhaustive: adding a variant would break
131 /// a downstream `match` in a 4.x release.
132 pub fn is_present(&self) -> bool {
133 std::env::var_os(self.name).is_some()
134 }
135
136 /// Read this variable as text, if it is set to anything.
137 pub fn text(&self) -> Option<String> {
138 std::env::var(self.name)
139 .ok()
140 .filter(|value| !value.is_empty())
141 }
142
143 /// Read this variable exactly as the host wrote it.
144 ///
145 /// Unlike [`EnvVar::path`] an empty value is still `Some`: some readers
146 /// have always treated `VAR=` as set, and this keeps them doing so.
147 pub fn os(&self) -> Option<std::ffi::OsString> {
148 std::env::var_os(self.name)
149 }
150
151 /// Read this variable as Unicode exactly as written, empty included.
152 ///
153 /// `None` when unset *or* not valid Unicode, matching `std::env::var`.
154 pub fn string(&self) -> Option<String> {
155 std::env::var(self.name).ok()
156 }
157
158 /// Read this variable as a path, if it is set to anything.
159 ///
160 /// Takes the value as the host wrote it: a path that is not valid Unicode
161 /// is still a path, and lossily repairing it would point somewhere else.
162 pub fn path(&self) -> Option<std::path::PathBuf> {
163 std::env::var_os(self.name)
164 .filter(|value| !value.is_empty())
165 .map(std::path::PathBuf::from)
166 }
167
168 /// Parse the value, applying this variable's declared rule for zero.
169 ///
170 /// # Panics
171 /// If the variable is not declared as a number. A programming error in
172 /// the reader, not something the environment can cause.
173 fn parsed<T>(&self) -> Option<T>
174 where
175 T: std::str::FromStr + Default + PartialEq,
176 {
177 let EnvKind::Number {
178 zero_selects_default,
179 } = self.kind
180 else {
181 panic!("{} is declared as {:?}, not a number", self.name, self.kind);
182 };
183 let parsed: T = std::env::var(self.name)
184 .ok()?
185 .trim()
186 .parse()
187 .ok()
188 .filter(|value: &T| !(zero_selects_default && *value == T::default()))?;
189 Some(parsed)
190 }
191}
192
193/// Spellings that turn an owned switch on. Anything else, including an
194/// unrecognised value, leaves it off.
195const AFFIRMATIVE: &[&str] = &["1", "true", "yes", "on"];
196
197/// Spellings that turn a foreign switch off. Anything else, including an
198/// unrecognised value, leaves it on.
199const NEGATIVE: &[&str] = &["", "0", "false", "no", "off"];
200
201/// Read a switch the reader owns: on only for a recognised affirmative.
202pub fn flag_owned(name: &str) -> bool {
203 match std::env::var_os(name) {
204 Some(value) => AFFIRMATIVE.contains(&normalize(&value).as_str()),
205 None => false,
206 }
207}
208
209/// Read a switch someone else writes: off only for a recognised negative.
210///
211/// Unset is still off -- absence is not a value, and reading it as "on" would
212/// make every process claim every marker.
213pub fn flag_foreign(name: &str) -> bool {
214 match std::env::var_os(name) {
215 Some(value) => !NEGATIVE.contains(&normalize(&value).as_str()),
216 None => false,
217 }
218}
219
220/// Read an escape hatch that is on unless turned off.
221///
222/// Unset is *on*, which is what separates this from [`flag_foreign`]: the
223/// caller is asking whether the default behaviour still applies, and it does
224/// until someone says otherwise. Every recognised falsy spelling opens the
225/// hatch, so a user who reaches for `=false` or `=off` gets the fallback they
226/// were plainly asking for rather than silently keeping the default.
227pub fn flag_opt_out(name: &str) -> bool {
228 match std::env::var_os(name) {
229 Some(value) => !NEGATIVE.contains(&normalize(&value).as_str()),
230 None => true,
231 }
232}
233
234/// Read a variable whose *name* is caller-supplied data, exactly as the host
235/// wrote it.
236///
237/// For the few readers that are handed a name rather than choosing one -- a
238/// descriptor-passing key agreed with a parent, an embedder's disclosure
239/// allowlist. There is nothing to declare for those: the variable belongs to
240/// whoever supplied the name. Every other read goes through a declared
241/// [`EnvVar`]; the `running_process_env_direct` Dylint lint rejects a string
242/// literal passed here, so this cannot become a way around declaring one.
243pub fn os_named(name: &str) -> Option<std::ffi::OsString> {
244 std::env::var_os(name)
245}
246
247/// [`os_named`] as Unicode: `None` when unset *or* not valid Unicode,
248/// matching `std::env::var`.
249pub fn string_named(name: &str) -> Option<String> {
250 std::env::var(name).ok()
251}
252
253/// Whether a *value already in hand* reads as a foreign switch being on.
254///
255/// Callers that scan another process's environment block have the value
256/// without being able to read it from their own environment.
257pub fn value_is_affirmative_foreign(value: &str) -> bool {
258 !NEGATIVE.contains(&value.trim().to_ascii_lowercase().as_str())
259}
260
261/// Declare environment variables as `EnvVar` constants plus a table of all of
262/// them, so a crate's inventory is one list rather than scattered literals.
263///
264/// ```ignore
265/// declare_env_vars! {
266/// /// The table's doc.
267/// pub const TABLE;
268/// HOME => "HOME", EnvKind::Path, Owner::Foreign, "unset", "Home dir.";
269/// }
270/// ```
271#[macro_export]
272macro_rules! declare_env_vars {
273 (
274 $(#[$table_meta:meta])*
275 pub const $table:ident;
276 $($ident:ident => $name:literal, $kind:expr, $owner:expr, $default:literal, $summary:literal;)*
277 ) => {
278 $(
279 #[doc = $summary]
280 ///
281 #[doc = concat!("Environment variable `", $name, "`. Unset: ", $default, ".")]
282 pub const $ident: $crate::env::EnvVar = $crate::env::EnvVar {
283 name: $name,
284 kind: $kind,
285 owner: $owner,
286 default: $default,
287 summary: $summary,
288 };
289 )*
290
291 $(#[$table_meta])*
292 pub const $table: &[$crate::env::EnvVar] = &[$($ident),*];
293 };
294}
295
296fn normalize(value: &OsStr) -> String {
297 value.to_string_lossy().trim().to_ascii_lowercase()
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303
304 /// These tests mutate the real process environment, so two of them setting
305 /// the same variable at once would read each other's value.
306 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
307
308 fn with_var<T>(name: &str, value: Option<&str>, body: impl FnOnce() -> T) -> T {
309 let _guard = ENV_LOCK
310 .lock()
311 .unwrap_or_else(std::sync::PoisonError::into_inner);
312 let previous = std::env::var_os(name);
313 match value {
314 Some(value) => std::env::set_var(name, value),
315 None => std::env::remove_var(name),
316 }
317 let outcome = body();
318 match previous {
319 Some(previous) => std::env::set_var(name, previous),
320 None => std::env::remove_var(name),
321 }
322 outcome
323 }
324
325 const PROBE: &str = "PLATFORM_INTERNAL_ENV_MECHANISM_PROBE";
326
327 fn var(kind: EnvKind) -> EnvVar {
328 EnvVar {
329 name: PROBE,
330 kind,
331 owner: Owner::Crate,
332 default: "unset",
333 summary: "test probe",
334 }
335 }
336
337 #[test]
338 fn the_three_flag_readers_disagree_only_on_unrecognised_values_and_unset() {
339 for (value, owned, foreign, opt_out) in [
340 (Some("1"), true, true, true),
341 (Some(" TRUE "), true, true, true),
342 (Some("0"), false, false, false),
343 (Some("off"), false, false, false),
344 (Some("maybe"), false, true, true),
345 (None, false, false, true),
346 ] {
347 assert_eq!(
348 with_var(PROBE, value, || flag_owned(PROBE)),
349 owned,
350 "{value:?}"
351 );
352 assert_eq!(
353 with_var(PROBE, value, || flag_foreign(PROBE)),
354 foreign,
355 "{value:?}"
356 );
357 assert_eq!(
358 with_var(PROBE, value, || flag_opt_out(PROBE)),
359 opt_out,
360 "{value:?}"
361 );
362 }
363 }
364
365 #[test]
366 fn a_declared_flag_reads_through_its_own_semantics() {
367 assert!(with_var(PROBE, Some("yes"), || var(EnvKind::OwnedFlag).is_set()));
368 assert!(!with_var(PROBE, None, || var(EnvKind::OwnedFlag).is_set()));
369 assert!(with_var(PROBE, None, || var(EnvKind::OptOutFlag).is_set()));
370 assert!(with_var(PROBE, Some("exactly"), || {
371 var(EnvKind::ExactValue("exactly")).is_set()
372 }));
373 assert!(!with_var(PROBE, Some("Exactly"), || {
374 var(EnvKind::ExactValue("exactly")).is_set()
375 }));
376 }
377
378 #[test]
379 fn numbers_honour_the_declared_meaning_of_zero_and_reject_garbage() {
380 let selects = var(EnvKind::Number {
381 zero_selects_default: true,
382 });
383 let honours = var(EnvKind::Number {
384 zero_selects_default: false,
385 });
386 assert_eq!(with_var(PROBE, Some("0"), || selects.count_or(9)), 9);
387 assert_eq!(with_var(PROBE, Some("0"), || honours.count_or(9)), 0);
388 assert_eq!(with_var(PROBE, Some(" 42 "), || honours.count_or(9)), 42);
389 assert_eq!(with_var(PROBE, Some("many"), || honours.count_or(9)), 9);
390 assert_eq!(with_var(PROBE, Some("8080"), || honours.port()), Some(8080));
391 assert_eq!(
392 with_var(PROBE, Some("250"), || {
393 honours.millis_or(std::time::Duration::from_secs(1))
394 }),
395 std::time::Duration::from_millis(250)
396 );
397 }
398
399 #[test]
400 fn text_and_path_treat_empty_as_absent_and_keep_the_value_verbatim() {
401 let text = var(EnvKind::Text);
402 assert_eq!(with_var(PROBE, Some(""), || text.text()), None);
403 assert_eq!(
404 with_var(PROBE, Some("a b"), || text.text()),
405 Some("a b".into())
406 );
407 assert_eq!(with_var(PROBE, Some(""), || text.path()), None);
408 assert_eq!(
409 with_var(PROBE, Some("/x/y"), || text.path()),
410 Some(std::path::PathBuf::from("/x/y"))
411 );
412 }
413
414 #[test]
415 fn os_and_string_keep_an_empty_value_as_set() {
416 let text = var(EnvKind::Text);
417 assert_eq!(
418 with_var(PROBE, Some(""), || text.os()),
419 Some(std::ffi::OsString::new())
420 );
421 assert_eq!(
422 with_var(PROBE, Some(""), || text.string()),
423 Some(String::new())
424 );
425 assert_eq!(with_var(PROBE, None, || text.os()), None);
426 assert_eq!(with_var(PROBE, None, || text.string()), None);
427 }
428
429 #[test]
430 fn a_presence_switch_is_on_for_any_value_even_empty_or_zero() {
431 let presence = var(EnvKind::Text);
432 for value in ["", "0", "off", "1"] {
433 assert!(
434 with_var(PROBE, Some(value), || presence.is_present()),
435 "{value:?}"
436 );
437 }
438 assert!(!with_var(PROBE, None, || presence.is_present()));
439 }
440
441 #[test]
442 #[should_panic(expected = "not a flag")]
443 fn reading_a_non_flag_as_a_flag_is_a_programming_error() {
444 with_var(PROBE, Some("1"), || var(EnvKind::Text).is_set());
445 }
446
447 #[test]
448 fn a_value_already_in_hand_reads_like_the_foreign_flag() {
449 for value in ["1", "true", "maybe"] {
450 assert!(value_is_affirmative_foreign(value), "{value}");
451 }
452 for value in ["", "0", " OFF "] {
453 assert!(!value_is_affirmative_foreign(value), "{value}");
454 }
455 }
456}