Skip to main content

suminuri_wire/
selector.rs

1//! Which leaves get encrypted — `sops.go`'s `shouldBeEncrypted`, reproduced
2//! exactly including its order-dependence.
3//!
4//! Six stages run in a fixed order and **each one overwrites the last**, so this
5//! is not a set of independent filters that could be reordered or combined with
6//! `&&`. Two of the stages (`encrypted_suffix`, `encrypted_regex`) begin by
7//! resetting the verdict to `false`, which means a later stage can *un-exempt*
8//! something an earlier one exempted. Getting the order wrong yields a file that
9//! encrypts the wrong subset — and the failure is silent, because such a file is
10//! internally consistent and verifies against its own MAC.
11//!
12//! Two traps worth naming, both measured from the source rather than the docs:
13//!
14//! - the suffix and regex tests run against **every component of the path**, not
15//!   just the leaf's own key. A parent named `foo_unencrypted` silently exempts
16//!   its entire subtree.
17//! - the regexes are **unanchored** Go RE2 (`regexp.Match`, no `^…$` added), so
18//!   `encrypted_regex: "data"` matches `metadata` too. Rust's `regex` crate is
19//!   the same syntax family and the same unanchored semantics, which is why it
20//!   is the right dependency and a PCRE would not be.
21
22use crate::WireError;
23use crate::aad::AadPath;
24use regex::Regex;
25
26/// The verdict for one leaf.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Selection {
29    /// Encrypt this leaf, and count it toward the MAC either way.
30    Encrypt,
31    /// Leave this leaf in the clear. Under `mac_only_encrypted` it also drops out
32    /// of the MAC.
33    Clear,
34}
35
36impl Selection {
37    #[must_use]
38    pub fn is_encrypted(self) -> bool {
39        matches!(self, Self::Encrypt)
40    }
41}
42
43/// The compiled form of a file's encryption policy.
44///
45/// Built once per file from the metadata so the regexes compile once instead of
46/// per leaf, and so a bad pattern is a named error at load time rather than a
47/// silently-non-matching regex at walk time. Upstream calls `regexp.Match` per
48/// leaf and **discards the compile error** (`matched, _ :=`), so an invalid
49/// pattern there behaves as "never matches" — which is the round-up this type
50/// removes.
51#[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
61/// sops's default when no selector at all is configured.
62pub const DEFAULT_UNENCRYPTED_SUFFIX: &str = "_unencrypted";
63
64impl EncryptionSelector {
65    /// Compile a policy. Every field is the metadata field of the same name;
66    /// `None`/empty means "not configured", matching upstream's `""` test.
67    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    /// The policy a file gets when nothing is configured: `_unencrypted` as the
101    /// exempting suffix, everything else encrypted.
102    #[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    /// Whether any selector is configured at all.
111    ///
112    /// Used to decide whether to fall back to [`Self::default_policy`], which is
113    /// what upstream does by defaulting `UnencryptedSuffix` when the whole set is
114    /// empty.
115    #[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    /// Whether `unencrypted_comment_regex` is set, which the encrypt path needs
126    /// to know so it can refuse a self-defeating file.
127    #[must_use]
128    pub fn has_unencrypted_comment_regex(&self) -> bool {
129        self.unencrypted_comment_regex.is_some()
130    }
131
132    /// Whether a rendered encrypted comment would match
133    /// `unencrypted_comment_regex` — which would make the file permanently
134    /// undecryptable, because the comment would be skipped on the way back in.
135    /// Upstream refuses too.
136    #[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    /// Decide one leaf.
144    ///
145    /// `comments_stack` is the stack of active comment sets, innermost last —
146    /// the shape upstream threads through its walker so that a comment can turn
147    /// encryption on or off for the values that follow it. `is_comment` says
148    /// whether the leaf *is itself* a comment, which only stage 6 cares about.
149    #[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        // 1. unencrypted_suffix — any component ending with it exempts the leaf.
160        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        // 2. encrypted_suffix — resets to false, then opts specific paths back in.
167        if let Some(suffix) = &self.encrypted_suffix {
168            encrypted = components.iter().any(|c| c.ends_with(suffix.as_str()));
169        }
170
171        // 3. unencrypted_regex — any matching component exempts.
172        if let Some(re) = &self.unencrypted_regex {
173            if components.iter().any(|c| re.is_match(c)) {
174                encrypted = false;
175            }
176        }
177
178        // 4. encrypted_regex — resets to false, then opts back in.
179        if let Some(re) = &self.encrypted_regex {
180            encrypted = components.iter().any(|c| re.is_match(c));
181        }
182
183        // 5. unencrypted_comment_regex — any active comment matching exempts.
184        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        // 6. encrypted_comment_regex — resets to false, then opts back in, with
191        //    one carve-out: when the leaf is itself a comment, the *last line of
192        //    the innermost comment set* is skipped. That is the leaf's own text,
193        //    and without the carve-out a comment matching the regex would
194        //    trivially encrypt itself.
195        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    /// The trap: the suffix test runs over *every* path component, so a parent
246    /// exempts its whole subtree. Not documented upstream; read off the loop.
247    #[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    /// Stage order is load-bearing: stage 4 resets the verdict, so it can
269    /// re-encrypt something stage 3 exempted. Reordering the stages breaks this.
270    #[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        // stage 3 exempts (matches ^pub), stage 4 resets and opts back in
275        assert_eq!(sel(&s, &["public_key"]), Selection::Encrypt);
276        // stage 3 exempts, stage 4 resets and does not opt back in
277        assert_eq!(sel(&s, &["published"]), Selection::Clear);
278    }
279
280    /// Go's `regexp.Match` is unanchored and Rust's `is_match` is too. If this
281    /// ever fails, someone added `^…$` and every existing file's subset changed.
282    #[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    /// Upstream discards the regex compile error and treats a bad pattern as
294    /// "never matches" — a silently wrong subset. Here it is named at load time.
295    #[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    /// Stage 6's carve-out: a comment does not encrypt *itself* just by matching.
316    #[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        // an empty string is "not configured", matching upstream's `!= ""` test
347        assert!(
348            EncryptionSelector::new(Some(""), Some(""), Some(""), None, None, None)
349                .expect("compile")
350                .is_unconfigured()
351        );
352    }
353}