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// --- Identity shape validation (kopia username@hostname:path contract) -------
68
69/// Generous byte cap for a single identity component. kopia imposes none; this only
70/// bounds adversarial input (a hostname mirrors DNS's 253 here).
71pub(crate) const IDENTITY_MAX_LEN: usize = 253;
72
73/// The shape problem (if any) with a kopia identity `username`/`hostname` component.
74/// kopia (`snapshot.ParseSourceInfo`) splits a source on the **first** `@` and
75/// **first** `:` with no escaping, so an embedded delimiter silently reparses the
76/// identity into a *different* one; whitespace and ASCII control characters survive
77/// verbatim but make the identity un-typeable/un-findable on a later
78/// `snapshot list --source`. This is the minimal shape rule — NOT a character class;
79/// dots, dashes, slashes and unicode letters all pass.
80fn identity_char_problem(value: &str) -> Option<String> {
81    if value.is_empty() {
82        return Some("must not be empty".to_string());
83    }
84    if value.len() > IDENTITY_MAX_LEN {
85        return Some(format!(
86            "is {} bytes; the maximum is {IDENTITY_MAX_LEN}",
87            value.len()
88        ));
89    }
90    if value.contains('@') {
91        return Some("must not contain '@' (kopia's username/hostname delimiter)".to_string());
92    }
93    if value.contains(':') {
94        return Some("must not contain ':' (kopia's hostname/path delimiter)".to_string());
95    }
96    if let Some(c) = value.chars().find(|c| c.is_ascii_whitespace()) {
97        return Some(format!("must not contain whitespace (found {c:?})"));
98    }
99    if let Some(c) = value.chars().find(|c| c.is_ascii_control()) {
100        return Some(format!("must not contain control characters (found {c:?})"));
101    }
102    None
103}
104
105/// Validate a resolved kopia identity component (`username`/`hostname`). Shape-only
106/// (see [`identity_char_problem`]); `field` names the surface for the message. Called
107/// both from the static admission validator (on explicit overrides) and from
108/// [`crate::resolve_identity`] (on the fully-resolved value, covering CEL results and
109/// defaults), so a bad identity can never be pinned.
110pub fn validate_identity_component(field: &str, value: &str) -> ValidationResult {
111    match identity_char_problem(value) {
112        None => Ok(()),
113        Some(reason) => Err(ValidationError::IdentityComponentInvalid {
114            field: field.to_string(),
115            value: value.to_string(),
116            reason,
117        }),
118    }
119}
120
121/// Maximum length of `Repository`/`ClusterRepository` `identityDefaults.cluster`.
122/// A cluster identity is a short, human-chosen suffix appended onto a namespace
123/// name — not free text — so this is generous headroom well under DNS's 253-byte
124/// label ceiling, not a real constraint in practice.
125pub const CLUSTER_NAME_MAX_LEN: usize = 32;
126
127/// Validate a `Repository`/`ClusterRepository` `identityDefaults.cluster`: an RFC 1123 label
128/// (`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`), 1..=[`CLUSTER_NAME_MAX_LEN`] characters,
129/// with dots called out explicitly as forbidden even though a well-formed RFC
130/// 1123 label never contains one anyway — the message needs to explain *why* to
131/// whoever hits it: `cluster` is concatenated onto a namespace as
132/// `<namespace>.<cluster>` for the default hostname (see
133/// [`crate::identity::resolve_identity`]), and [`crate::identity::classify_hostname`]
134/// splits that hostname back apart at the FIRST `.`, so a dot anywhere in
135/// `cluster` would make that split ambiguous.
136pub fn validate_cluster_name(value: &str) -> ValidationResult {
137    if value.is_empty() {
138        return Err(ValidationError::ClusterNameInvalid {
139            value: value.to_string(),
140            reason: "must not be empty".to_string(),
141        });
142    }
143    if value.len() > CLUSTER_NAME_MAX_LEN {
144        return Err(ValidationError::ClusterNameInvalid {
145            value: value.to_string(),
146            reason: format!(
147                "is {} characters; the maximum is {CLUSTER_NAME_MAX_LEN}",
148                value.len()
149            ),
150        });
151    }
152    if value.contains('.') {
153        return Err(ValidationError::ClusterNameInvalid {
154            value: value.to_string(),
155            reason: "must not contain '.' — the first '.' in a hostname is the \
156                     namespace/cluster delimiter"
157                .to_string(),
158        });
159    }
160    let is_rfc1123_label = value
161        .bytes()
162        .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
163        && value
164            .as_bytes()
165            .first()
166            .is_some_and(u8::is_ascii_alphanumeric)
167        && value
168            .as_bytes()
169            .last()
170            .is_some_and(u8::is_ascii_alphanumeric);
171    if !is_rfc1123_label {
172        return Err(ValidationError::ClusterNameInvalid {
173            value: value.to_string(),
174            reason: "must be a lowercase RFC 1123 label: lowercase alphanumeric \
175                     characters or '-', starting and ending with an alphanumeric \
176                     character"
177                .to_string(),
178        });
179    }
180    Ok(())
181}
182
183/// Validate a kopia identity `sourcePath` (the part after the first `:`). Lenient:
184/// spaces and `:` are allowed (only the first `:` is kopia's delimiter, and the rest
185/// is the path verbatim), but the path must be non-empty and free of newlines / ASCII
186/// control characters.
187pub fn validate_source_path(field: &str, value: &str) -> ValidationResult {
188    let reason = if value.is_empty() {
189        Some("must not be empty when set".to_string())
190    } else {
191        value
192            .chars()
193            .find(|c| c.is_ascii_control())
194            .map(|c| format!("must not contain control characters (found {c:?})"))
195    };
196    match reason {
197        None => Ok(()),
198        Some(reason) => Err(ValidationError::IdentitySourcePathInvalid {
199            field: field.to_string(),
200            value: value.to_string(),
201            reason,
202        }),
203    }
204}
205
206// --- Fork-on-edit guard (re-identifying a policy with history orphans snapshots) ---
207
208/// Pure decision for the fork-on-edit guard on a `username@hostname` change. Returns
209/// `Some(IdentityWouldFork)` iff the policy has snapshot history, the change was not
210/// acknowledged, and the resolved identity actually differs. The webhook does the IO
211/// (read the old object's pinned identity + history, resolve the new identity) and
212/// calls this.
213///
214/// ```
215/// use kopiur_api::validate::detect_identity_fork;
216///
217/// // History + a real change + no ack → fork.
218/// assert!(detect_identity_fork("pg@billing", "pg@payments", true, false).is_some());
219/// // No history yet (e.g. typo fixed before the first backup) → allowed.
220/// assert!(detect_identity_fork("pg@billing", "pg@payments", false, false).is_none());
221/// // Acknowledged → allowed.
222/// assert!(detect_identity_fork("pg@billing", "pg@payments", true, true).is_none());
223/// // No actual change → allowed.
224/// assert!(detect_identity_fork("pg@billing", "pg@billing", true, false).is_none());
225/// ```
226pub fn detect_identity_fork(
227    old_identity: &str,
228    new_identity: &str,
229    has_history: bool,
230    acknowledged: bool,
231) -> Option<ValidationError> {
232    (has_history && !acknowledged && old_identity != new_identity).then(|| {
233        ValidationError::IdentityWouldFork {
234            old: old_identity.to_string(),
235            new: new_identity.to_string(),
236        }
237    })
238}
239
240/// The `(pvcName, effectivePath)` kopia would record for a PVC-addressed source: an
241/// explicit `sourcePathOverride`, else the `/pvc/<name>` default (mirrors
242/// [`crate::resolve_identity`]). `None` for non-PVC sources (selector/NFS), which the
243/// path-fork guard does not reason about — their data identity is the selection/export
244/// itself, not an editable per-source path.
245fn pvc_source_effective_path(source: &Source) -> Option<(String, String)> {
246    let name = source.pvc.as_ref()?.name.clone();
247    let path = source
248        .source_path_override
249        .clone()
250        .unwrap_or_else(|| format!("/pvc/{name}"));
251    Some((name, path))
252}
253
254/// Pure decision for the fork-on-edit guard on a per-source path change. A PVC's kopia
255/// source path is part of its identity, so changing `sourcePathOverride` on a PVC that
256/// already has history orphans that PVC's snapshots exactly as a username/hostname
257/// change would. Sources are matched across the edit by PVC name (paths are never
258/// CEL-driven, so an old-vs-new spec diff is complete); selector/NFS sources are out of
259/// scope. Returns the first offending change.
260pub fn detect_source_path_fork(
261    old: &SnapshotPolicySpec,
262    new: &SnapshotPolicySpec,
263    has_history: bool,
264    acknowledged: bool,
265) -> Option<ValidationError> {
266    if !has_history || acknowledged {
267        return None;
268    }
269    let old_paths: BTreeMap<String, String> = old
270        .sources
271        .iter()
272        .filter_map(pvc_source_effective_path)
273        .collect();
274    for source in &new.sources {
275        if let Some((name, new_path)) = pvc_source_effective_path(source)
276            && let Some(old_path) = old_paths.get(&name)
277            && *old_path != new_path
278        {
279            return Some(ValidationError::IdentityWouldFork {
280                old: old_path.clone(),
281                new: new_path,
282            });
283        }
284    }
285    None
286}
287
288// --- Repository identityDefaults edit guard (fleet-wide silent re-identification) ---
289
290/// Pure decision for the repository `identityDefaults`-edit guard. An edit to a
291/// `Repository`/`ClusterRepository`'s `identityDefaults` (`cluster`,
292/// `hostnameExpr`, or `usernameExpr`) changes what every consumer
293/// `SnapshotPolicy` relying on those defaults resolves to — silently, with no
294/// per-policy edit to acknowledge it (unlike [`detect_identity_fork`], which
295/// guards a policy's own edit). Returns
296/// `Some(`[`ValidationError::RepositoryIdentityWouldFork`]`)` iff
297/// `identityDefaults` actually changed, at least one consumer has snapshot
298/// history, and the change is not acknowledged. The webhook does the IO (list
299/// consumer `SnapshotPolicy`s, read the ack annotation) and calls this.
300///
301/// ```
302/// use kopiur_api::common::IdentityDefaults;
303/// use kopiur_api::validate::detect_repository_identity_change;
304///
305/// let old = IdentityDefaults { cluster: Some("east".into()), ..Default::default() };
306/// let new = IdentityDefaults { cluster: Some("west".into()), ..Default::default() };
307/// let consumers = ["billing/pg".to_string()];
308///
309/// // Change + a consumer with history + no ack → rejected, naming the consumer.
310/// assert!(detect_repository_identity_change(Some(&old), Some(&new), false, &consumers).is_some());
311/// // No consumers with history → nothing to orphan → allowed.
312/// assert!(detect_repository_identity_change(Some(&old), Some(&new), false, &[]).is_none());
313/// // Acknowledged → allowed.
314/// assert!(detect_repository_identity_change(Some(&old), Some(&new), true, &consumers).is_none());
315/// // No actual change → allowed.
316/// assert!(detect_repository_identity_change(Some(&old), Some(&old), false, &consumers).is_none());
317/// ```
318pub fn detect_repository_identity_change(
319    old: Option<&IdentityDefaults>,
320    new: Option<&IdentityDefaults>,
321    acknowledged: bool,
322    consumers_with_history: &[String],
323) -> Option<ValidationError> {
324    if acknowledged || consumers_with_history.is_empty() || old == new {
325        return None;
326    }
327    Some(ValidationError::RepositoryIdentityWouldFork {
328        consumers: consumers_with_history.to_vec(),
329    })
330}