Skip to main content

usage/spec/
unknown_env.rs

1use std::collections::BTreeSet;
2use std::ffi::OsStr;
3
4use serde::Serialize;
5
6use crate::Spec;
7use crate::error::Result;
8use crate::kdl::{self, KdlNode};
9use crate::spec::cmd::SpecCommand;
10use crate::spec::context::ParsingContext;
11use crate::spec::helpers::{NodeHelper, string_entry};
12
13/// Refuse environment variables under the application's prefix that nothing declares.
14///
15/// `unknown_flags "error"` turns a misspelled flag into an error. A misspelled or stale
16/// environment variable gets no such check by default — `MYAPP_LISTEN_TLS_CRET` is read by
17/// nobody, and the program runs on its defaults. That failure is worst where nobody watches a
18/// command line: systemd units, container manifests, Helm values.
19///
20/// ```kdl
21/// unknown_env prefix="MOQ_" {
22///     allow "MOQ_VAAPI_DEVICE" "MOQ_V4L2_ENCODER"
23/// }
24/// ```
25///
26/// The prefix only scopes the check. It never builds a name: every variable is still spelled
27/// out in full where it is declared, so a name seen in an environment can be found by searching
28/// for it.
29///
30/// A variable is known when any flag or argument anywhere in the command tree names it — as
31/// `env`, an `env_fallback` or a `deprecated_env` — or when a `config` property reads it, or
32/// when it is listed in `allow`. The whole tree counts, not only the command that ran: a
33/// variable meant for `serve` is not a typo when the user runs `publish`. `allow` is for
34/// variables read outside the spec, such as by a library.
35#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
36#[non_exhaustive]
37pub struct SpecUnknownEnv {
38    /// Only variables whose names start with this are checked.
39    pub prefix: String,
40    /// Variables under the prefix that the spec does not declare but something still reads.
41    pub allow: Vec<String>,
42}
43
44impl SpecUnknownEnv {
45    pub fn new(prefix: impl Into<String>) -> Self {
46        Self {
47            prefix: prefix.into(),
48            allow: Vec::new(),
49        }
50    }
51
52    /// A variable read outside the spec, which is therefore not a mistake.
53    pub fn allow(mut self, name: impl Into<String>) -> Self {
54        self.allow.push(name.into());
55        self
56    }
57
58    pub(crate) fn parse(ctx: &ParsingContext, node: &NodeHelper<'_>) -> Result<Self> {
59        node.ensure_arg_len(0..=0)?;
60        node.ensure_props_unrepeated(&["prefix"])?;
61        let mut decl = Self::default();
62        for (key, value) in node.props() {
63            match key {
64                "prefix" => decl.prefix = value.ensure_string()?,
65                key => bail_parse!(ctx, value.entry.span(), "unsupported unknown_env key {key}"),
66            }
67        }
68        for child in node.children() {
69            match child.name() {
70                "allow" => {
71                    // Names only. Anything else here was meant as a name and would otherwise be
72                    // dropped, leaving a variable the author meant to allow refused at run time.
73                    if let Some((key, value)) = child.props().into_iter().next() {
74                        bail_parse!(ctx, value.entry.span(), "unsupported allow key {key}");
75                    }
76                    if !child.children().is_empty() {
77                        bail_parse!(ctx, child.span(), "allow takes names, not child nodes");
78                    }
79                    for name in child.args() {
80                        decl.allow.push(name.ensure_string()?);
81                    }
82                }
83                key => bail_parse!(
84                    ctx,
85                    child.node.name().span(),
86                    "unsupported unknown_env value {key}"
87                ),
88            }
89        }
90        // An empty prefix would make every variable in the environment this CLI's business,
91        // `PATH` and `HOME` included.
92        if decl.prefix.is_empty() {
93            bail_parse!(
94                ctx,
95                node.span(),
96                "unknown_env needs a non-empty prefix, as in unknown_env prefix=\"MYAPP_\""
97            );
98        }
99        Ok(decl)
100    }
101
102    /// The variables under the prefix that `spec` neither declares nor allows, sorted.
103    ///
104    /// `names` is the environment's variable names. A name that is not valid UTF-8 cannot be
105    /// one the spec declares, so one under the prefix is reported in its lossy spelling rather
106    /// than skipped.
107    pub fn unknown<'n>(
108        &self,
109        spec: &Spec,
110        names: impl IntoIterator<Item = &'n OsStr>,
111    ) -> Vec<String> {
112        self.unknown_with(spec, &[], names, false)
113    }
114
115    /// [`Self::unknown`], also counting what `resolved` declares.
116    ///
117    /// A mount brings commands in at parse time, so the spec alone does not hold them. The
118    /// parser passes the commands it walked, each with everything under it. `exact` compares
119    /// names exactly on every platform, for a map that is read by exact key.
120    pub(crate) fn unknown_with<'n>(
121        &self,
122        spec: &Spec,
123        resolved: &[SpecCommand],
124        names: impl IntoIterator<Item = &'n OsStr>,
125        exact: bool,
126    ) -> Vec<String> {
127        let fold = cfg!(windows) && !exact;
128        // An empty prefix owns nothing. The KDL reader refuses one; a policy built in code that
129        // has one must not make `PATH` an error.
130        if self.prefix.is_empty() {
131            return Vec::new();
132        }
133        let mut known = declared_env_names(spec);
134        for cmd in resolved {
135            collect_cmd(cmd, &mut known);
136        }
137        let mut unknown: Vec<&OsStr> = names
138            .into_iter()
139            .filter(|name| {
140                // A name that is not UTF-8 is never a declared one, even when its lossy
141                // spelling happens to equal a declaration that contains U+FFFD.
142                // The prefix decides whose variable it is, which is the platform's question even
143                // for a map read by exact key: `ex_ready` is this CLI's on Windows, and unknown.
144                starts_with_env(valid_head(name), &self.prefix, cfg!(windows))
145                    && name.to_str().is_none_or(|name| {
146                        !known.iter().any(|k| same_env(k, name, fold))
147                            && !self.allow.iter().any(|a| same_env(a, name, fold))
148                    })
149            })
150            .collect();
151        // Each variable once, by the comparison that matched it: under folding `EX_TYPO` and
152        // `ex_typo` are one variable, and listing it twice would read as two mistakes. Two names
153        // that are not UTF-8 can share a lossy spelling and still be two variables, so those are
154        // kept apart by their bytes.
155        unknown.sort_by_cached_key(|name| {
156            (fold_env(&name.to_string_lossy(), fold).into_owned(), *name)
157        });
158        unknown.dedup_by(|a, b| match (a.to_str(), b.to_str()) {
159            (Some(a), Some(b)) => same_env(a, b, fold),
160            _ => a == b,
161        });
162        unknown
163            .into_iter()
164            .map(|name| name.to_string_lossy().into_owned())
165            .collect()
166    }
167
168    /// Whether `other` is the same policy: one prefix and the same set of allowed names, in
169    /// whatever order or repetition they were written.
170    pub(crate) fn same_policy(&self, other: &Self) -> bool {
171        use std::collections::BTreeSet;
172        self.prefix == other.prefix
173            && self.allow.iter().collect::<BTreeSet<_>>()
174                == other.allow.iter().collect::<BTreeSet<_>>()
175    }
176}
177
178impl From<&SpecUnknownEnv> for KdlNode {
179    fn from(decl: &SpecUnknownEnv) -> Self {
180        let mut node = KdlNode::new("unknown_env");
181        node.push(string_entry(Some("prefix"), &decl.prefix));
182        if !decl.allow.is_empty() {
183            let mut children = kdl::KdlDocument::new();
184            let mut allow = KdlNode::new("allow");
185            for name in &decl.allow {
186                allow.push(string_entry(None, name));
187            }
188            children.nodes_mut().push(allow);
189            node.set_children(children);
190        }
191        node
192    }
193}
194
195/// Every environment variable `spec` reads: each flag's and argument's `env`, `env_fallback`
196/// and `deprecated_env` across the whole command tree, clauses included, and every `config`
197/// property's.
198pub fn declared_env_names(spec: &Spec) -> BTreeSet<&str> {
199    let mut names = BTreeSet::new();
200    collect_cmd(&spec.cmd, &mut names);
201    for prop in spec.config.props.values() {
202        names.extend(prop.env.iter().map(String::as_str));
203        names.extend(prop.envs.iter().map(String::as_str));
204        names.extend(prop.deprecated_envs.iter().map(String::as_str));
205    }
206    names
207}
208
209fn collect_cmd<'a>(cmd: &'a SpecCommand, names: &mut BTreeSet<&'a str>) {
210    for flag in &cmd.flags {
211        names.extend(flag.env_names());
212    }
213    for arg in &cmd.args {
214        names.extend(arg.env_names());
215    }
216    if let Some(clause) = &cmd.clause {
217        for flag in &clause.flags {
218            names.extend(flag.env_names());
219        }
220        for arg in &clause.args {
221            names.extend(arg.env_names());
222        }
223    }
224    for sub in cmd.subcommands.values() {
225        collect_cmd(sub, names);
226    }
227}
228
229/// A name as Windows compares it, when `fold` says to.
230///
231/// Windows variable names are case-insensitive — `std::env::var("PATH")` finds `Path` — so a
232/// variable set as `Moq_Port` is the one a spec declares as `MOQ_PORT`. Windows folds one
233/// character at a time, `ä` with `Ä` but `ß` never with `SS`, so a character whose uppercase is
234/// longer than itself is kept as it is rather than expanded.
235fn fold_env(name: &str, fold: bool) -> std::borrow::Cow<'_, str> {
236    if !fold {
237        return name.into();
238    }
239    name.chars()
240        .map(|c| {
241            let mut upper = c.to_uppercase();
242            match (upper.next(), upper.next()) {
243                (Some(u), None) => u,
244                _ => c,
245            }
246        })
247        .collect::<String>()
248        .into()
249}
250
251fn same_env(a: &str, b: &str, fold: bool) -> bool {
252    fold_env(a, fold) == fold_env(b, fold)
253}
254
255/// Folding keeps one character for one, so a folded prefix is a prefix of the folded name
256/// exactly when the original is.
257fn starts_with_env(name: &str, prefix: &str, fold: bool) -> bool {
258    fold_env(name, fold).starts_with(&*fold_env(prefix, fold))
259}
260
261/// The part of `name` before its first byte that is not UTF-8.
262///
263/// The prefix is UTF-8, so a name can only start with it there. Matching the lossy spelling
264/// instead would let a prefix containing U+FFFD claim a name whose bytes are something else.
265fn valid_head(name: &OsStr) -> &str {
266    let bytes = name.as_encoded_bytes();
267    match std::str::from_utf8(bytes) {
268        Ok(name) => name,
269        Err(e) => std::str::from_utf8(&bytes[..e.valid_up_to()]).unwrap_or_default(),
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    fn unknown(spec: &str, env: &[&str]) -> Vec<String> {
278        let spec: Spec = spec.parse().unwrap();
279        spec.unknown_env
280            .as_ref()
281            .expect("the spec declares unknown_env")
282            .unknown(&spec, env.iter().map(OsStr::new))
283    }
284
285    #[test]
286    fn the_whole_tree_and_every_kind_of_name_is_known() {
287        let spec = r#"
288            name "ex"
289            bin "ex"
290            unknown_env prefix="EX_" {
291                allow "EX_LIBRARY"
292            }
293            flag "--port <port>" env="EX_PORT" env_fallback="EX_OLD_PORT"
294            cmd "serve" {
295                flag "--tls" env="EX_TLS" deprecated_env="EX_SSL"
296                arg "[dir]" env="EX_DIR"
297            }
298            config {
299                prop "jobs" env="EX_JOBS"
300            }
301        "#;
302        let env = [
303            "EX_PORT",
304            "EX_OLD_PORT",
305            "EX_TLS",
306            "EX_SSL",
307            "EX_DIR",
308            "EX_JOBS",
309            "EX_LIBRARY",
310            "HOME",
311            "EXTRA",
312        ];
313        assert_eq!(unknown(spec, &env), Vec::<String>::new());
314        let env = ["EX_PROT", "EX_TSL", "EX_PORT", "EX_TSL"];
315        assert_eq!(unknown(spec, &env), ["EX_PROT", "EX_TSL"]);
316    }
317
318    #[test]
319    fn round_trips_through_kdl() {
320        let spec: Spec =
321            "name \"ex\"\nunknown_env prefix=\"EX_\" {\n    allow \"EX_A\" \"EX_B\"\n}\n"
322                .parse()
323                .unwrap();
324        let decl = spec.unknown_env.clone().unwrap();
325        assert_eq!(decl.prefix, "EX_");
326        assert_eq!(decl.allow, ["EX_A", "EX_B"]);
327        let kdl = spec.to_string();
328        assert!(kdl.contains("unknown_env prefix=EX_"), "{kdl}");
329        let reparsed: Spec = kdl.parse().unwrap();
330        assert_eq!(reparsed.unknown_env, Some(decl));
331    }
332
333    #[test]
334    fn a_prefix_is_required() {
335        let err = "name \"ex\"\nunknown_env\n".parse::<Spec>().unwrap_err();
336        assert!(format!("{err:?}").contains("prefix"), "{err:?}");
337        let err = "name \"ex\"\nunknown_env prefix=\"EX_\" bogus=\"x\"\n"
338            .parse::<Spec>()
339            .unwrap_err();
340        assert!(format!("{err:?}").contains("bogus"), "{err:?}");
341        let err = "name \"ex\"\nunknown_env prefix=\"EX_\" {\n    allow name=\"EX_A\"\n}\n"
342            .parse::<Spec>()
343            .unwrap_err();
344        assert!(format!("{err:?}").contains("allow key name"), "{err:?}");
345    }
346
347    #[test]
348    fn an_empty_prefix_built_in_code_owns_nothing() {
349        let spec: Spec = "name \"ex\"\n".parse().unwrap();
350        let decl = SpecUnknownEnv::default();
351        assert!(decl.unknown(&spec, [OsStr::new("PATH")]).is_empty());
352    }
353
354    #[cfg(unix)]
355    #[test]
356    fn a_name_that_is_not_utf8_is_reported_not_matched() {
357        use std::os::unix::ffi::OsStrExt;
358        // A declaration containing U+FFFD, which is what the lossy spelling of the
359        // invalid byte becomes.
360        let spec: Spec =
361            "name \"ex\"\nunknown_env prefix=\"EX_\"\nflag \"--x <x>\" env=\"EX_\u{fffd}\"\n"
362                .parse()
363                .unwrap();
364        let decl = spec.unknown_env.clone().unwrap();
365        let name = OsStr::from_bytes(b"EX_\xff");
366        assert_eq!(decl.unknown(&spec, [name]), ["EX_\u{fffd}"]);
367        // Two variables with one lossy spelling are still two.
368        let other = OsStr::from_bytes(b"EX_\xfe");
369        assert_eq!(
370            decl.unknown(&spec, [name, other, name]),
371            ["EX_\u{fffd}", "EX_\u{fffd}"]
372        );
373        // A prefix ending in U+FFFD owns a name whose bytes spell it, not one whose invalid
374        // byte merely reads as it.
375        let decl = SpecUnknownEnv::new("EX_\u{fffd}");
376        let spelled = OsStr::new("EX_\u{fffd}X");
377        assert_eq!(decl.unknown(&spec, [name, spelled]), ["EX_\u{fffd}X"]);
378    }
379
380    #[test]
381    fn the_same_policy_may_arrive_twice_in_any_order() {
382        let spec: Spec = "name \"ex\"\n\
383            unknown_env prefix=\"EX_\" {\n    allow \"EX_A\" \"EX_B\"\n}\n\
384            unknown_env prefix=\"EX_\" {\n    allow \"EX_B\" \"EX_A\" \"EX_B\"\n}\n"
385            .parse()
386            .unwrap();
387        assert_eq!(spec.unknown_env.unwrap().allow, ["EX_B", "EX_A", "EX_B"]);
388        let err = "name \"ex\"\n\
389            unknown_env prefix=\"EX_\" {\n    allow \"EX_A\"\n}\n\
390            unknown_env prefix=\"EX_\" {\n    allow \"EX_B\"\n}\n"
391            .parse::<Spec>()
392            .unwrap_err();
393        assert!(format!("{err:?}").contains("only once"), "{err:?}");
394    }
395}