1use crate::WireError;
23use crate::aad::AadPath;
24use regex::Regex;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Selection {
29 Encrypt,
31 Clear,
34}
35
36impl Selection {
37 #[must_use]
38 pub fn is_encrypted(self) -> bool {
39 matches!(self, Self::Encrypt)
40 }
41}
42
43#[derive(Debug, Default)]
52pub struct EncryptionSelector {
53 unencrypted_suffix: Option<String>,
54 encrypted_suffix: Option<String>,
55 unencrypted_regex: Option<Regex>,
56 encrypted_regex: Option<Regex>,
57 unencrypted_comment_regex: Option<Regex>,
58 encrypted_comment_regex: Option<Regex>,
59}
60
61pub const DEFAULT_UNENCRYPTED_SUFFIX: &str = "_unencrypted";
63
64#[must_use]
77pub fn regex_is_match(pattern: &str, text: &str) -> bool {
78 Regex::new(pattern).is_ok_and(|re| re.is_match(text))
79}
80
81impl EncryptionSelector {
82 pub fn new(
85 unencrypted_suffix: Option<&str>,
86 encrypted_suffix: Option<&str>,
87 unencrypted_regex: Option<&str>,
88 encrypted_regex: Option<&str>,
89 unencrypted_comment_regex: Option<&str>,
90 encrypted_comment_regex: Option<&str>,
91 ) -> Result<Self, WireError> {
92 let compile = |p: Option<&str>| -> Result<Option<Regex>, WireError> {
93 match p.filter(|s| !s.is_empty()) {
94 None => Ok(None),
95 Some(p) => Regex::new(p)
96 .map(Some)
97 .map_err(|e| WireError::BadSelectorRegex {
98 pattern: p.to_string(),
99 reason: e.to_string(),
100 }),
101 }
102 };
103 Ok(Self {
104 unencrypted_suffix: unencrypted_suffix
105 .filter(|s| !s.is_empty())
106 .map(str::to_string),
107 encrypted_suffix: encrypted_suffix
108 .filter(|s| !s.is_empty())
109 .map(str::to_string),
110 unencrypted_regex: compile(unencrypted_regex)?,
111 encrypted_regex: compile(encrypted_regex)?,
112 unencrypted_comment_regex: compile(unencrypted_comment_regex)?,
113 encrypted_comment_regex: compile(encrypted_comment_regex)?,
114 })
115 }
116
117 #[must_use]
120 pub fn default_policy() -> Self {
121 Self {
122 unencrypted_suffix: Some(DEFAULT_UNENCRYPTED_SUFFIX.to_string()),
123 ..Self::default()
124 }
125 }
126
127 #[must_use]
133 pub fn is_unconfigured(&self) -> bool {
134 self.unencrypted_suffix.is_none()
135 && self.encrypted_suffix.is_none()
136 && self.unencrypted_regex.is_none()
137 && self.encrypted_regex.is_none()
138 && self.unencrypted_comment_regex.is_none()
139 && self.encrypted_comment_regex.is_none()
140 }
141
142 #[must_use]
145 pub fn has_unencrypted_comment_regex(&self) -> bool {
146 self.unencrypted_comment_regex.is_some()
147 }
148
149 #[must_use]
154 pub fn encrypted_comment_would_be_skipped(&self, rendered: &str) -> bool {
155 self.unencrypted_comment_regex
156 .as_ref()
157 .is_some_and(|r| r.is_match(rendered))
158 }
159
160 #[must_use]
167 pub fn select(
168 &self,
169 path: &AadPath,
170 comments_stack: &[Vec<String>],
171 is_comment: bool,
172 ) -> Selection {
173 let components = path.components();
174 let mut encrypted = true;
175
176 if let Some(suffix) = &self.unencrypted_suffix {
178 if components.iter().any(|c| c.ends_with(suffix.as_str())) {
179 encrypted = false;
180 }
181 }
182
183 if let Some(suffix) = &self.encrypted_suffix {
185 encrypted = components.iter().any(|c| c.ends_with(suffix.as_str()));
186 }
187
188 if let Some(re) = &self.unencrypted_regex {
190 if components.iter().any(|c| re.is_match(c)) {
191 encrypted = false;
192 }
193 }
194
195 if let Some(re) = &self.encrypted_regex {
197 encrypted = components.iter().any(|c| re.is_match(c));
198 }
199
200 if let Some(re) = &self.unencrypted_comment_regex {
202 if comments_stack.iter().flatten().any(|c| re.is_match(c)) {
203 encrypted = false;
204 }
205 }
206
207 if let Some(re) = &self.encrypted_comment_regex {
213 let last_set = comments_stack.len().saturating_sub(1);
214 let last_line = comments_stack
215 .last()
216 .map_or(0, |s| s.len().saturating_sub(1));
217 encrypted = comments_stack.iter().enumerate().any(|(i, set)| {
218 set.iter().enumerate().any(|(j, c)| {
219 let is_own_text = is_comment && i == last_set && j == last_line;
220 !is_own_text && re.is_match(c)
221 })
222 });
223 }
224
225 if encrypted {
226 Selection::Encrypt
227 } else {
228 Selection::Clear
229 }
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 fn path(parts: &[&str]) -> AadPath {
238 let mut p = AadPath::root();
239 for c in parts {
240 p.push_key(*c);
241 }
242 p
243 }
244
245 fn sel(s: &EncryptionSelector, parts: &[&str]) -> Selection {
246 s.select(&path(parts), &[], false)
247 }
248
249 #[test]
250 fn everything_is_encrypted_by_default() {
251 let s = EncryptionSelector::default();
252 assert_eq!(sel(&s, &["a", "b"]), Selection::Encrypt);
253 }
254
255 #[test]
256 fn the_default_policy_exempts_the_underscore_suffix() {
257 let s = EncryptionSelector::default_policy();
258 assert_eq!(sel(&s, &["port_unencrypted"]), Selection::Clear);
259 assert_eq!(sel(&s, &["port"]), Selection::Encrypt);
260 }
261
262 #[test]
265 fn a_suffixed_parent_exempts_its_whole_subtree() {
266 let s = EncryptionSelector::default_policy();
267 assert_eq!(
268 sel(&s, &["metadata_unencrypted", "deeply", "nested"]),
269 Selection::Clear
270 );
271 }
272
273 #[test]
274 fn encrypted_suffix_inverts_the_default() {
275 let s =
276 EncryptionSelector::new(None, Some("_enc"), None, None, None, None).expect("compile");
277 assert_eq!(sel(&s, &["password_enc"]), Selection::Encrypt);
278 assert_eq!(
279 sel(&s, &["hostname"]),
280 Selection::Clear,
281 "encrypted_suffix resets to false"
282 );
283 }
284
285 #[test]
288 fn a_later_stage_overrides_an_earlier_exemption() {
289 let s = EncryptionSelector::new(None, None, Some("^pub"), Some("^public_key$"), None, None)
290 .expect("compile");
291 assert_eq!(sel(&s, &["public_key"]), Selection::Encrypt);
293 assert_eq!(sel(&s, &["published"]), Selection::Clear);
295 }
296
297 #[test]
300 fn regexes_are_unanchored_like_go() {
301 let s =
302 EncryptionSelector::new(None, None, None, Some("data"), None, None).expect("compile");
303 assert_eq!(
304 sel(&s, &["metadata"]),
305 Selection::Encrypt,
306 "substring match, as upstream"
307 );
308 }
309
310 #[test]
313 fn a_bad_regex_is_named_at_load_time() {
314 let err = EncryptionSelector::new(None, None, Some("(unclosed"), None, None, None)
315 .err()
316 .expect("must refuse");
317 assert!(
318 matches!(err, WireError::BadSelectorRegex { .. }),
319 "got {err:?}"
320 );
321 }
322
323 #[test]
324 fn an_active_comment_can_exempt_a_value() {
325 let s = EncryptionSelector::new(None, None, None, None, Some("plaintext"), None)
326 .expect("compile");
327 let stack = vec![vec!["this one is plaintext on purpose".to_string()]];
328 assert_eq!(s.select(&path(&["k"]), &stack, false), Selection::Clear);
329 assert_eq!(s.select(&path(&["k"]), &[], false), Selection::Encrypt);
330 }
331
332 #[test]
334 fn a_comment_matching_the_encrypt_regex_does_not_encrypt_itself() {
335 let s =
336 EncryptionSelector::new(None, None, None, None, None, Some("SECRET")).expect("compile");
337 let own = vec![vec!["SECRET below".to_string()]];
338 assert_eq!(
339 s.select(&path(&["k"]), &own, true),
340 Selection::Clear,
341 "the comment's own last line is skipped"
342 );
343 assert_eq!(
344 s.select(&path(&["k"]), &own, false),
345 Selection::Encrypt,
346 "but the value that follows it is encrypted"
347 );
348 }
349
350 #[test]
351 fn a_self_defeating_comment_regex_is_detectable() {
352 let s = EncryptionSelector::new(None, None, None, None, Some("^ENC\\["), Some("x"))
353 .expect("compile");
354 assert!(s.has_unencrypted_comment_regex());
355 assert!(s.encrypted_comment_would_be_skipped("ENC[AES256_GCM,data:…]"));
356 assert!(!s.encrypted_comment_would_be_skipped("a normal comment"));
357 }
358
359 #[test]
360 fn is_unconfigured_distinguishes_empty_from_set() {
361 assert!(EncryptionSelector::default().is_unconfigured());
362 assert!(!EncryptionSelector::default_policy().is_unconfigured());
363 assert!(
365 EncryptionSelector::new(Some(""), Some(""), Some(""), None, None, None)
366 .expect("compile")
367 .is_unconfigured()
368 );
369 }
370}