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 and range.** Every duration must parse and fall inside kopia's own bounds,
146///   and every count must clear its floor (see [`epoch_floor_errors`], which owns the
147///   ranges). These checks matter more here than elsewhere: these are the first CRD
148///   durations that reach a kopia CLI, and this module's contract is that a value the
149///   webhook admits never fails at reconcile time — nor, since #458, silently applies
150///   nothing.
151/// - **Applicability.** A `mode: ReadOnly` repository can never apply them — kopia
152///   hard-errors `set-parameters` on a read-only connection — so declaring them there is
153///   a configuration mistake. Reject it rather than silently ignore the block, matching
154///   how `volumeSnapshotClassName` + an NFS source is handled.
155pub fn validate_repository_parameters(
156    parameters: Option<&crate::repository::RepositoryParameters>,
157    mode: crate::common::RepositoryMode,
158    backend: &crate::backend::Backend,
159    context: &str,
160) -> Vec<ValidationError> {
161    let mut errs = Vec::new();
162    let Some(parameters) = parameters else {
163        return errs;
164    };
165    // `epoch` and `blobRetention` are validated as INDEPENDENT blocks. Returning early when
166    // one is absent would make the other's rules dead code — declaring only blobRetention
167    // must still be checked.
168    errs.extend(validate_blob_retention(
169        parameters.blob_retention.as_ref(),
170        mode,
171        backend,
172        context,
173    ));
174    let Some(epoch) = parameters.epoch.as_ref() else {
175        return errs;
176    };
177    if !mode.allows_writes() {
178        errs.push(ValidationError::InvalidFieldValue {
179            field: format!("{context} spec.parameters.epoch"),
180            reason: "a ReadOnly repository cannot apply repository parameters. Fix: remove \
181                     spec.parameters, or set them on the cluster that owns the repository \
182                     (mode: ReadWrite)"
183                .to_string(),
184        });
185    }
186    let mut duration = |field: &str, raw: &Option<String>| {
187        let Some(raw) = raw.as_deref() else { return };
188        let field = format!("{context} spec.parameters.epoch.{field}");
189        match crate::duration::parse_go_duration(raw) {
190            None => errs.push(ValidationError::InvalidFieldValue {
191                field,
192                reason: format!(
193                    "{raw:?} is not a valid duration. Fix: use a Go-style duration with a \
194                     single unit, like 6h, 90m, or 30s; omit the field to keep kopia's current \
195                     value"
196                ),
197            }),
198            // kopia stores these as a Go `time.Duration` — an i64 NANOSECOND count, so it
199            // tops out near 292 years, and `parse_go_duration` happily accepts far more
200            // than that (`"999999999999999999"` is a valid bare-seconds value). Bound it
201            // here rather than let the drift comparator's `as i64` wrap it to a negative
202            // number, and to keep this module's contract: a value the webhook admits must
203            // never fail at reconcile time.
204            Some(d) if i64::try_from(d.as_nanos()).is_err() => {
205                errs.push(ValidationError::InvalidFieldValue {
206                    field,
207                    reason: format!(
208                        "{raw:?} is too large (the maximum is about 292 years). Fix: use a \
209                         realistic epoch duration, e.g. 6h"
210                    ),
211                });
212            }
213            Some(_) => {}
214        }
215    };
216    duration("minDuration", &epoch.min_duration);
217    duration("refreshFrequency", &epoch.refresh_frequency);
218
219    // Range floors (#458). Grammar above, applicability below, floors here — kept in their
220    // own pure function because they are the rules with real arithmetic in them (the
221    // minDuration one derives its floor from a sibling field), and because they are the
222    // rules that most need to be readable next to kopia's own `Parameters.Validate()`.
223    errs.extend(epoch_floor_errors(epoch, context));
224    errs
225}
226
227/// kopia's own epoch floors, from `internal/epoch/epoch_manager.go` `Parameters.Validate()`
228/// and re-verified empirically against the pinned kopia 0.23.1 binary (see
229/// `crates/kopia/tests/integration_epoch_floors.rs`, which asserts every quoted error text
230/// below against the real CLI).
231///
232/// **Why these are admission errors and not runtime warnings** — issue #458. `kopia
233/// repository set-parameters` MERGES the flags it is given into the repository's existing
234/// parameters and then validates the whole resulting set. One out-of-range value therefore
235/// refuses the entire call, discarding every other parameter in the same apply. The reporter
236/// declared `advanceOnCount: 5` + `advanceOnSizeMiB: 2` alongside a perfectly legal
237/// `minDuration`, and lost the `minDuration` too: kopiur had already accepted the object, so
238/// the only evidence was a Warning event and a `status.parameters.epoch` that quietly
239/// disagreed with `spec`.
240///
241/// Pure, and takes the spec sub-object rather than the whole spec, so every rule is unit
242/// testable on its own (`validate/tests.rs`, `#458` section).
243///
244/// **What a pure validator cannot know.** Three of these rules involve the repository's LIVE
245/// parameters, which admission cannot read, so each one substitutes kopia's default. That is
246/// right for every repository kopiur itself configured, and can be wrong for one changed out
247/// of band — in which case kopiur rejects a call kopia would accept. The messages say so and
248/// point at `status.parameters.epoch.*`, where the live values are already mirrored:
249///
250/// - The `minDuration` bound measures against kopia's `20m` refresh default when no
251///   `refreshFrequency` is declared. Reachable through kopiur itself: apply
252///   `{minDuration: 60m, refreshFrequency: 5m}`, then drop `refreshFrequency` from spec (a
253///   spec `None` means "don't touch", so the live `5m` stays) and `minDuration: 30m` becomes
254///   legal on that repository while this rule still rejects it.
255/// - The `refreshFrequency` ceiling assumes the `4h` `cleanupSafetyMargin` default. kopiur
256///   has no field for the margin, but kopia's CLI does (`--epoch-cleanup-safety-margin`), so
257///   a margin raised to, say, `23h` makes `refreshFrequency: 90m` legal.
258/// - **Not enforced at all**, for the same reason: the mirror of the `minDuration` rule — a
259///   `refreshFrequency` declared ALONE must also satisfy `3 x refresh <= live minDuration`.
260///   Substituting kopia's `24h` default there would reject almost nothing, so only the
261///   margin ceiling is applied to that field.
262fn epoch_floor_errors(
263    epoch: &crate::repository::EpochParameters,
264    context: &str,
265) -> Vec<ValidationError> {
266    let mut errs = Vec::new();
267    let field = |name: &str| format!("{context} spec.parameters.epoch.{name}");
268
269    for (name, value, floor, why, whole_call_refused) in [
270        (
271            "advanceOnCount",
272            epoch.advance_on_count,
273            KOPIA_MIN_ADVANCE_ON_COUNT,
274            "kopia rejects lower values",
275            true,
276        ),
277        (
278            "advanceOnSizeMiB",
279            epoch.advance_on_size_mb,
280            KOPIA_MIN_ADVANCE_ON_SIZE_MIB,
281            "kopia rejects lower values",
282            true,
283        ),
284        (
285            "checkpointFrequency",
286            epoch.checkpoint_frequency,
287            KOPIA_MIN_CHECKPOINT_FREQUENCY,
288            "kopia rejects lower values",
289            true,
290        ),
291        (
292            "deleteParallelism",
293            epoch.delete_parallelism,
294            KOPIUR_MIN_DELETE_PARALLELISM,
295            "epoch cleanup would run with no workers",
296            // kopia's own set-parameters merge-then-validate-whole-set behavior (why the
297            // other three fields carry WHOLE_CALL_REFUSED) cannot apply here: kopia never
298            // looks at this field, so violating this floor can never be what trips kopia's
299            // whole-call refusal.
300            false,
301        ),
302    ] {
303        let Some(v) = value else { continue };
304        if v < floor {
305            // The zero note is only true OF A ZERO. Concatenating it onto every out-of-range
306            // value buried the part that applied under ~230 characters of the part that did
307            // not.
308            let mut reason = format!("{v} is below the minimum of {floor}: {why}.");
309            if whole_call_refused {
310                reason.push(' ');
311                reason.push_str(WHOLE_CALL_REFUSED);
312            }
313            if v == 0 {
314                reason.push(' ');
315                reason.push_str(ZERO_IS_DROPPED);
316            }
317            reason.push_str(&format!(
318                " Fix: use {floor} or more, or omit the field to keep kopia's current value"
319            ));
320            errs.push(ValidationError::InvalidFieldValue {
321                field: field(name),
322                reason,
323            });
324        }
325    }
326
327    // Durations are re-parsed here rather than threaded in from the grammar pass above, so
328    // this function stays pure and independently testable. An unparseable value yields
329    // `None` and is skipped — it already has its own, better, grammar error.
330    let parse = |raw: &Option<String>| raw.as_deref().and_then(crate::duration::parse_go_duration);
331    let declared_refresh = parse(&epoch.refresh_frequency);
332
333    let refresh_raw = epoch.refresh_frequency.as_deref().unwrap_or_default();
334    // A parsed ZERO is the #458 failure mode itself, on the one epoch field that has a
335    // ceiling but no floor. kopia's `setDurationParameter` is `if v == 0 { return }`, so the
336    // flag never reaches the format blob, `anyChange` stays false, and the command exits 0
337    // with `no changes`. Worse than a no-op downstream: the spec renders `"0s"`, the drift
338    // comparator sees 0 against the live 20m forever, and kopiur re-sends the flag on every
339    // single bootstrap while `status` permanently disagrees with `spec`.
340    //
341    // `minDuration` needs no zero rule of its own — 0 is below its 10m floor — and the four
342    // integers are all caught by their own `>= 1` floors. This is the last field.
343    if declared_refresh == Some(std::time::Duration::ZERO) {
344        errs.push(ValidationError::InvalidFieldValue {
345            field: field("refreshFrequency"),
346            reason: format!(
347                "{refresh_raw:?} is zero, which kopia silently ignores, so kopiur would \
348                 re-apply it forever. Fix: use 1s or more, or omit the field to keep kopia's \
349                 current value"
350            ),
351        });
352    } else if declared_refresh.is_some_and(|r| r > MAX_EPOCH_REFRESH_FREQUENCY) {
353        errs.push(ValidationError::InvalidFieldValue {
354            field: field("refreshFrequency"),
355            reason: format!(
356                "{refresh_raw:?} exceeds the maximum of 80m: kopia requires a cleanup safety \
357                 margin (assumed to be the 4h default) of at least 3x refreshFrequency. \
358                 {WHOLE_CALL_REFUSED} Fix: use 80m or less; if \
359                 `status.parameters.epoch.cleanupSafetyMargin` is larger, apply the change \
360                 with kopia directly"
361            ),
362        });
363    }
364
365    if let Some(min) = parse(&epoch.min_duration) {
366        errs.extend(min_duration_floor_error(
367            min,
368            epoch.min_duration.as_deref().unwrap_or_default(),
369            declared_refresh,
370            epoch.refresh_frequency.is_some(),
371            &field("minDuration"),
372        ));
373    }
374    errs
375}
376
377/// The `minDuration` floor, split out because it is the only rule whose bound is DERIVED —
378/// `max(10m, 3 x refreshFrequency)` — and the only one where TWO of kopia's rules can bite
379/// at once.
380///
381/// `refresh_declared` distinguishes "the user set a refreshFrequency we could parse" from
382/// "the user set one we could not" (grammar error already emitted → skip the derived bound
383/// rather than measure against a default they plainly did not intend).
384///
385/// Both violated rules are reported when both apply. Naming the derived floor while
386/// explaining only the absolute one mismatched why-against-floor, and it withheld the fix
387/// that actually works for a sub-10m value: raise `minDuration` TO 10m and declare a small
388/// `refreshFrequency` beside it. "Lowering the refresh cannot help" is only true with
389/// `minDuration` held fixed, which is precisely what the user is being asked to change.
390fn min_duration_floor_error(
391    min: std::time::Duration,
392    raw: &str,
393    declared_refresh: Option<std::time::Duration>,
394    refresh_declared: bool,
395    field: &str,
396) -> Vec<ValidationError> {
397    // An unparseable refreshFrequency makes the 3x bound unknowable; the absolute floor
398    // still applies.
399    let refresh = match (refresh_declared, declared_refresh) {
400        (true, None) => None,
401        (true, Some(r)) => Some((r, "the declared refreshFrequency")),
402        (false, _) => Some((
403            KOPIA_DEFAULT_EPOCH_REFRESH_FREQUENCY,
404            "kopia's default refreshFrequency of 20m (the live value is in \
405             `status.parameters.epoch.refreshFrequency`)",
406        )),
407    };
408    let derived = refresh.map(|(r, source)| (r.saturating_mul(3), source));
409    let floor = derived.map_or(KOPIA_MIN_EPOCH_DURATION, |(d, _)| {
410        d.max(KOPIA_MIN_EPOCH_DURATION)
411    });
412    if min >= floor {
413        return Vec::new();
414    }
415
416    // WHICH of kopia's two rules the value breaks. Both can, and then both are named.
417    let below_absolute = min < KOPIA_MIN_EPOCH_DURATION;
418    let derived_bites = derived.is_some_and(|(d, _)| min < d && d > KOPIA_MIN_EPOCH_DURATION);
419    let mut why = Vec::new();
420    if below_absolute {
421        why.push("kopia's minimum is 10m".to_string());
422    }
423    if derived_bites {
424        let (_, source) = derived.expect("derived_bites implies a derived bound");
425        why.push(format!(
426            "kopia requires `minDuration >= 3 x refreshFrequency`, using {source}"
427        ));
428    }
429
430    // …and the fix for exactly that combination. The refresh suggestion is always computed
431    // from a minDuration that would itself be legal, so following it cannot fail the other
432    // rule on the next apply.
433    let fix = match (below_absolute, derived_bites) {
434        (true, true) => format!(
435            "Fix: use {} or more, or raise minDuration to 10m and set `refreshFrequency` to {} or \
436             less",
437            render_minutes(floor),
438            suggest_refresh(KOPIA_MIN_EPOCH_DURATION),
439        ),
440        (false, true) => format!(
441            "Fix: use {} or more, or set `refreshFrequency` to {} or less",
442            render_minutes(floor),
443            suggest_refresh(min),
444        ),
445        // Below 10m with the 3x rule already satisfied: raising minDuration is the only move.
446        (true, false) => format!("Fix: use {} or more", render_minutes(floor)),
447        (false, false) => unreachable!("min < floor implies at least one rule was broken"),
448    };
449    vec![ValidationError::InvalidFieldValue {
450        field: field.to_string(),
451        reason: format!(
452            "{raw:?} is below the minimum of {}: {}. {WHOLE_CALL_REFUSED} {fix}",
453            render_minutes(floor),
454            why.join("; and "),
455        ),
456    }]
457}
458
459/// The largest `refreshFrequency` that would make `min` a legal `minDuration`, rounded DOWN
460/// to a whole minute so the suggestion reads like something a human would write (`3m`, not
461/// `200s`). Rounding down can only make the suggestion safer — it never pushes
462/// `3 x refresh` back above `minDuration`.
463///
464/// CAPPED at [`MAX_EPOCH_REFRESH_FREQUENCY`], because the naive third is only valid while it
465/// stays under that ceiling: `minDuration: 300m` would otherwise suggest `100m`, which the
466/// ceiling immediately rejects. Advice that fails the very next admission is worse than no
467/// advice.
468fn suggest_refresh(min: std::time::Duration) -> String {
469    let third = std::time::Duration::from_secs(min.as_secs() / 3).min(MAX_EPOCH_REFRESH_FREQUENCY);
470    if third.as_secs() >= 60 {
471        format!("{}m", third.as_secs() / 60)
472    } else {
473        crate::render_go_duration(third)
474    }
475}
476
477/// Render an epoch floor the way the docs and the issue talk about it — in minutes (`60m`,
478/// not `render_go_duration`'s `1h`). Every floor in this module lives in the minutes range
479/// (the largest possible is 3 x the 80m refresh ceiling), so minutes is the unit that makes
480/// two floors comparable at a glance; anything not a whole minute falls back.
481fn render_minutes(d: std::time::Duration) -> String {
482    let secs = d.as_secs();
483    if secs != 0 && secs.is_multiple_of(60) {
484        format!("{}m", secs / 60)
485    } else {
486        crate::render_go_duration(d)
487    }
488}
489
490/// The sentence every epoch-floor message carries, and the whole reason #458 was a data
491/// problem rather than a cosmetic one.
492const WHOLE_CALL_REFUSED: &str = "One out-of-range value makes kopia reject every \
493     parameter in the same apply.";
494
495/// Why `0` is rejected rather than tolerated as "leave it alone": kopia's CLI treats `0` on
496/// these integer flags as "flag not set" and prints `no changes`, which is the worst of both
497/// worlds — the user sees success and the repository is untouched. Verified on 0.23.1.
498const ZERO_IS_DROPPED: &str = "kopia would silently ignore 0.";
499
500/// kopia's `minEpochAdvanceOnCount`: "epoch advance on count too low" below this.
501const KOPIA_MIN_ADVANCE_ON_COUNT: i64 = 10;
502/// kopia's minimum advance-on-size threshold, in MiB: "epoch advance on size too low".
503const KOPIA_MIN_ADVANCE_ON_SIZE_MIB: i64 = 1;
504/// kopia's minimum full-checkpoint frequency: "invalid epoch range compaction period".
505const KOPIA_MIN_CHECKPOINT_FREQUENCY: i64 = 1;
506/// kopiur's own floor — kopia never validates `DeleteParallelism`.
507const KOPIUR_MIN_DELETE_PARALLELISM: i64 = 1;
508/// kopia's `minEpochDuration`: "minimum epoch duration too low" below this.
509const KOPIA_MIN_EPOCH_DURATION: std::time::Duration = std::time::Duration::from_secs(10 * 60);
510/// kopia's default `EpochRefreshFrequency`, which is what a repository still has when the
511/// user declares only `minDuration`. The reason `minDuration: 10m` alone always fails.
512const KOPIA_DEFAULT_EPOCH_REFRESH_FREQUENCY: std::time::Duration =
513    std::time::Duration::from_secs(20 * 60);
514/// kopia's default `CleanupSafetyMargin` (4h) divided by 3. kopiur cannot set the margin, so
515/// this is a constant ceiling on `refreshFrequency`.
516const MAX_EPOCH_REFRESH_FREQUENCY: std::time::Duration = std::time::Duration::from_secs(80 * 60);
517
518/// kopia's minimum blob-retention period, straight from its own `Validate()`:
519/// "invalid retention-period, the minimum required is 1-day and there is no maximum limit".
520const MIN_RETENTION_PERIOD: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60);
521
522/// `spec.parameters.blobRetention` rules (#332).
523///
524/// Three classes of rule, all of which turn a guaranteed *runtime* failure into an admission
525/// error:
526///
527/// - **Backend applicability.** On a backend without object lock, `set-parameters` does not
528///   no-op — it hard-fails with `blob-retention: unsupported put-blob option`. Since the
529///   bootstrap re-runs it on every reconcile, declaring retention on such a backend would
530///   produce a recurring Warning event and permanently diverged status, forever.
531/// - **Grammar and range.** The period must parse and clear kopia's 1-day floor.
532/// - **Applicability to `mode: ReadOnly`**, exactly as for `epoch`.
533///
534/// Deliberately NOT checked: whether the period exceeds the full-maintenance interval. kopia
535/// only enforces that when `--extend-object-locks` is on (`CheckExtendRetention` returns
536/// early otherwise), and kopiur does not set that flag — so such a guard would reject
537/// configurations kopia accepts.
538fn validate_blob_retention(
539    retention: Option<&crate::repository::BlobRetention>,
540    mode: crate::common::RepositoryMode,
541    backend: &crate::backend::Backend,
542    context: &str,
543) -> Vec<ValidationError> {
544    use crate::backend::Backend;
545    let mut errs = Vec::new();
546    let Some(retention) = retention else {
547        return errs;
548    };
549    let field = format!("{context} spec.parameters.blobRetention");
550
551    if !mode.allows_writes() {
552        errs.push(ValidationError::InvalidFieldValue {
553            field: field.clone(),
554            reason: "a ReadOnly repository cannot apply blob retention. Fix: set \
555                     blobRetention on the cluster that owns the repository (mode: ReadWrite)"
556                .to_string(),
557        });
558    }
559
560    // Exhaustive on purpose: a new Backend variant must be classified here before it
561    // compiles, rather than silently inheriting "supported" and hard-failing at runtime.
562    let supported = match backend {
563        Backend::S3(_) | Backend::Azure(_) | Backend::Gcs(_) => true,
564        Backend::B2(_)
565        | Backend::Filesystem(_)
566        | Backend::Sftp(_)
567        | Backend::WebDav(_)
568        | Backend::Rclone(_)
569        | Backend::Gdrive(_) => false,
570    };
571    if !supported {
572        errs.push(ValidationError::InvalidFieldValue {
573            field: field.clone(),
574            reason: format!(
575                "the {} backend does not support object lock. Fix: remove \
576                 spec.parameters.blobRetention, or use an S3, Azure, or GCS bucket created \
577                 with object lock enabled",
578                backend.kind_str()
579            ),
580        });
581    }
582
583    // Only the window variants carry a period; `Disabled` has nothing to check, and kopia
584    // short-circuits `--retention-mode=none` before its own validation.
585    let Some(window) = retention.window() else {
586        return errs;
587    };
588    let period_field = format!("{field}.{}.period", retention.kind_str().to_lowercase());
589    match crate::duration::parse_go_duration(&window.period) {
590        None => errs.push(ValidationError::InvalidFieldValue {
591            field: period_field,
592            reason: format!(
593                "{:?} is not a valid duration. Fix: use a Go-style duration with a single \
594                 unit of h, m, or s — write 30 days as 720h, not 30d",
595                window.period
596            ),
597        }),
598        // Same 292-year ceiling as the epoch durations, and for the same reason: kopia stores
599        // this as an i64 nanosecond count, and the drift comparator compares in those units.
600        Some(d) if i64::try_from(d.as_nanos()).is_err() => {
601            errs.push(ValidationError::InvalidFieldValue {
602                field: period_field,
603                reason: format!(
604                    "{:?} is too large (the maximum is about 292 years)",
605                    window.period
606                ),
607            });
608        }
609        Some(d) if d < MIN_RETENTION_PERIOD => {
610            errs.push(ValidationError::InvalidFieldValue {
611                field: period_field,
612                reason: format!(
613                    "{:?} is below kopia's minimum of 1 day. Fix: use 24h or more",
614                    window.period
615                ),
616            });
617        }
618        Some(_) => {}
619    }
620    errs
621}
622
623/// `spec.health` rules shared by `Repository` and `ClusterRepository`
624/// (ADR-0005 §13). The index-blob warning threshold must be non-negative: a
625/// negative count is nonsensical, and `0` is the documented sentinel that
626/// disables the warning (so it is allowed). `context` names the kind for the
627/// message ("Repository" / "ClusterRepository").
628pub fn validate_repository_health(
629    health: Option<&RepositoryHealthSpec>,
630    context: &str,
631) -> ValidationResult {
632    if let Some(h) = health
633        && let Some(t) = h.index_blob_warn_threshold
634        && t < 0
635    {
636        return Err(ValidationError::InvalidFieldValue {
637            field: format!("{context} health.indexBlobWarnThreshold"),
638            reason: format!("must be >= 0 (got {t}); 0 disables the warning"),
639        });
640    }
641    if let Some(probe) = health.and_then(|h| h.probe.as_ref()) {
642        if let Some(raw) = probe.interval.as_deref() {
643            match crate::duration::parse_go_duration(raw) {
644                None => {
645                    return Err(ValidationError::InvalidFieldValue {
646                        field: format!("{context} health.probe.interval"),
647                        reason: format!(
648                            "{raw:?} is not a valid duration. Fix: use a Go-style duration like \
649                             30s, 5m, or 1h; omit the field for the default (30m)"
650                        ),
651                    });
652                }
653                Some(d) if d < crate::consts::MIN_HEALTH_PROBE_INTERVAL => {
654                    return Err(ValidationError::InvalidFieldValue {
655                        field: format!("{context} health.probe.interval"),
656                        reason: format!(
657                            "{raw:?} is shorter than the 30s minimum. Fix: use 30s or more \
658                             (default 30m)"
659                        ),
660                    });
661                }
662                Some(_) => {}
663            }
664        }
665        if let Some(t) = probe.failure_threshold
666            && t < 1
667        {
668            return Err(ValidationError::InvalidFieldValue {
669                field: format!("{context} health.probe.failureThreshold"),
670                reason: format!(
671                    "must be >= 1 (got {t}); it is how many failed probes in a row raise the \
672                     warning"
673                ),
674            });
675        }
676    }
677    Ok(())
678}
679
680/// `spec.concurrency` rules shared by `Repository` and `ClusterRepository`
681/// (`context` names the kind for the message). `maxConcurrentDeleteJobs` must be
682/// at least 1: 0 would never delete anything, and there is deliberately no
683/// "unlimited" spelling. `maxConcurrentJobs` keeps its own contract (0 means
684/// unlimited), so it is not checked here.
685pub fn validate_repository_concurrency(
686    concurrency: Option<&crate::common::ConcurrencySpec>,
687    context: &str,
688) -> Vec<ValidationError> {
689    concurrency
690        .and_then(|c| c.max_concurrent_delete_jobs)
691        .and_then(|n| {
692            super::require_min(
693                &format!("{context} spec.concurrency.maxConcurrentDeleteJobs"),
694                i64::from(n),
695                super::NumericBound::Count,
696            )
697        })
698        .into_iter()
699        .collect()
700}
701
702/// Whether a [`Retention`] selects **no** snapshots — every bucket unset or `0`. The
703/// controller only prunes when `spec.retention` is `Some` ([`crate::retention::select_kept`]
704/// over the buckets), so a `Some(keeps-nothing)` retention prunes *every* `Snapshot`
705/// immediately: silent data loss. (`retention: None` is the safe "don't prune" case and is
706/// NOT flagged.)
707pub(crate) fn retention_keeps_nothing(r: &Retention) -> bool {
708    [
709        r.keep_latest,
710        r.keep_hourly,
711        r.keep_daily,
712        r.keep_weekly,
713        r.keep_monthly,
714        r.keep_annual,
715    ]
716    .into_iter()
717    .all(|bucket| bucket.unwrap_or(0) == 0)
718}
719
720/// A `Repository` spec does not carry kopia-side (repo-level) retention policy,
721/// which would conflict with CR-driven GFS retention (ADR §4.4 exclusivity).
722///
723/// The current [`RepositorySpec`] deliberately models no inline retention field, so
724/// this **always passes today**. It exists as the enforcement hook so that if a
725/// future field (e.g. `spec.policy.keepDaily`) is ever added, wiring it here is the
726/// one obvious place — and the rule is already named and tested. Be pragmatic: we
727/// do not invent a field to reject.
728pub fn validate_repository_no_inline_retention(_spec: &RepositorySpec) -> ValidationResult {
729    // No inline-retention field exists on RepositorySpec. If one is added later,
730    // return Err(ValidationError::InlineRetentionForbidden { field: "<name>" }) here.
731    Ok(())
732}
733
734/// Validate a `spec.maintenance` block on a `Repository`/`ClusterRepository`,
735/// accumulating problems (ADR §3.7):
736/// - any override schedule's quick/full crons must parse (same parser as runtime);
737/// - `namespace` is **cluster-scope only** — it selects where the namespaced
738///   managed `Maintenance` lands for a `ClusterRepository`, and is forbidden on a
739///   namespaced `Repository` (whose `Maintenance` always lives in its own ns).
740///
741/// `cluster_scoped` is the only thing that differs between the two repository
742/// kinds, so one validator serves both call sites.
743pub fn validate_repository_maintenance(
744    maintenance: &RepositoryMaintenanceSpec,
745    cluster_scoped: bool,
746) -> Vec<ValidationError> {
747    let mut errs = Vec::new();
748    if let Some(schedule) = &maintenance.schedule {
749        if let Err(e) = validate_cron(&schedule.quick.cron) {
750            errs.push(e);
751        }
752        if let Err(e) = validate_cron(&schedule.full.cron) {
753            errs.push(e);
754        }
755        for tz in [
756            schedule.timezone.as_deref(),
757            schedule.quick.timezone.as_deref(),
758            schedule.full.timezone.as_deref(),
759        ] {
760            if let Err(e) = validate_timezone(tz) {
761                errs.push(e);
762            }
763        }
764    }
765    if !cluster_scoped && let Some(ns) = &maintenance.namespace {
766        errs.push(ValidationError::MaintenanceNamespaceOnNamespacedRepo {
767            namespace: ns.clone(),
768        });
769    }
770    errs
771}
772
773/// Accumulate every create-time-immutable field that changed between `old` and
774/// `new` repository specs (ADR-0005 §7). Shared by both repository kinds via the
775/// thin [`validate_repository_immutability`] / [`validate_cluster_repository_immutability`]
776/// wrappers, which pass the `encryption` password ref + the `create.{splitter,hash,
777/// encryption}` algorithms — the fields kopia bakes into the repository format.
778///
779/// Pure: the webhook supplies `old`/`new` from the admission request's old/new
780/// objects; CREATE has no old object, so this is only wired into the UPDATE path.
781fn diff_immutable_repo_fields(
782    old_create: Option<&crate::common::CreateBehavior>,
783    new_create: Option<&crate::common::CreateBehavior>,
784) -> Vec<ValidationError> {
785    let mut errs = Vec::new();
786    // NOTE: `encryption` (the password Secret *reference*) is deliberately NOT immutable.
787    // kopia bakes only the resolved password *value* and the `create.*` algorithms into
788    // the repository format — never the Secret name/namespace/key. Locking the reference
789    // was both over-strict (a Secret rename with identical content was rejected, breaking
790    // GitOps) and under-strict (editing a Secret's content in place — the actual password
791    // change kopia would reject — sailed through). kopia also supports `change-password`,
792    // so the password is operationally mutable; a genuinely wrong ref surfaces at connect
793    // time, not at admission. We only enforce the create-time algorithms below.
794    // The create-time kopia algorithms. Compared field-wise so the message names the
795    // exact field. `create` itself may be absent on either side (absent ⇒ None algos).
796    let old_splitter = old_create.and_then(|c| c.splitter.as_deref());
797    let new_splitter = new_create.and_then(|c| c.splitter.as_deref());
798    if old_splitter != new_splitter {
799        errs.push(ValidationError::Immutable {
800            field: "create.splitter".to_string(),
801        });
802    }
803    let old_hash = old_create.and_then(|c| c.hash.as_deref());
804    let new_hash = new_create.and_then(|c| c.hash.as_deref());
805    if old_hash != new_hash {
806        errs.push(ValidationError::Immutable {
807            field: "create.hash".to_string(),
808        });
809    }
810    let old_enc = old_create.and_then(|c| c.encryption.as_deref());
811    let new_enc = new_create.and_then(|c| c.encryption.as_deref());
812    if old_enc != new_enc {
813        errs.push(ValidationError::Immutable {
814            field: "create.encryption".to_string(),
815        });
816    }
817    // ECC (Reed-Solomon parity) is baked into the repository format at create time
818    // (ADR-0005 §13(a)) — immutable post-create like the other create knobs.
819    let old_ecc = old_create.and_then(|c| c.ecc.as_ref());
820    let new_ecc = new_create.and_then(|c| c.ecc.as_ref());
821    if old_ecc != new_ecc {
822        errs.push(ValidationError::Immutable {
823            field: "create.ecc".to_string(),
824        });
825    }
826    errs
827}
828
829/// Reject changes to create-time-immutable `Repository` fields on UPDATE (ADR-0005
830/// §7): `create.splitter`, `create.hash`, `create.encryption`, `create.ecc`. Returns
831/// every changed field so a user sees them all at once. Empty ⇒ no immutable change.
832///
833/// `encryption` (the password Secret reference) is intentionally NOT in this set — only
834/// the resolved password value is fixed in the kopia format, and the reference is not a
835/// reliable proxy for it (see [`diff_immutable_repo_fields`]). Renaming the Secret is fine.
836///
837/// ```
838/// use kopiur_api::repository::RepositorySpec;
839/// use kopiur_api::validate::validate_repository_immutability;
840/// # use kopiur_api::backend::{Backend, FilesystemBackend};
841/// # use kopiur_api::common::{CreateBehavior, Encryption, SecretKeyRef};
842/// # fn spec(splitter: Option<&str>) -> RepositorySpec {
843/// #     RepositorySpec {
844/// #         backend: Backend::Filesystem(FilesystemBackend { path: "/r".into(), volume: None }),
845/// #         encryption: Encryption { password_secret_ref: SecretKeyRef { name: "s".into(), namespace: None, key: None } },
846/// #         create: Some(CreateBehavior { enabled: true, encryption: None, splitter: splitter.map(String::from), hash: None, ecc: None }),
847/// #         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,
848/// #     }
849/// # }
850/// // Unchanged splitter → accepted.
851/// assert!(validate_repository_immutability(&spec(Some("FIXED-4M")), &spec(Some("FIXED-4M"))).is_empty());
852/// // Changed splitter → rejected.
853/// assert!(!validate_repository_immutability(&spec(Some("FIXED-4M")), &spec(Some("DYNAMIC"))).is_empty());
854/// ```
855pub fn validate_repository_immutability(
856    old: &RepositorySpec,
857    new: &RepositorySpec,
858) -> Vec<ValidationError> {
859    diff_immutable_repo_fields(old.create.as_ref(), new.create.as_ref())
860}
861
862/// Reject changes to create-time-immutable `ClusterRepository` fields on UPDATE
863/// (ADR-0005 §7). Same field set as [`validate_repository_immutability`].
864pub fn validate_cluster_repository_immutability(
865    old: &ClusterRepositorySpec,
866    new: &ClusterRepositorySpec,
867) -> Vec<ValidationError> {
868    diff_immutable_repo_fields(old.create.as_ref(), new.create.as_ref())
869}
870
871/// Validate a `Repository` spec, accumulating all problems (ADR §3.1).
872pub fn validate_repository(spec: &RepositorySpec) -> Vec<ValidationError> {
873    let mut errs = Vec::new();
874    if let Err(e) = validate_repository_no_inline_retention(spec) {
875        errs.push(e);
876    }
877    if let Err(e) = validate_backend(&spec.backend) {
878        errs.push(e);
879    }
880    if let Some(m) = &spec.maintenance {
881        errs.extend(validate_repository_maintenance(m, false));
882    }
883    if let Some(c) = &spec.catalog {
884        errs.extend(validate_catalog_bounds(c, false));
885    }
886    // Identity CEL expressions must compile + trial-evaluate to a string at admission
887    // (ADR-0004 §5), so a typo / out-of-scope variable is rejected on `kubectl apply`.
888    // Mirrors `validate_cluster_repository`'s identical block.
889    if let Some(id) = &spec.identity_defaults {
890        if let Some(expr) = &id.hostname_expr
891            && let Err(e) = crate::identity::validate_identity_expr(expr)
892        {
893            errs.push(e);
894        }
895        if let Some(expr) = &id.username_expr
896            && let Err(e) = crate::identity::validate_identity_expr(expr)
897        {
898            errs.push(e);
899        }
900        // `cluster` becomes part of the default hostname (`<namespace>.<cluster>`)
901        // and `classify_hostname` splits on the first `.`, so it must be a clean
902        // RFC 1123 label with no dot of its own (M1/M5).
903        if let Some(cluster) = &id.cluster
904            && let Err(e) = validate_cluster_name(cluster)
905        {
906            errs.push(e);
907        }
908    }
909    errs.extend(validate_foreign_snapshots_cluster_coupling(
910        spec.catalog.as_ref(),
911        spec.identity_defaults
912            .as_ref()
913            .and_then(|id| id.cluster.as_deref()),
914    ));
915    if let Some(md) = &spec.mover_defaults
916        && let Some(res) = &md.resources
917        && let Err(e) = validate_resources(res, "Repository moverDefaults")
918    {
919        errs.push(e);
920    }
921    if let Some(server) = &spec.server {
922        errs.extend(validate_server(server, spec.mode));
923    }
924    errs.extend(validate_repository_parameters(
925        spec.parameters.as_ref(),
926        spec.mode,
927        &spec.backend,
928        "Repository",
929    ));
930    if let Err(e) = validate_repository_health(spec.health.as_ref(), "Repository") {
931        errs.push(e);
932    }
933    errs.extend(validate_repository_concurrency(
934        spec.concurrency.as_ref(),
935        "Repository",
936    ));
937    if let Some(b) = &spec.bootstrap
938        && let Some(fp) = &b.failure_policy
939        && let Err(e) = validate_failure_policy(fp, "Repository spec.bootstrap")
940    {
941        errs.push(e);
942    }
943    if let Err(e) = validate_timezone(
944        spec.schedule_defaults
945            .as_ref()
946            .and_then(|d| d.timezone.as_deref()),
947    ) {
948        errs.push(e);
949    }
950    // `scheduleDefaults.jitter` is a NEW field, so both its parse and its 24h bound
951    // are safe in this shared aggregate: no stored `Repository` can carry a value
952    // these reject, and the reconciler re-running them can therefore brick nothing.
953    // (The 24h bound on the pre-existing per-cron `jitter` fields is a different
954    // story — see `validate::admission`.)
955    //
956    // Asymmetry worth naming: the `Repository` reconciler re-runs only a partial
957    // validator set while `ClusterRepository` re-runs the whole aggregate, so this
958    // rule is re-checked at reconcile for one kind and not the other. Acceptable —
959    // the webhook covers both kinds identically, and that is the gate.
960    if let Some(j) = spec
961        .schedule_defaults
962        .as_ref()
963        .and_then(|d| d.jitter.as_deref())
964        && let Err(e) = validate_jitter_bounds("spec.scheduleDefaults.jitter", j)
965    {
966        errs.push(e);
967    }
968    if let Some(md) = &spec.mover_defaults {
969        errs.extend(validate_pod_metadata(md));
970    }
971    // #380: `spec.seed` rules derivable from the spec alone. The namespaced arm
972    // of the co-resident seed-Secret rule and the migrate-mode self-reference
973    // check need the CR's own namespace/name, so the webhook calls
974    // `validate_seed_secret_namespace` / `validate_seed_not_self` separately.
975    errs.extend(validate_repository_seed(
976        spec.seed.as_ref(),
977        &spec.backend,
978        spec.mode,
979        spec.create.as_ref(),
980        RepositoryKind::Repository,
981    ));
982    errs
983}
984
985/// The actionable admission warning for an inline-NFS filesystem repo whose
986/// `moverDefaults` grant write access only via `fsGroup`. **`fsGroup` is silently
987/// ignored on NFS** (the kubelet doesn't recursively chown in-tree NFS mounts), so
988/// the mover/server/bootstrap reach the export as the unprivileged uid and the
989/// repo `connect`/`create` fails with `permission denied`. Non-blocking (a user
990/// fixing it NAS-side via Mapall can ignore it). Kept short for the admission
991/// response (kube truncates very long warnings).
992pub const NFS_FSGROUP_WARNING: &str = "NFS filesystem repo: fsGroup is ignored on NFS. \
993     Fix: grant the mover write access via moverDefaults.podSecurityContext.supplementalGroups \
994     (with a group-writable export), securityContext.runAsUser, or NAS-side Mapall";
995
996/// The actionable admission warning for an S3 backend pairing `tls.caBundleRef`
997/// with `tls.insecureSkipVerify: true`: kopia's `--disable-tls-verification`
998/// wins, so the referenced CA bundle is ignored while insecureSkipVerify is set.
999/// Deliberately a WARNING, never a hard error — the `ClusterRepository` and
1000/// `RepositoryReplication` reconcilers defensively re-validate the FULL spec on
1001/// every reconcile and hard-error on failure, so promoting this combination to
1002/// an error would brick every already-persisted CR carrying it on operator
1003/// upgrade, with no admission request in flight for a user to react to. (The
1004/// `caBundleRef` + `disableTls` contradiction IS a hard error, but only because
1005/// `caBundleRef` never worked at all before this validation existed — no
1006/// working CR can carry it.) Kept short for the admission response (kube
1007/// truncates very long warnings).
1008pub const S3_TLS_SKIP_VERIFY_WARNING: &str = "s3 tls: caBundleRef is ignored while \
1009     insecureSkipVerify is set; remove insecureSkipVerify once the CA bundle works";
1010
1011/// Whether `moverDefaults` configures an NFS-effective write identity — i.e. a
1012/// `runAsUser` (container or pod) that owns the export, or a `supplementalGroups`
1013/// the export is group-writable by. `fsGroup` deliberately does **not** count: it
1014/// is a no-op on NFS.
1015fn nfs_write_identity_configured(mover_defaults: Option<&MoverDefaults>) -> bool {
1016    let Some(md) = mover_defaults else {
1017        return false;
1018    };
1019    let container_uid = md.security_context.as_ref().and_then(|sc| sc.run_as_user);
1020    let pod_uid = md
1021        .pod_security_context
1022        .as_ref()
1023        .and_then(|psc| psc.run_as_user);
1024    let suppl_groups = md
1025        .pod_security_context
1026        .as_ref()
1027        .and_then(|psc| psc.supplemental_groups.as_ref())
1028        .is_some_and(|g| !g.is_empty());
1029    container_uid.is_some() || pod_uid.is_some() || suppl_groups
1030}
1031
1032/// Non-blocking admission warnings for a `Repository`/`ClusterRepository`. Shared
1033/// by both handlers (the rules can't fork). Today: the inline-NFS + `fsGroup`-only
1034/// footgun (see [`NFS_FSGROUP_WARNING`]) and the S3 `caBundleRef` +
1035/// `insecureSkipVerify` shadowing (see [`S3_TLS_SKIP_VERIFY_WARNING`]). Takes the
1036/// resolved `backend` + `moverDefaults` so it serves both kinds without
1037/// re-deriving them.
1038pub fn repository_warnings(
1039    backend: &Backend,
1040    mover_defaults: Option<&MoverDefaults>,
1041) -> Vec<String> {
1042    let mut warnings = Vec::new();
1043    let inline_nfs = matches!(
1044        backend,
1045        Backend::Filesystem(fs) if matches!(fs.volume, Some(RepoVolume::Nfs(_)))
1046    );
1047    if inline_nfs && !nfs_write_identity_configured(mover_defaults) {
1048        warnings.push(NFS_FSGROUP_WARNING.to_string());
1049    }
1050    if let Backend::S3(s3) = backend
1051        && let Some(tls) = &s3.tls
1052        && tls.ca_bundle_ref.is_some()
1053        && tls.insecure_skip_verify
1054    {
1055        warnings.push(S3_TLS_SKIP_VERIFY_WARNING.to_string());
1056    }
1057    warnings
1058}
1059
1060/// Validate `spec.catalog` (ADR §3.1/§3.2): the refresh interval must parse and
1061/// respect the floor, the retain bounds must be enforceable, and
1062/// `fallbackNamespace` only means something on a cluster-scoped repository
1063/// (`cluster_scoped`). One validator for both kinds so the rules cannot fork.
1064pub fn validate_catalog_bounds(
1065    catalog: &crate::common::CatalogBounds,
1066    cluster_scoped: bool,
1067) -> Vec<ValidationError> {
1068    let mut errs = Vec::new();
1069    if let Some(raw) = catalog.refresh_interval.as_deref() {
1070        match crate::duration::parse_go_duration(raw) {
1071            None => errs.push(ValidationError::InvalidFieldValue {
1072                field: "catalog.refreshInterval".to_string(),
1073                reason: format!(
1074                    "{raw:?} is not a valid duration. Fix: use a Go-style duration like 30s, \
1075                     5m, or 1h; omit the field for the default (1h)"
1076                ),
1077            }),
1078            Some(d) if d < crate::consts::MIN_CATALOG_REFRESH_INTERVAL => {
1079                errs.push(ValidationError::InvalidFieldValue {
1080                    field: "catalog.refreshInterval".to_string(),
1081                    reason: format!(
1082                        "{raw:?} is shorter than the 30s minimum. Fix: use 30s or more \
1083                         (default 1h)"
1084                    ),
1085                });
1086            }
1087            Some(_) => {}
1088        }
1089    }
1090    if let Some(retain) = &catalog.retain {
1091        if let Some(n) = retain.per_identity
1092            && n < 0
1093        {
1094            errs.push(ValidationError::InvalidFieldValue {
1095                field: "catalog.retain.perIdentity".to_string(),
1096                reason: format!(
1097                    "{n} is negative. Fix: use how many discovered Snapshots to keep per \
1098                     identity, 0 to keep none, or omit the field to keep all"
1099                ),
1100            });
1101        }
1102        if let Some(d) = retain.max_age_days
1103            && d < 1
1104        {
1105            errs.push(ValidationError::InvalidFieldValue {
1106                field: "catalog.retain.maxAgeDays".to_string(),
1107                reason: format!(
1108                    "{d} is not a valid age. Fix: use a positive number of days, or omit the \
1109                     field for no limit"
1110                ),
1111            });
1112        }
1113    }
1114    if !cluster_scoped && catalog.fallback_namespace.is_some() {
1115        errs.push(ValidationError::InvalidFieldValue {
1116            field: "catalog.fallbackNamespace".to_string(),
1117            reason: "only applies to a ClusterRepository; a Repository always puts discovered \
1118                     Snapshots in its own namespace. Fix: remove the field"
1119                .to_string(),
1120        });
1121    }
1122    // `foreignSnapshots: Fallback` needs somewhere to land, and only a
1123    // ClusterRepository can place discovered Snapshots outside their own
1124    // namespace — mirrors the fallbackNamespace rule directly above.
1125    if matches!(
1126        catalog.foreign_snapshots,
1127        Some(crate::common::ForeignSnapshots::Fallback)
1128    ) {
1129        if catalog.fallback_namespace.is_none() {
1130            errs.push(ValidationError::InvalidFieldValue {
1131                field: "catalog.foreignSnapshots".to_string(),
1132                reason: "Fallback requires catalog.fallbackNamespace. Fix: set \
1133                         fallbackNamespace, or use Ignore"
1134                    .to_string(),
1135            });
1136        }
1137        if !cluster_scoped {
1138            errs.push(ValidationError::InvalidFieldValue {
1139                field: "catalog.foreignSnapshots".to_string(),
1140                reason: "Fallback only applies to a ClusterRepository; a Repository always \
1141                         uses its own namespace. Fix: use Ignore or omit"
1142                    .to_string(),
1143            });
1144        }
1145    }
1146    errs
1147}
1148
1149/// The `identityDefaults.cluster` × `catalog.foreignSnapshots` cross-field
1150/// rules (multi-cluster shared-repo): classifying a snapshot as another
1151/// cluster's is undecidable without a cluster identity (a), and adopting one
1152/// must never silently switch off an already-configured fallback collector
1153/// (d). Shared by both repository kinds — `cluster` is the resolved
1154/// `identityDefaults.cluster` value, `None` when the repository has no cluster
1155/// identity set (or, on a namespaced `Repository`, no `identityDefaults` set at
1156/// all). Without a cluster, rule (d) is a no-op (it requires one to fire) while
1157/// rule (a) still rejects any `foreignSnapshots` set there.
1158pub fn validate_foreign_snapshots_cluster_coupling(
1159    catalog: Option<&crate::common::CatalogBounds>,
1160    cluster: Option<&str>,
1161) -> Vec<ValidationError> {
1162    let Some(catalog) = catalog else {
1163        return Vec::new();
1164    };
1165    let mut errs = Vec::new();
1166    let has_cluster = cluster.is_some_and(|c| !c.is_empty());
1167    if catalog.foreign_snapshots.is_some() && !has_cluster {
1168        errs.push(ValidationError::ForeignSnapshotsRequiresCluster);
1169    }
1170    if has_cluster && catalog.fallback_namespace.is_some() && catalog.foreign_snapshots.is_none() {
1171        errs.push(ValidationError::ForeignSnapshotsChoiceRequired);
1172    }
1173    errs
1174}
1175
1176/// Validate a `RepositoryReplication` spec, accumulating all problems (ADR-0005
1177/// §13(d)): the `sourceRef` is well-formed, the schedule cron parses, the
1178/// destination backend's content is valid, and (when a mover is set) it's
1179/// well-formed. The "destination differs from source" rule needs the resolved
1180/// source backend, which this pure validator cannot fetch — the webhook resolves it
1181/// and calls [`replication_destination_differs`] separately.
1182pub fn validate_repository_replication(spec: &RepositoryReplicationSpec) -> Vec<ValidationError> {
1183    let mut errs = Vec::new();
1184    if let Err(e) = validate_repository_ref(&spec.source_ref) {
1185        errs.push(e);
1186    }
1187    if let Err(e) = validate_cron(&spec.schedule.cron) {
1188        errs.push(e);
1189    }
1190    if let Err(e) = validate_timezone(spec.schedule.timezone.as_deref()) {
1191        errs.push(e);
1192    }
1193    if let Err(e) = validate_backend(&spec.destination) {
1194        errs.push(e);
1195    }
1196    if let Some(m) = &spec.mover {
1197        if let Err(e) = validate_mover(m, "RepositoryReplication mover") {
1198            errs.push(e);
1199        }
1200        // A replication mover copies blobs repo→repo and never touches a workload's files, so
1201        // `repository_replication.rs` never resolves inheritance — it passes the explicit
1202        // contexts straight to `resolve_mover`. The field was therefore ACCEPTED and silently
1203        // dropped: the manifest claimed the mover ran as the workload, and it did not. Reject
1204        // it instead of ignoring it.
1205        if let Err(e) = super::forbid_inherit(
1206            m,
1207            "RepositoryReplication spec",
1208            "does not apply to a replication mover (there is no workload). Fix: remove it; \
1209             set mover.securityContext if the destination needs a particular UID/GID",
1210        ) {
1211            errs.push(e);
1212        }
1213    }
1214    if let Some(sync) = &spec.sync {
1215        if let Some(p) = sync.parallel
1216            && let Some(e) = require_min(
1217                "RepositoryReplication spec.sync.parallel",
1218                p.into(),
1219                NumericBound::Count,
1220            )
1221        {
1222            errs.push(e);
1223        }
1224        if let Some(s) = sync.max_download_speed_bytes_per_second
1225            && let Some(e) = require_min(
1226                "RepositoryReplication spec.sync.maxDownloadSpeedBytesPerSecond",
1227                s,
1228                NumericBound::RatePerSecond,
1229            )
1230        {
1231            errs.push(e);
1232        }
1233        if let Some(s) = sync.max_upload_speed_bytes_per_second
1234            && let Some(e) = require_min(
1235                "RepositoryReplication spec.sync.maxUploadSpeedBytesPerSecond",
1236                s,
1237                NumericBound::RatePerSecond,
1238            )
1239        {
1240            errs.push(e);
1241        }
1242    }
1243    errs
1244}
1245
1246/// Validate a `SnapshotReplication` spec, accumulating all problems (issue
1247/// #368): both repository refs are well-formed and not literally the same
1248/// reference, the schedule cron/timezone/jitter parse, every identity matcher
1249/// sets at least one component and every set component is a compilable glob,
1250/// `migrate.parallel` is >= 1, a `retention` pruning keeps at least something,
1251/// and (when a mover is set) it's well-formed and does not claim
1252/// `inheritSecurityContextFrom`. The "two different refs resolving to the same
1253/// storage" rule needs both resolved backends, which this pure validator cannot
1254/// fetch — the webhook resolves them and compares [`backend_target_key`]s.
1255pub fn validate_snapshot_replication(spec: &SnapshotReplicationSpec) -> Vec<ValidationError> {
1256    let mut errs = Vec::new();
1257    for r in [&spec.source_ref, &spec.destination_ref] {
1258        if let Err(e) = validate_repository_ref(r) {
1259            errs.push(e);
1260        }
1261    }
1262    // Literal self-target: same kind + name + namespace *field*. Two None
1263    // namespaces both resolve to this CR's own namespace, so they are equal too;
1264    // a None-vs-Some pair MAY still collide (Some(ns) == the CR's namespace) but
1265    // deciding that needs the CR's metadata, which a spec-only validator does
1266    // not have — the webhook's backend_target_key comparison backstops it.
1267    if spec.source_ref.kind == spec.destination_ref.kind
1268        && spec.source_ref.name == spec.destination_ref.name
1269        && spec.source_ref.namespace == spec.destination_ref.namespace
1270    {
1271        errs.push(ValidationError::SnapshotReplicationSelfTarget {
1272            kind: match spec.source_ref.kind {
1273                RepositoryKind::Repository => "Repository".to_string(),
1274                RepositoryKind::ClusterRepository => "ClusterRepository".to_string(),
1275            },
1276            name: spec.source_ref.name.clone(),
1277        });
1278    }
1279    if let Err(e) = validate_cron(&spec.schedule.cron) {
1280        errs.push(e);
1281    }
1282    if let Err(e) = validate_timezone(spec.schedule.timezone.as_deref()) {
1283        errs.push(e);
1284    }
1285    if let Err(e) = validate_jitter(
1286        "SnapshotReplication spec.schedule.jitter",
1287        spec.schedule.jitter.as_deref(),
1288    ) {
1289        errs.push(e);
1290    }
1291    if let Some(m) = &spec.mover {
1292        if let Err(e) = validate_mover(m, "SnapshotReplication mover") {
1293            errs.push(e);
1294        }
1295        // Same accepted-then-ignored hazard as the RepositoryReplication arm
1296        // above: a snapshot-replication mover copies snapshot manifests
1297        // repo-to-repo and never reads a workload's files, so there is no
1298        // workload identity to inherit and the reconciler never resolves the
1299        // field. Reject it instead of ignoring it.
1300        if let Err(e) = super::forbid_inherit(
1301            m,
1302            "SnapshotReplication spec",
1303            "does not apply to a snapshot-replication mover (there is no workload). Fix: \
1304             remove it; set mover.securityContext if a repository needs a particular UID/GID",
1305        ) {
1306            errs.push(e);
1307        }
1308    }
1309    if let Some(ids) = spec.selection.as_ref().and_then(|s| s.identities.as_ref()) {
1310        for (list_name, list) in [("include", &ids.include), ("exclude", &ids.exclude)] {
1311            for (i, m) in list.iter().enumerate() {
1312                let field =
1313                    format!("SnapshotReplication spec.selection.identities.{list_name}[{i}]");
1314                if m.username.is_none() && m.hostname.is_none() && m.source_path.is_none() {
1315                    errs.push(ValidationError::EmptyIdentityMatcher { field });
1316                    continue;
1317                }
1318                for (comp, val) in [
1319                    ("username", &m.username),
1320                    ("hostname", &m.hostname),
1321                    ("sourcePath", &m.source_path),
1322                ] {
1323                    if let Some(pattern) = val
1324                        && let Err(reason) = validate_component_glob(pattern)
1325                    {
1326                        errs.push(ValidationError::InvalidFieldValue {
1327                            field: format!("{field}.{comp}"),
1328                            reason,
1329                        });
1330                    }
1331                }
1332            }
1333        }
1334    }
1335    if let Some(migrate) = spec.migrate.as_ref() {
1336        if let Some(p) = migrate.parallel
1337            && let Some(e) = require_min(
1338                "SnapshotReplication spec.migrate.parallel",
1339                p.into(),
1340                NumericBound::Count,
1341            )
1342        {
1343            errs.push(e);
1344        }
1345        // Per-side migrate caps: the same "a rate is >= 1" rule the throttle
1346        // knobs carry everywhere, applied to each side independently so a
1347        // message names the side that is wrong.
1348        if let Some(throttle) = migrate.throttle.as_ref() {
1349            for (side, block) in [
1350                ("source", throttle.source.as_ref()),
1351                ("destination", throttle.destination.as_ref()),
1352            ] {
1353                if let Some(block) = block {
1354                    errs.extend(validate_throttle(
1355                        &format!("SnapshotReplication spec.migrate.throttle.{side}"),
1356                        block,
1357                    ));
1358                }
1359            }
1360        }
1361    }
1362    if let Some(pruning) = &spec.pruning {
1363        // Exhaustive: a new pruning mode cannot compile without deciding its
1364        // admission rule here.
1365        match pruning {
1366            Pruning::None(_) | Pruning::MirrorSource(_) => {}
1367            Pruning::Retention(r) => {
1368                let keeps_any = [
1369                    r.keep_latest,
1370                    r.keep_hourly,
1371                    r.keep_daily,
1372                    r.keep_weekly,
1373                    r.keep_monthly,
1374                    r.keep_annual,
1375                ]
1376                .iter()
1377                .any(Option::is_some);
1378                if !keeps_any {
1379                    errs.push(ValidationError::RetentionKeepsNothing);
1380                }
1381            }
1382        }
1383    }
1384    errs
1385}
1386
1387/// Whether a replication's `destination` backend differs from its source
1388/// repository's backend (ADR-0005 §13(d)). Replicating a repository to *itself* is a
1389/// no-op (or a loop), so the webhook rejects it. Pure so the decision is unit-tested;
1390/// the webhook resolves the source backend (it has a client) and calls this. A
1391/// "same" destination is detected structurally by [`backend_target_key`]: same
1392/// backend kind AND the same identifying target — which for S3 includes the
1393/// endpoint and region (not just bucket+prefix), for Azure the storage account,
1394/// and for a filesystem the backing volume, so two distinct providers that share
1395/// a bucket/container/path name are NOT mistaken for the same repository (#248).
1396///
1397/// ```
1398/// use kopiur_api::backend::{Backend, FilesystemBackend, S3Backend};
1399/// use kopiur_api::validate::replication_destination_differs;
1400///
1401/// let fs_a = Backend::Filesystem(FilesystemBackend { path: "/a".into(), volume: None });
1402/// let fs_b = Backend::Filesystem(FilesystemBackend { path: "/b".into(), volume: None });
1403/// // Different paths → differ.
1404/// assert!(replication_destination_differs(&fs_a, &fs_b));
1405/// // Same path → same target (would be a self-replication).
1406/// assert!(!replication_destination_differs(&fs_a, &fs_a));
1407/// // Different backend kinds always differ.
1408/// let s3 = Backend::S3(S3Backend { bucket: "b".into(), prefix: None, endpoint: None, region: None, auth: None, tls: None });
1409/// assert!(replication_destination_differs(&fs_a, &s3));
1410/// // Same bucket name at two DIFFERENT S3 endpoints → distinct targets (#248).
1411/// let s3_nas = Backend::S3(S3Backend { bucket: "kopiur".into(), prefix: None, endpoint: Some("nas.example:3000".into()), region: None, auth: None, tls: None });
1412/// 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 });
1413/// assert!(replication_destination_differs(&s3_nas, &s3_e2));
1414/// ```
1415pub fn replication_destination_differs(
1416    source: &crate::backend::Backend,
1417    dest: &crate::backend::Backend,
1418) -> bool {
1419    backend_target_key(source) != backend_target_key(dest)
1420}
1421
1422/// Two DISTINCT filesystem repositories that share the same in-pod
1423/// `backend.path` cannot ride one replication mover pod: the Job mounts each
1424/// repo's volume at its own `path`, and two volumeMounts at one `mountPath`
1425/// make the pod spec invalid — a failure that would otherwise surface only as
1426/// a reconcile-time Job-create error. [`backend_target_key`] deliberately keys
1427/// filesystem targets by (path, volume) so this pair PASSES the self-target
1428/// check (different volumes = genuinely different repos); the mount collision
1429/// is a separate, mover-topology constraint, so it gets its own guard.
1430///
1431/// Returns the shared path when both backends are filesystem-backed and their
1432/// `path`s are equal (regardless of volume); `None` otherwise. Callers deny at
1433/// admission and defensively re-check at Job-spawn time.
1434///
1435/// ```
1436/// use kopiur_api::backend::Backend;
1437/// use kopiur_api::validate::replication_filesystem_mount_collision;
1438/// let fs = |path: &str, pvc: &str| -> Backend {
1439///     serde_json::from_value(serde_json::json!({
1440///         "filesystem": { "path": path, "volume": { "pvc": { "name": pvc } } }
1441///     }))
1442///     .unwrap()
1443/// };
1444/// let a = fs("/repo", "a");
1445/// assert_eq!(
1446///     replication_filesystem_mount_collision(&a, &fs("/repo", "b")),
1447///     Some("/repo".to_string())
1448/// );
1449/// assert_eq!(replication_filesystem_mount_collision(&a, &fs("/repo-dst", "b")), None);
1450/// ```
1451pub fn replication_filesystem_mount_collision(
1452    source: &crate::backend::Backend,
1453    dest: &crate::backend::Backend,
1454) -> Option<String> {
1455    use crate::backend::Backend;
1456    match (source, dest) {
1457        (Backend::Filesystem(s), Backend::Filesystem(d)) if s.path == d.path => {
1458            Some(s.path.clone())
1459        }
1460        _ => None,
1461    }
1462}
1463
1464/// A structural identity key for a backend (kind + identifying target), used by
1465/// [`replication_destination_differs`] to decide whether two backends point at the
1466/// same storage. Exhaustive over [`crate::backend::Backend`] so a new backend cannot
1467/// compile until its key is defined.
1468///
1469/// Each arm must fold in EVERY field that distinguishes the storage *target*
1470/// (never credentials) — dropping one makes two genuinely-distinct destinations
1471/// collide onto the same key, so [`replication_destination_differs`] wrongly
1472/// reports a self-replication and the webhook rejects a valid `RepositoryReplication`
1473/// (issue #248): two different S3 providers sharing the bucket name `kopiur`
1474/// resolved to the same `s3:kopiur/` key when only `bucket`+`prefix` were keyed.
1475/// Fields are labelled (`endpoint=…;region=…`) so distinct tuples can't concatenate
1476/// into an identical string. Auth/TLS are deliberately excluded: the same bucket
1477/// reached with different credentials is still the same storage.
1478fn backend_target_key(backend: &crate::backend::Backend) -> String {
1479    use crate::backend::{Backend, RepoVolume};
1480    let kind = backend.kind_str();
1481    let target = match backend {
1482        Backend::Filesystem(f) => {
1483            // `path` is a mount path INSIDE the mover pod and is commonly the
1484            // same default (`/repo`) across repositories, so the backing volume
1485            // (a distinct PVC or NFS export) is what actually distinguishes two
1486            // filesystem targets.
1487            let vol = match &f.volume {
1488                None => "none".to_string(),
1489                Some(RepoVolume::Pvc(p)) => format!("pvc={}", p.name),
1490                Some(RepoVolume::Nfs(n)) => format!("nfs={}:{}", n.server, n.path),
1491            };
1492            format!("path={};volume={vol}", f.path)
1493        }
1494        Backend::S3(s) => format!(
1495            "endpoint={};region={};bucket={};prefix={}",
1496            s.endpoint.clone().unwrap_or_default(),
1497            s.region.clone().unwrap_or_default(),
1498            s.bucket,
1499            s.prefix.clone().unwrap_or_default(),
1500        ),
1501        Backend::Azure(a) => format!(
1502            "account={};container={};prefix={}",
1503            a.storage_account.clone().unwrap_or_default(),
1504            a.container,
1505            a.prefix.clone().unwrap_or_default(),
1506        ),
1507        // GCS/B2 bucket names are globally unique (no endpoint/account to key),
1508        // so bucket+prefix is the complete target identity.
1509        Backend::Gcs(g) => format!(
1510            "bucket={};prefix={}",
1511            g.bucket,
1512            g.prefix.clone().unwrap_or_default()
1513        ),
1514        Backend::B2(b) => format!(
1515            "bucket={};prefix={}",
1516            b.bucket,
1517            b.prefix.clone().unwrap_or_default()
1518        ),
1519        Backend::Sftp(s) => format!(
1520            "host={};port={};path={}",
1521            s.host,
1522            s.port.map(|p| p.to_string()).unwrap_or_default(),
1523            s.path,
1524        ),
1525        Backend::WebDav(w) => format!("url={}", w.url),
1526        Backend::Rclone(r) => format!("remotePath={}", r.remote_path),
1527        Backend::Gdrive(g) => format!("folderId={}", g.folder_id),
1528    };
1529    format!("{kind}:{target}")
1530}
1531
1532/// Validate a `Maintenance` spec, accumulating all problems (ADR §3.7).
1533pub fn validate_maintenance(spec: &MaintenanceSpec) -> Vec<ValidationError> {
1534    let mut errs = Vec::new();
1535    if let Err(e) = validate_repository_ref(&spec.repository) {
1536        errs.push(e);
1537    }
1538    // `ownership.ownerAliases` become kopia identity components once run
1539    // through `kopia_lease_identity` (M6), so each alias gets the identity
1540    // shape rule — the same validator the resolved hostname/username go
1541    // through. `owner` itself is deliberately NOT tightened here: it predates
1542    // this rule, stored CRs may carry arbitrary strings the lease sanitizer
1543    // already handles, and the controller re-validates defensively on every
1544    // reconcile — a new rejection would hard-stop a working Maintenance.
1545    // Aliases are new with this rule, so no stored object can regress.
1546    for (i, alias) in spec.ownership.owner_aliases.iter().enumerate() {
1547        if let Err(e) = validate_identity_component(&format!("ownership.ownerAliases[{i}]"), alias)
1548        {
1549            errs.push(e);
1550        }
1551    }
1552    if let Err(e) = validate_cron(&spec.schedule.quick.cron) {
1553        errs.push(e);
1554    }
1555    if let Err(e) = validate_cron(&spec.schedule.full.cron) {
1556        errs.push(e);
1557    }
1558    for tz in [
1559        spec.schedule.timezone.as_deref(),
1560        spec.schedule.quick.timezone.as_deref(),
1561        spec.schedule.full.timezone.as_deref(),
1562    ] {
1563        if let Err(e) = validate_timezone(tz) {
1564            errs.push(e);
1565        }
1566    }
1567    if let Some(m) = &spec.mover {
1568        if let Err(e) = forbid_pvc_consumer(
1569            m,
1570            "maintenance",
1571            "Fix: set mover.securityContext explicitly",
1572        ) {
1573            errs.push(e);
1574        }
1575        if let Err(e) = forbid_snapshot_inherit(
1576            m,
1577            "maintenance",
1578            "is restore-only. Fix: set mover.securityContext explicitly",
1579        ) {
1580            errs.push(e);
1581        }
1582        if let Err(e) = validate_mover(m, "Maintenance mover") {
1583            errs.push(e);
1584        }
1585    }
1586    if let Some(fp) = &spec.failure_policy
1587        && let Err(e) = validate_failure_policy(fp, "Maintenance")
1588    {
1589        errs.push(e);
1590    }
1591    errs
1592}
1593
1594/// Validate a `ClusterRepository` spec, accumulating all problems (ADR §3.2).
1595///
1596/// `All(false)` is rejected as meaningless (SKILL: "`false` is rejected by webhook").
1597pub fn validate_cluster_repository(spec: &ClusterRepositorySpec) -> Vec<ValidationError> {
1598    let mut errs = Vec::new();
1599    if let AllowedNamespaces::All(false) = spec.allowed_namespaces {
1600        errs.push(ValidationError::MissingRequiredField {
1601            field: "allowedNamespaces.all must be true to grant access (false is meaningless)"
1602                .to_string(),
1603        });
1604    }
1605    if let Err(e) = validate_backend(&spec.backend) {
1606        errs.push(e);
1607    }
1608    if let Some(m) = &spec.maintenance {
1609        errs.extend(validate_repository_maintenance(m, true));
1610    }
1611    // Identity CEL expressions must compile + trial-evaluate to a string at admission
1612    // (ADR-0004 §5), so a typo / out-of-scope variable is rejected on `kubectl apply`.
1613    if let Some(id) = &spec.identity_defaults {
1614        if let Some(expr) = &id.hostname_expr
1615            && let Err(e) = crate::identity::validate_identity_expr(expr)
1616        {
1617            errs.push(e);
1618        }
1619        if let Some(expr) = &id.username_expr
1620            && let Err(e) = crate::identity::validate_identity_expr(expr)
1621        {
1622            errs.push(e);
1623        }
1624        // `cluster` becomes part of the default hostname (`<namespace>.<cluster>`)
1625        // and `classify_hostname` splits on the first `.`, so it must be a clean
1626        // RFC 1123 label with no dot of its own (M1).
1627        if let Some(cluster) = &id.cluster
1628            && let Err(e) = validate_cluster_name(cluster)
1629        {
1630            errs.push(e);
1631        }
1632    }
1633    if let Some(c) = &spec.catalog {
1634        errs.extend(validate_catalog_bounds(c, true));
1635    }
1636    errs.extend(validate_foreign_snapshots_cluster_coupling(
1637        spec.catalog.as_ref(),
1638        spec.identity_defaults
1639            .as_ref()
1640            .and_then(|id| id.cluster.as_deref()),
1641    ));
1642    if let Some(md) = &spec.mover_defaults
1643        && let Some(res) = &md.resources
1644        && let Err(e) = validate_resources(res, "ClusterRepository moverDefaults")
1645    {
1646        errs.push(e);
1647    }
1648    if let Some(server) = &spec.server {
1649        if server.namespace.trim().is_empty() {
1650            errs.push(ValidationError::ServerNamespaceRequired);
1651        }
1652        errs.extend(validate_server(&server.server, spec.mode));
1653    }
1654    errs.extend(validate_repository_parameters(
1655        spec.parameters.as_ref(),
1656        spec.mode,
1657        &spec.backend,
1658        "ClusterRepository",
1659    ));
1660    if let Err(e) = validate_repository_health(spec.health.as_ref(), "ClusterRepository") {
1661        errs.push(e);
1662    }
1663    errs.extend(validate_repository_concurrency(
1664        spec.concurrency.as_ref(),
1665        "ClusterRepository",
1666    ));
1667    if let Some(b) = &spec.bootstrap
1668        && let Some(fp) = &b.failure_policy
1669        && let Err(e) = validate_failure_policy(fp, "ClusterRepository spec.bootstrap")
1670    {
1671        errs.push(e);
1672    }
1673    if let Err(e) = validate_timezone(
1674        spec.schedule_defaults
1675            .as_ref()
1676            .and_then(|d| d.timezone.as_deref()),
1677    ) {
1678        errs.push(e);
1679    }
1680    // Same new-field reasoning as `validate_repository` above: parse + 24h bound on
1681    // `scheduleDefaults.jitter`, and the reserved-key rule on the new
1682    // `moverDefaults.podLabels`/`podAnnotations`. This aggregate IS re-run whole at
1683    // reconcile, which is exactly why only new-field rules may land in it.
1684    if let Some(j) = spec
1685        .schedule_defaults
1686        .as_ref()
1687        .and_then(|d| d.jitter.as_deref())
1688        && let Err(e) = validate_jitter_bounds("spec.scheduleDefaults.jitter", j)
1689    {
1690        errs.push(e);
1691    }
1692    if let Some(md) = &spec.mover_defaults {
1693        errs.extend(validate_pod_metadata(md));
1694    }
1695    // #380: same seed rules, cluster arm — a ClusterRepository's seed-source
1696    // Secret must not pin a namespace (its movers resolve credentials in the
1697    // operator's own namespace, which a cluster-scoped spec cannot name).
1698    errs.extend(validate_repository_seed(
1699        spec.seed.as_ref(),
1700        &spec.backend,
1701        spec.mode,
1702        spec.create.as_ref(),
1703        RepositoryKind::ClusterRepository,
1704    ));
1705    errs
1706}
1707
1708// --- spec.seed (#380) -------------------------------------------------------
1709
1710/// Validate `spec.seed` on a `Repository`/`ClusterRepository`, accumulating
1711/// every independent problem.
1712///
1713/// Everything here is derivable from the spec alone, so both aggregate
1714/// validators call it and the controller gets it for free on its defensive
1715/// re-validation. Two seed rules are NOT derivable and live beside it as
1716/// separately-callable helpers the webhook invokes with the request's context:
1717/// [`validate_seed_secret_namespace`] (the namespaced arm of the co-resident
1718/// Secret rule) and [`validate_seed_not_self`] (migrate-mode self-reference).
1719///
1720/// The rules, and why each exists:
1721///
1722/// * A seed runs in a mover Job, so neither the repository nor a filesystem
1723///   seed source may be a **bare path** — nothing would be mounted at it.
1724/// * `mode: ReadOnly` and seeding are contradictory.
1725/// * A mode-specific tuning block (`sync`/`migrate`/`credentialProjection`)
1726///   paired with the other mode's source would be inert.
1727/// * Blob mode inherits the mirror's repository format, so explicit
1728///   `create.{splitter,hash,encryption,ecc}` would be inert too.
1729/// * Blob mode must not read its own storage, must not collide on an in-pod
1730///   mount path, and must not mix workload identity with static keys in the one
1731///   seeding pod (the same three rules a `RepositoryReplication` gets, for the
1732///   same reason: one pod, two backends).
1733/// * A `ClusterRepository`'s seed-source Secret must not pin a namespace — a
1734///   cluster-scoped repository resolves credentials in the operator namespace,
1735///   which the spec cannot name.
1736pub fn validate_repository_seed(
1737    seed: Option<&SeedSpec>,
1738    repo_backend: &Backend,
1739    mode: RepositoryMode,
1740    create: Option<&CreateBehavior>,
1741    kind: RepositoryKind,
1742) -> Vec<ValidationError> {
1743    let Some(seed) = seed else {
1744        return Vec::new();
1745    };
1746    let mut errs = Vec::new();
1747    if let Backend::Filesystem(fs) = repo_backend
1748        && fs.volume.is_none()
1749    {
1750        errs.push(ValidationError::SeedRequiresMountableRepository {
1751            path: fs.path.clone(),
1752        });
1753    }
1754    if !mode.allows_writes() {
1755        errs.push(ValidationError::SeedOnReadOnlyRepository);
1756    }
1757    errs.extend(seed_tuning_pairing(seed));
1758    if let Some(fp) = &seed.failure_policy
1759        && let Err(e) = validate_failure_policy(fp, &format!("{} spec.seed", kind.kind_str()))
1760    {
1761        errs.push(e);
1762    }
1763    // Exhaustive over `SeedSource`: a new seed source cannot compile until its
1764    // per-mode rules are decided here.
1765    match &seed.from {
1766        SeedSource::Backend(b) => {
1767            errs.extend(validate_seed_blob_source(b, repo_backend, create, kind));
1768            if let Some(sync) = &seed.sync {
1769                errs.extend(validate_seed_sync_options(sync));
1770            }
1771        }
1772        SeedSource::Repository(r) => {
1773            if let Err(e) = validate_repository_ref(r) {
1774                errs.push(e);
1775            }
1776            if let Some(p) = seed.migrate.as_ref().and_then(|m| m.parallel)
1777                && let Some(e) =
1778                    require_min("spec.seed.migrate.parallel", p.into(), NumericBound::Count)
1779            {
1780                errs.push(e);
1781            }
1782            // Per-side seed caps, the same "a rate is >= 1" rule the throttle
1783            // knobs carry everywhere. Validated per side so the message names
1784            // the side that is wrong: `source` is the REPLICA being read,
1785            // `destination` this repository — and mixing them up is the likely
1786            // authoring mistake.
1787            if let Some(throttle) = seed.migrate.as_ref().and_then(|m| m.throttle.as_ref()) {
1788                for (side, block) in [
1789                    ("source", throttle.source.as_ref()),
1790                    ("destination", throttle.destination.as_ref()),
1791                ] {
1792                    if let Some(block) = block {
1793                        errs.extend(validate_throttle(
1794                            &format!("spec.seed.migrate.throttle.{side}"),
1795                            block,
1796                        ));
1797                    }
1798                }
1799            }
1800        }
1801    }
1802    errs
1803}
1804
1805/// A mode-specific `spec.seed` block must match the `from` variant it tunes, or
1806/// it is an inert field. `credentialProjection` is refused only when actually
1807/// enabled: an explicit `enabled: false` requests nothing and stays legal so a
1808/// GitOps template can emit the block unconditionally.
1809fn seed_tuning_pairing(seed: &SeedSpec) -> Vec<ValidationError> {
1810    // The `from` key this seed actually sets.
1811    let actual = match &seed.from {
1812        SeedSource::Backend(_) => SEED_FROM_BACKEND,
1813        SeedSource::Repository(_) => SEED_FROM_REPOSITORY,
1814    };
1815    // (tuning field, the `from` key it belongs to, whether it is present).
1816    // `expected` is carried in the row rather than re-derived from `field`, so a
1817    // fourth tuning block cannot silently inherit the wrong expectation.
1818    let rows: [(&str, &str, bool); 3] = [
1819        ("sync", SEED_FROM_BACKEND, seed.sync.is_some()),
1820        ("migrate", SEED_FROM_REPOSITORY, seed.migrate.is_some()),
1821        (
1822            "credentialProjection",
1823            SEED_FROM_REPOSITORY,
1824            seed.credential_projection
1825                .as_ref()
1826                .is_some_and(|p| p.enabled),
1827        ),
1828    ];
1829    rows.into_iter()
1830        .filter(|(_, expected, present)| *present && *expected != actual)
1831        .map(
1832            |(field, expected, _)| ValidationError::SeedTuningNotApplicable {
1833                field: field.to_string(),
1834                expected_source: expected.to_string(),
1835                actual_source: actual.to_string(),
1836            },
1837        )
1838        .collect()
1839}
1840
1841/// The two `spec.seed.from` wire keys, named once so the pairing table and its
1842/// messages cannot drift from the externally-tagged [`SeedSource`] variants.
1843const SEED_FROM_BACKEND: &str = "backend";
1844/// See [`SEED_FROM_BACKEND`].
1845const SEED_FROM_REPOSITORY: &str = "repository";
1846
1847/// Blob-mode (`seed.from.backend`) rules: the source backend is well-formed and
1848/// mountable, its credential Secret is reachable for a cluster-scoped
1849/// repository, it is not this repository's own storage, it does not collide on
1850/// an in-pod mount path, its auth pairs safely with the local backend in one
1851/// pod, its `sync` knobs are positive, and `spec.create`'s format algorithms are
1852/// not silently discarded.
1853fn validate_seed_blob_source(
1854    source: &Backend,
1855    repo_backend: &Backend,
1856    create: Option<&CreateBehavior>,
1857    kind: RepositoryKind,
1858) -> Vec<ValidationError> {
1859    let mut errs = Vec::new();
1860    if let Err(e) = validate_backend(source) {
1861        errs.push(e);
1862    }
1863    if let Backend::Filesystem(fs) = source
1864        && fs.volume.is_none()
1865    {
1866        errs.push(ValidationError::SeedSourceRequiresMountableBackend {
1867            path: fs.path.clone(),
1868        });
1869    }
1870    // A ClusterRepository's movers resolve credentials in the operator's own
1871    // namespace, which a cluster-scoped spec cannot name — so the seed Secret
1872    // must not pin one. The namespaced arm needs the CR's namespace and lives in
1873    // `validate_seed_secret_namespace`.
1874    if kind == RepositoryKind::ClusterRepository
1875        && let Some(secret) = crate::creds::backend_auth_secret_ref(source)
1876        && let Some(ns) = secret.namespace.as_deref()
1877    {
1878        errs.push(ValidationError::SeedSourceSecretNamespaceForbidden {
1879            secret: secret.name.clone(),
1880            namespace: ns.to_string(),
1881        });
1882    }
1883    if !replication_destination_differs(repo_backend, source) {
1884        errs.push(ValidationError::SeedSourceSameAsRepository {
1885            backend: source.kind_str().to_string(),
1886        });
1887    }
1888    if let Some(path) = replication_filesystem_mount_collision(repo_backend, source) {
1889        errs.push(ValidationError::SeedMountPathCollision { path });
1890    }
1891    // One pod carries both credential sets, so the replication VERDICT applies
1892    // verbatim: a same-kind static/workloadIdentity mix would let the ambient
1893    // credential chain pick up the other side's keys. `AuthPairKind::Seed` only
1894    // changes what the rejection says — `spec.seed.from.backend` and the seeding
1895    // Job, not "destination" and a replication mover that never runs (#380).
1896    if let Err(e) = validate_replication_auth(repo_backend, source, AuthPairKind::Seed) {
1897        errs.push(e);
1898    }
1899    let inert = inert_create_fields(create);
1900    if !inert.is_empty() {
1901        errs.push(ValidationError::SeedCreateOptionsInert { fields: inert });
1902    }
1903    errs
1904}
1905
1906/// The `spec.create.*` format algorithms a blob-mode seed would discard (the
1907/// copy inherits the mirror's repository-format blob). `create.enabled` is NOT
1908/// in the set: a seed-armed bootstrap never falls back to `create`, so the flag
1909/// keeps its ordinary meaning for every later connect.
1910fn inert_create_fields(create: Option<&CreateBehavior>) -> Vec<String> {
1911    let Some(c) = create else {
1912        return Vec::new();
1913    };
1914    [
1915        ("create.splitter", c.splitter.is_some()),
1916        ("create.hash", c.hash.is_some()),
1917        ("create.encryption", c.encryption.is_some()),
1918        ("create.ecc", c.ecc.is_some()),
1919    ]
1920    .into_iter()
1921    .filter(|(_, set)| *set)
1922    .map(|(field, _)| field.to_string())
1923    .collect()
1924}
1925
1926/// Blob-mode `sync` knobs are positive counts/rates. Split out of
1927/// [`validate_repository_seed`] because it is called for the blob arm only.
1928fn validate_seed_sync_options(sync: &SeedSyncOptions) -> Vec<ValidationError> {
1929    let mut errs = Vec::new();
1930    if let Some(p) = sync.parallel
1931        && let Some(e) = require_min("spec.seed.sync.parallel", p.into(), NumericBound::Count)
1932    {
1933        errs.push(e);
1934    }
1935    if let Some(s) = sync.max_download_speed_bytes_per_second
1936        && let Some(e) = require_min(
1937            "spec.seed.sync.maxDownloadSpeedBytesPerSecond",
1938            s,
1939            NumericBound::RatePerSecond,
1940        )
1941    {
1942        errs.push(e);
1943    }
1944    if let Some(s) = sync.max_upload_speed_bytes_per_second
1945        && let Some(e) = require_min(
1946            "spec.seed.sync.maxUploadSpeedBytesPerSecond",
1947            s,
1948            NumericBound::RatePerSecond,
1949        )
1950    {
1951        errs.push(e);
1952    }
1953    errs
1954}
1955
1956/// The namespaced arm of the co-resident seed-Secret rule: a `Repository`'s
1957/// blob-mode seed source Secret must be unset or name the repository's OWN
1958/// namespace, because the seeding Job runs there and `envFrom` is
1959/// namespace-local. Needs the CR's namespace, which the spec does not carry, so
1960/// the webhook calls it with `req.namespace` (the cluster-scoped arm — "must be
1961/// unset" — is fully spec-derivable and lives in
1962/// [`validate_repository_seed`]).
1963///
1964/// ```
1965/// use kopiur_api::seed::{SeedSpec, SeedSource};
1966/// use kopiur_api::validate::validate_seed_secret_namespace;
1967///
1968/// let seed_with = |ns: serde_json::Value| -> SeedSpec {
1969///     let from: SeedSource = serde_json::from_value(serde_json::json!({
1970///         "backend": { "s3": { "bucket": "mirror", "auth": { "secretRef": ns } } }
1971///     }))
1972///     .unwrap();
1973///     SeedSpec { from, sync: None, migrate: None, allow_empty_source: false,
1974///                failure_policy: None, credential_projection: None }
1975/// };
1976/// // Unset namespace = "the repository's own" → fine.
1977/// let ok = seed_with(serde_json::json!({ "name": "mirror-creds" }));
1978/// assert!(validate_seed_secret_namespace(Some(&ok), "backups").is_ok());
1979/// // Same namespace spelled out → also fine.
1980/// let same = seed_with(serde_json::json!({ "name": "mirror-creds", "namespace": "backups" }));
1981/// assert!(validate_seed_secret_namespace(Some(&same), "backups").is_ok());
1982/// // Another namespace → the Job could never read it.
1983/// let other = seed_with(serde_json::json!({ "name": "mirror-creds", "namespace": "elsewhere" }));
1984/// assert!(validate_seed_secret_namespace(Some(&other), "backups").is_err());
1985/// ```
1986pub fn validate_seed_secret_namespace(
1987    seed: Option<&SeedSpec>,
1988    repository_namespace: &str,
1989) -> ValidationResult {
1990    let Some(source) = seed.and_then(crate::seed::seed_backend) else {
1991        return Ok(());
1992    };
1993    let Some(secret) = crate::creds::backend_auth_secret_ref(source) else {
1994        return Ok(());
1995    };
1996    match secret.namespace.as_deref() {
1997        Some(ns) if ns != repository_namespace => {
1998            Err(ValidationError::SeedSourceSecretNamespaceMismatch {
1999                secret: secret.name.clone(),
2000                namespace: ns.to_string(),
2001                repository_namespace: repository_namespace.to_string(),
2002            })
2003        }
2004        _ => Ok(()),
2005    }
2006}
2007
2008/// A migrate-mode seed must not point at the repository being defined. Uses the
2009/// shared [`crate::common::repo_key`] normalization, so a `Repository` naming
2010/// its own namespace explicitly and one omitting it are both caught. Needs the
2011/// CR's own identity, which the spec does not carry.
2012///
2013/// ```
2014/// use kopiur_api::common::RepositoryKind;
2015/// use kopiur_api::seed::{SeedSpec, SeedSource};
2016/// use kopiur_api::validate::validate_seed_not_self;
2017///
2018/// let seed = |from: serde_json::Value| -> SeedSpec {
2019///     SeedSpec { from: serde_json::from_value(from).unwrap(), sync: None, migrate: None,
2020///                allow_empty_source: false, failure_policy: None, credential_projection: None }
2021/// };
2022/// let itself = seed(serde_json::json!({ "repository": { "name": "nas" } }));
2023/// assert!(
2024///     validate_seed_not_self(Some(&itself), RepositoryKind::Repository, "nas", "backups")
2025///         .is_err()
2026/// );
2027/// let other = seed(serde_json::json!({ "repository": { "name": "offsite" } }));
2028/// assert!(
2029///     validate_seed_not_self(Some(&other), RepositoryKind::Repository, "nas", "backups").is_ok()
2030/// );
2031/// ```
2032pub fn validate_seed_not_self(
2033    seed: Option<&SeedSpec>,
2034    self_kind: RepositoryKind,
2035    self_name: &str,
2036    self_namespace: &str,
2037) -> ValidationResult {
2038    let Some(source) = seed.and_then(crate::seed::seed_repository_ref) else {
2039        return Ok(());
2040    };
2041    let own = RepositoryRef {
2042        kind: self_kind,
2043        name: self_name.to_string(),
2044        namespace: None,
2045    };
2046    if crate::common::repo_key(source, self_namespace)
2047        == crate::common::repo_key(&own, self_namespace)
2048    {
2049        return Err(ValidationError::SeedSourceSelfReference {
2050            kind: source.kind.kind_str().to_string(),
2051            name: source.name.clone(),
2052        });
2053    }
2054    Ok(())
2055}