use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Constraint {
ModelBound {
model_id: String,
},
NameBound {
name_pattern: String,
},
TimeBound {
not_before: DateTime<Utc>,
not_after: DateTime<Utc>,
},
CountBound {
max_issuances: u32,
},
GeographicBound {
regions: Vec<String>,
},
SubjectBound {
subjects: Vec<String>,
},
}
impl Constraint {
pub fn satisfies(&self, value: &ScopeValue) -> bool {
match (self, value) {
(Constraint::ModelBound { model_id }, ScopeValue::ModelId(actual)) => {
model_id == actual
}
(Constraint::NameBound { name_pattern }, ScopeValue::Name(actual)) => {
glob_match(name_pattern, actual)
}
(
Constraint::TimeBound {
not_before,
not_after,
},
ScopeValue::Time(when),
) => when >= not_before && when <= not_after,
(Constraint::CountBound { max_issuances }, ScopeValue::Count(used)) => {
*used <= *max_issuances
}
(Constraint::GeographicBound { regions }, ScopeValue::Region(actual)) => {
regions.iter().any(|r| r == actual)
}
(Constraint::SubjectBound { subjects }, ScopeValue::Subject(actual)) => {
subjects.iter().any(|s| s == actual)
}
_ => false,
}
}
}
#[derive(Debug, Clone)]
pub enum ScopeValue<'a> {
ModelId(&'a str),
Name(&'a str),
Time(DateTime<Utc>),
Count(u32),
Region(&'a str),
Subject(&'a str),
}
fn glob_match(pattern: &str, value: &str) -> bool {
fn helper(p: &[u8], v: &[u8]) -> bool {
match (p.first(), v.first()) {
(Some(b'*'), _) => helper(&p[1..], v) || (!v.is_empty() && helper(p, &v[1..])),
(Some(b'?'), Some(_)) => helper(&p[1..], &v[1..]),
(Some(pc), Some(vc)) if pc == vc => helper(&p[1..], &v[1..]),
(None, None) => true,
_ => false,
}
}
helper(pattern.as_bytes(), value.as_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn model_bound_matches() {
let c = Constraint::ModelBound {
model_id: "FM-2026-A".into(),
};
assert!(c.satisfies(&ScopeValue::ModelId("FM-2026-A")));
assert!(!c.satisfies(&ScopeValue::ModelId("FM-2026-B")));
}
#[test]
fn name_bound_glob_matches() {
let c = Constraint::NameBound {
name_pattern: "*.example.com".into(),
};
assert!(c.satisfies(&ScopeValue::Name("www.example.com")));
assert!(c.satisfies(&ScopeValue::Name("api.example.com")));
assert!(!c.satisfies(&ScopeValue::Name("example.com")));
assert!(!c.satisfies(&ScopeValue::Name("evil.org")));
}
#[test]
fn geographic_bound_matches() {
let c = Constraint::GeographicBound {
regions: vec!["europe".into(), "americas".into()],
};
assert!(c.satisfies(&ScopeValue::Region("europe")));
assert!(c.satisfies(&ScopeValue::Region("americas")));
assert!(!c.satisfies(&ScopeValue::Region("asia-pacific")));
}
}