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}