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
64/// An unanchored regex match, or `false` on a pattern that does not compile.
65///
66/// Exported so `.sops.yaml`'s `path_regex` matching uses the **same engine** the
67/// selectors do. Both are reproducing Go RE2 semantics, and two regex crates in
68/// one tool would be two subtly different answers to the same question.
69///
70/// The swallowed compile error is upstream's behaviour: `regexp.MatchString`'s
71/// error is discarded at every sops call site, so a bad pattern matches nothing.
72/// Where the pattern is a file's own *policy* that silence is a real hazard, which
73/// is why [`EncryptionSelector::new`] compiles up front and refuses instead — this
74/// entry point is for the config's `path_regex`, where falling through to the next
75/// rule is at least visible.
76#[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    /// Compile a policy. Every field is the metadata field of the same name;
83    /// `None`/empty means "not configured", matching upstream's `""` test.
84    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    /// The policy a file gets when nothing is configured: `_unencrypted` as the
118    /// exempting suffix, everything else encrypted.
119    #[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    /// Whether any selector is configured at all.
128    ///
129    /// Used to decide whether to fall back to [`Self::default_policy`], which is
130    /// what upstream does by defaulting `UnencryptedSuffix` when the whole set is
131    /// empty.
132    #[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    /// Whether `unencrypted_comment_regex` is set, which the encrypt path needs
143    /// to know so it can refuse a self-defeating file.
144    #[must_use]
145    pub fn has_unencrypted_comment_regex(&self) -> bool {
146        self.unencrypted_comment_regex.is_some()
147    }
148
149    /// Whether a rendered encrypted comment would match
150    /// `unencrypted_comment_regex` — which would make the file permanently
151    /// undecryptable, because the comment would be skipped on the way back in.
152    /// Upstream refuses too.
153    #[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    /// Decide one leaf.
161    ///
162    /// `comments_stack` is the stack of active comment sets, innermost last —
163    /// the shape upstream threads through its walker so that a comment can turn
164    /// encryption on or off for the values that follow it. `is_comment` says
165    /// whether the leaf *is itself* a comment, which only stage 6 cares about.
166    #[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        // 1. unencrypted_suffix — any component ending with it exempts the leaf.
177        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        // 2. encrypted_suffix — resets to false, then opts specific paths back in.
184        if let Some(suffix) = &self.encrypted_suffix {
185            encrypted = components.iter().any(|c| c.ends_with(suffix.as_str()));
186        }
187
188        // 3. unencrypted_regex — any matching component exempts.
189        if let Some(re) = &self.unencrypted_regex {
190            if components.iter().any(|c| re.is_match(c)) {
191                encrypted = false;
192            }
193        }
194
195        // 4. encrypted_regex — resets to false, then opts back in.
196        if let Some(re) = &self.encrypted_regex {
197            encrypted = components.iter().any(|c| re.is_match(c));
198        }
199
200        // 5. unencrypted_comment_regex — any active comment matching exempts.
201        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        // 6. encrypted_comment_regex — resets to false, then opts back in, with
208        //    one carve-out: when the leaf is itself a comment, the *last line of
209        //    the innermost comment set* is skipped. That is the leaf's own text,
210        //    and without the carve-out a comment matching the regex would
211        //    trivially encrypt itself.
212        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    /// The trap: the suffix test runs over *every* path component, so a parent
263    /// exempts its whole subtree. Not documented upstream; read off the loop.
264    #[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    /// Stage order is load-bearing: stage 4 resets the verdict, so it can
286    /// re-encrypt something stage 3 exempted. Reordering the stages breaks this.
287    #[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        // stage 3 exempts (matches ^pub), stage 4 resets and opts back in
292        assert_eq!(sel(&s, &["public_key"]), Selection::Encrypt);
293        // stage 3 exempts, stage 4 resets and does not opt back in
294        assert_eq!(sel(&s, &["published"]), Selection::Clear);
295    }
296
297    /// Go's `regexp.Match` is unanchored and Rust's `is_match` is too. If this
298    /// ever fails, someone added `^…$` and every existing file's subset changed.
299    #[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    /// Upstream discards the regex compile error and treats a bad pattern as
311    /// "never matches" — a silently wrong subset. Here it is named at load time.
312    #[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    /// Stage 6's carve-out: a comment does not encrypt *itself* just by matching.
333    #[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        // an empty string is "not configured", matching upstream's `!= ""` test
364        assert!(
365            EncryptionSelector::new(Some(""), Some(""), Some(""), None, None, None)
366                .expect("compile")
367                .is_unconfigured()
368        );
369    }
370}