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
64impl EncryptionSelector {
65 pub fn new(
68 unencrypted_suffix: Option<&str>,
69 encrypted_suffix: Option<&str>,
70 unencrypted_regex: Option<&str>,
71 encrypted_regex: Option<&str>,
72 unencrypted_comment_regex: Option<&str>,
73 encrypted_comment_regex: Option<&str>,
74 ) -> Result<Self, WireError> {
75 let compile = |p: Option<&str>| -> Result<Option<Regex>, WireError> {
76 match p.filter(|s| !s.is_empty()) {
77 None => Ok(None),
78 Some(p) => Regex::new(p)
79 .map(Some)
80 .map_err(|e| WireError::BadSelectorRegex {
81 pattern: p.to_string(),
82 reason: e.to_string(),
83 }),
84 }
85 };
86 Ok(Self {
87 unencrypted_suffix: unencrypted_suffix
88 .filter(|s| !s.is_empty())
89 .map(str::to_string),
90 encrypted_suffix: encrypted_suffix
91 .filter(|s| !s.is_empty())
92 .map(str::to_string),
93 unencrypted_regex: compile(unencrypted_regex)?,
94 encrypted_regex: compile(encrypted_regex)?,
95 unencrypted_comment_regex: compile(unencrypted_comment_regex)?,
96 encrypted_comment_regex: compile(encrypted_comment_regex)?,
97 })
98 }
99
100 #[must_use]
103 pub fn default_policy() -> Self {
104 Self {
105 unencrypted_suffix: Some(DEFAULT_UNENCRYPTED_SUFFIX.to_string()),
106 ..Self::default()
107 }
108 }
109
110 #[must_use]
116 pub fn is_unconfigured(&self) -> bool {
117 self.unencrypted_suffix.is_none()
118 && self.encrypted_suffix.is_none()
119 && self.unencrypted_regex.is_none()
120 && self.encrypted_regex.is_none()
121 && self.unencrypted_comment_regex.is_none()
122 && self.encrypted_comment_regex.is_none()
123 }
124
125 #[must_use]
128 pub fn has_unencrypted_comment_regex(&self) -> bool {
129 self.unencrypted_comment_regex.is_some()
130 }
131
132 #[must_use]
137 pub fn encrypted_comment_would_be_skipped(&self, rendered: &str) -> bool {
138 self.unencrypted_comment_regex
139 .as_ref()
140 .is_some_and(|r| r.is_match(rendered))
141 }
142
143 #[must_use]
150 pub fn select(
151 &self,
152 path: &AadPath,
153 comments_stack: &[Vec<String>],
154 is_comment: bool,
155 ) -> Selection {
156 let components = path.components();
157 let mut encrypted = true;
158
159 if let Some(suffix) = &self.unencrypted_suffix {
161 if components.iter().any(|c| c.ends_with(suffix.as_str())) {
162 encrypted = false;
163 }
164 }
165
166 if let Some(suffix) = &self.encrypted_suffix {
168 encrypted = components.iter().any(|c| c.ends_with(suffix.as_str()));
169 }
170
171 if let Some(re) = &self.unencrypted_regex {
173 if components.iter().any(|c| re.is_match(c)) {
174 encrypted = false;
175 }
176 }
177
178 if let Some(re) = &self.encrypted_regex {
180 encrypted = components.iter().any(|c| re.is_match(c));
181 }
182
183 if let Some(re) = &self.unencrypted_comment_regex {
185 if comments_stack.iter().flatten().any(|c| re.is_match(c)) {
186 encrypted = false;
187 }
188 }
189
190 if let Some(re) = &self.encrypted_comment_regex {
196 let last_set = comments_stack.len().saturating_sub(1);
197 let last_line = comments_stack
198 .last()
199 .map_or(0, |s| s.len().saturating_sub(1));
200 encrypted = comments_stack.iter().enumerate().any(|(i, set)| {
201 set.iter().enumerate().any(|(j, c)| {
202 let is_own_text = is_comment && i == last_set && j == last_line;
203 !is_own_text && re.is_match(c)
204 })
205 });
206 }
207
208 if encrypted {
209 Selection::Encrypt
210 } else {
211 Selection::Clear
212 }
213 }
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219
220 fn path(parts: &[&str]) -> AadPath {
221 let mut p = AadPath::root();
222 for c in parts {
223 p.push_key(*c);
224 }
225 p
226 }
227
228 fn sel(s: &EncryptionSelector, parts: &[&str]) -> Selection {
229 s.select(&path(parts), &[], false)
230 }
231
232 #[test]
233 fn everything_is_encrypted_by_default() {
234 let s = EncryptionSelector::default();
235 assert_eq!(sel(&s, &["a", "b"]), Selection::Encrypt);
236 }
237
238 #[test]
239 fn the_default_policy_exempts_the_underscore_suffix() {
240 let s = EncryptionSelector::default_policy();
241 assert_eq!(sel(&s, &["port_unencrypted"]), Selection::Clear);
242 assert_eq!(sel(&s, &["port"]), Selection::Encrypt);
243 }
244
245 #[test]
248 fn a_suffixed_parent_exempts_its_whole_subtree() {
249 let s = EncryptionSelector::default_policy();
250 assert_eq!(
251 sel(&s, &["metadata_unencrypted", "deeply", "nested"]),
252 Selection::Clear
253 );
254 }
255
256 #[test]
257 fn encrypted_suffix_inverts_the_default() {
258 let s =
259 EncryptionSelector::new(None, Some("_enc"), None, None, None, None).expect("compile");
260 assert_eq!(sel(&s, &["password_enc"]), Selection::Encrypt);
261 assert_eq!(
262 sel(&s, &["hostname"]),
263 Selection::Clear,
264 "encrypted_suffix resets to false"
265 );
266 }
267
268 #[test]
271 fn a_later_stage_overrides_an_earlier_exemption() {
272 let s = EncryptionSelector::new(None, None, Some("^pub"), Some("^public_key$"), None, None)
273 .expect("compile");
274 assert_eq!(sel(&s, &["public_key"]), Selection::Encrypt);
276 assert_eq!(sel(&s, &["published"]), Selection::Clear);
278 }
279
280 #[test]
283 fn regexes_are_unanchored_like_go() {
284 let s =
285 EncryptionSelector::new(None, None, None, Some("data"), None, None).expect("compile");
286 assert_eq!(
287 sel(&s, &["metadata"]),
288 Selection::Encrypt,
289 "substring match, as upstream"
290 );
291 }
292
293 #[test]
296 fn a_bad_regex_is_named_at_load_time() {
297 let err = EncryptionSelector::new(None, None, Some("(unclosed"), None, None, None)
298 .err()
299 .expect("must refuse");
300 assert!(
301 matches!(err, WireError::BadSelectorRegex { .. }),
302 "got {err:?}"
303 );
304 }
305
306 #[test]
307 fn an_active_comment_can_exempt_a_value() {
308 let s = EncryptionSelector::new(None, None, None, None, Some("plaintext"), None)
309 .expect("compile");
310 let stack = vec![vec!["this one is plaintext on purpose".to_string()]];
311 assert_eq!(s.select(&path(&["k"]), &stack, false), Selection::Clear);
312 assert_eq!(s.select(&path(&["k"]), &[], false), Selection::Encrypt);
313 }
314
315 #[test]
317 fn a_comment_matching_the_encrypt_regex_does_not_encrypt_itself() {
318 let s =
319 EncryptionSelector::new(None, None, None, None, None, Some("SECRET")).expect("compile");
320 let own = vec![vec!["SECRET below".to_string()]];
321 assert_eq!(
322 s.select(&path(&["k"]), &own, true),
323 Selection::Clear,
324 "the comment's own last line is skipped"
325 );
326 assert_eq!(
327 s.select(&path(&["k"]), &own, false),
328 Selection::Encrypt,
329 "but the value that follows it is encrypted"
330 );
331 }
332
333 #[test]
334 fn a_self_defeating_comment_regex_is_detectable() {
335 let s = EncryptionSelector::new(None, None, None, None, Some("^ENC\\["), Some("x"))
336 .expect("compile");
337 assert!(s.has_unencrypted_comment_regex());
338 assert!(s.encrypted_comment_would_be_skipped("ENC[AES256_GCM,data:…]"));
339 assert!(!s.encrypted_comment_would_be_skipped("a normal comment"));
340 }
341
342 #[test]
343 fn is_unconfigured_distinguishes_empty_from_set() {
344 assert!(EncryptionSelector::default().is_unconfigured());
345 assert!(!EncryptionSelector::default_policy().is_unconfigured());
346 assert!(
348 EncryptionSelector::new(Some(""), Some(""), Some(""), None, None, None)
349 .expect("compile")
350 .is_unconfigured()
351 );
352 }
353}