#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KeyGlob {
AnyKey,
Pattern(Vec<GlobSeg>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GlobSeg {
Literal(String),
Star,
DoubleStar,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum GlobError {
#[error("empty glob term or segment in `{0}`")]
Empty(String),
#[error("wildcard `{wildcard}` is only valid as the last segment, in `{term}`")]
WildcardNotLast {
term: String,
wildcard: String,
},
#[error("intra-segment wildcard in segment `{segment}` of `{term}`")]
IntraSegment {
term: String,
segment: String,
},
}
impl KeyGlob {
pub fn parse(s: &str) -> Result<Self, GlobError> {
if s == "*" {
return Ok(Self::AnyKey);
}
if s.is_empty() {
return Err(GlobError::Empty(s.to_string()));
}
let raw: Vec<&str> = s.split('.').collect();
let last = raw.len() - 1;
let mut segs = Vec::with_capacity(raw.len());
for (i, seg) in raw.into_iter().enumerate() {
if seg.is_empty() {
return Err(GlobError::Empty(s.to_string()));
}
let is_last = i == last;
match seg {
"*" => {
if !is_last {
return Err(GlobError::WildcardNotLast {
term: s.to_string(),
wildcard: "*".to_string(),
});
}
segs.push(GlobSeg::Star);
}
"**" => {
if !is_last {
return Err(GlobError::WildcardNotLast {
term: s.to_string(),
wildcard: "**".to_string(),
});
}
segs.push(GlobSeg::DoubleStar);
}
literal => {
if literal.contains('*') {
return Err(GlobError::IntraSegment {
term: s.to_string(),
segment: literal.to_string(),
});
}
segs.push(GlobSeg::Literal(literal.to_string()));
}
}
}
Ok(Self::Pattern(segs))
}
#[must_use]
pub fn source(&self) -> String {
match self {
Self::AnyKey => "*".to_string(),
Self::Pattern(segs) => segs
.iter()
.map(|seg| match seg {
GlobSeg::Literal(s) => s.as_str(),
GlobSeg::Star => "*",
GlobSeg::DoubleStar => "**",
})
.collect::<Vec<_>>()
.join("."),
}
}
#[must_use]
pub fn matches(&self, dotted_key: &str) -> bool {
let key: Vec<&str> = dotted_key.split('.').collect();
match self {
Self::AnyKey => true,
Self::Pattern(segs) => Self::pattern_matches(segs, &key),
}
}
fn pattern_matches(segs: &[GlobSeg], key: &[&str]) -> bool {
let Some((last_seg, lead)) = segs.split_last() else {
return false;
};
if key.len() < lead.len() {
return false;
}
for (seg, k) in lead.iter().zip(key) {
match seg {
GlobSeg::Literal(lit) => {
if lit != k {
return false;
}
}
GlobSeg::Star | GlobSeg::DoubleStar => return false,
}
}
let tail = key.get(lead.len()..).unwrap_or(&[]);
match last_seg {
GlobSeg::Star => tail.len() == 1,
GlobSeg::DoubleStar => !tail.is_empty(),
GlobSeg::Literal(lit) => tail.len() == 1 && tail.first() == Some(&lit.as_str()),
}
}
#[must_use]
pub const fn is_any_key(&self) -> bool {
matches!(self, Self::AnyKey)
}
#[must_use]
pub fn matches_all(&self) -> bool {
match self {
Self::AnyKey => true,
Self::Pattern(segs) => matches!(segs.as_slice(), [GlobSeg::DoubleStar]),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn source_round_trips_through_parse() {
for s in [
"*",
"web.tls.signing_key",
"web.tls.*",
"web.**",
"grafana.admin_password",
] {
let g = KeyGlob::parse(s).expect("parses");
assert_eq!(g.source(), s, "source spelling must round-trip");
assert_eq!(KeyGlob::parse(&g.source()).expect("reparse"), g);
}
}
#[test]
fn spec_table_against_web_tls_signing_key() {
let key = "web.tls.signing_key";
let cases: &[(&str, bool)] = &[
("web.tls.signing_key", true), ("web.tls.*", true), ("web.*", false), ("web.**", true), ("web", false), ];
for (glob, expect) in cases {
let g = KeyGlob::parse(glob).expect("table globs parse");
assert_eq!(g.matches(key), *expect, "glob `{glob}` vs `{key}`");
}
assert!(matches!(
KeyGlob::parse("web*"),
Err(GlobError::IntraSegment { .. })
));
}
#[test]
fn star_matches_exactly_one_segment_no_dot_crossing() {
let g = KeyGlob::parse("web.tls.*").unwrap();
assert!(g.matches("web.tls.signing_key"));
assert!(!g.matches("web.tls.a.b")); assert!(!g.matches("web.tls")); }
#[test]
fn doublestar_is_one_or_more_not_zero() {
let g = KeyGlob::parse("web.**").unwrap();
assert!(g.matches("web.tls")); assert!(g.matches("web.tls.signing_key")); assert!(!g.matches("web")); assert!(!g.matches("other.tls")); }
#[test]
fn any_key_matches_everything() {
let g = KeyGlob::parse("*").unwrap();
assert!(g.is_any_key());
assert!(g.matches("anything"));
assert!(g.matches("a.b.c.d"));
}
#[test]
fn literal_only_is_exact() {
let g = KeyGlob::parse("nats.account").unwrap();
assert!(g.matches("nats.account"));
assert!(!g.matches("nats.account.sub"));
assert!(!g.matches("nats"));
assert!(!g.matches("NATS.account")); }
#[test]
fn intra_segment_wildcard_is_fatal() {
assert!(matches!(
KeyGlob::parse("web*"),
Err(GlobError::IntraSegment { .. })
));
assert!(matches!(
KeyGlob::parse("web.tls*"),
Err(GlobError::IntraSegment { .. })
));
assert!(matches!(
KeyGlob::parse("*key"),
Err(GlobError::IntraSegment { .. })
));
assert!(matches!(
KeyGlob::parse("web.*key"),
Err(GlobError::IntraSegment { .. })
));
}
#[test]
fn wildcard_not_in_last_position_is_fatal() {
assert!(matches!(
KeyGlob::parse("user.*.ssh.authorized_keys"),
Err(GlobError::WildcardNotLast { .. })
));
assert!(matches!(
KeyGlob::parse("web.**.x"),
Err(GlobError::WildcardNotLast { .. })
));
assert!(matches!(
KeyGlob::parse("*.tls"),
Err(GlobError::WildcardNotLast { .. })
));
}
#[test]
fn empty_segments_are_fatal() {
assert!(matches!(KeyGlob::parse(""), Err(GlobError::Empty(_))));
assert!(matches!(
KeyGlob::parse("web..tls"),
Err(GlobError::Empty(_))
));
assert!(matches!(KeyGlob::parse(".web"), Err(GlobError::Empty(_))));
assert!(matches!(KeyGlob::parse("web."), Err(GlobError::Empty(_))));
}
#[test]
fn doublestar_alone_matches_one_or_more() {
let g = KeyGlob::parse("**").unwrap();
assert!(matches!(g, KeyGlob::Pattern(_)));
assert!(g.matches("a"));
assert!(g.matches("a.b"));
}
#[test]
fn matches_all_covers_star_and_bare_doublestar() {
assert!(KeyGlob::parse("*").unwrap().matches_all());
assert!(KeyGlob::parse("**").unwrap().matches_all());
for narrower in ["web.**", "web.*", "web.tls.signing_key"] {
assert!(
!KeyGlob::parse(narrower).unwrap().matches_all(),
"`{narrower}` must not count as match-everything"
);
}
}
}