use crate::WireError;
use crate::aad::AadPath;
use regex::Regex;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Selection {
Encrypt,
Clear,
}
impl Selection {
#[must_use]
pub fn is_encrypted(self) -> bool {
matches!(self, Self::Encrypt)
}
}
#[derive(Debug, Default)]
pub struct EncryptionSelector {
unencrypted_suffix: Option<String>,
encrypted_suffix: Option<String>,
unencrypted_regex: Option<Regex>,
encrypted_regex: Option<Regex>,
unencrypted_comment_regex: Option<Regex>,
encrypted_comment_regex: Option<Regex>,
}
pub const DEFAULT_UNENCRYPTED_SUFFIX: &str = "_unencrypted";
impl EncryptionSelector {
pub fn new(
unencrypted_suffix: Option<&str>,
encrypted_suffix: Option<&str>,
unencrypted_regex: Option<&str>,
encrypted_regex: Option<&str>,
unencrypted_comment_regex: Option<&str>,
encrypted_comment_regex: Option<&str>,
) -> Result<Self, WireError> {
let compile = |p: Option<&str>| -> Result<Option<Regex>, WireError> {
match p.filter(|s| !s.is_empty()) {
None => Ok(None),
Some(p) => Regex::new(p)
.map(Some)
.map_err(|e| WireError::BadSelectorRegex {
pattern: p.to_string(),
reason: e.to_string(),
}),
}
};
Ok(Self {
unencrypted_suffix: unencrypted_suffix
.filter(|s| !s.is_empty())
.map(str::to_string),
encrypted_suffix: encrypted_suffix
.filter(|s| !s.is_empty())
.map(str::to_string),
unencrypted_regex: compile(unencrypted_regex)?,
encrypted_regex: compile(encrypted_regex)?,
unencrypted_comment_regex: compile(unencrypted_comment_regex)?,
encrypted_comment_regex: compile(encrypted_comment_regex)?,
})
}
#[must_use]
pub fn default_policy() -> Self {
Self {
unencrypted_suffix: Some(DEFAULT_UNENCRYPTED_SUFFIX.to_string()),
..Self::default()
}
}
#[must_use]
pub fn is_unconfigured(&self) -> bool {
self.unencrypted_suffix.is_none()
&& self.encrypted_suffix.is_none()
&& self.unencrypted_regex.is_none()
&& self.encrypted_regex.is_none()
&& self.unencrypted_comment_regex.is_none()
&& self.encrypted_comment_regex.is_none()
}
#[must_use]
pub fn has_unencrypted_comment_regex(&self) -> bool {
self.unencrypted_comment_regex.is_some()
}
#[must_use]
pub fn encrypted_comment_would_be_skipped(&self, rendered: &str) -> bool {
self.unencrypted_comment_regex
.as_ref()
.is_some_and(|r| r.is_match(rendered))
}
#[must_use]
pub fn select(
&self,
path: &AadPath,
comments_stack: &[Vec<String>],
is_comment: bool,
) -> Selection {
let components = path.components();
let mut encrypted = true;
if let Some(suffix) = &self.unencrypted_suffix {
if components.iter().any(|c| c.ends_with(suffix.as_str())) {
encrypted = false;
}
}
if let Some(suffix) = &self.encrypted_suffix {
encrypted = components.iter().any(|c| c.ends_with(suffix.as_str()));
}
if let Some(re) = &self.unencrypted_regex {
if components.iter().any(|c| re.is_match(c)) {
encrypted = false;
}
}
if let Some(re) = &self.encrypted_regex {
encrypted = components.iter().any(|c| re.is_match(c));
}
if let Some(re) = &self.unencrypted_comment_regex {
if comments_stack.iter().flatten().any(|c| re.is_match(c)) {
encrypted = false;
}
}
if let Some(re) = &self.encrypted_comment_regex {
let last_set = comments_stack.len().saturating_sub(1);
let last_line = comments_stack
.last()
.map_or(0, |s| s.len().saturating_sub(1));
encrypted = comments_stack.iter().enumerate().any(|(i, set)| {
set.iter().enumerate().any(|(j, c)| {
let is_own_text = is_comment && i == last_set && j == last_line;
!is_own_text && re.is_match(c)
})
});
}
if encrypted {
Selection::Encrypt
} else {
Selection::Clear
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn path(parts: &[&str]) -> AadPath {
let mut p = AadPath::root();
for c in parts {
p.push_key(*c);
}
p
}
fn sel(s: &EncryptionSelector, parts: &[&str]) -> Selection {
s.select(&path(parts), &[], false)
}
#[test]
fn everything_is_encrypted_by_default() {
let s = EncryptionSelector::default();
assert_eq!(sel(&s, &["a", "b"]), Selection::Encrypt);
}
#[test]
fn the_default_policy_exempts_the_underscore_suffix() {
let s = EncryptionSelector::default_policy();
assert_eq!(sel(&s, &["port_unencrypted"]), Selection::Clear);
assert_eq!(sel(&s, &["port"]), Selection::Encrypt);
}
#[test]
fn a_suffixed_parent_exempts_its_whole_subtree() {
let s = EncryptionSelector::default_policy();
assert_eq!(
sel(&s, &["metadata_unencrypted", "deeply", "nested"]),
Selection::Clear
);
}
#[test]
fn encrypted_suffix_inverts_the_default() {
let s =
EncryptionSelector::new(None, Some("_enc"), None, None, None, None).expect("compile");
assert_eq!(sel(&s, &["password_enc"]), Selection::Encrypt);
assert_eq!(
sel(&s, &["hostname"]),
Selection::Clear,
"encrypted_suffix resets to false"
);
}
#[test]
fn a_later_stage_overrides_an_earlier_exemption() {
let s = EncryptionSelector::new(None, None, Some("^pub"), Some("^public_key$"), None, None)
.expect("compile");
assert_eq!(sel(&s, &["public_key"]), Selection::Encrypt);
assert_eq!(sel(&s, &["published"]), Selection::Clear);
}
#[test]
fn regexes_are_unanchored_like_go() {
let s =
EncryptionSelector::new(None, None, None, Some("data"), None, None).expect("compile");
assert_eq!(
sel(&s, &["metadata"]),
Selection::Encrypt,
"substring match, as upstream"
);
}
#[test]
fn a_bad_regex_is_named_at_load_time() {
let err = EncryptionSelector::new(None, None, Some("(unclosed"), None, None, None)
.err()
.expect("must refuse");
assert!(
matches!(err, WireError::BadSelectorRegex { .. }),
"got {err:?}"
);
}
#[test]
fn an_active_comment_can_exempt_a_value() {
let s = EncryptionSelector::new(None, None, None, None, Some("plaintext"), None)
.expect("compile");
let stack = vec![vec!["this one is plaintext on purpose".to_string()]];
assert_eq!(s.select(&path(&["k"]), &stack, false), Selection::Clear);
assert_eq!(s.select(&path(&["k"]), &[], false), Selection::Encrypt);
}
#[test]
fn a_comment_matching_the_encrypt_regex_does_not_encrypt_itself() {
let s =
EncryptionSelector::new(None, None, None, None, None, Some("SECRET")).expect("compile");
let own = vec![vec!["SECRET below".to_string()]];
assert_eq!(
s.select(&path(&["k"]), &own, true),
Selection::Clear,
"the comment's own last line is skipped"
);
assert_eq!(
s.select(&path(&["k"]), &own, false),
Selection::Encrypt,
"but the value that follows it is encrypted"
);
}
#[test]
fn a_self_defeating_comment_regex_is_detectable() {
let s = EncryptionSelector::new(None, None, None, None, Some("^ENC\\["), Some("x"))
.expect("compile");
assert!(s.has_unencrypted_comment_regex());
assert!(s.encrypted_comment_would_be_skipped("ENC[AES256_GCM,data:…]"));
assert!(!s.encrypted_comment_would_be_skipped("a normal comment"));
}
#[test]
fn is_unconfigured_distinguishes_empty_from_set() {
assert!(EncryptionSelector::default().is_unconfigured());
assert!(!EncryptionSelector::default_policy().is_unconfigured());
assert!(
EncryptionSelector::new(Some(""), Some(""), Some(""), None, None, None)
.expect("compile")
.is_unconfigured()
);
}
}