Skip to main content

kopiur_api/validate/
snapshot_replication_overlap.rs

1//! The pure identity-overlap decision for `SnapshotReplication` admission
2//! (issue #368).
3//!
4//! A replication copies SOURCE identities into the destination repository. When
5//! a destination-side `SnapshotPolicy` writes **directly** into that same
6//! repository under an identity the replication's `spec.selection` would also
7//! select, replicated copies and the policy's own snapshots interleave in one
8//! kopia identity's history — and with `pruning: mirrorSource` a source-side
9//! deletion cascades into an identity the destination does not merely mirror
10//! (the data-loss combination the webhook denies).
11//!
12//! This module is only the **decision**: the webhook lists the destination's
13//! policies, resolves their identities, and calls
14//! [`replication_identity_overlap`]. Matching semantics are byte-identical to
15//! the replication mover's selection (`crates/mover/src/replicate.rs`): an
16//! identity is selected when it matches at least one `include` matcher (an
17//! empty `include` list means *everything*) and no `exclude` matcher — exclude
18//! always wins — and a fully-empty matcher defensively matches NOTHING (the
19//! webhook refuses one upstream, but an invalid matcher must never select or
20//! exclude the world).
21
22use crate::common::ResolvedIdentity;
23use crate::identity_string;
24use crate::snapshot_replication::{IdentityMatcher, component_glob_matches};
25
26/// The destination-side identities (rendered as kopia's
27/// `username@hostname[:path]`) that `include`/`exclude` would select — i.e.
28/// identities this replication would copy INTO while a destination policy also
29/// writes them directly. Empty means no overlap. Sorted and de-duplicated so
30/// the admission message (and any test) is deterministic.
31///
32/// ```
33/// use kopiur_api::common::ResolvedIdentity;
34/// use kopiur_api::snapshot_replication::IdentityMatcher;
35/// use kopiur_api::validate::replication_identity_overlap;
36///
37/// let dest = vec![ResolvedIdentity {
38///     username: "pg".into(),
39///     hostname: "billing".into(),
40///     source_path: Some("/pvc/data".into()),
41/// }];
42/// // An absent selection (empty include) selects everything → overlap.
43/// assert_eq!(
44///     replication_identity_overlap(&[], &[], &dest),
45///     vec!["pg@billing:/pvc/data".to_string()],
46/// );
47/// // Excluding the identity clears it.
48/// let exclude = vec![IdentityMatcher { username: Some("pg".into()), ..Default::default() }];
49/// assert!(replication_identity_overlap(&[], &exclude, &dest).is_empty());
50/// ```
51pub fn replication_identity_overlap(
52    include: &[IdentityMatcher],
53    exclude: &[IdentityMatcher],
54    identities: &[ResolvedIdentity],
55) -> Vec<String> {
56    let mut out: Vec<String> = identities
57        .iter()
58        .filter(|id| {
59            let included =
60                include.is_empty() || include.iter().any(|m| overlap_matcher_matches(m, id));
61            included && !exclude.iter().any(|m| overlap_matcher_matches(m, id))
62        })
63        .map(identity_string)
64        .collect();
65    out.sort();
66    out.dedup();
67    out
68}
69
70/// One matcher against one resolved identity: every PRESENT component must
71/// [`component_glob_matches`] its counterpart (an absent matcher component
72/// matches anything); an all-absent matcher matches nothing (defensive — see
73/// module doc). An identity with no `source_path` matches a `sourcePath`
74/// pattern only when the pattern covers the empty string (`"*"` does).
75fn overlap_matcher_matches(m: &IdentityMatcher, id: &ResolvedIdentity) -> bool {
76    if m.username.is_none() && m.hostname.is_none() && m.source_path.is_none() {
77        return false;
78    }
79    m.username
80        .as_deref()
81        .is_none_or(|p| component_glob_matches(p, &id.username))
82        && m.hostname
83            .as_deref()
84            .is_none_or(|p| component_glob_matches(p, &id.hostname))
85        && m.source_path
86            .as_deref()
87            .is_none_or(|p| component_glob_matches(p, id.source_path.as_deref().unwrap_or("")))
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    fn id(username: &str, hostname: &str, path: Option<&str>) -> ResolvedIdentity {
95        ResolvedIdentity {
96            username: username.into(),
97            hostname: hostname.into(),
98            source_path: path.map(str::to_string),
99        }
100    }
101
102    fn matcher(
103        username: Option<&str>,
104        hostname: Option<&str>,
105        source_path: Option<&str>,
106    ) -> IdentityMatcher {
107        IdentityMatcher {
108            username: username.map(str::to_string),
109            hostname: hostname.map(str::to_string),
110            source_path: source_path.map(str::to_string),
111        }
112    }
113
114    #[test]
115    fn empty_include_selects_every_identity() {
116        let ids = [
117            id("pg", "billing", Some("/pvc/data")),
118            id("redis", "cache", Some("/pvc/redis")),
119        ];
120        assert_eq!(
121            replication_identity_overlap(&[], &[], &ids),
122            vec![
123                "pg@billing:/pvc/data".to_string(),
124                "redis@cache:/pvc/redis".to_string(),
125            ],
126        );
127    }
128
129    #[test]
130    fn include_glob_narrows_and_all_set_components_must_match() {
131        let ids = [
132            id("pg-main", "billing", Some("/pvc/data")),
133            id("pg-replica", "other", Some("/pvc/data")),
134            id("redis", "billing", Some("/pvc/redis")),
135        ];
136        let include = [matcher(Some("pg-*"), Some("billing"), None)];
137        assert_eq!(
138            replication_identity_overlap(&include, &[], &ids),
139            vec!["pg-main@billing:/pvc/data".to_string()],
140        );
141    }
142
143    #[test]
144    fn exclude_wins_over_include() {
145        let ids = [
146            id("pg", "billing", Some("/pvc/data")),
147            id("pg", "staging", Some("/pvc/data")),
148        ];
149        let include = [matcher(Some("pg"), None, None)];
150        let exclude = [matcher(None, Some("staging"), None)];
151        assert_eq!(
152            replication_identity_overlap(&include, &exclude, &ids),
153            vec!["pg@billing:/pvc/data".to_string()],
154        );
155    }
156
157    #[test]
158    fn all_empty_matcher_matches_nothing_in_either_list() {
159        let ids = [id("pg", "billing", Some("/pvc/data"))];
160        let empty = [matcher(None, None, None)];
161        // As an include entry it selects nothing (include non-empty, no match).
162        assert!(replication_identity_overlap(&empty, &[], &ids).is_empty());
163        // As an exclude entry it excludes nothing.
164        assert_eq!(
165            replication_identity_overlap(&[], &empty, &ids).len(),
166            1,
167            "a defensively-inert empty matcher must not exclude the world"
168        );
169    }
170
171    #[test]
172    fn absent_source_path_matches_only_patterns_covering_empty() {
173        let ids = [id("cfg", "ns", None)];
174        let star = [matcher(None, None, Some("*"))];
175        assert_eq!(replication_identity_overlap(&star, &[], &ids).len(), 1);
176        let concrete = [matcher(None, None, Some("/pvc/*"))];
177        assert!(replication_identity_overlap(&concrete, &[], &ids).is_empty());
178    }
179
180    #[test]
181    fn result_is_sorted_and_deduplicated() {
182        let ids = [
183            id("z", "h", Some("/p")),
184            id("a", "h", Some("/p")),
185            id("a", "h", Some("/p")),
186        ];
187        assert_eq!(
188            replication_identity_overlap(&[], &[], &ids),
189            vec!["a@h:/p".to_string(), "z@h:/p".to_string()],
190        );
191    }
192}