Skip to main content

kopiur_api/validate/
repository.rs

1use super::*;
2use crate::backend::{Backend, RepoVolume};
3use crate::cluster_repository::{AllowedNamespaces, ClusterRepositorySpec};
4use crate::common::{
5    CreateBehavior, MoverDefaults, RepositoryKind, RepositoryMode, RepositoryRef, Retention,
6};
7use crate::error::{ValidationError, ValidationResult};
8use crate::maintenance::{MaintenanceSpec, RepositoryMaintenanceSpec};
9use crate::repository::{RepositoryHealthSpec, RepositorySpec};
10use crate::repository_replication::RepositoryReplicationSpec;
11use crate::seed::{SeedSource, SeedSpec, SeedSyncOptions};
12use crate::snapshot_replication::{Pruning, SnapshotReplicationSpec, validate_component_glob};
13use std::collections::BTreeMap;
14
15/// A `RepositoryRef` is well-formed: a `ClusterRepository` reference is by name
16/// only, so `namespace` MUST be absent (ADR §3.2/§3.3). A namespaced `Repository`
17/// reference may carry a namespace (cross-namespace references are allowed).
18///
19/// ```
20/// use kopiur_api::common::RepositoryRef;
21/// use kopiur_api::validate::validate_repository_ref;
22/// use kopiur_api::ValidationError;
23///
24/// // OK: a namespaced Repository reference may name a namespace.
25/// let ok: RepositoryRef = serde_json::from_value(serde_json::json!({
26///     "kind": "Repository", "name": "nas-primary", "namespace": "backups",
27/// }))
28/// .unwrap();
29/// assert!(validate_repository_ref(&ok).is_ok());
30///
31/// // Err: a ClusterRepository is referenced by name alone — a namespace is forbidden.
32/// let bad: RepositoryRef = serde_json::from_value(serde_json::json!({
33///     "kind": "ClusterRepository", "name": "shared", "namespace": "oops",
34/// }))
35/// .unwrap();
36/// assert_eq!(
37///     validate_repository_ref(&bad).unwrap_err(),
38///     ValidationError::ClusterRepoNamespaceForbidden { namespace: "oops".to_string() },
39/// );
40/// ```
41pub fn validate_repository_ref(r: &RepositoryRef) -> ValidationResult {
42    match r.kind {
43        RepositoryKind::ClusterRepository => match &r.namespace {
44            Some(ns) => Err(ValidationError::ClusterRepoNamespaceForbidden {
45                namespace: ns.clone(),
46            }),
47            None => Ok(()),
48        },
49        RepositoryKind::Repository => Ok(()),
50    }
51}
52
53/// A consumer namespace is permitted by a `ClusterRepository`'s tenancy gate
54/// (ADR §3.2/§4.3).
55///
56/// - `List`     → membership test.
57/// - `All(true)`→ always allowed; `All(false)` is meaningless and denies.
58/// - `Selector` → matched against `labels` (the consumer namespace's labels). The
59///   `crates/api` crate cannot fetch a `Namespace` object, so the caller (webhook)
60///   must supply the labels. **If `labels` is `None` we fail closed** with
61///   [`ValidationError::SelectorLabelsUnavailable`] rather than guess — the webhook
62///   never trusts unfiltered input (ADR §3.2). Selector matching here is a simple
63///   `matchLabels` superset test (the common case); `matchExpressions` is treated
64///   as "no constraint" for now and documented as such.
65pub fn validate_consumer_against_cluster_repo(
66    consumer_namespace: &str,
67    repo_name: &str,
68    allowed: &AllowedNamespaces,
69    labels: Option<&BTreeMap<String, String>>,
70) -> ValidationResult {
71    match allowed {
72        AllowedNamespaces::All(true) => Ok(()),
73        AllowedNamespaces::All(false) => Err(ValidationError::ConsumerNamespaceNotAllowed {
74            namespace: consumer_namespace.to_string(),
75            repo: repo_name.to_string(),
76        }),
77        AllowedNamespaces::List(names) => {
78            if names.iter().any(|n| n == consumer_namespace) {
79                Ok(())
80            } else {
81                Err(ValidationError::ConsumerNamespaceNotAllowed {
82                    namespace: consumer_namespace.to_string(),
83                    repo: repo_name.to_string(),
84                })
85            }
86        }
87        AllowedNamespaces::Selector(sel) => {
88            let Some(labels) = labels else {
89                return Err(ValidationError::SelectorLabelsUnavailable {
90                    namespace: consumer_namespace.to_string(),
91                    repo: repo_name.to_string(),
92                });
93            };
94            let match_labels = sel.match_labels.clone().unwrap_or_default();
95            // Every required label must be present with the required value.
96            let matches = match_labels
97                .iter()
98                .all(|(k, v)| labels.get(k).map(|got| got == v).unwrap_or(false));
99            if matches {
100                Ok(())
101            } else {
102                Err(ValidationError::ConsumerNamespaceNotAllowed {
103                    namespace: consumer_namespace.to_string(),
104                    repo: repo_name.to_string(),
105                })
106            }
107        }
108    }
109}
110
111/// A `Snapshot`'s `deletionPolicy` is legal for its origin (ADR §4.5).
112///
113/// `origin: discovered` forces `Retain`: `None` (defaults to `Retain`) and an
114/// explicit `Retain` pass; `Delete`/`Orphan` are rejected. `discovered`'s
115/// underlying kopia snapshot was never created by the operator, so it must
116/// never be the thing that deletes it. `adopted` is the one exception: an
117/// adopted row was deliberately re-attached to a `SnapshotPolicy` precisely so
118/// GFS retention (and any `deletionPolicy`) governs it like a produced backup —
119/// any policy is allowed. `replicated` copies are likewise operator-managed
120/// (the replication run minted the dest-side manifest AND the CR, stamping
121/// `deletionPolicy: Delete` at create), so any policy is allowed there too.
122/// `scheduled`/`manual` are unchanged (any policy).
123pub fn validate_backup_deletion_policy(
124    origin: crate::snapshot::Origin,
125    policy: Option<crate::common::DeletionPolicy>,
126) -> ValidationResult {
127    use crate::common::DeletionPolicy;
128    use crate::snapshot::Origin;
129    match origin {
130        Origin::Discovered => match policy {
131            None | Some(DeletionPolicy::Retain) => Ok(()),
132            Some(other) => Err(ValidationError::DiscoveredMustRetain {
133                got: format!("{other:?}"),
134            }),
135        },
136        Origin::Adopted | Origin::Scheduled | Origin::Manual | Origin::Replicated => Ok(()),
137    }
138}
139
140/// `spec.parameters` is well-formed and applicable (#258). Shared by both repository
141/// kinds via `context`, exactly like [`validate_repository_health`].
142///
143/// Two classes of rule:
144///
145/// - **Grammar.** Every duration must parse, and every count must be positive. The
146///   grammar check matters more here than elsewhere: these are the first CRD durations
147///   that reach a kopia CLI, and this module's contract is that a value the webhook
148///   admits never fails at reconcile time.
149/// - **Applicability.** A `mode: ReadOnly` repository can never apply them — kopia
150///   hard-errors `set-parameters` on a read-only connection — so declaring them there is
151///   a configuration mistake. Reject it rather than silently ignore the block, matching
152///   how `volumeSnapshotClassName` + an NFS source is handled.
153pub fn validate_repository_parameters(
154    parameters: Option<&crate::repository::RepositoryParameters>,
155    mode: crate::common::RepositoryMode,
156    backend: &crate::backend::Backend,
157    context: &str,
158) -> Vec<ValidationError> {
159    let mut errs = Vec::new();
160    let Some(parameters) = parameters else {
161        return errs;
162    };
163    // `epoch` and `blobRetention` are validated as INDEPENDENT blocks. Returning early when
164    // one is absent would make the other's rules dead code — declaring only blobRetention
165    // must still be checked.
166    errs.extend(validate_blob_retention(
167        parameters.blob_retention.as_ref(),
168        mode,
169        backend,
170        context,
171    ));
172    let Some(epoch) = parameters.epoch.as_ref() else {
173        return errs;
174    };
175    if !mode.allows_writes() {
176        errs.push(ValidationError::InvalidFieldValue {
177            field: format!("{context} spec.parameters.epoch"),
178            reason: "a ReadOnly repository cannot apply repository parameters: \
179                     `kopia repository set-parameters` rewrites the repository-global format \
180                     blob and fails outright on a read-only connection. Remove \
181                     spec.parameters, or set mode: ReadWrite on the cluster that owns this \
182                     repository (in a multi-cluster layout, declare the parameters there — \
183                     they are a property of the repository, not of each consumer)"
184                .to_string(),
185        });
186    }
187    let mut duration = |field: &str, raw: &Option<String>| {
188        let Some(raw) = raw.as_deref() else { return };
189        let field = format!("{context} spec.parameters.epoch.{field}");
190        match crate::duration::parse_go_duration(raw) {
191            None => errs.push(ValidationError::InvalidFieldValue {
192                field,
193                reason: format!(
194                    "{raw:?} is not a valid duration. Use a Go-style duration with a single \
195                     unit, like 6h, 90m, or 30s; omit the field to leave kopia's current \
196                     value untouched"
197                ),
198            }),
199            // kopia stores these as a Go `time.Duration` — an i64 NANOSECOND count, so it
200            // tops out near 292 years, and `parse_go_duration` happily accepts far more
201            // than that (`"999999999999999999"` is a valid bare-seconds value). Bound it
202            // here rather than let the drift comparator's `as i64` wrap it to a negative
203            // number, and to keep this module's contract: a value the webhook admits must
204            // never fail at reconcile time.
205            Some(d) if i64::try_from(d.as_nanos()).is_err() => {
206                errs.push(ValidationError::InvalidFieldValue {
207                    field,
208                    reason: format!(
209                        "{raw:?} is too large: kopia stores epoch durations as a 64-bit \
210                         nanosecond count, so the maximum is roughly 292 years. Use a \
211                         realistic epoch duration (hours, e.g. 6h)"
212                    ),
213                });
214            }
215            Some(_) => {}
216        }
217    };
218    duration("minDuration", &epoch.min_duration);
219    duration("refreshFrequency", &epoch.refresh_frequency);
220
221    let mut positive = |field: &str, v: Option<i64>| {
222        if let Some(v) = v
223            && v <= 0
224        {
225            errs.push(ValidationError::InvalidFieldValue {
226                field: format!("{context} spec.parameters.epoch.{field}"),
227                reason: format!(
228                    "must be > 0 (got {v}); omit the field to leave kopia's current value \
229                     untouched"
230                ),
231            });
232        }
233    };
234    positive("advanceOnCount", epoch.advance_on_count);
235    positive("advanceOnSizeMiB", epoch.advance_on_size_mb);
236    positive("checkpointFrequency", epoch.checkpoint_frequency);
237    positive("deleteParallelism", epoch.delete_parallelism);
238    errs
239}
240
241/// kopia's minimum blob-retention period, straight from its own `Validate()`:
242/// "invalid retention-period, the minimum required is 1-day and there is no maximum limit".
243const MIN_RETENTION_PERIOD: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60);
244
245/// `spec.parameters.blobRetention` rules (#332).
246///
247/// Three classes of rule, all of which turn a guaranteed *runtime* failure into an admission
248/// error:
249///
250/// - **Backend applicability.** On a backend without object lock, `set-parameters` does not
251///   no-op — it hard-fails with `blob-retention: unsupported put-blob option`. Since the
252///   bootstrap re-runs it on every reconcile, declaring retention on such a backend would
253///   produce a recurring Warning event and permanently diverged status, forever.
254/// - **Grammar and range.** The period must parse and clear kopia's 1-day floor.
255/// - **Applicability to `mode: ReadOnly`**, exactly as for `epoch`.
256///
257/// Deliberately NOT checked: whether the period exceeds the full-maintenance interval. kopia
258/// only enforces that when `--extend-object-locks` is on (`CheckExtendRetention` returns
259/// early otherwise), and kopiur does not set that flag — so such a guard would reject
260/// configurations kopia accepts.
261fn validate_blob_retention(
262    retention: Option<&crate::repository::BlobRetention>,
263    mode: crate::common::RepositoryMode,
264    backend: &crate::backend::Backend,
265    context: &str,
266) -> Vec<ValidationError> {
267    use crate::backend::Backend;
268    let mut errs = Vec::new();
269    let Some(retention) = retention else {
270        return errs;
271    };
272    let field = format!("{context} spec.parameters.blobRetention");
273
274    if !mode.allows_writes() {
275        errs.push(ValidationError::InvalidFieldValue {
276            field: field.clone(),
277            reason: "a ReadOnly repository cannot apply blob retention: \
278                     `kopia repository set-parameters` rewrites the repository-global format \
279                     blob and fails outright on a read-only connection. Declare \
280                     blobRetention on the cluster that owns this repository (mode: ReadWrite) \
281                     — object lock is a property of the repository, not of each consumer"
282                .to_string(),
283        });
284    }
285
286    // Exhaustive on purpose: a new Backend variant must be classified here before it
287    // compiles, rather than silently inheriting "supported" and hard-failing at runtime.
288    let supported = match backend {
289        Backend::S3(_) | Backend::Azure(_) | Backend::Gcs(_) => true,
290        Backend::B2(_)
291        | Backend::Filesystem(_)
292        | Backend::Sftp(_)
293        | Backend::WebDav(_)
294        | Backend::Rclone(_)
295        | Backend::Gdrive(_) => false,
296    };
297    if !supported {
298        errs.push(ValidationError::InvalidFieldValue {
299            field: field.clone(),
300            reason: format!(
301                "the {} backend does not support object lock, so kopia cannot apply blob \
302                 retention to it — `kopia repository set-parameters` fails with \
303                 `blob-retention: unsupported put-blob option`, and would keep failing on \
304                 every reconcile. Remove spec.parameters.blobRetention, or use an S3, Azure, \
305                 or GCS repository whose bucket had object lock enabled AT CREATION (it \
306                 cannot be turned on later)",
307                backend.kind_str()
308            ),
309        });
310    }
311
312    // Only the window variants carry a period; `Disabled` has nothing to check, and kopia
313    // short-circuits `--retention-mode=none` before its own validation.
314    let Some(window) = retention.window() else {
315        return errs;
316    };
317    let period_field = format!("{field}.{}.period", retention.kind_str().to_lowercase());
318    match crate::duration::parse_go_duration(&window.period) {
319        None => errs.push(ValidationError::InvalidFieldValue {
320            field: period_field,
321            reason: format!(
322                "{:?} is not a valid duration. Kopiur accepts a Go-style duration with a \
323                 single unit of h, m, or s — write 30 days as 720h, not 30d. (kopia's own CLI \
324                 does accept `30d`; kopiur keeps one duration grammar across every CRD field.)",
325                window.period
326            ),
327        }),
328        // Same 292-year ceiling as the epoch durations, and for the same reason: kopia stores
329        // this as an i64 nanosecond count, and the drift comparator compares in those units.
330        Some(d) if i64::try_from(d.as_nanos()).is_err() => {
331            errs.push(ValidationError::InvalidFieldValue {
332                field: period_field,
333                reason: format!(
334                    "{:?} is too large: kopia stores the retention period as a 64-bit \
335                     nanosecond count, so the maximum is roughly 292 years",
336                    window.period
337                ),
338            });
339        }
340        Some(d) if d < MIN_RETENTION_PERIOD => {
341            errs.push(ValidationError::InvalidFieldValue {
342                field: period_field,
343                reason: format!(
344                    "{:?} is below kopia's minimum: \"the minimum required is 1-day and there \
345                     is no maximum limit\". Use 24h or more",
346                    window.period
347                ),
348            });
349        }
350        Some(_) => {}
351    }
352    errs
353}
354
355/// `spec.health` rules shared by `Repository` and `ClusterRepository`
356/// (ADR-0005 §13). The index-blob warning threshold must be non-negative: a
357/// negative count is nonsensical, and `0` is the documented sentinel that
358/// disables the warning (so it is allowed). `context` names the kind for the
359/// message ("Repository" / "ClusterRepository").
360pub fn validate_repository_health(
361    health: Option<&RepositoryHealthSpec>,
362    context: &str,
363) -> ValidationResult {
364    if let Some(h) = health
365        && let Some(t) = h.index_blob_warn_threshold
366        && t < 0
367    {
368        return Err(ValidationError::InvalidFieldValue {
369            field: format!("{context} health.indexBlobWarnThreshold"),
370            reason: format!(
371                "must be >= 0 (got {t}); 0 disables the index-blob warning, a positive \
372                 value is the count above which a Warning is raised"
373            ),
374        });
375    }
376    if let Some(probe) = health.and_then(|h| h.probe.as_ref()) {
377        if let Some(raw) = probe.interval.as_deref() {
378            match crate::duration::parse_go_duration(raw) {
379                None => {
380                    return Err(ValidationError::InvalidFieldValue {
381                        field: format!("{context} health.probe.interval"),
382                        reason: format!(
383                            "{raw:?} is not a valid duration. Use a Go-style duration like 30s, \
384                             5m, or 1h; omit the field for the default (30m)"
385                        ),
386                    });
387                }
388                Some(d) if d < crate::consts::MIN_HEALTH_PROBE_INTERVAL => {
389                    return Err(ValidationError::InvalidFieldValue {
390                        field: format!("{context} health.probe.interval"),
391                        reason: format!(
392                            "{raw:?} is shorter than the 30s minimum. Each probe runs a mover \
393                             Job; use 30s or more (default 30m)"
394                        ),
395                    });
396                }
397                Some(_) => {}
398            }
399        }
400        if let Some(t) = probe.failure_threshold
401            && t < 1
402        {
403            return Err(ValidationError::InvalidFieldValue {
404                field: format!("{context} health.probe.failureThreshold"),
405                reason: format!(
406                    "must be >= 1 (got {t}); it is the number of consecutive failing probes \
407                     required before the warning is raised"
408                ),
409            });
410        }
411    }
412    Ok(())
413}
414
415/// Whether a [`Retention`] selects **no** snapshots — every bucket unset or `0`. The
416/// controller only prunes when `spec.retention` is `Some` ([`crate::retention::select_kept`]
417/// over the buckets), so a `Some(keeps-nothing)` retention prunes *every* `Snapshot`
418/// immediately: silent data loss. (`retention: None` is the safe "don't prune" case and is
419/// NOT flagged.)
420pub(crate) fn retention_keeps_nothing(r: &Retention) -> bool {
421    [
422        r.keep_latest,
423        r.keep_hourly,
424        r.keep_daily,
425        r.keep_weekly,
426        r.keep_monthly,
427        r.keep_annual,
428    ]
429    .into_iter()
430    .all(|bucket| bucket.unwrap_or(0) == 0)
431}
432
433/// A `Repository` spec does not carry kopia-side (repo-level) retention policy,
434/// which would conflict with CR-driven GFS retention (ADR §4.4 exclusivity).
435///
436/// The current [`RepositorySpec`] deliberately models no inline retention field, so
437/// this **always passes today**. It exists as the enforcement hook so that if a
438/// future field (e.g. `spec.policy.keepDaily`) is ever added, wiring it here is the
439/// one obvious place — and the rule is already named and tested. Be pragmatic: we
440/// do not invent a field to reject.
441pub fn validate_repository_no_inline_retention(_spec: &RepositorySpec) -> ValidationResult {
442    // No inline-retention field exists on RepositorySpec. If one is added later,
443    // return Err(ValidationError::InlineRetentionForbidden { field: "<name>" }) here.
444    Ok(())
445}
446
447/// Validate a `spec.maintenance` block on a `Repository`/`ClusterRepository`,
448/// accumulating problems (ADR §3.7):
449/// - any override schedule's quick/full crons must parse (same parser as runtime);
450/// - `namespace` is **cluster-scope only** — it selects where the namespaced
451///   managed `Maintenance` lands for a `ClusterRepository`, and is forbidden on a
452///   namespaced `Repository` (whose `Maintenance` always lives in its own ns).
453///
454/// `cluster_scoped` is the only thing that differs between the two repository
455/// kinds, so one validator serves both call sites.
456pub fn validate_repository_maintenance(
457    maintenance: &RepositoryMaintenanceSpec,
458    cluster_scoped: bool,
459) -> Vec<ValidationError> {
460    let mut errs = Vec::new();
461    if let Some(schedule) = &maintenance.schedule {
462        if let Err(e) = validate_cron(&schedule.quick.cron) {
463            errs.push(e);
464        }
465        if let Err(e) = validate_cron(&schedule.full.cron) {
466            errs.push(e);
467        }
468        for tz in [
469            schedule.timezone.as_deref(),
470            schedule.quick.timezone.as_deref(),
471            schedule.full.timezone.as_deref(),
472        ] {
473            if let Err(e) = validate_timezone(tz) {
474                errs.push(e);
475            }
476        }
477    }
478    if !cluster_scoped && let Some(ns) = &maintenance.namespace {
479        errs.push(ValidationError::MaintenanceNamespaceOnNamespacedRepo {
480            namespace: ns.clone(),
481        });
482    }
483    errs
484}
485
486/// Accumulate every create-time-immutable field that changed between `old` and
487/// `new` repository specs (ADR-0005 §7). Shared by both repository kinds via the
488/// thin [`validate_repository_immutability`] / [`validate_cluster_repository_immutability`]
489/// wrappers, which pass the `encryption` password ref + the `create.{splitter,hash,
490/// encryption}` algorithms — the fields kopia bakes into the repository format.
491///
492/// Pure: the webhook supplies `old`/`new` from the admission request's old/new
493/// objects; CREATE has no old object, so this is only wired into the UPDATE path.
494fn diff_immutable_repo_fields(
495    old_create: Option<&crate::common::CreateBehavior>,
496    new_create: Option<&crate::common::CreateBehavior>,
497) -> Vec<ValidationError> {
498    let mut errs = Vec::new();
499    // NOTE: `encryption` (the password Secret *reference*) is deliberately NOT immutable.
500    // kopia bakes only the resolved password *value* and the `create.*` algorithms into
501    // the repository format — never the Secret name/namespace/key. Locking the reference
502    // was both over-strict (a Secret rename with identical content was rejected, breaking
503    // GitOps) and under-strict (editing a Secret's content in place — the actual password
504    // change kopia would reject — sailed through). kopia also supports `change-password`,
505    // so the password is operationally mutable; a genuinely wrong ref surfaces at connect
506    // time, not at admission. We only enforce the create-time algorithms below.
507    // The create-time kopia algorithms. Compared field-wise so the message names the
508    // exact field. `create` itself may be absent on either side (absent ⇒ None algos).
509    let old_splitter = old_create.and_then(|c| c.splitter.as_deref());
510    let new_splitter = new_create.and_then(|c| c.splitter.as_deref());
511    if old_splitter != new_splitter {
512        errs.push(ValidationError::Immutable {
513            field: "create.splitter".to_string(),
514        });
515    }
516    let old_hash = old_create.and_then(|c| c.hash.as_deref());
517    let new_hash = new_create.and_then(|c| c.hash.as_deref());
518    if old_hash != new_hash {
519        errs.push(ValidationError::Immutable {
520            field: "create.hash".to_string(),
521        });
522    }
523    let old_enc = old_create.and_then(|c| c.encryption.as_deref());
524    let new_enc = new_create.and_then(|c| c.encryption.as_deref());
525    if old_enc != new_enc {
526        errs.push(ValidationError::Immutable {
527            field: "create.encryption".to_string(),
528        });
529    }
530    // ECC (Reed-Solomon parity) is baked into the repository format at create time
531    // (ADR-0005 §13(a)) — immutable post-create like the other create knobs.
532    let old_ecc = old_create.and_then(|c| c.ecc.as_ref());
533    let new_ecc = new_create.and_then(|c| c.ecc.as_ref());
534    if old_ecc != new_ecc {
535        errs.push(ValidationError::Immutable {
536            field: "create.ecc".to_string(),
537        });
538    }
539    errs
540}
541
542/// Reject changes to create-time-immutable `Repository` fields on UPDATE (ADR-0005
543/// §7): `create.splitter`, `create.hash`, `create.encryption`, `create.ecc`. Returns
544/// every changed field so a user sees them all at once. Empty ⇒ no immutable change.
545///
546/// `encryption` (the password Secret reference) is intentionally NOT in this set — only
547/// the resolved password value is fixed in the kopia format, and the reference is not a
548/// reliable proxy for it (see [`diff_immutable_repo_fields`]). Renaming the Secret is fine.
549///
550/// ```
551/// use kopiur_api::repository::RepositorySpec;
552/// use kopiur_api::validate::validate_repository_immutability;
553/// # use kopiur_api::backend::{Backend, FilesystemBackend};
554/// # use kopiur_api::common::{CreateBehavior, Encryption, SecretKeyRef};
555/// # fn spec(splitter: Option<&str>) -> RepositorySpec {
556/// #     RepositorySpec {
557/// #         backend: Backend::Filesystem(FilesystemBackend { path: "/r".into(), volume: None }),
558/// #         encryption: Encryption { password_secret_ref: SecretKeyRef { name: "s".into(), namespace: None, key: None } },
559/// #         create: Some(CreateBehavior { enabled: true, encryption: None, splitter: splitter.map(String::from), hash: None, ecc: None }),
560/// #         seed: None, bootstrap: None, mover_defaults: None, schedule_defaults: None, catalog: None, identity_defaults: None, server: None, maintenance: None, on_namespace_delete: Default::default(), mode: Default::default(), suspend: false, health: None, parameters: None, deletion_protection: None, concurrency: None,
561/// #     }
562/// # }
563/// // Unchanged splitter → accepted.
564/// assert!(validate_repository_immutability(&spec(Some("FIXED-4M")), &spec(Some("FIXED-4M"))).is_empty());
565/// // Changed splitter → rejected.
566/// assert!(!validate_repository_immutability(&spec(Some("FIXED-4M")), &spec(Some("DYNAMIC"))).is_empty());
567/// ```
568pub fn validate_repository_immutability(
569    old: &RepositorySpec,
570    new: &RepositorySpec,
571) -> Vec<ValidationError> {
572    diff_immutable_repo_fields(old.create.as_ref(), new.create.as_ref())
573}
574
575/// Reject changes to create-time-immutable `ClusterRepository` fields on UPDATE
576/// (ADR-0005 §7). Same field set as [`validate_repository_immutability`].
577pub fn validate_cluster_repository_immutability(
578    old: &ClusterRepositorySpec,
579    new: &ClusterRepositorySpec,
580) -> Vec<ValidationError> {
581    diff_immutable_repo_fields(old.create.as_ref(), new.create.as_ref())
582}
583
584/// Validate a `Repository` spec, accumulating all problems (ADR §3.1).
585pub fn validate_repository(spec: &RepositorySpec) -> Vec<ValidationError> {
586    let mut errs = Vec::new();
587    if let Err(e) = validate_repository_no_inline_retention(spec) {
588        errs.push(e);
589    }
590    if let Err(e) = validate_backend(&spec.backend) {
591        errs.push(e);
592    }
593    if let Some(m) = &spec.maintenance {
594        errs.extend(validate_repository_maintenance(m, false));
595    }
596    if let Some(c) = &spec.catalog {
597        errs.extend(validate_catalog_bounds(c, false));
598    }
599    // Identity CEL expressions must compile + trial-evaluate to a string at admission
600    // (ADR-0004 §5), so a typo / out-of-scope variable is rejected on `kubectl apply`.
601    // Mirrors `validate_cluster_repository`'s identical block.
602    if let Some(id) = &spec.identity_defaults {
603        if let Some(expr) = &id.hostname_expr
604            && let Err(e) = crate::identity::validate_identity_expr(expr)
605        {
606            errs.push(e);
607        }
608        if let Some(expr) = &id.username_expr
609            && let Err(e) = crate::identity::validate_identity_expr(expr)
610        {
611            errs.push(e);
612        }
613        // `cluster` becomes part of the default hostname (`<namespace>.<cluster>`)
614        // and `classify_hostname` splits on the first `.`, so it must be a clean
615        // RFC 1123 label with no dot of its own (M1/M5).
616        if let Some(cluster) = &id.cluster
617            && let Err(e) = validate_cluster_name(cluster)
618        {
619            errs.push(e);
620        }
621    }
622    errs.extend(validate_foreign_snapshots_cluster_coupling(
623        spec.catalog.as_ref(),
624        spec.identity_defaults
625            .as_ref()
626            .and_then(|id| id.cluster.as_deref()),
627    ));
628    if let Some(md) = &spec.mover_defaults
629        && let Some(res) = &md.resources
630        && let Err(e) = validate_resources(res, "Repository moverDefaults")
631    {
632        errs.push(e);
633    }
634    if let Some(server) = &spec.server {
635        errs.extend(validate_server(server, spec.mode));
636    }
637    errs.extend(validate_repository_parameters(
638        spec.parameters.as_ref(),
639        spec.mode,
640        &spec.backend,
641        "Repository",
642    ));
643    if let Err(e) = validate_repository_health(spec.health.as_ref(), "Repository") {
644        errs.push(e);
645    }
646    if let Some(b) = &spec.bootstrap
647        && let Some(fp) = &b.failure_policy
648        && let Err(e) = validate_failure_policy(fp, "Repository spec.bootstrap")
649    {
650        errs.push(e);
651    }
652    if let Err(e) = validate_timezone(
653        spec.schedule_defaults
654            .as_ref()
655            .and_then(|d| d.timezone.as_deref()),
656    ) {
657        errs.push(e);
658    }
659    // `scheduleDefaults.jitter` is a NEW field, so both its parse and its 24h bound
660    // are safe in this shared aggregate: no stored `Repository` can carry a value
661    // these reject, and the reconciler re-running them can therefore brick nothing.
662    // (The 24h bound on the pre-existing per-cron `jitter` fields is a different
663    // story — see `validate::admission`.)
664    //
665    // Asymmetry worth naming: the `Repository` reconciler re-runs only a partial
666    // validator set while `ClusterRepository` re-runs the whole aggregate, so this
667    // rule is re-checked at reconcile for one kind and not the other. Acceptable —
668    // the webhook covers both kinds identically, and that is the gate.
669    if let Some(j) = spec
670        .schedule_defaults
671        .as_ref()
672        .and_then(|d| d.jitter.as_deref())
673        && let Err(e) = validate_jitter_bounds("spec.scheduleDefaults.jitter", j)
674    {
675        errs.push(e);
676    }
677    if let Some(md) = &spec.mover_defaults {
678        errs.extend(validate_pod_metadata(md));
679    }
680    // #380: `spec.seed` rules derivable from the spec alone. The namespaced arm
681    // of the co-resident seed-Secret rule and the migrate-mode self-reference
682    // check need the CR's own namespace/name, so the webhook calls
683    // `validate_seed_secret_namespace` / `validate_seed_not_self` separately.
684    errs.extend(validate_repository_seed(
685        spec.seed.as_ref(),
686        &spec.backend,
687        spec.mode,
688        spec.create.as_ref(),
689        RepositoryKind::Repository,
690    ));
691    errs
692}
693
694/// The actionable admission warning for an inline-NFS filesystem repo whose
695/// `moverDefaults` grant write access only via `fsGroup`. **`fsGroup` is silently
696/// ignored on NFS** (the kubelet doesn't recursively chown in-tree NFS mounts), so
697/// the mover/server/bootstrap reach the export as the unprivileged uid and the
698/// repo `connect`/`create` fails with `permission denied`. Non-blocking (a user
699/// fixing it NAS-side via Mapall can ignore it). Kept short for the admission
700/// response (kube truncates very long warnings).
701pub const NFS_FSGROUP_WARNING: &str = "NFS filesystem repo: fsGroup is ignored on NFS — \
702     grant the mover write access via moverDefaults.podSecurityContext.supplementalGroups \
703     (with a group-writable export), securityContext.runAsUser, or NAS-side Mapall";
704
705/// The actionable admission warning for an S3 backend pairing `tls.caBundleRef`
706/// with `tls.insecureSkipVerify: true`: kopia's `--disable-tls-verification`
707/// wins, so the referenced CA bundle is ignored while insecureSkipVerify is set.
708/// Deliberately a WARNING, never a hard error — the `ClusterRepository` and
709/// `RepositoryReplication` reconcilers defensively re-validate the FULL spec on
710/// every reconcile and hard-error on failure, so promoting this combination to
711/// an error would brick every already-persisted CR carrying it on operator
712/// upgrade, with no admission request in flight for a user to react to. (The
713/// `caBundleRef` + `disableTls` contradiction IS a hard error, but only because
714/// `caBundleRef` never worked at all before this validation existed — no
715/// working CR can carry it.) Kept short for the admission response (kube
716/// truncates very long warnings).
717pub const S3_TLS_SKIP_VERIFY_WARNING: &str = "s3 tls: insecureSkipVerify wins at kopia — the \
718     referenced caBundleRef CA bundle is ignored while it is set; remove insecureSkipVerify \
719     once the CA bundle verifies the endpoint";
720
721/// Whether `moverDefaults` configures an NFS-effective write identity — i.e. a
722/// `runAsUser` (container or pod) that owns the export, or a `supplementalGroups`
723/// the export is group-writable by. `fsGroup` deliberately does **not** count: it
724/// is a no-op on NFS.
725fn nfs_write_identity_configured(mover_defaults: Option<&MoverDefaults>) -> bool {
726    let Some(md) = mover_defaults else {
727        return false;
728    };
729    let container_uid = md.security_context.as_ref().and_then(|sc| sc.run_as_user);
730    let pod_uid = md
731        .pod_security_context
732        .as_ref()
733        .and_then(|psc| psc.run_as_user);
734    let suppl_groups = md
735        .pod_security_context
736        .as_ref()
737        .and_then(|psc| psc.supplemental_groups.as_ref())
738        .is_some_and(|g| !g.is_empty());
739    container_uid.is_some() || pod_uid.is_some() || suppl_groups
740}
741
742/// Non-blocking admission warnings for a `Repository`/`ClusterRepository`. Shared
743/// by both handlers (the rules can't fork). Today: the inline-NFS + `fsGroup`-only
744/// footgun (see [`NFS_FSGROUP_WARNING`]) and the S3 `caBundleRef` +
745/// `insecureSkipVerify` shadowing (see [`S3_TLS_SKIP_VERIFY_WARNING`]). Takes the
746/// resolved `backend` + `moverDefaults` so it serves both kinds without
747/// re-deriving them.
748pub fn repository_warnings(
749    backend: &Backend,
750    mover_defaults: Option<&MoverDefaults>,
751) -> Vec<String> {
752    let mut warnings = Vec::new();
753    let inline_nfs = matches!(
754        backend,
755        Backend::Filesystem(fs) if matches!(fs.volume, Some(RepoVolume::Nfs(_)))
756    );
757    if inline_nfs && !nfs_write_identity_configured(mover_defaults) {
758        warnings.push(NFS_FSGROUP_WARNING.to_string());
759    }
760    if let Backend::S3(s3) = backend
761        && let Some(tls) = &s3.tls
762        && tls.ca_bundle_ref.is_some()
763        && tls.insecure_skip_verify
764    {
765        warnings.push(S3_TLS_SKIP_VERIFY_WARNING.to_string());
766    }
767    warnings
768}
769
770/// Validate `spec.catalog` (ADR §3.1/§3.2): the refresh interval must parse and
771/// respect the floor, the retain bounds must be enforceable, and
772/// `fallbackNamespace` only means something on a cluster-scoped repository
773/// (`cluster_scoped`). One validator for both kinds so the rules cannot fork.
774pub fn validate_catalog_bounds(
775    catalog: &crate::common::CatalogBounds,
776    cluster_scoped: bool,
777) -> Vec<ValidationError> {
778    let mut errs = Vec::new();
779    if let Some(raw) = catalog.refresh_interval.as_deref() {
780        match crate::duration::parse_go_duration(raw) {
781            None => errs.push(ValidationError::InvalidFieldValue {
782                field: "catalog.refreshInterval".to_string(),
783                reason: format!(
784                    "{raw:?} is not a valid duration. Use a Go-style duration like 30s, 5m, or \
785                     1h; omit the field for the default (1h)"
786                ),
787            }),
788            Some(d) if d < crate::consts::MIN_CATALOG_REFRESH_INTERVAL => {
789                errs.push(ValidationError::InvalidFieldValue {
790                    field: "catalog.refreshInterval".to_string(),
791                    reason: format!(
792                        "{raw:?} is shorter than the 30s minimum. Each re-scan of an \
793                         object-store repository runs a mover Job; use 30s or more (default 1h)"
794                    ),
795                });
796            }
797            Some(_) => {}
798        }
799    }
800    if let Some(retain) = &catalog.retain {
801        if let Some(n) = retain.per_identity
802            && n < 0
803        {
804            errs.push(ValidationError::InvalidFieldValue {
805                field: "catalog.retain.perIdentity".to_string(),
806                reason: format!(
807                    "{n} is negative. Use a positive count of discovered Snapshot CRs to keep \
808                     per identity, 0 to disable discovered-Snapshot materialization, or omit \
809                     the field to materialize everything"
810                ),
811            });
812        }
813        if let Some(d) = retain.max_age_days
814            && d < 1
815        {
816            errs.push(ValidationError::InvalidFieldValue {
817                field: "catalog.retain.maxAgeDays".to_string(),
818                reason: format!(
819                    "{d} is not a usable age bound. Use a positive number of days (snapshots \
820                     older than this get no discovered Snapshot CR), or omit the field for no \
821                     age bound"
822                ),
823            });
824        }
825    }
826    if !cluster_scoped && catalog.fallback_namespace.is_some() {
827        errs.push(ValidationError::InvalidFieldValue {
828            field: "catalog.fallbackNamespace".to_string(),
829            reason: "only a ClusterRepository places discovered Snapshots across namespaces; a \
830                     namespaced Repository always materializes into its own namespace — remove \
831                     the field (or move the repository to a ClusterRepository)"
832                .to_string(),
833        });
834    }
835    // `foreignSnapshots: Fallback` needs somewhere to land, and only a
836    // ClusterRepository can place discovered Snapshots outside their own
837    // namespace — mirrors the fallbackNamespace rule directly above.
838    if matches!(
839        catalog.foreign_snapshots,
840        Some(crate::common::ForeignSnapshots::Fallback)
841    ) {
842        if catalog.fallback_namespace.is_none() {
843            errs.push(ValidationError::InvalidFieldValue {
844                field: "catalog.foreignSnapshots".to_string(),
845                reason: "Fallback requires catalog.fallbackNamespace to be set (there is \
846                         nowhere to materialize a foreign snapshot otherwise); set \
847                         fallbackNamespace, or use Ignore"
848                    .to_string(),
849            });
850        }
851        if !cluster_scoped {
852            errs.push(ValidationError::InvalidFieldValue {
853                field: "catalog.foreignSnapshots".to_string(),
854                reason: "Fallback is only meaningful on a ClusterRepository; a namespaced \
855                         Repository already materializes into its own namespace; use Ignore or \
856                         omit"
857                    .to_string(),
858            });
859        }
860    }
861    errs
862}
863
864/// The `identityDefaults.cluster` × `catalog.foreignSnapshots` cross-field
865/// rules (multi-cluster shared-repo): classifying a snapshot as another
866/// cluster's is undecidable without a cluster identity (a), and adopting one
867/// must never silently switch off an already-configured fallback collector
868/// (d). Shared by both repository kinds — `cluster` is the resolved
869/// `identityDefaults.cluster` value, `None` when the repository has no cluster
870/// identity set (or, on a namespaced `Repository`, no `identityDefaults` set at
871/// all). Without a cluster, rule (d) is a no-op (it requires one to fire) while
872/// rule (a) still rejects any `foreignSnapshots` set there.
873pub fn validate_foreign_snapshots_cluster_coupling(
874    catalog: Option<&crate::common::CatalogBounds>,
875    cluster: Option<&str>,
876) -> Vec<ValidationError> {
877    let Some(catalog) = catalog else {
878        return Vec::new();
879    };
880    let mut errs = Vec::new();
881    let has_cluster = cluster.is_some_and(|c| !c.is_empty());
882    if catalog.foreign_snapshots.is_some() && !has_cluster {
883        errs.push(ValidationError::ForeignSnapshotsRequiresCluster);
884    }
885    if has_cluster && catalog.fallback_namespace.is_some() && catalog.foreign_snapshots.is_none() {
886        errs.push(ValidationError::ForeignSnapshotsChoiceRequired);
887    }
888    errs
889}
890
891/// Validate a `RepositoryReplication` spec, accumulating all problems (ADR-0005
892/// §13(d)): the `sourceRef` is well-formed, the schedule cron parses, the
893/// destination backend's content is valid, and (when a mover is set) it's
894/// well-formed. The "destination differs from source" rule needs the resolved
895/// source backend, which this pure validator cannot fetch — the webhook resolves it
896/// and calls [`replication_destination_differs`] separately.
897pub fn validate_repository_replication(spec: &RepositoryReplicationSpec) -> Vec<ValidationError> {
898    let mut errs = Vec::new();
899    if let Err(e) = validate_repository_ref(&spec.source_ref) {
900        errs.push(e);
901    }
902    if let Err(e) = validate_cron(&spec.schedule.cron) {
903        errs.push(e);
904    }
905    if let Err(e) = validate_timezone(spec.schedule.timezone.as_deref()) {
906        errs.push(e);
907    }
908    if let Err(e) = validate_backend(&spec.destination) {
909        errs.push(e);
910    }
911    if let Some(m) = &spec.mover {
912        if let Err(e) = validate_mover(m, "RepositoryReplication mover") {
913            errs.push(e);
914        }
915        // A replication mover copies blobs repo→repo and never touches a workload's files, so
916        // `repository_replication.rs` never resolves inheritance — it passes the explicit
917        // contexts straight to `resolve_mover`. The field was therefore ACCEPTED and silently
918        // dropped: the manifest claimed the mover ran as the workload, and it did not. Reject
919        // it instead of ignoring it.
920        if let Err(e) = super::forbid_inherit(
921            m,
922            "RepositoryReplication spec",
923            "is not honored by a replication mover, which copies repository blobs and never \
924             reads a workload's files — there is no workload whose identity it could take. \
925             Remove it; set mover.securityContext explicitly if the destination backend needs \
926             a particular UID/GID (e.g. a filesystem repository on an NFS export).",
927        ) {
928            errs.push(e);
929        }
930    }
931    if let Some(sync) = &spec.sync {
932        if let Some(p) = sync.parallel
933            && let Some(e) = require_min(
934                "RepositoryReplication spec.sync.parallel",
935                p.into(),
936                NumericBound::Count,
937            )
938        {
939            errs.push(e);
940        }
941        if let Some(s) = sync.max_download_speed_bytes_per_second
942            && let Some(e) = require_min(
943                "RepositoryReplication spec.sync.maxDownloadSpeedBytesPerSecond",
944                s,
945                NumericBound::RatePerSecond,
946            )
947        {
948            errs.push(e);
949        }
950        if let Some(s) = sync.max_upload_speed_bytes_per_second
951            && let Some(e) = require_min(
952                "RepositoryReplication spec.sync.maxUploadSpeedBytesPerSecond",
953                s,
954                NumericBound::RatePerSecond,
955            )
956        {
957            errs.push(e);
958        }
959    }
960    errs
961}
962
963/// Validate a `SnapshotReplication` spec, accumulating all problems (issue
964/// #368): both repository refs are well-formed and not literally the same
965/// reference, the schedule cron/timezone/jitter parse, every identity matcher
966/// sets at least one component and every set component is a compilable glob,
967/// `migrate.parallel` is >= 1, a `retention` pruning keeps at least something,
968/// and (when a mover is set) it's well-formed and does not claim
969/// `inheritSecurityContextFrom`. The "two different refs resolving to the same
970/// storage" rule needs both resolved backends, which this pure validator cannot
971/// fetch — the webhook resolves them and compares [`backend_target_key`]s.
972pub fn validate_snapshot_replication(spec: &SnapshotReplicationSpec) -> Vec<ValidationError> {
973    let mut errs = Vec::new();
974    for r in [&spec.source_ref, &spec.destination_ref] {
975        if let Err(e) = validate_repository_ref(r) {
976            errs.push(e);
977        }
978    }
979    // Literal self-target: same kind + name + namespace *field*. Two None
980    // namespaces both resolve to this CR's own namespace, so they are equal too;
981    // a None-vs-Some pair MAY still collide (Some(ns) == the CR's namespace) but
982    // deciding that needs the CR's metadata, which a spec-only validator does
983    // not have — the webhook's backend_target_key comparison backstops it.
984    if spec.source_ref.kind == spec.destination_ref.kind
985        && spec.source_ref.name == spec.destination_ref.name
986        && spec.source_ref.namespace == spec.destination_ref.namespace
987    {
988        errs.push(ValidationError::SnapshotReplicationSelfTarget {
989            kind: match spec.source_ref.kind {
990                RepositoryKind::Repository => "Repository".to_string(),
991                RepositoryKind::ClusterRepository => "ClusterRepository".to_string(),
992            },
993            name: spec.source_ref.name.clone(),
994        });
995    }
996    if let Err(e) = validate_cron(&spec.schedule.cron) {
997        errs.push(e);
998    }
999    if let Err(e) = validate_timezone(spec.schedule.timezone.as_deref()) {
1000        errs.push(e);
1001    }
1002    if let Err(e) = validate_jitter(
1003        "SnapshotReplication spec.schedule.jitter",
1004        spec.schedule.jitter.as_deref(),
1005    ) {
1006        errs.push(e);
1007    }
1008    if let Some(m) = &spec.mover {
1009        if let Err(e) = validate_mover(m, "SnapshotReplication mover") {
1010            errs.push(e);
1011        }
1012        // Same accepted-then-ignored hazard as the RepositoryReplication arm
1013        // above: a snapshot-replication mover copies snapshot manifests
1014        // repo-to-repo and never reads a workload's files, so there is no
1015        // workload identity to inherit and the reconciler never resolves the
1016        // field. Reject it instead of ignoring it.
1017        if let Err(e) = super::forbid_inherit(
1018            m,
1019            "SnapshotReplication spec",
1020            "is not honored by a snapshot-replication mover, which copies snapshot manifests \
1021             repository-to-repository and never reads a workload's files — there is no \
1022             workload whose identity it could take. Remove it; set mover.securityContext \
1023             explicitly if a filesystem-backed repository needs a particular UID/GID (e.g. \
1024             an NFS export)",
1025        ) {
1026            errs.push(e);
1027        }
1028    }
1029    if let Some(ids) = spec.selection.as_ref().and_then(|s| s.identities.as_ref()) {
1030        for (list_name, list) in [("include", &ids.include), ("exclude", &ids.exclude)] {
1031            for (i, m) in list.iter().enumerate() {
1032                let field =
1033                    format!("SnapshotReplication spec.selection.identities.{list_name}[{i}]");
1034                if m.username.is_none() && m.hostname.is_none() && m.source_path.is_none() {
1035                    errs.push(ValidationError::EmptyIdentityMatcher { field });
1036                    continue;
1037                }
1038                for (comp, val) in [
1039                    ("username", &m.username),
1040                    ("hostname", &m.hostname),
1041                    ("sourcePath", &m.source_path),
1042                ] {
1043                    if let Some(pattern) = val
1044                        && let Err(reason) = validate_component_glob(pattern)
1045                    {
1046                        errs.push(ValidationError::InvalidFieldValue {
1047                            field: format!("{field}.{comp}"),
1048                            reason,
1049                        });
1050                    }
1051                }
1052            }
1053        }
1054    }
1055    if let Some(migrate) = spec.migrate.as_ref() {
1056        if let Some(p) = migrate.parallel
1057            && let Some(e) = require_min(
1058                "SnapshotReplication spec.migrate.parallel",
1059                p.into(),
1060                NumericBound::Count,
1061            )
1062        {
1063            errs.push(e);
1064        }
1065        // Per-side migrate caps: the same "a rate is >= 1" rule the throttle
1066        // knobs carry everywhere, applied to each side independently so a
1067        // message names the side that is wrong.
1068        if let Some(throttle) = migrate.throttle.as_ref() {
1069            for (side, block) in [
1070                ("source", throttle.source.as_ref()),
1071                ("destination", throttle.destination.as_ref()),
1072            ] {
1073                if let Some(block) = block {
1074                    errs.extend(validate_throttle(
1075                        &format!("SnapshotReplication spec.migrate.throttle.{side}"),
1076                        block,
1077                    ));
1078                }
1079            }
1080        }
1081    }
1082    if let Some(pruning) = &spec.pruning {
1083        // Exhaustive: a new pruning mode cannot compile without deciding its
1084        // admission rule here.
1085        match pruning {
1086            Pruning::None(_) | Pruning::MirrorSource(_) => {}
1087            Pruning::Retention(r) => {
1088                let keeps_any = [
1089                    r.keep_latest,
1090                    r.keep_hourly,
1091                    r.keep_daily,
1092                    r.keep_weekly,
1093                    r.keep_monthly,
1094                    r.keep_annual,
1095                ]
1096                .iter()
1097                .any(Option::is_some);
1098                if !keeps_any {
1099                    errs.push(ValidationError::RetentionKeepsNothing);
1100                }
1101            }
1102        }
1103    }
1104    errs
1105}
1106
1107/// Whether a replication's `destination` backend differs from its source
1108/// repository's backend (ADR-0005 §13(d)). Replicating a repository to *itself* is a
1109/// no-op (or a loop), so the webhook rejects it. Pure so the decision is unit-tested;
1110/// the webhook resolves the source backend (it has a client) and calls this. A
1111/// "same" destination is detected structurally by [`backend_target_key`]: same
1112/// backend kind AND the same identifying target — which for S3 includes the
1113/// endpoint and region (not just bucket+prefix), for Azure the storage account,
1114/// and for a filesystem the backing volume, so two distinct providers that share
1115/// a bucket/container/path name are NOT mistaken for the same repository (#248).
1116///
1117/// ```
1118/// use kopiur_api::backend::{Backend, FilesystemBackend, S3Backend};
1119/// use kopiur_api::validate::replication_destination_differs;
1120///
1121/// let fs_a = Backend::Filesystem(FilesystemBackend { path: "/a".into(), volume: None });
1122/// let fs_b = Backend::Filesystem(FilesystemBackend { path: "/b".into(), volume: None });
1123/// // Different paths → differ.
1124/// assert!(replication_destination_differs(&fs_a, &fs_b));
1125/// // Same path → same target (would be a self-replication).
1126/// assert!(!replication_destination_differs(&fs_a, &fs_a));
1127/// // Different backend kinds always differ.
1128/// let s3 = Backend::S3(S3Backend { bucket: "b".into(), prefix: None, endpoint: None, region: None, auth: None, tls: None });
1129/// assert!(replication_destination_differs(&fs_a, &s3));
1130/// // Same bucket name at two DIFFERENT S3 endpoints → distinct targets (#248).
1131/// let s3_nas = Backend::S3(S3Backend { bucket: "kopiur".into(), prefix: None, endpoint: Some("nas.example:3000".into()), region: None, auth: None, tls: None });
1132/// let s3_e2 = Backend::S3(S3Backend { bucket: "kopiur".into(), prefix: None, endpoint: Some("t3u7.fra3.idrivee2-58.com".into()), region: Some("eu-central-2".into()), auth: None, tls: None });
1133/// assert!(replication_destination_differs(&s3_nas, &s3_e2));
1134/// ```
1135pub fn replication_destination_differs(
1136    source: &crate::backend::Backend,
1137    dest: &crate::backend::Backend,
1138) -> bool {
1139    backend_target_key(source) != backend_target_key(dest)
1140}
1141
1142/// Two DISTINCT filesystem repositories that share the same in-pod
1143/// `backend.path` cannot ride one replication mover pod: the Job mounts each
1144/// repo's volume at its own `path`, and two volumeMounts at one `mountPath`
1145/// make the pod spec invalid — a failure that would otherwise surface only as
1146/// a reconcile-time Job-create error. [`backend_target_key`] deliberately keys
1147/// filesystem targets by (path, volume) so this pair PASSES the self-target
1148/// check (different volumes = genuinely different repos); the mount collision
1149/// is a separate, mover-topology constraint, so it gets its own guard.
1150///
1151/// Returns the shared path when both backends are filesystem-backed and their
1152/// `path`s are equal (regardless of volume); `None` otherwise. Callers deny at
1153/// admission and defensively re-check at Job-spawn time.
1154///
1155/// ```
1156/// use kopiur_api::backend::Backend;
1157/// use kopiur_api::validate::replication_filesystem_mount_collision;
1158/// let fs = |path: &str, pvc: &str| -> Backend {
1159///     serde_json::from_value(serde_json::json!({
1160///         "filesystem": { "path": path, "volume": { "pvc": { "name": pvc } } }
1161///     }))
1162///     .unwrap()
1163/// };
1164/// let a = fs("/repo", "a");
1165/// assert_eq!(
1166///     replication_filesystem_mount_collision(&a, &fs("/repo", "b")),
1167///     Some("/repo".to_string())
1168/// );
1169/// assert_eq!(replication_filesystem_mount_collision(&a, &fs("/repo-dst", "b")), None);
1170/// ```
1171pub fn replication_filesystem_mount_collision(
1172    source: &crate::backend::Backend,
1173    dest: &crate::backend::Backend,
1174) -> Option<String> {
1175    use crate::backend::Backend;
1176    match (source, dest) {
1177        (Backend::Filesystem(s), Backend::Filesystem(d)) if s.path == d.path => {
1178            Some(s.path.clone())
1179        }
1180        _ => None,
1181    }
1182}
1183
1184/// A structural identity key for a backend (kind + identifying target), used by
1185/// [`replication_destination_differs`] to decide whether two backends point at the
1186/// same storage. Exhaustive over [`crate::backend::Backend`] so a new backend cannot
1187/// compile until its key is defined.
1188///
1189/// Each arm must fold in EVERY field that distinguishes the storage *target*
1190/// (never credentials) — dropping one makes two genuinely-distinct destinations
1191/// collide onto the same key, so [`replication_destination_differs`] wrongly
1192/// reports a self-replication and the webhook rejects a valid `RepositoryReplication`
1193/// (issue #248): two different S3 providers sharing the bucket name `kopiur`
1194/// resolved to the same `s3:kopiur/` key when only `bucket`+`prefix` were keyed.
1195/// Fields are labelled (`endpoint=…;region=…`) so distinct tuples can't concatenate
1196/// into an identical string. Auth/TLS are deliberately excluded: the same bucket
1197/// reached with different credentials is still the same storage.
1198fn backend_target_key(backend: &crate::backend::Backend) -> String {
1199    use crate::backend::{Backend, RepoVolume};
1200    let kind = backend.kind_str();
1201    let target = match backend {
1202        Backend::Filesystem(f) => {
1203            // `path` is a mount path INSIDE the mover pod and is commonly the
1204            // same default (`/repo`) across repositories, so the backing volume
1205            // (a distinct PVC or NFS export) is what actually distinguishes two
1206            // filesystem targets.
1207            let vol = match &f.volume {
1208                None => "none".to_string(),
1209                Some(RepoVolume::Pvc(p)) => format!("pvc={}", p.name),
1210                Some(RepoVolume::Nfs(n)) => format!("nfs={}:{}", n.server, n.path),
1211            };
1212            format!("path={};volume={vol}", f.path)
1213        }
1214        Backend::S3(s) => format!(
1215            "endpoint={};region={};bucket={};prefix={}",
1216            s.endpoint.clone().unwrap_or_default(),
1217            s.region.clone().unwrap_or_default(),
1218            s.bucket,
1219            s.prefix.clone().unwrap_or_default(),
1220        ),
1221        Backend::Azure(a) => format!(
1222            "account={};container={};prefix={}",
1223            a.storage_account.clone().unwrap_or_default(),
1224            a.container,
1225            a.prefix.clone().unwrap_or_default(),
1226        ),
1227        // GCS/B2 bucket names are globally unique (no endpoint/account to key),
1228        // so bucket+prefix is the complete target identity.
1229        Backend::Gcs(g) => format!(
1230            "bucket={};prefix={}",
1231            g.bucket,
1232            g.prefix.clone().unwrap_or_default()
1233        ),
1234        Backend::B2(b) => format!(
1235            "bucket={};prefix={}",
1236            b.bucket,
1237            b.prefix.clone().unwrap_or_default()
1238        ),
1239        Backend::Sftp(s) => format!(
1240            "host={};port={};path={}",
1241            s.host,
1242            s.port.map(|p| p.to_string()).unwrap_or_default(),
1243            s.path,
1244        ),
1245        Backend::WebDav(w) => format!("url={}", w.url),
1246        Backend::Rclone(r) => format!("remotePath={}", r.remote_path),
1247        Backend::Gdrive(g) => format!("folderId={}", g.folder_id),
1248    };
1249    format!("{kind}:{target}")
1250}
1251
1252/// Validate a `Maintenance` spec, accumulating all problems (ADR §3.7).
1253pub fn validate_maintenance(spec: &MaintenanceSpec) -> Vec<ValidationError> {
1254    let mut errs = Vec::new();
1255    if let Err(e) = validate_repository_ref(&spec.repository) {
1256        errs.push(e);
1257    }
1258    // `ownership.ownerAliases` become kopia identity components once run
1259    // through `kopia_lease_identity` (M6), so each alias gets the identity
1260    // shape rule — the same validator the resolved hostname/username go
1261    // through. `owner` itself is deliberately NOT tightened here: it predates
1262    // this rule, stored CRs may carry arbitrary strings the lease sanitizer
1263    // already handles, and the controller re-validates defensively on every
1264    // reconcile — a new rejection would hard-stop a working Maintenance.
1265    // Aliases are new with this rule, so no stored object can regress.
1266    for (i, alias) in spec.ownership.owner_aliases.iter().enumerate() {
1267        if let Err(e) = validate_identity_component(&format!("ownership.ownerAliases[{i}]"), alias)
1268        {
1269            errs.push(e);
1270        }
1271    }
1272    if let Err(e) = validate_cron(&spec.schedule.quick.cron) {
1273        errs.push(e);
1274    }
1275    if let Err(e) = validate_cron(&spec.schedule.full.cron) {
1276        errs.push(e);
1277    }
1278    for tz in [
1279        spec.schedule.timezone.as_deref(),
1280        spec.schedule.quick.timezone.as_deref(),
1281        spec.schedule.full.timezone.as_deref(),
1282    ] {
1283        if let Err(e) = validate_timezone(tz) {
1284            errs.push(e);
1285        }
1286    }
1287    if let Some(m) = &spec.mover {
1288        if let Err(e) = forbid_pvc_consumer(
1289            m,
1290            "maintenance",
1291            "Use an explicit mover.securityContext instead.",
1292        ) {
1293            errs.push(e);
1294        }
1295        if let Err(e) = forbid_snapshot_inherit(
1296            m,
1297            "maintenance",
1298            "a maintenance mover operates on the repository, not on a snapshot's data, so \
1299             there is no recorded identity to reproduce; `snapshot` is restore-only. Use an \
1300             explicit mover.securityContext instead.",
1301        ) {
1302            errs.push(e);
1303        }
1304        if let Err(e) = validate_mover(m, "Maintenance mover") {
1305            errs.push(e);
1306        }
1307    }
1308    if let Some(fp) = &spec.failure_policy
1309        && let Err(e) = validate_failure_policy(fp, "Maintenance")
1310    {
1311        errs.push(e);
1312    }
1313    errs
1314}
1315
1316/// Validate a `ClusterRepository` spec, accumulating all problems (ADR §3.2).
1317///
1318/// `All(false)` is rejected as meaningless (SKILL: "`false` is rejected by webhook").
1319pub fn validate_cluster_repository(spec: &ClusterRepositorySpec) -> Vec<ValidationError> {
1320    let mut errs = Vec::new();
1321    if let AllowedNamespaces::All(false) = spec.allowed_namespaces {
1322        errs.push(ValidationError::MissingRequiredField {
1323            field: "allowedNamespaces.all must be true to grant access (false is meaningless)"
1324                .to_string(),
1325        });
1326    }
1327    if let Err(e) = validate_backend(&spec.backend) {
1328        errs.push(e);
1329    }
1330    if let Some(m) = &spec.maintenance {
1331        errs.extend(validate_repository_maintenance(m, true));
1332    }
1333    // Identity CEL expressions must compile + trial-evaluate to a string at admission
1334    // (ADR-0004 §5), so a typo / out-of-scope variable is rejected on `kubectl apply`.
1335    if let Some(id) = &spec.identity_defaults {
1336        if let Some(expr) = &id.hostname_expr
1337            && let Err(e) = crate::identity::validate_identity_expr(expr)
1338        {
1339            errs.push(e);
1340        }
1341        if let Some(expr) = &id.username_expr
1342            && let Err(e) = crate::identity::validate_identity_expr(expr)
1343        {
1344            errs.push(e);
1345        }
1346        // `cluster` becomes part of the default hostname (`<namespace>.<cluster>`)
1347        // and `classify_hostname` splits on the first `.`, so it must be a clean
1348        // RFC 1123 label with no dot of its own (M1).
1349        if let Some(cluster) = &id.cluster
1350            && let Err(e) = validate_cluster_name(cluster)
1351        {
1352            errs.push(e);
1353        }
1354    }
1355    if let Some(c) = &spec.catalog {
1356        errs.extend(validate_catalog_bounds(c, true));
1357    }
1358    errs.extend(validate_foreign_snapshots_cluster_coupling(
1359        spec.catalog.as_ref(),
1360        spec.identity_defaults
1361            .as_ref()
1362            .and_then(|id| id.cluster.as_deref()),
1363    ));
1364    if let Some(md) = &spec.mover_defaults
1365        && let Some(res) = &md.resources
1366        && let Err(e) = validate_resources(res, "ClusterRepository moverDefaults")
1367    {
1368        errs.push(e);
1369    }
1370    if let Some(server) = &spec.server {
1371        if server.namespace.trim().is_empty() {
1372            errs.push(ValidationError::ServerNamespaceRequired);
1373        }
1374        errs.extend(validate_server(&server.server, spec.mode));
1375    }
1376    errs.extend(validate_repository_parameters(
1377        spec.parameters.as_ref(),
1378        spec.mode,
1379        &spec.backend,
1380        "ClusterRepository",
1381    ));
1382    if let Err(e) = validate_repository_health(spec.health.as_ref(), "ClusterRepository") {
1383        errs.push(e);
1384    }
1385    if let Some(b) = &spec.bootstrap
1386        && let Some(fp) = &b.failure_policy
1387        && let Err(e) = validate_failure_policy(fp, "ClusterRepository spec.bootstrap")
1388    {
1389        errs.push(e);
1390    }
1391    if let Err(e) = validate_timezone(
1392        spec.schedule_defaults
1393            .as_ref()
1394            .and_then(|d| d.timezone.as_deref()),
1395    ) {
1396        errs.push(e);
1397    }
1398    // Same new-field reasoning as `validate_repository` above: parse + 24h bound on
1399    // `scheduleDefaults.jitter`, and the reserved-key rule on the new
1400    // `moverDefaults.podLabels`/`podAnnotations`. This aggregate IS re-run whole at
1401    // reconcile, which is exactly why only new-field rules may land in it.
1402    if let Some(j) = spec
1403        .schedule_defaults
1404        .as_ref()
1405        .and_then(|d| d.jitter.as_deref())
1406        && let Err(e) = validate_jitter_bounds("spec.scheduleDefaults.jitter", j)
1407    {
1408        errs.push(e);
1409    }
1410    if let Some(md) = &spec.mover_defaults {
1411        errs.extend(validate_pod_metadata(md));
1412    }
1413    // #380: same seed rules, cluster arm — a ClusterRepository's seed-source
1414    // Secret must not pin a namespace (its movers resolve credentials in the
1415    // operator's own namespace, which a cluster-scoped spec cannot name).
1416    errs.extend(validate_repository_seed(
1417        spec.seed.as_ref(),
1418        &spec.backend,
1419        spec.mode,
1420        spec.create.as_ref(),
1421        RepositoryKind::ClusterRepository,
1422    ));
1423    errs
1424}
1425
1426// --- spec.seed (#380) -------------------------------------------------------
1427
1428/// Validate `spec.seed` on a `Repository`/`ClusterRepository`, accumulating
1429/// every independent problem.
1430///
1431/// Everything here is derivable from the spec alone, so both aggregate
1432/// validators call it and the controller gets it for free on its defensive
1433/// re-validation. Two seed rules are NOT derivable and live beside it as
1434/// separately-callable helpers the webhook invokes with the request's context:
1435/// [`validate_seed_secret_namespace`] (the namespaced arm of the co-resident
1436/// Secret rule) and [`validate_seed_not_self`] (migrate-mode self-reference).
1437///
1438/// The rules, and why each exists:
1439///
1440/// * A seed runs in a mover Job, so neither the repository nor a filesystem
1441///   seed source may be a **bare path** — nothing would be mounted at it.
1442/// * `mode: ReadOnly` and seeding are contradictory.
1443/// * A mode-specific tuning block (`sync`/`migrate`/`credentialProjection`)
1444///   paired with the other mode's source would be inert.
1445/// * Blob mode inherits the mirror's repository format, so explicit
1446///   `create.{splitter,hash,encryption,ecc}` would be inert too.
1447/// * Blob mode must not read its own storage, must not collide on an in-pod
1448///   mount path, and must not mix workload identity with static keys in the one
1449///   seeding pod (the same three rules a `RepositoryReplication` gets, for the
1450///   same reason: one pod, two backends).
1451/// * A `ClusterRepository`'s seed-source Secret must not pin a namespace — a
1452///   cluster-scoped repository resolves credentials in the operator namespace,
1453///   which the spec cannot name.
1454pub fn validate_repository_seed(
1455    seed: Option<&SeedSpec>,
1456    repo_backend: &Backend,
1457    mode: RepositoryMode,
1458    create: Option<&CreateBehavior>,
1459    kind: RepositoryKind,
1460) -> Vec<ValidationError> {
1461    let Some(seed) = seed else {
1462        return Vec::new();
1463    };
1464    let mut errs = Vec::new();
1465    if let Backend::Filesystem(fs) = repo_backend
1466        && fs.volume.is_none()
1467    {
1468        errs.push(ValidationError::SeedRequiresMountableRepository {
1469            path: fs.path.clone(),
1470        });
1471    }
1472    if !mode.allows_writes() {
1473        errs.push(ValidationError::SeedOnReadOnlyRepository);
1474    }
1475    errs.extend(seed_tuning_pairing(seed));
1476    if let Some(fp) = &seed.failure_policy
1477        && let Err(e) = validate_failure_policy(fp, &format!("{} spec.seed", kind.kind_str()))
1478    {
1479        errs.push(e);
1480    }
1481    // Exhaustive over `SeedSource`: a new seed source cannot compile until its
1482    // per-mode rules are decided here.
1483    match &seed.from {
1484        SeedSource::Backend(b) => {
1485            errs.extend(validate_seed_blob_source(b, repo_backend, create, kind));
1486            if let Some(sync) = &seed.sync {
1487                errs.extend(validate_seed_sync_options(sync));
1488            }
1489        }
1490        SeedSource::Repository(r) => {
1491            if let Err(e) = validate_repository_ref(r) {
1492                errs.push(e);
1493            }
1494            if let Some(p) = seed.migrate.as_ref().and_then(|m| m.parallel)
1495                && let Some(e) =
1496                    require_min("spec.seed.migrate.parallel", p.into(), NumericBound::Count)
1497            {
1498                errs.push(e);
1499            }
1500            // Per-side seed caps, the same "a rate is >= 1" rule the throttle
1501            // knobs carry everywhere. Validated per side so the message names
1502            // the side that is wrong: `source` is the REPLICA being read,
1503            // `destination` this repository — and mixing them up is the likely
1504            // authoring mistake.
1505            if let Some(throttle) = seed.migrate.as_ref().and_then(|m| m.throttle.as_ref()) {
1506                for (side, block) in [
1507                    ("source", throttle.source.as_ref()),
1508                    ("destination", throttle.destination.as_ref()),
1509                ] {
1510                    if let Some(block) = block {
1511                        errs.extend(validate_throttle(
1512                            &format!("spec.seed.migrate.throttle.{side}"),
1513                            block,
1514                        ));
1515                    }
1516                }
1517            }
1518        }
1519    }
1520    errs
1521}
1522
1523/// A mode-specific `spec.seed` block must match the `from` variant it tunes, or
1524/// it is an inert field. `credentialProjection` is refused only when actually
1525/// enabled: an explicit `enabled: false` requests nothing and stays legal so a
1526/// GitOps template can emit the block unconditionally.
1527fn seed_tuning_pairing(seed: &SeedSpec) -> Vec<ValidationError> {
1528    // The `from` key this seed actually sets.
1529    let actual = match &seed.from {
1530        SeedSource::Backend(_) => SEED_FROM_BACKEND,
1531        SeedSource::Repository(_) => SEED_FROM_REPOSITORY,
1532    };
1533    // (tuning field, the `from` key it belongs to, whether it is present).
1534    // `expected` is carried in the row rather than re-derived from `field`, so a
1535    // fourth tuning block cannot silently inherit the wrong expectation.
1536    let rows: [(&str, &str, bool); 3] = [
1537        ("sync", SEED_FROM_BACKEND, seed.sync.is_some()),
1538        ("migrate", SEED_FROM_REPOSITORY, seed.migrate.is_some()),
1539        (
1540            "credentialProjection",
1541            SEED_FROM_REPOSITORY,
1542            seed.credential_projection
1543                .as_ref()
1544                .is_some_and(|p| p.enabled),
1545        ),
1546    ];
1547    rows.into_iter()
1548        .filter(|(_, expected, present)| *present && *expected != actual)
1549        .map(
1550            |(field, expected, _)| ValidationError::SeedTuningNotApplicable {
1551                field: field.to_string(),
1552                expected_source: expected.to_string(),
1553                actual_source: actual.to_string(),
1554            },
1555        )
1556        .collect()
1557}
1558
1559/// The two `spec.seed.from` wire keys, named once so the pairing table and its
1560/// messages cannot drift from the externally-tagged [`SeedSource`] variants.
1561const SEED_FROM_BACKEND: &str = "backend";
1562/// See [`SEED_FROM_BACKEND`].
1563const SEED_FROM_REPOSITORY: &str = "repository";
1564
1565/// Blob-mode (`seed.from.backend`) rules: the source backend is well-formed and
1566/// mountable, its credential Secret is reachable for a cluster-scoped
1567/// repository, it is not this repository's own storage, it does not collide on
1568/// an in-pod mount path, its auth pairs safely with the local backend in one
1569/// pod, its `sync` knobs are positive, and `spec.create`'s format algorithms are
1570/// not silently discarded.
1571fn validate_seed_blob_source(
1572    source: &Backend,
1573    repo_backend: &Backend,
1574    create: Option<&CreateBehavior>,
1575    kind: RepositoryKind,
1576) -> Vec<ValidationError> {
1577    let mut errs = Vec::new();
1578    if let Err(e) = validate_backend(source) {
1579        errs.push(e);
1580    }
1581    if let Backend::Filesystem(fs) = source
1582        && fs.volume.is_none()
1583    {
1584        errs.push(ValidationError::SeedSourceRequiresMountableBackend {
1585            path: fs.path.clone(),
1586        });
1587    }
1588    // A ClusterRepository's movers resolve credentials in the operator's own
1589    // namespace, which a cluster-scoped spec cannot name — so the seed Secret
1590    // must not pin one. The namespaced arm needs the CR's namespace and lives in
1591    // `validate_seed_secret_namespace`.
1592    if kind == RepositoryKind::ClusterRepository
1593        && let Some(secret) = crate::creds::backend_auth_secret_ref(source)
1594        && let Some(ns) = secret.namespace.as_deref()
1595    {
1596        errs.push(ValidationError::SeedSourceSecretNamespaceForbidden {
1597            secret: secret.name.clone(),
1598            namespace: ns.to_string(),
1599        });
1600    }
1601    if !replication_destination_differs(repo_backend, source) {
1602        errs.push(ValidationError::SeedSourceSameAsRepository {
1603            backend: source.kind_str().to_string(),
1604        });
1605    }
1606    if let Some(path) = replication_filesystem_mount_collision(repo_backend, source) {
1607        errs.push(ValidationError::SeedMountPathCollision { path });
1608    }
1609    // One pod carries both credential sets, so the replication VERDICT applies
1610    // verbatim: a same-kind static/workloadIdentity mix would let the ambient
1611    // credential chain pick up the other side's keys. `AuthPairKind::Seed` only
1612    // changes what the rejection says — `spec.seed.from.backend` and the seeding
1613    // Job, not "destination" and a replication mover that never runs (#380).
1614    if let Err(e) = validate_replication_auth(repo_backend, source, AuthPairKind::Seed) {
1615        errs.push(e);
1616    }
1617    let inert = inert_create_fields(create);
1618    if !inert.is_empty() {
1619        errs.push(ValidationError::SeedCreateOptionsInert { fields: inert });
1620    }
1621    errs
1622}
1623
1624/// The `spec.create.*` format algorithms a blob-mode seed would discard (the
1625/// copy inherits the mirror's repository-format blob). `create.enabled` is NOT
1626/// in the set: a seed-armed bootstrap never falls back to `create`, so the flag
1627/// keeps its ordinary meaning for every later connect.
1628fn inert_create_fields(create: Option<&CreateBehavior>) -> Vec<String> {
1629    let Some(c) = create else {
1630        return Vec::new();
1631    };
1632    [
1633        ("create.splitter", c.splitter.is_some()),
1634        ("create.hash", c.hash.is_some()),
1635        ("create.encryption", c.encryption.is_some()),
1636        ("create.ecc", c.ecc.is_some()),
1637    ]
1638    .into_iter()
1639    .filter(|(_, set)| *set)
1640    .map(|(field, _)| field.to_string())
1641    .collect()
1642}
1643
1644/// Blob-mode `sync` knobs are positive counts/rates. Split out of
1645/// [`validate_repository_seed`] because it is called for the blob arm only.
1646fn validate_seed_sync_options(sync: &SeedSyncOptions) -> Vec<ValidationError> {
1647    let mut errs = Vec::new();
1648    if let Some(p) = sync.parallel
1649        && let Some(e) = require_min("spec.seed.sync.parallel", p.into(), NumericBound::Count)
1650    {
1651        errs.push(e);
1652    }
1653    if let Some(s) = sync.max_download_speed_bytes_per_second
1654        && let Some(e) = require_min(
1655            "spec.seed.sync.maxDownloadSpeedBytesPerSecond",
1656            s,
1657            NumericBound::RatePerSecond,
1658        )
1659    {
1660        errs.push(e);
1661    }
1662    if let Some(s) = sync.max_upload_speed_bytes_per_second
1663        && let Some(e) = require_min(
1664            "spec.seed.sync.maxUploadSpeedBytesPerSecond",
1665            s,
1666            NumericBound::RatePerSecond,
1667        )
1668    {
1669        errs.push(e);
1670    }
1671    errs
1672}
1673
1674/// The namespaced arm of the co-resident seed-Secret rule: a `Repository`'s
1675/// blob-mode seed source Secret must be unset or name the repository's OWN
1676/// namespace, because the seeding Job runs there and `envFrom` is
1677/// namespace-local. Needs the CR's namespace, which the spec does not carry, so
1678/// the webhook calls it with `req.namespace` (the cluster-scoped arm — "must be
1679/// unset" — is fully spec-derivable and lives in
1680/// [`validate_repository_seed`]).
1681///
1682/// ```
1683/// use kopiur_api::seed::{SeedSpec, SeedSource};
1684/// use kopiur_api::validate::validate_seed_secret_namespace;
1685///
1686/// let seed_with = |ns: serde_json::Value| -> SeedSpec {
1687///     let from: SeedSource = serde_json::from_value(serde_json::json!({
1688///         "backend": { "s3": { "bucket": "mirror", "auth": { "secretRef": ns } } }
1689///     }))
1690///     .unwrap();
1691///     SeedSpec { from, sync: None, migrate: None, allow_empty_source: false,
1692///                failure_policy: None, credential_projection: None }
1693/// };
1694/// // Unset namespace = "the repository's own" → fine.
1695/// let ok = seed_with(serde_json::json!({ "name": "mirror-creds" }));
1696/// assert!(validate_seed_secret_namespace(Some(&ok), "backups").is_ok());
1697/// // Same namespace spelled out → also fine.
1698/// let same = seed_with(serde_json::json!({ "name": "mirror-creds", "namespace": "backups" }));
1699/// assert!(validate_seed_secret_namespace(Some(&same), "backups").is_ok());
1700/// // Another namespace → the Job could never read it.
1701/// let other = seed_with(serde_json::json!({ "name": "mirror-creds", "namespace": "elsewhere" }));
1702/// assert!(validate_seed_secret_namespace(Some(&other), "backups").is_err());
1703/// ```
1704pub fn validate_seed_secret_namespace(
1705    seed: Option<&SeedSpec>,
1706    repository_namespace: &str,
1707) -> ValidationResult {
1708    let Some(source) = seed.and_then(crate::seed::seed_backend) else {
1709        return Ok(());
1710    };
1711    let Some(secret) = crate::creds::backend_auth_secret_ref(source) else {
1712        return Ok(());
1713    };
1714    match secret.namespace.as_deref() {
1715        Some(ns) if ns != repository_namespace => {
1716            Err(ValidationError::SeedSourceSecretNamespaceMismatch {
1717                secret: secret.name.clone(),
1718                namespace: ns.to_string(),
1719                repository_namespace: repository_namespace.to_string(),
1720            })
1721        }
1722        _ => Ok(()),
1723    }
1724}
1725
1726/// A migrate-mode seed must not point at the repository being defined. Uses the
1727/// shared [`crate::common::repo_key`] normalization, so a `Repository` naming
1728/// its own namespace explicitly and one omitting it are both caught. Needs the
1729/// CR's own identity, which the spec does not carry.
1730///
1731/// ```
1732/// use kopiur_api::common::RepositoryKind;
1733/// use kopiur_api::seed::{SeedSpec, SeedSource};
1734/// use kopiur_api::validate::validate_seed_not_self;
1735///
1736/// let seed = |from: serde_json::Value| -> SeedSpec {
1737///     SeedSpec { from: serde_json::from_value(from).unwrap(), sync: None, migrate: None,
1738///                allow_empty_source: false, failure_policy: None, credential_projection: None }
1739/// };
1740/// let itself = seed(serde_json::json!({ "repository": { "name": "nas" } }));
1741/// assert!(
1742///     validate_seed_not_self(Some(&itself), RepositoryKind::Repository, "nas", "backups")
1743///         .is_err()
1744/// );
1745/// let other = seed(serde_json::json!({ "repository": { "name": "offsite" } }));
1746/// assert!(
1747///     validate_seed_not_self(Some(&other), RepositoryKind::Repository, "nas", "backups").is_ok()
1748/// );
1749/// ```
1750pub fn validate_seed_not_self(
1751    seed: Option<&SeedSpec>,
1752    self_kind: RepositoryKind,
1753    self_name: &str,
1754    self_namespace: &str,
1755) -> ValidationResult {
1756    let Some(source) = seed.and_then(crate::seed::seed_repository_ref) else {
1757        return Ok(());
1758    };
1759    let own = RepositoryRef {
1760        kind: self_kind,
1761        name: self_name.to_string(),
1762        namespace: None,
1763    };
1764    if crate::common::repo_key(source, self_namespace)
1765        == crate::common::repo_key(&own, self_namespace)
1766    {
1767        return Err(ValidationError::SeedSourceSelfReference {
1768            kind: source.kind.kind_str().to_string(),
1769            name: source.name.clone(),
1770        });
1771    }
1772    Ok(())
1773}