Skip to main content

kopiur_api/validate/
identity.rs

1use crate::common::IdentityDefaults;
2use crate::error::{ValidationError, ValidationResult};
3use crate::snapshot_policy::{SnapshotPolicySpec, Source};
4use std::collections::BTreeMap;
5
6/// An already-admitted `SnapshotPolicy`'s identity, keyed for collision detection
7/// (ADR-0005 §6). `repo_key` is a normalized repository identity (e.g.
8/// `"ClusterRepository/shared"` or `"Repository/backups/nas"`) so two policies are
9/// "the same repository" only when their keys match; `name` is the policy's
10/// `namespace/name` for the actionable message.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct ExistingIdentity {
13    /// The other policy's resolved `username@hostname[:path]` identity string.
14    pub identity: String,
15    /// The other policy's normalized repository key.
16    pub repo_key: String,
17    /// `namespace/name` of the other policy (for the conflict message).
18    pub name: String,
19}
20
21/// Detect whether a `SnapshotPolicy`'s resolved identity collides with an
22/// already-admitted policy's identity **in the same repository** (ADR-0005 §6).
23/// Pure so the decision is unit-tested; the webhook does the IO (list policies,
24/// resolve each identity) and calls this. Returns the conflicting `namespace/name`
25/// or `None`.
26///
27/// - `self_name` is the candidate's own `namespace/name`, skipped so a re-apply of
28///   the same object never collides with itself.
29/// - A collision requires BOTH the same `repo_key` AND the same `identity` string.
30///
31/// ```
32/// use kopiur_api::validate::{detect_identity_collision, ExistingIdentity};
33///
34/// let existing = vec![ExistingIdentity {
35///     identity: "pg@billing:/pvc/data".into(),
36///     repo_key: "ClusterRepository/shared".into(),
37///     name: "billing/pg-a".into(),
38/// }];
39/// // Same identity + same repo, different policy → collision.
40/// assert_eq!(
41///     detect_identity_collision("pg@billing:/pvc/data", "ClusterRepository/shared", "billing/pg-b", &existing),
42///     Some("billing/pg-a".to_string()),
43/// );
44/// // Same identity but a DIFFERENT repository → no collision (separate snapshot history).
45/// assert_eq!(
46///     detect_identity_collision("pg@billing:/pvc/data", "Repository/billing/nas", "billing/pg-b", &existing),
47///     None,
48/// );
49/// // Self (same name) is skipped.
50/// assert_eq!(
51///     detect_identity_collision("pg@billing:/pvc/data", "ClusterRepository/shared", "billing/pg-a", &existing),
52///     None,
53/// );
54/// ```
55pub fn detect_identity_collision(
56    self_identity: &str,
57    self_repo_key: &str,
58    self_name: &str,
59    existing: &[ExistingIdentity],
60) -> Option<String> {
61    existing
62        .iter()
63        .find(|e| e.name != self_name && e.repo_key == self_repo_key && e.identity == self_identity)
64        .map(|e| e.name.clone())
65}
66
67/// A detected identity collision, naming WHICH `(identity, repository)` pair
68/// collided — with a multi-repository policy contributing N pairs, the deny
69/// message must say which member repository is the problem (the others may be
70/// perfectly fine). Produced by [`detect_identity_collision_multi`].
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct CollisionHit {
73    /// `namespace/name` of the already-admitted conflicting policy.
74    pub conflict: String,
75    /// The resolved `username@hostname[:path]` identity that collided.
76    pub identity: String,
77    /// The normalized repository key the collision happened in.
78    pub repo_key: String,
79}
80
81/// The N-pair generalization of [`detect_identity_collision`]: the candidate
82/// policy contributes one `(identity, repo_key)` pair per member repository
83/// (each identity resolved under THAT repository's `identityDefaults`), and a
84/// collision is the FIRST pair that matches an existing pair. Loops the
85/// single-pair kernel, so `N = 1` is exactly the old behavior.
86///
87/// ```
88/// use kopiur_api::validate::{detect_identity_collision_multi, CollisionHit, ExistingIdentity};
89///
90/// let existing = vec![ExistingIdentity {
91///     identity: "pg@billing:/pvc/data".into(),
92///     repo_key: "ClusterRepository/shared".into(),
93///     name: "billing/pg-a".into(),
94/// }];
95/// // The same identity in a DIFFERENT repository is fine; the pair that also
96/// // lands in `ClusterRepository/shared` collides — and the hit names it.
97/// let pairs = vec![
98///     ("pg@billing:/pvc/data".to_string(), "Repository/billing/nas".to_string()),
99///     ("pg@billing:/pvc/data".to_string(), "ClusterRepository/shared".to_string()),
100/// ];
101/// assert_eq!(
102///     detect_identity_collision_multi(&pairs, "billing/pg-b", &existing),
103///     Some(CollisionHit {
104///         conflict: "billing/pg-a".into(),
105///         identity: "pg@billing:/pvc/data".into(),
106///         repo_key: "ClusterRepository/shared".into(),
107///     }),
108/// );
109/// // Disjoint repositories → no collision at all.
110/// let disjoint = vec![("pg@billing:/pvc/data".to_string(), "Repository/billing/nas".to_string())];
111/// assert_eq!(detect_identity_collision_multi(&disjoint, "billing/pg-b", &existing), None);
112/// ```
113pub fn detect_identity_collision_multi(
114    self_pairs: &[(String, String)],
115    self_name: &str,
116    existing: &[ExistingIdentity],
117) -> Option<CollisionHit> {
118    self_pairs.iter().find_map(|(identity, repo_key)| {
119        detect_identity_collision(identity, repo_key, self_name, existing).map(|conflict| {
120            CollisionHit {
121                conflict,
122                identity: identity.clone(),
123                repo_key: repo_key.clone(),
124            }
125        })
126    })
127}
128
129// --- Identity shape validation (kopia username@hostname:path contract) -------
130
131/// Generous byte cap for a single identity component. kopia imposes none; this only
132/// bounds adversarial input (a hostname mirrors DNS's 253 here).
133pub(crate) const IDENTITY_MAX_LEN: usize = 253;
134
135/// The shape problem (if any) with a kopia identity `username`/`hostname` component.
136/// kopia (`snapshot.ParseSourceInfo`) splits a source on the **first** `@` and
137/// **first** `:` with no escaping, so an embedded delimiter silently reparses the
138/// identity into a *different* one; whitespace and ASCII control characters survive
139/// verbatim but make the identity un-typeable/un-findable on a later
140/// `snapshot list --source`. This is the minimal shape rule — NOT a character class;
141/// dots, dashes, slashes and unicode letters all pass.
142fn identity_char_problem(value: &str) -> Option<String> {
143    if value.is_empty() {
144        return Some("must not be empty".to_string());
145    }
146    if value.len() > IDENTITY_MAX_LEN {
147        return Some(format!(
148            "is {} bytes; the maximum is {IDENTITY_MAX_LEN}",
149            value.len()
150        ));
151    }
152    if value.contains('@') {
153        return Some("must not contain '@' (kopia's username/hostname delimiter)".to_string());
154    }
155    if value.contains(':') {
156        return Some("must not contain ':' (kopia's hostname/path delimiter)".to_string());
157    }
158    if let Some(c) = value.chars().find(|c| c.is_ascii_whitespace()) {
159        return Some(format!("must not contain whitespace (found {c:?})"));
160    }
161    if let Some(c) = value.chars().find(|c| c.is_ascii_control()) {
162        return Some(format!("must not contain control characters (found {c:?})"));
163    }
164    None
165}
166
167/// Validate a resolved kopia identity component (`username`/`hostname`). Shape-only
168/// (see [`identity_char_problem`]); `field` names the surface for the message. Called
169/// both from the static admission validator (on explicit overrides) and from
170/// [`crate::resolve_identity`] (on the fully-resolved value, covering CEL results and
171/// defaults), so a bad identity can never be pinned.
172pub fn validate_identity_component(field: &str, value: &str) -> ValidationResult {
173    match identity_char_problem(value) {
174        None => Ok(()),
175        Some(reason) => Err(ValidationError::IdentityComponentInvalid {
176            field: field.to_string(),
177            value: value.to_string(),
178            reason,
179        }),
180    }
181}
182
183/// Maximum length of `Repository`/`ClusterRepository` `identityDefaults.cluster`.
184/// A cluster identity is a short, human-chosen suffix appended onto a namespace
185/// name — not free text — so this is generous headroom well under DNS's 253-byte
186/// label ceiling, not a real constraint in practice.
187pub const CLUSTER_NAME_MAX_LEN: usize = 32;
188
189/// Validate a `Repository`/`ClusterRepository` `identityDefaults.cluster`: an RFC 1123 label
190/// (`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`), 1..=[`CLUSTER_NAME_MAX_LEN`] characters,
191/// with dots called out explicitly as forbidden even though a well-formed RFC
192/// 1123 label never contains one anyway — the message needs to explain *why* to
193/// whoever hits it: `cluster` is concatenated onto a namespace as
194/// `<namespace>.<cluster>` for the default hostname (see
195/// [`crate::identity::resolve_identity`]), and [`crate::identity::classify_hostname`]
196/// splits that hostname back apart at the FIRST `.`, so a dot anywhere in
197/// `cluster` would make that split ambiguous.
198pub fn validate_cluster_name(value: &str) -> ValidationResult {
199    if value.is_empty() {
200        return Err(ValidationError::ClusterNameInvalid {
201            value: value.to_string(),
202            reason: "must not be empty".to_string(),
203        });
204    }
205    if value.len() > CLUSTER_NAME_MAX_LEN {
206        return Err(ValidationError::ClusterNameInvalid {
207            value: value.to_string(),
208            reason: format!(
209                "is {} characters; the maximum is {CLUSTER_NAME_MAX_LEN}",
210                value.len()
211            ),
212        });
213    }
214    if value.contains('.') {
215        return Err(ValidationError::ClusterNameInvalid {
216            value: value.to_string(),
217            reason: "must not contain '.' — the first '.' in a hostname is the \
218                     namespace/cluster delimiter"
219                .to_string(),
220        });
221    }
222    let is_rfc1123_label = value
223        .bytes()
224        .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
225        && value
226            .as_bytes()
227            .first()
228            .is_some_and(u8::is_ascii_alphanumeric)
229        && value
230            .as_bytes()
231            .last()
232            .is_some_and(u8::is_ascii_alphanumeric);
233    if !is_rfc1123_label {
234        return Err(ValidationError::ClusterNameInvalid {
235            value: value.to_string(),
236            reason: "must be a lowercase RFC 1123 label: lowercase alphanumeric \
237                     characters or '-', starting and ending with an alphanumeric \
238                     character"
239                .to_string(),
240        });
241    }
242    Ok(())
243}
244
245/// Validate a kopia identity `sourcePath` (the part after the first `:`). Lenient:
246/// spaces and `:` are allowed (only the first `:` is kopia's delimiter, and the rest
247/// is the path verbatim), but the path must be non-empty and free of newlines / ASCII
248/// control characters.
249pub fn validate_source_path(field: &str, value: &str) -> ValidationResult {
250    let reason = if value.is_empty() {
251        Some("must not be empty when set".to_string())
252    } else {
253        value
254            .chars()
255            .find(|c| c.is_ascii_control())
256            .map(|c| format!("must not contain control characters (found {c:?})"))
257    };
258    match reason {
259        None => Ok(()),
260        Some(reason) => Err(ValidationError::IdentitySourcePathInvalid {
261            field: field.to_string(),
262            value: value.to_string(),
263            reason,
264        }),
265    }
266}
267
268// --- Fork-on-edit guard (re-identifying a policy with history orphans snapshots) ---
269
270/// Pure decision for the fork-on-edit guard on a `username@hostname` change. Returns
271/// `Some(IdentityWouldFork)` iff the policy has snapshot history, the change was not
272/// acknowledged, and the resolved identity actually differs. The webhook does the IO
273/// (read the old object's pinned identity + history, resolve the new identity) and
274/// calls this.
275///
276/// ```
277/// use kopiur_api::validate::detect_identity_fork;
278///
279/// // History + a real change + no ack → fork.
280/// assert!(detect_identity_fork("pg@billing", "pg@payments", true, false).is_some());
281/// // No history yet (e.g. typo fixed before the first backup) → allowed.
282/// assert!(detect_identity_fork("pg@billing", "pg@payments", false, false).is_none());
283/// // Acknowledged → allowed.
284/// assert!(detect_identity_fork("pg@billing", "pg@payments", true, true).is_none());
285/// // No actual change → allowed.
286/// assert!(detect_identity_fork("pg@billing", "pg@billing", true, false).is_none());
287/// ```
288pub fn detect_identity_fork(
289    old_identity: &str,
290    new_identity: &str,
291    has_history: bool,
292    acknowledged: bool,
293) -> Option<ValidationError> {
294    (has_history && !acknowledged && old_identity != new_identity).then(|| {
295        ValidationError::IdentityWouldFork {
296            old: old_identity.to_string(),
297            new: new_identity.to_string(),
298        }
299    })
300}
301
302/// The per-repository generalization of [`detect_identity_fork`] for
303/// multi-repository policies (plan B5). The unit of identity is the
304/// `(repo_key, identity-under-that-repo's-identityDefaults)` pair, so:
305///
306/// - `old_baselines` maps each repo_key the OLD object had a resolved identity
307///   for (from `status.resolved.repositories`, with the top-level identity as
308///   the single-repo fallback) to its `username@hostname`;
309/// - `new_identities` maps each repo_key of the NEW spec's repository set to
310///   the freshly-resolved `username@hostname` under that repository's defaults;
311/// - the edit **forks** iff any repo_key present in BOTH maps resolves
312///   differently (the error names that repository). An ADDED repository (in
313///   `new` only) has no history to orphan — no fork. A REMOVED repository (in
314///   `old` only) is not a fork either (the caller surfaces it as a warning).
315///
316/// ```
317/// use std::collections::BTreeMap;
318/// use kopiur_api::error::ValidationError;
319/// use kopiur_api::validate::detect_identity_fork_multi;
320///
321/// let old: BTreeMap<String, String> = [
322///     ("Repository/billing/a".to_string(), "pg@east".to_string()),
323///     ("Repository/billing/b".to_string(), "pg@west".to_string()),
324/// ].into();
325/// let mut new = old.clone();
326///
327/// // Unchanged → allowed.
328/// assert!(detect_identity_fork_multi(&old, &new, true, false).is_none());
329///
330/// // Repo b re-resolves differently → fork naming b.
331/// new.insert("Repository/billing/b".to_string(), "pg@flipped".to_string());
332/// assert!(matches!(
333///     detect_identity_fork_multi(&old, &new, true, false),
334///     Some(ValidationError::IdentityWouldForkInRepository { repo, .. })
335///         if repo == "Repository/billing/b"
336/// ));
337/// // No history / acknowledged → allowed.
338/// assert!(detect_identity_fork_multi(&old, &new, false, false).is_none());
339/// assert!(detect_identity_fork_multi(&old, &new, true, true).is_none());
340///
341/// // An ADDED repository (no old baseline) never forks.
342/// new = old.clone();
343/// new.insert("ClusterRepository/offsite".to_string(), "pg@east".to_string());
344/// assert!(detect_identity_fork_multi(&old, &new, true, false).is_none());
345/// ```
346pub fn detect_identity_fork_multi(
347    old_baselines: &BTreeMap<String, String>,
348    new_identities: &BTreeMap<String, String>,
349    has_history: bool,
350    acknowledged: bool,
351) -> Option<ValidationError> {
352    if !has_history || acknowledged {
353        return None;
354    }
355    new_identities.iter().find_map(|(repo_key, new_uh)| {
356        old_baselines
357            .get(repo_key)
358            .filter(|old_uh| *old_uh != new_uh)
359            .map(|old_uh| ValidationError::IdentityWouldForkInRepository {
360                repo: repo_key.clone(),
361                old: old_uh.clone(),
362                new: new_uh.clone(),
363            })
364    })
365}
366
367/// The `(pvcName, effectivePath)` kopia would record for a PVC-addressed source: an
368/// explicit `sourcePathOverride`, else the `/pvc/<name>` default (mirrors
369/// [`crate::resolve_identity`]). `None` for non-PVC sources (selector/NFS), which the
370/// path-fork guard does not reason about — their data identity is the selection/export
371/// itself, not an editable per-source path.
372fn pvc_source_effective_path(source: &Source) -> Option<(String, String)> {
373    let name = source.pvc.as_ref()?.name.clone();
374    let path = source
375        .source_path_override
376        .clone()
377        .unwrap_or_else(|| format!("/pvc/{name}"));
378    Some((name, path))
379}
380
381/// The `sourcePathStrategy` a **selector** source resolves to, keyed by a stable
382/// identifier for that source.
383///
384/// Selector sources have no PVC name to key on and their matched set changes
385/// over time, so they are keyed by position. That is exactly right for this
386/// guard: the question is "did source #N's path SHAPE change", not "which PVCs
387/// does it match today".
388///
389/// This exists because flipping `PvcName` → `PvcNamespacedName` rewrites every
390/// member's kopia path (`/pvc/x` → `/pvc/ns/x`), which re-identifies the source
391/// and orphans every manifest it has taken — precisely what this guard is for,
392/// and precisely what it did not cover while the selector was unimplemented.
393fn selector_source_strategies(spec: &SnapshotPolicySpec) -> BTreeMap<usize, &'static str> {
394    spec.sources
395        .iter()
396        .enumerate()
397        .filter(|(_, s)| s.pvc_selector.is_some())
398        .map(|(i, s)| {
399            let label = match s
400                .source_path_strategy
401                .unwrap_or(crate::snapshot_policy::SourcePathStrategy::PvcName)
402            {
403                crate::snapshot_policy::SourcePathStrategy::PvcName => "/pvc/<name>",
404                crate::snapshot_policy::SourcePathStrategy::PvcNamespacedName => {
405                    "/pvc/<namespace>/<name>"
406                }
407            };
408            (i, label)
409        })
410        .collect()
411}
412
413/// Pure decision for the fork-on-edit guard on a per-source path change. A PVC's kopia
414/// source path is part of its identity, so changing `sourcePathOverride` on a PVC that
415/// already has history orphans that PVC's snapshots exactly as a username/hostname
416/// change would. Sources are matched across the edit by PVC name (paths are never
417/// CEL-driven, so an old-vs-new spec diff is complete); selector/NFS sources are out of
418/// scope. Returns the first offending change.
419pub fn detect_source_path_fork(
420    old: &SnapshotPolicySpec,
421    new: &SnapshotPolicySpec,
422    has_history: bool,
423    acknowledged: bool,
424) -> Option<ValidationError> {
425    if !has_history || acknowledged {
426        return None;
427    }
428    let old_paths: BTreeMap<String, String> = old
429        .sources
430        .iter()
431        .filter_map(pvc_source_effective_path)
432        .collect();
433    for source in &new.sources {
434        if let Some((name, new_path)) = pvc_source_effective_path(source)
435            && let Some(old_path) = old_paths.get(&name)
436            && *old_path != new_path
437        {
438            return Some(ValidationError::IdentityWouldFork {
439                old: old_path.clone(),
440                new: new_path,
441            });
442        }
443    }
444    // Same guard for selector sources: a `sourcePathStrategy` flip rewrites
445    // every matched PVC's kopia path at once, so it forks harder than any
446    // single `sourcePathOverride` edit could.
447    let old_strategies = selector_source_strategies(old);
448    for (index, new_shape) in selector_source_strategies(new) {
449        if let Some(old_shape) = old_strategies.get(&index)
450            && *old_shape != new_shape
451        {
452            return Some(ValidationError::IdentityWouldFork {
453                old: (*old_shape).to_string(),
454                new: new_shape.to_string(),
455            });
456        }
457    }
458    None
459}
460
461// --- Repository identityDefaults edit guard (fleet-wide silent re-identification) ---
462
463/// Pure decision for the repository `identityDefaults`-edit guard. An edit to a
464/// `Repository`/`ClusterRepository`'s `identityDefaults` (`cluster`,
465/// `hostnameExpr`, or `usernameExpr`) changes what every consumer
466/// `SnapshotPolicy` relying on those defaults resolves to — silently, with no
467/// per-policy edit to acknowledge it (unlike [`detect_identity_fork`], which
468/// guards a policy's own edit). Returns
469/// `Some(`[`ValidationError::RepositoryIdentityWouldFork`]`)` iff
470/// `identityDefaults` actually changed, at least one consumer has snapshot
471/// history, and the change is not acknowledged. The webhook does the IO (list
472/// consumer `SnapshotPolicy`s, read the ack annotation) and calls this.
473///
474/// ```
475/// use kopiur_api::common::IdentityDefaults;
476/// use kopiur_api::validate::detect_repository_identity_change;
477///
478/// let old = IdentityDefaults { cluster: Some("east".into()), ..Default::default() };
479/// let new = IdentityDefaults { cluster: Some("west".into()), ..Default::default() };
480/// let consumers = ["billing/pg".to_string()];
481///
482/// // Change + a consumer with history + no ack → rejected, naming the consumer.
483/// assert!(detect_repository_identity_change(Some(&old), Some(&new), false, &consumers).is_some());
484/// // No consumers with history → nothing to orphan → allowed.
485/// assert!(detect_repository_identity_change(Some(&old), Some(&new), false, &[]).is_none());
486/// // Acknowledged → allowed.
487/// assert!(detect_repository_identity_change(Some(&old), Some(&new), true, &consumers).is_none());
488/// // No actual change → allowed.
489/// assert!(detect_repository_identity_change(Some(&old), Some(&old), false, &consumers).is_none());
490/// ```
491pub fn detect_repository_identity_change(
492    old: Option<&IdentityDefaults>,
493    new: Option<&IdentityDefaults>,
494    acknowledged: bool,
495    consumers_with_history: &[String],
496) -> Option<ValidationError> {
497    if acknowledged || consumers_with_history.is_empty() || old == new {
498        return None;
499    }
500    Some(ValidationError::RepositoryIdentityWouldFork {
501        consumers: consumers_with_history.to_vec(),
502    })
503}