use std::fmt;
const MIN_NAMESPACE_LEN: usize = 2;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EnvInheritSpec {
Exact(String),
Prefix(String),
}
impl EnvInheritSpec {
#[must_use]
pub fn matches(&self, name: &str) -> bool {
match self {
Self::Exact(n) => name == n,
Self::Prefix(prefix) => name.starts_with(prefix),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EnvInheritError {
Empty,
LeadingWildcard(String),
EmbeddedWildcard(String),
MultipleWildcards(String),
UnanchoredPrefix(String),
TrivialPrefix(String),
InvalidChar { pattern: String, ch: char },
}
impl fmt::Display for EnvInheritError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => {
write!(f, "--env-inherit: empty pattern")
}
Self::LeadingWildcard(p) => write!(
f,
"--env-inherit '{p}': a leading wildcard is not allowed. A '*' may \
appear only as the last character of a namespace prefix (e.g. \
RITE_*). Suffix selection like *_TOKEN would match secrets across \
every namespace at once."
),
Self::EmbeddedWildcard(p) => write!(
f,
"--env-inherit '{p}': '*' is allowed only as the final character \
(e.g. RITE_*), not inside the pattern."
),
Self::MultipleWildcards(p) => write!(
f,
"--env-inherit '{p}': only a single trailing '*' is allowed."
),
Self::UnanchoredPrefix(p) => {
let prefix = p.trim_end_matches('*');
write!(
f,
"--env-inherit '{p}': a wildcard prefix must end at an underscore \
boundary (e.g. {prefix}_*), so it cannot match unrelated names."
)
}
Self::TrivialPrefix(p) => write!(
f,
"--env-inherit '{p}': the prefix before '*' is too broad. Name a \
concrete namespace of at least {MIN_NAMESPACE_LEN} characters, \
such as RITE_*."
),
Self::InvalidChar { pattern, ch } => write!(
f,
"--env-inherit '{pattern}': invalid character '{ch}' in variable \
name. Use letters, digits, and underscores, and do not start with \
a digit."
),
}
}
}
impl std::error::Error for EnvInheritError {}
fn validate_name_chars(s: &str) -> Result<(), EnvInheritError> {
for (i, ch) in s.char_indices() {
let valid_char = ch == '_' || ch.is_ascii_alphanumeric();
let leading_digit = i == 0 && ch.is_ascii_digit();
if !valid_char || leading_digit {
return Err(EnvInheritError::InvalidChar {
pattern: s.to_string(),
ch,
});
}
}
Ok(())
}
pub fn parse_spec(pattern: &str) -> Result<EnvInheritSpec, EnvInheritError> {
if pattern.is_empty() {
return Err(EnvInheritError::Empty);
}
let star_count = pattern.matches('*').count();
if star_count == 0 {
validate_name_chars(pattern)?;
return Ok(EnvInheritSpec::Exact(pattern.to_string()));
}
if star_count > 1 {
return Err(EnvInheritError::MultipleWildcards(pattern.to_string()));
}
if pattern.starts_with('*') {
return Err(EnvInheritError::LeadingWildcard(pattern.to_string()));
}
if !pattern.ends_with('*') {
return Err(EnvInheritError::EmbeddedWildcard(pattern.to_string()));
}
let prefix = &pattern[..pattern.len() - '*'.len_utf8()];
validate_name_chars(prefix)?;
let namespace = prefix.trim_end_matches('_');
if namespace.len() < MIN_NAMESPACE_LEN {
return Err(EnvInheritError::TrivialPrefix(pattern.to_string()));
}
if !prefix.ends_with('_') {
return Err(EnvInheritError::UnanchoredPrefix(pattern.to_string()));
}
Ok(EnvInheritSpec::Prefix(prefix.to_string()))
}
pub fn resolve<S: std::hash::BuildHasher>(
patterns: &[String],
env: &[(String, String)],
already_set: &std::collections::HashSet<&str, S>,
) -> Result<Vec<(String, String)>, EnvInheritError> {
let specs = patterns
.iter()
.map(|p| parse_spec(p))
.collect::<Result<Vec<_>, _>>()?;
let mut seen = std::collections::HashSet::new();
let mut matched = Vec::new();
for (name, value) in env {
let selected = specs.iter().any(|spec| spec.matches(name));
if selected && !already_set.contains(name.as_str()) && seen.insert(name.as_str()) {
matched.push((name.clone(), value.clone()));
}
}
Ok(matched)
}
#[cfg(test)]
mod tests {
use super::*;
fn env() -> Vec<(String, String)> {
[
("RITE_AGENT", "vessel-dev"),
("RITE_CHANNEL", "vessel"),
("RITE_TOKEN", "s3cret"),
("RITEOUS", "not-in-namespace"),
("EDITOR", "vim"),
("GITHUB_TOKEN", "gh-secret"),
("AWS_SECRET_ACCESS_KEY", "aws-secret"),
]
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[test]
fn exact_name_parses() {
assert_eq!(
parse_spec("EDITOR"),
Ok(EnvInheritSpec::Exact("EDITOR".into()))
);
}
#[test]
fn exact_name_matches_only_itself() {
let spec = parse_spec("RITE_AGENT").unwrap();
assert!(spec.matches("RITE_AGENT"));
assert!(!spec.matches("RITE_AGENTS"));
assert!(!spec.matches("RITE_AGEN"));
}
#[test]
fn empty_is_rejected() {
assert_eq!(parse_spec(""), Err(EnvInheritError::Empty));
}
#[test]
fn invalid_char_is_rejected() {
assert!(matches!(
parse_spec("FOO.BAR"),
Err(EnvInheritError::InvalidChar { .. })
));
assert!(matches!(
parse_spec("FOO=BAR"),
Err(EnvInheritError::InvalidChar { .. })
));
}
#[test]
fn leading_digit_is_rejected() {
assert!(matches!(
parse_spec("1FOO"),
Err(EnvInheritError::InvalidChar { .. })
));
}
#[test]
fn trailing_wildcard_parses_to_prefix() {
assert_eq!(
parse_spec("RITE_*"),
Ok(EnvInheritSpec::Prefix("RITE_".into()))
);
}
#[test]
fn prefix_matches_namespace_but_not_bare_name() {
let spec = parse_spec("RITE_*").unwrap();
assert!(spec.matches("RITE_AGENT"));
assert!(spec.matches("RITE_CHANNEL"));
assert!(!spec.matches("RITEOUS"));
assert!(!spec.matches("RITE"));
}
#[test]
fn multi_segment_prefix_is_allowed() {
assert_eq!(
parse_spec("MY_APP_"),
Ok(EnvInheritSpec::Exact("MY_APP_".into()))
);
assert_eq!(
parse_spec("MY_APP_*"),
Ok(EnvInheritSpec::Prefix("MY_APP_".into()))
);
}
#[test]
fn leading_wildcard_is_rejected() {
assert_eq!(
parse_spec("*_TOKEN"),
Err(EnvInheritError::LeadingWildcard("*_TOKEN".into()))
);
assert_eq!(
parse_spec("*"),
Err(EnvInheritError::LeadingWildcard("*".into()))
);
}
#[test]
fn embedded_wildcard_is_rejected() {
assert_eq!(
parse_spec("RITE_*_TOKEN"),
Err(EnvInheritError::EmbeddedWildcard("RITE_*_TOKEN".into()))
);
assert_eq!(
parse_spec("FO*O"),
Err(EnvInheritError::EmbeddedWildcard("FO*O".into()))
);
}
#[test]
fn multiple_wildcards_are_rejected() {
assert_eq!(
parse_spec("RITE_*_*"),
Err(EnvInheritError::MultipleWildcards("RITE_*_*".into()))
);
}
#[test]
fn unanchored_prefix_is_rejected() {
assert_eq!(
parse_spec("AWS*"),
Err(EnvInheritError::UnanchoredPrefix("AWS*".into()))
);
}
#[test]
fn trivial_prefix_is_rejected() {
assert_eq!(
parse_spec("_*"),
Err(EnvInheritError::TrivialPrefix("_*".into()))
);
assert_eq!(
parse_spec("__*"),
Err(EnvInheritError::TrivialPrefix("__*".into()))
);
}
#[test]
fn one_letter_namespace_is_rejected() {
assert_eq!(
parse_spec("A_*"),
Err(EnvInheritError::TrivialPrefix("A_*".into()))
);
assert_eq!(parse_spec("AB_*"), Ok(EnvInheritSpec::Prefix("AB_".into())));
}
fn no_explicit() -> std::collections::HashSet<&'static str> {
std::collections::HashSet::new()
}
#[test]
fn resolve_expands_namespace_and_exact() {
let matched = resolve(&["RITE_*".into(), "EDITOR".into()], &env(), &no_explicit()).unwrap();
let names: Vec<&str> = matched.iter().map(|(n, _)| n.as_str()).collect();
assert_eq!(
names,
["RITE_AGENT", "RITE_CHANNEL", "RITE_TOKEN", "EDITOR"]
);
}
#[test]
fn resolve_dedupes_overlapping_patterns() {
let matched = resolve(
&["RITE_*".into(), "RITE_AGENT".into()],
&env(),
&no_explicit(),
)
.unwrap();
let count = matched.iter().filter(|(n, _)| n == "RITE_AGENT").count();
assert_eq!(count, 1);
}
#[test]
fn resolve_aborts_on_any_malformed_pattern() {
let result = resolve(&["RITE_*".into(), "*_TOKEN".into()], &env(), &no_explicit());
assert!(matches!(result, Err(EnvInheritError::LeadingWildcard(_))));
}
#[test]
fn resolve_does_not_reach_cross_namespace_secrets() {
let matched = resolve(&["RITE_*".into()], &env(), &no_explicit()).unwrap();
let names: Vec<&str> = matched.iter().map(|(n, _)| n.as_str()).collect();
assert!(!names.contains(&"GITHUB_TOKEN"));
assert!(!names.contains(&"AWS_SECRET_ACCESS_KEY"));
}
#[test]
fn resolve_skips_names_already_set_explicitly() {
let explicit: std::collections::HashSet<&str> = ["RITE_AGENT"].into_iter().collect();
let matched = resolve(&["RITE_*".into()], &env(), &explicit).unwrap();
let names: Vec<&str> = matched.iter().map(|(n, _)| n.as_str()).collect();
assert!(!names.contains(&"RITE_AGENT"));
assert!(names.contains(&"RITE_CHANNEL"));
}
}