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/// The kopia path each `stream` source records, keyed by position.
414///
415/// Keyed by INDEX, like [`selector_source_strategies`] and for the same reason: a
416/// stream source has no PVC name to key on. Unlike a selector, though, its path
417/// is not a shape — it IS the identity: a `stream` source records
418/// `/stream/<fileName>` (or its `sourcePathOverride`), and renaming `fileName`
419/// rewrites that path, re-identifying the source and orphaning every manifest it
420/// has ever taken. That is precisely the fork this guard exists to catch, and it
421/// was uncovered while `stream` was new: `pvc_source_effective_path` returns
422/// `None` for a source with no `pvc`, so a `fileName` rename passed admission
423/// silently and the next backup started a fresh history under the new path while
424/// the old one aged out under retention.
425///
426/// Routed through [`crate::expand::source_kopia_path`] — the one derivation every
427/// identity site uses — so the guard keys on exactly the string the backup
428/// records. The namespace it takes is inert for a stream source (it only shapes a
429/// PVC path), hence the empty argument.
430fn stream_source_paths(spec: &SnapshotPolicySpec) -> BTreeMap<usize, String> {
431    spec.sources
432        .iter()
433        .enumerate()
434        .filter(|(_, s)| s.stream.is_some())
435        .filter_map(|(i, s)| crate::expand::source_kopia_path(s, i, "").map(|p| (i, p)))
436        .collect()
437}
438
439/// The kopia path EVERY source records, keyed by position — regardless of kind.
440///
441/// The backstop arm. The other two key on things that only exist within a kind
442/// (a PVC name, a stream `fileName`), so a source that CHANGES KIND at a fixed
443/// index slipped past both: `stream → pvc` at index 0 rewrites the recorded path
444/// from `/stream/dump.sql` to `/pvc/data`, but the PVC arm finds no old entry
445/// under the new name and `stream_source_paths(new)` is empty, so neither fires.
446/// That is the orphan-every-manifest outcome this guard exists for, reached by
447/// the most obvious edit a user could make.
448///
449/// Keyed by INDEX because index is what `Snapshot.spec.source.sourceIndex` pins:
450/// the child at index 0 would start backing up something else. That also means
451/// REORDERING two plain `pvc:` sources trips this arm, which is correct for the
452/// same reason (and for a selector-free policy only `sources[0]` is ever backed
453/// up, so the reorder genuinely changes what is captured). The
454/// `allow-identity-change` ack releases it like any other fork.
455///
456/// A source with no derivable path (a selector, whose path is per-member)
457/// contributes nothing here; the strategy arm covers those.
458fn source_paths_by_index(spec: &SnapshotPolicySpec) -> BTreeMap<usize, String> {
459    spec.sources
460        .iter()
461        .enumerate()
462        .filter_map(|(i, s)| crate::expand::source_kopia_path(s, i, "").map(|p| (i, p)))
463        .collect()
464}
465
466/// Pure decision for the fork-on-edit guard on a per-source path change. A PVC's kopia
467/// source path is part of its identity, so changing `sourcePathOverride` on a PVC that
468/// already has history orphans that PVC's snapshots exactly as a username/hostname
469/// change would. Sources are matched across the edit by PVC name (paths are never
470/// CEL-driven, so an old-vs-new spec diff is complete); selector/NFS sources are out of
471/// scope. Returns the first offending change.
472pub fn detect_source_path_fork(
473    old: &SnapshotPolicySpec,
474    new: &SnapshotPolicySpec,
475    has_history: bool,
476    acknowledged: bool,
477) -> Option<ValidationError> {
478    if !has_history || acknowledged {
479        return None;
480    }
481    let old_paths: BTreeMap<String, String> = old
482        .sources
483        .iter()
484        .filter_map(pvc_source_effective_path)
485        .collect();
486    for source in &new.sources {
487        if let Some((name, new_path)) = pvc_source_effective_path(source)
488            && let Some(old_path) = old_paths.get(&name)
489            && *old_path != new_path
490        {
491            return Some(ValidationError::IdentityWouldFork {
492                old: old_path.clone(),
493                new: new_path,
494            });
495        }
496    }
497    // Same guard for selector sources: a `sourcePathStrategy` flip rewrites
498    // every matched PVC's kopia path at once, so it forks harder than any
499    // single `sourcePathOverride` edit could.
500    let old_strategies = selector_source_strategies(old);
501    for (index, new_shape) in selector_source_strategies(new) {
502        if let Some(old_shape) = old_strategies.get(&index)
503            && *old_shape != new_shape
504        {
505            return Some(ValidationError::IdentityWouldFork {
506                old: (*old_shape).to_string(),
507                new: new_shape.to_string(),
508            });
509        }
510    }
511    // And for `stream` sources, where the path IS the identity: renaming
512    // `fileName` (or setting/changing `sourcePathOverride`) moves the source from
513    // `/stream/<old>` to `/stream/<new>`, so the next backup starts a fresh
514    // history while the old one silently ages out under retention. Keyed by
515    // position because a stream source has no PVC name to match across the edit.
516    let old_streams = stream_source_paths(old);
517    for (index, new_path) in stream_source_paths(new) {
518        if let Some(old_path) = old_streams.get(&index)
519            && *old_path != new_path
520        {
521            return Some(ValidationError::IdentityWouldFork {
522                old: old_path.clone(),
523                new: new_path,
524            });
525        }
526    }
527    // Backstop: ANY source kind, keyed by position. Catches a source that changed
528    // KIND at a fixed index (`stream → pvc`, `pvc → nfs`, ...), which the two
529    // kind-specific arms above structurally cannot see. Runs LAST so the
530    // kind-specific arms keep producing their better-targeted messages — this one
531    // only fires for the cases they missed. See [`source_paths_by_index`].
532    let old_by_index = source_paths_by_index(old);
533    for (index, new_path) in source_paths_by_index(new) {
534        if let Some(old_path) = old_by_index.get(&index)
535            && *old_path != new_path
536        {
537            return Some(ValidationError::IdentityWouldFork {
538                old: old_path.clone(),
539                new: new_path,
540            });
541        }
542    }
543    None
544}
545
546// --- Repository identityDefaults edit guard (fleet-wide silent re-identification) ---
547
548/// Pure decision for the repository `identityDefaults`-edit guard. An edit to a
549/// `Repository`/`ClusterRepository`'s `identityDefaults` (`cluster`,
550/// `hostnameExpr`, or `usernameExpr`) changes what every consumer
551/// `SnapshotPolicy` relying on those defaults resolves to — silently, with no
552/// per-policy edit to acknowledge it (unlike [`detect_identity_fork`], which
553/// guards a policy's own edit). Returns
554/// `Some(`[`ValidationError::RepositoryIdentityWouldFork`]`)` iff
555/// `identityDefaults` actually changed, at least one consumer has snapshot
556/// history, and the change is not acknowledged. The webhook does the IO (list
557/// consumer `SnapshotPolicy`s, read the ack annotation) and calls this.
558///
559/// ```
560/// use kopiur_api::common::IdentityDefaults;
561/// use kopiur_api::validate::detect_repository_identity_change;
562///
563/// let old = IdentityDefaults { cluster: Some("east".into()), ..Default::default() };
564/// let new = IdentityDefaults { cluster: Some("west".into()), ..Default::default() };
565/// let consumers = ["billing/pg".to_string()];
566///
567/// // Change + a consumer with history + no ack → rejected, naming the consumer.
568/// assert!(detect_repository_identity_change(Some(&old), Some(&new), false, &consumers).is_some());
569/// // No consumers with history → nothing to orphan → allowed.
570/// assert!(detect_repository_identity_change(Some(&old), Some(&new), false, &[]).is_none());
571/// // Acknowledged → allowed.
572/// assert!(detect_repository_identity_change(Some(&old), Some(&new), true, &consumers).is_none());
573/// // No actual change → allowed.
574/// assert!(detect_repository_identity_change(Some(&old), Some(&old), false, &consumers).is_none());
575/// ```
576pub fn detect_repository_identity_change(
577    old: Option<&IdentityDefaults>,
578    new: Option<&IdentityDefaults>,
579    acknowledged: bool,
580    consumers_with_history: &[String],
581) -> Option<ValidationError> {
582    if acknowledged || consumers_with_history.is_empty() || old == new {
583        return None;
584    }
585    Some(ValidationError::RepositoryIdentityWouldFork {
586        consumers: consumers_with_history.to_vec(),
587    })
588}