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#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
36#[non_exhaustive]
37pub struct SpecUnknownEnv {
38 pub prefix: String,
40 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 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 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 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 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 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 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 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 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 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
195pub 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
229fn 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
255fn starts_with_env(name: &str, prefix: &str, fold: bool) -> bool {
258 fold_env(name, fold).starts_with(&*fold_env(prefix, fold))
259}
260
261fn 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 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 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 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}