use std::collections::BTreeSet;
use std::ffi::OsStr;
use serde::Serialize;
use crate::Spec;
use crate::error::Result;
use crate::kdl::{self, KdlNode};
use crate::spec::cmd::SpecCommand;
use crate::spec::context::ParsingContext;
use crate::spec::helpers::{NodeHelper, string_entry};
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct SpecUnknownEnv {
pub prefix: String,
pub allow: Vec<String>,
}
impl SpecUnknownEnv {
pub fn new(prefix: impl Into<String>) -> Self {
Self {
prefix: prefix.into(),
allow: Vec::new(),
}
}
pub fn allow(mut self, name: impl Into<String>) -> Self {
self.allow.push(name.into());
self
}
pub(crate) fn parse(ctx: &ParsingContext, node: &NodeHelper<'_>) -> Result<Self> {
node.ensure_arg_len(0..=0)?;
node.ensure_props_unrepeated(&["prefix"])?;
let mut decl = Self::default();
for (key, value) in node.props() {
match key {
"prefix" => decl.prefix = value.ensure_string()?,
key => bail_parse!(ctx, value.entry.span(), "unsupported unknown_env key {key}"),
}
}
for child in node.children() {
match child.name() {
"allow" => {
if let Some((key, value)) = child.props().into_iter().next() {
bail_parse!(ctx, value.entry.span(), "unsupported allow key {key}");
}
if !child.children().is_empty() {
bail_parse!(ctx, child.span(), "allow takes names, not child nodes");
}
for name in child.args() {
decl.allow.push(name.ensure_string()?);
}
}
key => bail_parse!(
ctx,
child.node.name().span(),
"unsupported unknown_env value {key}"
),
}
}
if decl.prefix.is_empty() {
bail_parse!(
ctx,
node.span(),
"unknown_env needs a non-empty prefix, as in unknown_env prefix=\"MYAPP_\""
);
}
Ok(decl)
}
pub fn unknown<'n>(
&self,
spec: &Spec,
names: impl IntoIterator<Item = &'n OsStr>,
) -> Vec<String> {
self.unknown_with(spec, &[], names, false)
}
pub(crate) fn unknown_with<'n>(
&self,
spec: &Spec,
resolved: &[SpecCommand],
names: impl IntoIterator<Item = &'n OsStr>,
exact: bool,
) -> Vec<String> {
let fold = cfg!(windows) && !exact;
if self.prefix.is_empty() {
return Vec::new();
}
let mut known = declared_env_names(spec);
for cmd in resolved {
collect_cmd(cmd, &mut known);
}
let mut unknown: Vec<&OsStr> = names
.into_iter()
.filter(|name| {
starts_with_env(valid_head(name), &self.prefix, cfg!(windows))
&& name.to_str().is_none_or(|name| {
!known.iter().any(|k| same_env(k, name, fold))
&& !self.allow.iter().any(|a| same_env(a, name, fold))
})
})
.collect();
unknown.sort_by_cached_key(|name| {
(fold_env(&name.to_string_lossy(), fold).into_owned(), *name)
});
unknown.dedup_by(|a, b| match (a.to_str(), b.to_str()) {
(Some(a), Some(b)) => same_env(a, b, fold),
_ => a == b,
});
unknown
.into_iter()
.map(|name| name.to_string_lossy().into_owned())
.collect()
}
pub(crate) fn same_policy(&self, other: &Self) -> bool {
use std::collections::BTreeSet;
self.prefix == other.prefix
&& self.allow.iter().collect::<BTreeSet<_>>()
== other.allow.iter().collect::<BTreeSet<_>>()
}
}
impl From<&SpecUnknownEnv> for KdlNode {
fn from(decl: &SpecUnknownEnv) -> Self {
let mut node = KdlNode::new("unknown_env");
node.push(string_entry(Some("prefix"), &decl.prefix));
if !decl.allow.is_empty() {
let mut children = kdl::KdlDocument::new();
let mut allow = KdlNode::new("allow");
for name in &decl.allow {
allow.push(string_entry(None, name));
}
children.nodes_mut().push(allow);
node.set_children(children);
}
node
}
}
pub fn declared_env_names(spec: &Spec) -> BTreeSet<&str> {
let mut names = BTreeSet::new();
collect_cmd(&spec.cmd, &mut names);
for prop in spec.config.props.values() {
names.extend(prop.env.iter().map(String::as_str));
names.extend(prop.envs.iter().map(String::as_str));
names.extend(prop.deprecated_envs.iter().map(String::as_str));
}
names
}
fn collect_cmd<'a>(cmd: &'a SpecCommand, names: &mut BTreeSet<&'a str>) {
for flag in &cmd.flags {
names.extend(flag.env_names());
}
for arg in &cmd.args {
names.extend(arg.env_names());
}
if let Some(clause) = &cmd.clause {
for flag in &clause.flags {
names.extend(flag.env_names());
}
for arg in &clause.args {
names.extend(arg.env_names());
}
}
for sub in cmd.subcommands.values() {
collect_cmd(sub, names);
}
}
fn fold_env(name: &str, fold: bool) -> std::borrow::Cow<'_, str> {
if !fold {
return name.into();
}
name.chars()
.map(|c| {
let mut upper = c.to_uppercase();
match (upper.next(), upper.next()) {
(Some(u), None) => u,
_ => c,
}
})
.collect::<String>()
.into()
}
fn same_env(a: &str, b: &str, fold: bool) -> bool {
fold_env(a, fold) == fold_env(b, fold)
}
fn starts_with_env(name: &str, prefix: &str, fold: bool) -> bool {
fold_env(name, fold).starts_with(&*fold_env(prefix, fold))
}
fn valid_head(name: &OsStr) -> &str {
let bytes = name.as_encoded_bytes();
match std::str::from_utf8(bytes) {
Ok(name) => name,
Err(e) => std::str::from_utf8(&bytes[..e.valid_up_to()]).unwrap_or_default(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn unknown(spec: &str, env: &[&str]) -> Vec<String> {
let spec: Spec = spec.parse().unwrap();
spec.unknown_env
.as_ref()
.expect("the spec declares unknown_env")
.unknown(&spec, env.iter().map(OsStr::new))
}
#[test]
fn the_whole_tree_and_every_kind_of_name_is_known() {
let spec = r#"
name "ex"
bin "ex"
unknown_env prefix="EX_" {
allow "EX_LIBRARY"
}
flag "--port <port>" env="EX_PORT" env_fallback="EX_OLD_PORT"
cmd "serve" {
flag "--tls" env="EX_TLS" deprecated_env="EX_SSL"
arg "[dir]" env="EX_DIR"
}
config {
prop "jobs" env="EX_JOBS"
}
"#;
let env = [
"EX_PORT",
"EX_OLD_PORT",
"EX_TLS",
"EX_SSL",
"EX_DIR",
"EX_JOBS",
"EX_LIBRARY",
"HOME",
"EXTRA",
];
assert_eq!(unknown(spec, &env), Vec::<String>::new());
let env = ["EX_PROT", "EX_TSL", "EX_PORT", "EX_TSL"];
assert_eq!(unknown(spec, &env), ["EX_PROT", "EX_TSL"]);
}
#[test]
fn round_trips_through_kdl() {
let spec: Spec =
"name \"ex\"\nunknown_env prefix=\"EX_\" {\n allow \"EX_A\" \"EX_B\"\n}\n"
.parse()
.unwrap();
let decl = spec.unknown_env.clone().unwrap();
assert_eq!(decl.prefix, "EX_");
assert_eq!(decl.allow, ["EX_A", "EX_B"]);
let kdl = spec.to_string();
assert!(kdl.contains("unknown_env prefix=EX_"), "{kdl}");
let reparsed: Spec = kdl.parse().unwrap();
assert_eq!(reparsed.unknown_env, Some(decl));
}
#[test]
fn a_prefix_is_required() {
let err = "name \"ex\"\nunknown_env\n".parse::<Spec>().unwrap_err();
assert!(format!("{err:?}").contains("prefix"), "{err:?}");
let err = "name \"ex\"\nunknown_env prefix=\"EX_\" bogus=\"x\"\n"
.parse::<Spec>()
.unwrap_err();
assert!(format!("{err:?}").contains("bogus"), "{err:?}");
let err = "name \"ex\"\nunknown_env prefix=\"EX_\" {\n allow name=\"EX_A\"\n}\n"
.parse::<Spec>()
.unwrap_err();
assert!(format!("{err:?}").contains("allow key name"), "{err:?}");
}
#[test]
fn an_empty_prefix_built_in_code_owns_nothing() {
let spec: Spec = "name \"ex\"\n".parse().unwrap();
let decl = SpecUnknownEnv::default();
assert!(decl.unknown(&spec, [OsStr::new("PATH")]).is_empty());
}
#[cfg(unix)]
#[test]
fn a_name_that_is_not_utf8_is_reported_not_matched() {
use std::os::unix::ffi::OsStrExt;
let spec: Spec =
"name \"ex\"\nunknown_env prefix=\"EX_\"\nflag \"--x <x>\" env=\"EX_\u{fffd}\"\n"
.parse()
.unwrap();
let decl = spec.unknown_env.clone().unwrap();
let name = OsStr::from_bytes(b"EX_\xff");
assert_eq!(decl.unknown(&spec, [name]), ["EX_\u{fffd}"]);
let other = OsStr::from_bytes(b"EX_\xfe");
assert_eq!(
decl.unknown(&spec, [name, other, name]),
["EX_\u{fffd}", "EX_\u{fffd}"]
);
let decl = SpecUnknownEnv::new("EX_\u{fffd}");
let spelled = OsStr::new("EX_\u{fffd}X");
assert_eq!(decl.unknown(&spec, [name, spelled]), ["EX_\u{fffd}X"]);
}
#[test]
fn the_same_policy_may_arrive_twice_in_any_order() {
let spec: Spec = "name \"ex\"\n\
unknown_env prefix=\"EX_\" {\n allow \"EX_A\" \"EX_B\"\n}\n\
unknown_env prefix=\"EX_\" {\n allow \"EX_B\" \"EX_A\" \"EX_B\"\n}\n"
.parse()
.unwrap();
assert_eq!(spec.unknown_env.unwrap().allow, ["EX_B", "EX_A", "EX_B"]);
let err = "name \"ex\"\n\
unknown_env prefix=\"EX_\" {\n allow \"EX_A\"\n}\n\
unknown_env prefix=\"EX_\" {\n allow \"EX_B\"\n}\n"
.parse::<Spec>()
.unwrap_err();
assert!(format!("{err:?}").contains("only once"), "{err:?}");
}
}