#![forbid(unsafe_code)]
pub mod builtin;
pub mod user;
pub use builtin::{BUILTINS, Builtin, builtins, find};
pub use user::{Catalogue, LoadError, LoadWarning, LoadWarningKind, UserPattern, UserPatternFile};
use std::borrow::Cow;
use std::fmt;
use regex::Regex;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Low,
Medium,
High,
}
impl fmt::Display for Severity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PatternMetadata {
pub provider_id: Cow<'static, str>,
pub retrieval_url_template: Cow<'static, str>,
pub default_expiry_days: Option<u32>,
pub scopes_hint: Vec<Cow<'static, str>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RotationMethodSpec {
Manual,
ProviderUi {
url_template: &'static str,
},
ProviderApi,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RotationSpec {
pub method: RotationMethodSpec,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HttpMethod {
Get,
Post,
Head,
}
impl HttpMethod {
pub fn as_str(self) -> &'static str {
match self {
Self::Get => "GET",
Self::Post => "POST",
Self::Head => "HEAD",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LivenessAuth {
Bearer,
BasicUser,
BasicPassword,
Header {
name: &'static str,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LivenessKind {
Http {
url: &'static str,
method: HttpMethod,
auth: LivenessAuth,
expect_status: u16,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LivenessSpec {
pub kind: LivenessKind,
}
pub trait SecretPattern: Send + Sync {
fn id(&self) -> &str;
fn display_name(&self) -> &str;
fn format_regex(&self) -> &Regex;
fn severity(&self) -> Severity;
fn metadata(&self) -> Option<&PatternMetadata> {
None
}
fn rotation(&self) -> Option<&RotationSpec> {
None
}
fn liveness(&self) -> Option<&LivenessSpec> {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::OnceLock;
struct Minimal;
impl SecretPattern for Minimal {
fn id(&self) -> &str {
"minimal"
}
fn display_name(&self) -> &str {
"Minimal Test Pattern"
}
fn severity(&self) -> Severity {
Severity::Low
}
fn format_regex(&self) -> &Regex {
static R: OnceLock<Regex> = OnceLock::new();
R.get_or_init(|| Regex::new(r"^min_[a-z0-9]{8}$").unwrap())
}
}
struct Full;
impl SecretPattern for Full {
fn id(&self) -> &str {
"full"
}
fn display_name(&self) -> &str {
"Full Test Pattern"
}
fn severity(&self) -> Severity {
Severity::High
}
fn format_regex(&self) -> &Regex {
static R: OnceLock<Regex> = OnceLock::new();
R.get_or_init(|| Regex::new(r"^full_.+$").unwrap())
}
fn metadata(&self) -> Option<&PatternMetadata> {
static M: OnceLock<PatternMetadata> = OnceLock::new();
Some(M.get_or_init(|| PatternMetadata {
provider_id: Cow::Borrowed("test"),
retrieval_url_template: Cow::Borrowed("https://test.example/tokens"),
default_expiry_days: Some(90),
scopes_hint: vec![Cow::Borrowed("read"), Cow::Borrowed("write")],
}))
}
fn rotation(&self) -> Option<&RotationSpec> {
static R: OnceLock<RotationSpec> = OnceLock::new();
Some(R.get_or_init(|| RotationSpec {
method: RotationMethodSpec::Manual,
}))
}
fn liveness(&self) -> Option<&LivenessSpec> {
static L: OnceLock<LivenessSpec> = OnceLock::new();
Some(L.get_or_init(|| LivenessSpec {
kind: LivenessKind::Http {
url: "https://test.example/api/me",
method: HttpMethod::Get,
auth: LivenessAuth::Bearer,
expect_status: 200,
},
}))
}
}
#[test]
fn severity_serializes_lowercase() {
assert_eq!(serde_json::to_string(&Severity::Low).unwrap(), "\"low\"");
assert_eq!(
serde_json::to_string(&Severity::Medium).unwrap(),
"\"medium\""
);
assert_eq!(serde_json::to_string(&Severity::High).unwrap(), "\"high\"");
}
#[test]
fn severity_deserializes_lowercase() {
let s: Severity = serde_json::from_str("\"high\"").unwrap();
assert_eq!(s, Severity::High);
}
#[test]
fn severity_display_matches_serde() {
for s in [Severity::Low, Severity::Medium, Severity::High] {
let displayed = format!("{s}");
let serded: String = serde_json::from_value(serde_json::to_value(s).unwrap()).unwrap();
assert_eq!(displayed, serded);
}
}
#[test]
fn severity_orders_low_below_high() {
assert!(Severity::Low < Severity::Medium);
assert!(Severity::Medium < Severity::High);
}
#[test]
fn http_method_as_str_matches_uppercase_wire_form() {
assert_eq!(HttpMethod::Get.as_str(), "GET");
assert_eq!(HttpMethod::Post.as_str(), "POST");
assert_eq!(HttpMethod::Head.as_str(), "HEAD");
}
#[test]
fn minimal_pattern_exposes_mandatory_accessors() {
let p = Minimal;
assert_eq!(p.id(), "minimal");
assert_eq!(p.display_name(), "Minimal Test Pattern");
assert_eq!(p.severity(), Severity::Low);
assert!(p.format_regex().is_match("min_abc12345"));
assert!(!p.format_regex().is_match("nope"));
}
#[test]
fn minimal_pattern_optional_accessors_default_to_none() {
let p = Minimal;
assert!(p.metadata().is_none());
assert!(p.rotation().is_none());
assert!(p.liveness().is_none());
}
#[test]
fn full_pattern_exposes_metadata_layer() {
let p = Full;
let m = p.metadata().expect("Full provides metadata");
assert_eq!(m.provider_id, "test");
assert_eq!(m.retrieval_url_template, "https://test.example/tokens");
assert_eq!(m.default_expiry_days, Some(90));
assert_eq!(
m.scopes_hint,
vec![Cow::Borrowed("read"), Cow::Borrowed("write")]
);
}
#[test]
fn full_pattern_exposes_rotation_layer() {
let p = Full;
let r = p.rotation().expect("Full provides rotation");
assert_eq!(r.method, RotationMethodSpec::Manual);
}
#[test]
fn full_pattern_exposes_liveness_layer() {
let p = Full;
let l = p.liveness().expect("Full provides liveness");
match &l.kind {
LivenessKind::Http {
url,
method,
auth,
expect_status,
} => {
assert_eq!(*url, "https://test.example/api/me");
assert_eq!(*method, HttpMethod::Get);
assert_eq!(*auth, LivenessAuth::Bearer);
assert_eq!(*expect_status, 200);
}
}
}
#[test]
fn trait_is_object_safe_and_send_sync() {
let patterns: Vec<&dyn SecretPattern> = vec![&Minimal, &Full];
assert_eq!(patterns.len(), 2);
fn assert_send_sync<T: Send + Sync + ?Sized>() {}
assert_send_sync::<dyn SecretPattern>();
}
#[test]
fn liveness_auth_header_variant_carries_name() {
let a = LivenessAuth::Header {
name: "PRIVATE-TOKEN",
};
match a {
LivenessAuth::Header { name } => assert_eq!(name, "PRIVATE-TOKEN"),
_ => panic!("expected Header variant"),
}
}
}