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::{MoverDefaults, RepositoryKind, RepositoryRef, Retention};
5use crate::error::{ValidationError, ValidationResult};
6use crate::maintenance::{MaintenanceSpec, RepositoryMaintenanceSpec};
7use crate::repository::{RepositoryHealthSpec, RepositorySpec};
8use crate::repository_replication::RepositoryReplicationSpec;
9use std::collections::BTreeMap;
10
11/// A `RepositoryRef` is well-formed: a `ClusterRepository` reference is by name
12/// only, so `namespace` MUST be absent (ADR §3.2/§3.3). A namespaced `Repository`
13/// reference may carry a namespace (cross-namespace references are allowed).
14///
15/// ```
16/// use kopiur_api::common::RepositoryRef;
17/// use kopiur_api::validate::validate_repository_ref;
18/// use kopiur_api::ValidationError;
19///
20/// // OK: a namespaced Repository reference may name a namespace.
21/// let ok: RepositoryRef = serde_json::from_value(serde_json::json!({
22///     "kind": "Repository", "name": "nas-primary", "namespace": "backups",
23/// }))
24/// .unwrap();
25/// assert!(validate_repository_ref(&ok).is_ok());
26///
27/// // Err: a ClusterRepository is referenced by name alone — a namespace is forbidden.
28/// let bad: RepositoryRef = serde_json::from_value(serde_json::json!({
29///     "kind": "ClusterRepository", "name": "shared", "namespace": "oops",
30/// }))
31/// .unwrap();
32/// assert_eq!(
33///     validate_repository_ref(&bad).unwrap_err(),
34///     ValidationError::ClusterRepoNamespaceForbidden { namespace: "oops".to_string() },
35/// );
36/// ```
37pub fn validate_repository_ref(r: &RepositoryRef) -> ValidationResult {
38    match r.kind {
39        RepositoryKind::ClusterRepository => match &r.namespace {
40            Some(ns) => Err(ValidationError::ClusterRepoNamespaceForbidden {
41                namespace: ns.clone(),
42            }),
43            None => Ok(()),
44        },
45        RepositoryKind::Repository => Ok(()),
46    }
47}
48
49/// A consumer namespace is permitted by a `ClusterRepository`'s tenancy gate
50/// (ADR §3.2/§4.3).
51///
52/// - `List`     → membership test.
53/// - `All(true)`→ always allowed; `All(false)` is meaningless and denies.
54/// - `Selector` → matched against `labels` (the consumer namespace's labels). The
55///   `crates/api` crate cannot fetch a `Namespace` object, so the caller (webhook)
56///   must supply the labels. **If `labels` is `None` we fail closed** with
57///   [`ValidationError::SelectorLabelsUnavailable`] rather than guess — the webhook
58///   never trusts unfiltered input (ADR §3.2). Selector matching here is a simple
59///   `matchLabels` superset test (the common case); `matchExpressions` is treated
60///   as "no constraint" for now and documented as such.
61pub fn validate_consumer_against_cluster_repo(
62    consumer_namespace: &str,
63    repo_name: &str,
64    allowed: &AllowedNamespaces,
65    labels: Option<&BTreeMap<String, String>>,
66) -> ValidationResult {
67    match allowed {
68        AllowedNamespaces::All(true) => Ok(()),
69        AllowedNamespaces::All(false) => Err(ValidationError::ConsumerNamespaceNotAllowed {
70            namespace: consumer_namespace.to_string(),
71            repo: repo_name.to_string(),
72        }),
73        AllowedNamespaces::List(names) => {
74            if names.iter().any(|n| n == consumer_namespace) {
75                Ok(())
76            } else {
77                Err(ValidationError::ConsumerNamespaceNotAllowed {
78                    namespace: consumer_namespace.to_string(),
79                    repo: repo_name.to_string(),
80                })
81            }
82        }
83        AllowedNamespaces::Selector(sel) => {
84            let Some(labels) = labels else {
85                return Err(ValidationError::SelectorLabelsUnavailable {
86                    namespace: consumer_namespace.to_string(),
87                    repo: repo_name.to_string(),
88                });
89            };
90            let match_labels = sel.match_labels.clone().unwrap_or_default();
91            // Every required label must be present with the required value.
92            let matches = match_labels
93                .iter()
94                .all(|(k, v)| labels.get(k).map(|got| got == v).unwrap_or(false));
95            if matches {
96                Ok(())
97            } else {
98                Err(ValidationError::ConsumerNamespaceNotAllowed {
99                    namespace: consumer_namespace.to_string(),
100                    repo: repo_name.to_string(),
101                })
102            }
103        }
104    }
105}
106
107/// A `Snapshot`'s `deletionPolicy` is legal for its origin (ADR §4.5).
108///
109/// `origin: discovered` forces `Retain`: `None` (defaults to `Retain`) and an
110/// explicit `Retain` pass; `Delete`/`Orphan` are rejected. `discovered`'s
111/// underlying kopia snapshot was never created by the operator, so it must
112/// never be the thing that deletes it. `adopted` is the one exception: an
113/// adopted row was deliberately re-attached to a `SnapshotPolicy` precisely so
114/// GFS retention (and any `deletionPolicy`) governs it like a produced backup —
115/// any policy is allowed. `scheduled`/`manual` are unchanged (any policy).
116pub fn validate_backup_deletion_policy(
117    origin: crate::snapshot::Origin,
118    policy: Option<crate::common::DeletionPolicy>,
119) -> ValidationResult {
120    use crate::common::DeletionPolicy;
121    use crate::snapshot::Origin;
122    match origin {
123        Origin::Discovered => match policy {
124            None | Some(DeletionPolicy::Retain) => Ok(()),
125            Some(other) => Err(ValidationError::DiscoveredMustRetain {
126                got: format!("{other:?}"),
127            }),
128        },
129        Origin::Adopted | Origin::Scheduled | Origin::Manual => Ok(()),
130    }
131}
132
133/// `spec.parameters` is well-formed and applicable (#258). Shared by both repository
134/// kinds via `context`, exactly like [`validate_repository_health`].
135///
136/// Two classes of rule:
137///
138/// - **Grammar.** Every duration must parse, and every count must be positive. The
139///   grammar check matters more here than elsewhere: these are the first CRD durations
140///   that reach a kopia CLI, and this module's contract is that a value the webhook
141///   admits never fails at reconcile time.
142/// - **Applicability.** A `mode: ReadOnly` repository can never apply them — kopia
143///   hard-errors `set-parameters` on a read-only connection — so declaring them there is
144///   a configuration mistake. Reject it rather than silently ignore the block, matching
145///   how `volumeSnapshotClassName` + an NFS source is handled.
146pub fn validate_repository_parameters(
147    parameters: Option<&crate::repository::RepositoryParameters>,
148    mode: crate::common::RepositoryMode,
149    context: &str,
150) -> Vec<ValidationError> {
151    let mut errs = Vec::new();
152    let Some(epoch) = parameters.and_then(|p| p.epoch.as_ref()) else {
153        return errs;
154    };
155    if !mode.allows_writes() {
156        errs.push(ValidationError::InvalidFieldValue {
157            field: format!("{context} spec.parameters.epoch"),
158            reason: "a ReadOnly repository cannot apply repository parameters: \
159                     `kopia repository set-parameters` rewrites the repository-global format \
160                     blob and fails outright on a read-only connection. Remove \
161                     spec.parameters, or set mode: ReadWrite on the cluster that owns this \
162                     repository (in a multi-cluster layout, declare the parameters there — \
163                     they are a property of the repository, not of each consumer)"
164                .to_string(),
165        });
166    }
167    let mut duration = |field: &str, raw: &Option<String>| {
168        let Some(raw) = raw.as_deref() else { return };
169        let field = format!("{context} spec.parameters.epoch.{field}");
170        match crate::duration::parse_go_duration(raw) {
171            None => errs.push(ValidationError::InvalidFieldValue {
172                field,
173                reason: format!(
174                    "{raw:?} is not a valid duration. Use a Go-style duration with a single \
175                     unit, like 6h, 90m, or 30s; omit the field to leave kopia's current \
176                     value untouched"
177                ),
178            }),
179            // kopia stores these as a Go `time.Duration` — an i64 NANOSECOND count, so it
180            // tops out near 292 years, and `parse_go_duration` happily accepts far more
181            // than that (`"999999999999999999"` is a valid bare-seconds value). Bound it
182            // here rather than let the drift comparator's `as i64` wrap it to a negative
183            // number, and to keep this module's contract: a value the webhook admits must
184            // never fail at reconcile time.
185            Some(d) if i64::try_from(d.as_nanos()).is_err() => {
186                errs.push(ValidationError::InvalidFieldValue {
187                    field,
188                    reason: format!(
189                        "{raw:?} is too large: kopia stores epoch durations as a 64-bit \
190                         nanosecond count, so the maximum is roughly 292 years. Use a \
191                         realistic epoch duration (hours, e.g. 6h)"
192                    ),
193                });
194            }
195            Some(_) => {}
196        }
197    };
198    duration("minDuration", &epoch.min_duration);
199    duration("refreshFrequency", &epoch.refresh_frequency);
200
201    let mut positive = |field: &str, v: Option<i64>| {
202        if let Some(v) = v
203            && v <= 0
204        {
205            errs.push(ValidationError::InvalidFieldValue {
206                field: format!("{context} spec.parameters.epoch.{field}"),
207                reason: format!(
208                    "must be > 0 (got {v}); omit the field to leave kopia's current value \
209                     untouched"
210                ),
211            });
212        }
213    };
214    positive("advanceOnCount", epoch.advance_on_count);
215    positive("advanceOnSizeMiB", epoch.advance_on_size_mb);
216    positive("checkpointFrequency", epoch.checkpoint_frequency);
217    positive("deleteParallelism", epoch.delete_parallelism);
218    errs
219}
220
221/// `spec.health` rules shared by `Repository` and `ClusterRepository`
222/// (ADR-0005 §13). The index-blob warning threshold must be non-negative: a
223/// negative count is nonsensical, and `0` is the documented sentinel that
224/// disables the warning (so it is allowed). `context` names the kind for the
225/// message ("Repository" / "ClusterRepository").
226pub fn validate_repository_health(
227    health: Option<&RepositoryHealthSpec>,
228    context: &str,
229) -> ValidationResult {
230    if let Some(h) = health
231        && let Some(t) = h.index_blob_warn_threshold
232        && t < 0
233    {
234        return Err(ValidationError::InvalidFieldValue {
235            field: format!("{context} health.indexBlobWarnThreshold"),
236            reason: format!(
237                "must be >= 0 (got {t}); 0 disables the index-blob warning, a positive \
238                 value is the count above which a Warning is raised"
239            ),
240        });
241    }
242    if let Some(probe) = health.and_then(|h| h.probe.as_ref()) {
243        if let Some(raw) = probe.interval.as_deref() {
244            match crate::duration::parse_go_duration(raw) {
245                None => {
246                    return Err(ValidationError::InvalidFieldValue {
247                        field: format!("{context} health.probe.interval"),
248                        reason: format!(
249                            "{raw:?} is not a valid duration. Use a Go-style duration like 30s, \
250                             5m, or 1h; omit the field for the default (30m)"
251                        ),
252                    });
253                }
254                Some(d) if d < crate::consts::MIN_HEALTH_PROBE_INTERVAL => {
255                    return Err(ValidationError::InvalidFieldValue {
256                        field: format!("{context} health.probe.interval"),
257                        reason: format!(
258                            "{raw:?} is shorter than the 30s minimum. Each probe runs a mover \
259                             Job; use 30s or more (default 30m)"
260                        ),
261                    });
262                }
263                Some(_) => {}
264            }
265        }
266        if let Some(t) = probe.failure_threshold
267            && t < 1
268        {
269            return Err(ValidationError::InvalidFieldValue {
270                field: format!("{context} health.probe.failureThreshold"),
271                reason: format!(
272                    "must be >= 1 (got {t}); it is the number of consecutive failing probes \
273                     required before the warning is raised"
274                ),
275            });
276        }
277    }
278    Ok(())
279}
280
281/// Whether a [`Retention`] selects **no** snapshots — every bucket unset or `0`. The
282/// controller only prunes when `spec.retention` is `Some` ([`crate::retention::select_kept`]
283/// over the buckets), so a `Some(keeps-nothing)` retention prunes *every* `Snapshot`
284/// immediately: silent data loss. (`retention: None` is the safe "don't prune" case and is
285/// NOT flagged.)
286pub(crate) fn retention_keeps_nothing(r: &Retention) -> bool {
287    [
288        r.keep_latest,
289        r.keep_hourly,
290        r.keep_daily,
291        r.keep_weekly,
292        r.keep_monthly,
293        r.keep_annual,
294    ]
295    .into_iter()
296    .all(|bucket| bucket.unwrap_or(0) == 0)
297}
298
299/// A `Repository` spec does not carry kopia-side (repo-level) retention policy,
300/// which would conflict with CR-driven GFS retention (ADR §4.4 exclusivity).
301///
302/// The current [`RepositorySpec`] deliberately models no inline retention field, so
303/// this **always passes today**. It exists as the enforcement hook so that if a
304/// future field (e.g. `spec.policy.keepDaily`) is ever added, wiring it here is the
305/// one obvious place — and the rule is already named and tested. Be pragmatic: we
306/// do not invent a field to reject.
307pub fn validate_repository_no_inline_retention(_spec: &RepositorySpec) -> ValidationResult {
308    // No inline-retention field exists on RepositorySpec. If one is added later,
309    // return Err(ValidationError::InlineRetentionForbidden { field: "<name>" }) here.
310    Ok(())
311}
312
313/// Validate a `spec.maintenance` block on a `Repository`/`ClusterRepository`,
314/// accumulating problems (ADR §3.7):
315/// - any override schedule's quick/full crons must parse (same parser as runtime);
316/// - `namespace` is **cluster-scope only** — it selects where the namespaced
317///   managed `Maintenance` lands for a `ClusterRepository`, and is forbidden on a
318///   namespaced `Repository` (whose `Maintenance` always lives in its own ns).
319///
320/// `cluster_scoped` is the only thing that differs between the two repository
321/// kinds, so one validator serves both call sites.
322pub fn validate_repository_maintenance(
323    maintenance: &RepositoryMaintenanceSpec,
324    cluster_scoped: bool,
325) -> Vec<ValidationError> {
326    let mut errs = Vec::new();
327    if let Some(schedule) = &maintenance.schedule {
328        if let Err(e) = validate_cron(&schedule.quick.cron) {
329            errs.push(e);
330        }
331        if let Err(e) = validate_cron(&schedule.full.cron) {
332            errs.push(e);
333        }
334        for tz in [
335            schedule.timezone.as_deref(),
336            schedule.quick.timezone.as_deref(),
337            schedule.full.timezone.as_deref(),
338        ] {
339            if let Err(e) = validate_timezone(tz) {
340                errs.push(e);
341            }
342        }
343    }
344    if !cluster_scoped && let Some(ns) = &maintenance.namespace {
345        errs.push(ValidationError::MaintenanceNamespaceOnNamespacedRepo {
346            namespace: ns.clone(),
347        });
348    }
349    errs
350}
351
352/// Accumulate every create-time-immutable field that changed between `old` and
353/// `new` repository specs (ADR-0005 §7). Shared by both repository kinds via the
354/// thin [`validate_repository_immutability`] / [`validate_cluster_repository_immutability`]
355/// wrappers, which pass the `encryption` password ref + the `create.{splitter,hash,
356/// encryption}` algorithms — the fields kopia bakes into the repository format.
357///
358/// Pure: the webhook supplies `old`/`new` from the admission request's old/new
359/// objects; CREATE has no old object, so this is only wired into the UPDATE path.
360fn diff_immutable_repo_fields(
361    old_create: Option<&crate::common::CreateBehavior>,
362    new_create: Option<&crate::common::CreateBehavior>,
363) -> Vec<ValidationError> {
364    let mut errs = Vec::new();
365    // NOTE: `encryption` (the password Secret *reference*) is deliberately NOT immutable.
366    // kopia bakes only the resolved password *value* and the `create.*` algorithms into
367    // the repository format — never the Secret name/namespace/key. Locking the reference
368    // was both over-strict (a Secret rename with identical content was rejected, breaking
369    // GitOps) and under-strict (editing a Secret's content in place — the actual password
370    // change kopia would reject — sailed through). kopia also supports `change-password`,
371    // so the password is operationally mutable; a genuinely wrong ref surfaces at connect
372    // time, not at admission. We only enforce the create-time algorithms below.
373    // The create-time kopia algorithms. Compared field-wise so the message names the
374    // exact field. `create` itself may be absent on either side (absent ⇒ None algos).
375    let old_splitter = old_create.and_then(|c| c.splitter.as_deref());
376    let new_splitter = new_create.and_then(|c| c.splitter.as_deref());
377    if old_splitter != new_splitter {
378        errs.push(ValidationError::Immutable {
379            field: "create.splitter".to_string(),
380        });
381    }
382    let old_hash = old_create.and_then(|c| c.hash.as_deref());
383    let new_hash = new_create.and_then(|c| c.hash.as_deref());
384    if old_hash != new_hash {
385        errs.push(ValidationError::Immutable {
386            field: "create.hash".to_string(),
387        });
388    }
389    let old_enc = old_create.and_then(|c| c.encryption.as_deref());
390    let new_enc = new_create.and_then(|c| c.encryption.as_deref());
391    if old_enc != new_enc {
392        errs.push(ValidationError::Immutable {
393            field: "create.encryption".to_string(),
394        });
395    }
396    // ECC (Reed-Solomon parity) is baked into the repository format at create time
397    // (ADR-0005 §13(a)) — immutable post-create like the other create knobs.
398    let old_ecc = old_create.and_then(|c| c.ecc.as_ref());
399    let new_ecc = new_create.and_then(|c| c.ecc.as_ref());
400    if old_ecc != new_ecc {
401        errs.push(ValidationError::Immutable {
402            field: "create.ecc".to_string(),
403        });
404    }
405    errs
406}
407
408/// Reject changes to create-time-immutable `Repository` fields on UPDATE (ADR-0005
409/// §7): `create.splitter`, `create.hash`, `create.encryption`, `create.ecc`. Returns
410/// every changed field so a user sees them all at once. Empty ⇒ no immutable change.
411///
412/// `encryption` (the password Secret reference) is intentionally NOT in this set — only
413/// the resolved password value is fixed in the kopia format, and the reference is not a
414/// reliable proxy for it (see [`diff_immutable_repo_fields`]). Renaming the Secret is fine.
415///
416/// ```
417/// use kopiur_api::repository::RepositorySpec;
418/// use kopiur_api::validate::validate_repository_immutability;
419/// # use kopiur_api::backend::{Backend, FilesystemBackend};
420/// # use kopiur_api::common::{CreateBehavior, Encryption, SecretKeyRef};
421/// # fn spec(splitter: Option<&str>) -> RepositorySpec {
422/// #     RepositorySpec {
423/// #         backend: Backend::Filesystem(FilesystemBackend { path: "/r".into(), volume: None }),
424/// #         encryption: Encryption { password_secret_ref: SecretKeyRef { name: "s".into(), namespace: None, key: None } },
425/// #         create: Some(CreateBehavior { enabled: true, encryption: None, splitter: splitter.map(String::from), hash: None, ecc: None }),
426/// #         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,
427/// #     }
428/// # }
429/// // Unchanged splitter → accepted.
430/// assert!(validate_repository_immutability(&spec(Some("FIXED-4M")), &spec(Some("FIXED-4M"))).is_empty());
431/// // Changed splitter → rejected.
432/// assert!(!validate_repository_immutability(&spec(Some("FIXED-4M")), &spec(Some("DYNAMIC"))).is_empty());
433/// ```
434pub fn validate_repository_immutability(
435    old: &RepositorySpec,
436    new: &RepositorySpec,
437) -> Vec<ValidationError> {
438    diff_immutable_repo_fields(old.create.as_ref(), new.create.as_ref())
439}
440
441/// Reject changes to create-time-immutable `ClusterRepository` fields on UPDATE
442/// (ADR-0005 §7). Same field set as [`validate_repository_immutability`].
443pub fn validate_cluster_repository_immutability(
444    old: &ClusterRepositorySpec,
445    new: &ClusterRepositorySpec,
446) -> Vec<ValidationError> {
447    diff_immutable_repo_fields(old.create.as_ref(), new.create.as_ref())
448}
449
450/// Validate a `Repository` spec, accumulating all problems (ADR §3.1).
451pub fn validate_repository(spec: &RepositorySpec) -> Vec<ValidationError> {
452    let mut errs = Vec::new();
453    if let Err(e) = validate_repository_no_inline_retention(spec) {
454        errs.push(e);
455    }
456    if let Err(e) = validate_backend(&spec.backend) {
457        errs.push(e);
458    }
459    if let Some(m) = &spec.maintenance {
460        errs.extend(validate_repository_maintenance(m, false));
461    }
462    if let Some(c) = &spec.catalog {
463        errs.extend(validate_catalog_bounds(c, false));
464    }
465    // Identity CEL expressions must compile + trial-evaluate to a string at admission
466    // (ADR-0004 §5), so a typo / out-of-scope variable is rejected on `kubectl apply`.
467    // Mirrors `validate_cluster_repository`'s identical block.
468    if let Some(id) = &spec.identity_defaults {
469        if let Some(expr) = &id.hostname_expr
470            && let Err(e) = crate::identity::validate_identity_expr(expr)
471        {
472            errs.push(e);
473        }
474        if let Some(expr) = &id.username_expr
475            && let Err(e) = crate::identity::validate_identity_expr(expr)
476        {
477            errs.push(e);
478        }
479        // `cluster` becomes part of the default hostname (`<namespace>.<cluster>`)
480        // and `classify_hostname` splits on the first `.`, so it must be a clean
481        // RFC 1123 label with no dot of its own (M1/M5).
482        if let Some(cluster) = &id.cluster
483            && let Err(e) = validate_cluster_name(cluster)
484        {
485            errs.push(e);
486        }
487    }
488    errs.extend(validate_foreign_snapshots_cluster_coupling(
489        spec.catalog.as_ref(),
490        spec.identity_defaults
491            .as_ref()
492            .and_then(|id| id.cluster.as_deref()),
493    ));
494    if let Some(md) = &spec.mover_defaults
495        && let Some(res) = &md.resources
496        && let Err(e) = validate_resources(res, "Repository moverDefaults")
497    {
498        errs.push(e);
499    }
500    if let Some(server) = &spec.server {
501        errs.extend(validate_server(server, spec.mode));
502    }
503    errs.extend(validate_repository_parameters(
504        spec.parameters.as_ref(),
505        spec.mode,
506        "Repository",
507    ));
508    if let Err(e) = validate_repository_health(spec.health.as_ref(), "Repository") {
509        errs.push(e);
510    }
511    if let Some(b) = &spec.bootstrap
512        && let Some(fp) = &b.failure_policy
513        && let Err(e) = validate_failure_policy(fp, "Repository spec.bootstrap")
514    {
515        errs.push(e);
516    }
517    if let Err(e) = validate_timezone(
518        spec.schedule_defaults
519            .as_ref()
520            .and_then(|d| d.timezone.as_deref()),
521    ) {
522        errs.push(e);
523    }
524    errs
525}
526
527/// The actionable admission warning for an inline-NFS filesystem repo whose
528/// `moverDefaults` grant write access only via `fsGroup`. **`fsGroup` is silently
529/// ignored on NFS** (the kubelet doesn't recursively chown in-tree NFS mounts), so
530/// the mover/server/bootstrap reach the export as the unprivileged uid and the
531/// repo `connect`/`create` fails with `permission denied`. Non-blocking (a user
532/// fixing it NAS-side via Mapall can ignore it). Kept short for the admission
533/// response (kube truncates very long warnings).
534pub const NFS_FSGROUP_WARNING: &str = "NFS filesystem repo: fsGroup is ignored on NFS — \
535     grant the mover write access via moverDefaults.podSecurityContext.supplementalGroups \
536     (with a group-writable export), securityContext.runAsUser, or NAS-side Mapall";
537
538/// Whether `moverDefaults` configures an NFS-effective write identity — i.e. a
539/// `runAsUser` (container or pod) that owns the export, or a `supplementalGroups`
540/// the export is group-writable by. `fsGroup` deliberately does **not** count: it
541/// is a no-op on NFS.
542fn nfs_write_identity_configured(mover_defaults: Option<&MoverDefaults>) -> bool {
543    let Some(md) = mover_defaults else {
544        return false;
545    };
546    let container_uid = md.security_context.as_ref().and_then(|sc| sc.run_as_user);
547    let pod_uid = md
548        .pod_security_context
549        .as_ref()
550        .and_then(|psc| psc.run_as_user);
551    let suppl_groups = md
552        .pod_security_context
553        .as_ref()
554        .and_then(|psc| psc.supplemental_groups.as_ref())
555        .is_some_and(|g| !g.is_empty());
556    container_uid.is_some() || pod_uid.is_some() || suppl_groups
557}
558
559/// Non-blocking admission warnings for a `Repository`/`ClusterRepository`. Shared
560/// by both handlers (the rules can't fork). Today: the inline-NFS + `fsGroup`-only
561/// footgun (see [`NFS_FSGROUP_WARNING`]). Takes the resolved `backend` +
562/// `moverDefaults` so it serves both kinds without re-deriving them.
563pub fn repository_warnings(
564    backend: &Backend,
565    mover_defaults: Option<&MoverDefaults>,
566) -> Vec<String> {
567    let mut warnings = Vec::new();
568    let inline_nfs = matches!(
569        backend,
570        Backend::Filesystem(fs) if matches!(fs.volume, Some(RepoVolume::Nfs(_)))
571    );
572    if inline_nfs && !nfs_write_identity_configured(mover_defaults) {
573        warnings.push(NFS_FSGROUP_WARNING.to_string());
574    }
575    warnings
576}
577
578/// Validate `spec.catalog` (ADR §3.1/§3.2): the refresh interval must parse and
579/// respect the floor, the retain bounds must be enforceable, and
580/// `fallbackNamespace` only means something on a cluster-scoped repository
581/// (`cluster_scoped`). One validator for both kinds so the rules cannot fork.
582pub fn validate_catalog_bounds(
583    catalog: &crate::common::CatalogBounds,
584    cluster_scoped: bool,
585) -> Vec<ValidationError> {
586    let mut errs = Vec::new();
587    if let Some(raw) = catalog.refresh_interval.as_deref() {
588        match crate::duration::parse_go_duration(raw) {
589            None => errs.push(ValidationError::InvalidFieldValue {
590                field: "catalog.refreshInterval".to_string(),
591                reason: format!(
592                    "{raw:?} is not a valid duration. Use a Go-style duration like 30s, 5m, or \
593                     1h; omit the field for the default (1h)"
594                ),
595            }),
596            Some(d) if d < crate::consts::MIN_CATALOG_REFRESH_INTERVAL => {
597                errs.push(ValidationError::InvalidFieldValue {
598                    field: "catalog.refreshInterval".to_string(),
599                    reason: format!(
600                        "{raw:?} is shorter than the 30s minimum. Each re-scan of an \
601                         object-store repository runs a mover Job; use 30s or more (default 1h)"
602                    ),
603                });
604            }
605            Some(_) => {}
606        }
607    }
608    if let Some(retain) = &catalog.retain {
609        if let Some(n) = retain.per_identity
610            && n < 0
611        {
612            errs.push(ValidationError::InvalidFieldValue {
613                field: "catalog.retain.perIdentity".to_string(),
614                reason: format!(
615                    "{n} is negative. Use a positive count of discovered Snapshot CRs to keep \
616                     per identity, 0 to disable discovered-Snapshot materialization, or omit \
617                     the field to materialize everything"
618                ),
619            });
620        }
621        if let Some(d) = retain.max_age_days
622            && d < 1
623        {
624            errs.push(ValidationError::InvalidFieldValue {
625                field: "catalog.retain.maxAgeDays".to_string(),
626                reason: format!(
627                    "{d} is not a usable age bound. Use a positive number of days (snapshots \
628                     older than this get no discovered Snapshot CR), or omit the field for no \
629                     age bound"
630                ),
631            });
632        }
633    }
634    if !cluster_scoped && catalog.fallback_namespace.is_some() {
635        errs.push(ValidationError::InvalidFieldValue {
636            field: "catalog.fallbackNamespace".to_string(),
637            reason: "only a ClusterRepository places discovered Snapshots across namespaces; a \
638                     namespaced Repository always materializes into its own namespace — remove \
639                     the field (or move the repository to a ClusterRepository)"
640                .to_string(),
641        });
642    }
643    // `foreignSnapshots: Fallback` needs somewhere to land, and only a
644    // ClusterRepository can place discovered Snapshots outside their own
645    // namespace — mirrors the fallbackNamespace rule directly above.
646    if matches!(
647        catalog.foreign_snapshots,
648        Some(crate::common::ForeignSnapshots::Fallback)
649    ) {
650        if catalog.fallback_namespace.is_none() {
651            errs.push(ValidationError::InvalidFieldValue {
652                field: "catalog.foreignSnapshots".to_string(),
653                reason: "Fallback requires catalog.fallbackNamespace to be set (there is \
654                         nowhere to materialize a foreign snapshot otherwise); set \
655                         fallbackNamespace, or use Ignore"
656                    .to_string(),
657            });
658        }
659        if !cluster_scoped {
660            errs.push(ValidationError::InvalidFieldValue {
661                field: "catalog.foreignSnapshots".to_string(),
662                reason: "Fallback is only meaningful on a ClusterRepository; a namespaced \
663                         Repository already materializes into its own namespace; use Ignore or \
664                         omit"
665                    .to_string(),
666            });
667        }
668    }
669    errs
670}
671
672/// The `identityDefaults.cluster` × `catalog.foreignSnapshots` cross-field
673/// rules (multi-cluster shared-repo): classifying a snapshot as another
674/// cluster's is undecidable without a cluster identity (a), and adopting one
675/// must never silently switch off an already-configured fallback collector
676/// (d). Shared by both repository kinds — `cluster` is the resolved
677/// `identityDefaults.cluster` value, `None` when the repository has no cluster
678/// identity set (or, on a namespaced `Repository`, no `identityDefaults` set at
679/// all). Without a cluster, rule (d) is a no-op (it requires one to fire) while
680/// rule (a) still rejects any `foreignSnapshots` set there.
681pub fn validate_foreign_snapshots_cluster_coupling(
682    catalog: Option<&crate::common::CatalogBounds>,
683    cluster: Option<&str>,
684) -> Vec<ValidationError> {
685    let Some(catalog) = catalog else {
686        return Vec::new();
687    };
688    let mut errs = Vec::new();
689    let has_cluster = cluster.is_some_and(|c| !c.is_empty());
690    if catalog.foreign_snapshots.is_some() && !has_cluster {
691        errs.push(ValidationError::ForeignSnapshotsRequiresCluster);
692    }
693    if has_cluster && catalog.fallback_namespace.is_some() && catalog.foreign_snapshots.is_none() {
694        errs.push(ValidationError::ForeignSnapshotsChoiceRequired);
695    }
696    errs
697}
698
699/// Validate a `RepositoryReplication` spec, accumulating all problems (ADR-0005
700/// §13(d)): the `sourceRef` is well-formed, the schedule cron parses, the
701/// destination backend's content is valid, and (when a mover is set) it's
702/// well-formed. The "destination differs from source" rule needs the resolved
703/// source backend, which this pure validator cannot fetch — the webhook resolves it
704/// and calls [`replication_destination_differs`] separately.
705pub fn validate_repository_replication(spec: &RepositoryReplicationSpec) -> Vec<ValidationError> {
706    let mut errs = Vec::new();
707    if let Err(e) = validate_repository_ref(&spec.source_ref) {
708        errs.push(e);
709    }
710    if let Err(e) = validate_cron(&spec.schedule.cron) {
711        errs.push(e);
712    }
713    if let Err(e) = validate_timezone(spec.schedule.timezone.as_deref()) {
714        errs.push(e);
715    }
716    if let Err(e) = validate_backend(&spec.destination) {
717        errs.push(e);
718    }
719    if let Some(m) = &spec.mover {
720        if let Err(e) = validate_mover(m, "RepositoryReplication mover") {
721            errs.push(e);
722        }
723        // A replication mover copies blobs repo→repo and never touches a workload's files, so
724        // `repository_replication.rs` never resolves inheritance — it passes the explicit
725        // contexts straight to `resolve_mover`. The field was therefore ACCEPTED and silently
726        // dropped: the manifest claimed the mover ran as the workload, and it did not. Reject
727        // it instead of ignoring it.
728        if let Err(e) = super::forbid_inherit(
729            m,
730            "RepositoryReplication spec",
731            "is not honored by a replication mover, which copies repository blobs and never \
732             reads a workload's files — there is no workload whose identity it could take. \
733             Remove it; set mover.securityContext explicitly if the destination backend needs \
734             a particular UID/GID (e.g. a filesystem repository on an NFS export).",
735        ) {
736            errs.push(e);
737        }
738    }
739    if let Some(sync) = &spec.sync {
740        if let Some(p) = sync.parallel
741            && let Some(e) = require_min("RepositoryReplication spec.sync.parallel", p.into(), 1)
742        {
743            errs.push(e);
744        }
745        if let Some(s) = sync.max_download_speed_bytes_per_second
746            && let Some(e) = require_min(
747                "RepositoryReplication spec.sync.maxDownloadSpeedBytesPerSecond",
748                s,
749                1,
750            )
751        {
752            errs.push(e);
753        }
754        if let Some(s) = sync.max_upload_speed_bytes_per_second
755            && let Some(e) = require_min(
756                "RepositoryReplication spec.sync.maxUploadSpeedBytesPerSecond",
757                s,
758                1,
759            )
760        {
761            errs.push(e);
762        }
763    }
764    errs
765}
766
767/// Whether a replication's `destination` backend differs from its source
768/// repository's backend (ADR-0005 §13(d)). Replicating a repository to *itself* is a
769/// no-op (or a loop), so the webhook rejects it. Pure so the decision is unit-tested;
770/// the webhook resolves the source backend (it has a client) and calls this. A
771/// "same" destination is detected structurally by [`backend_target_key`]: same
772/// backend kind AND the same identifying target — which for S3 includes the
773/// endpoint and region (not just bucket+prefix), for Azure the storage account,
774/// and for a filesystem the backing volume, so two distinct providers that share
775/// a bucket/container/path name are NOT mistaken for the same repository (#248).
776///
777/// ```
778/// use kopiur_api::backend::{Backend, FilesystemBackend, S3Backend};
779/// use kopiur_api::validate::replication_destination_differs;
780///
781/// let fs_a = Backend::Filesystem(FilesystemBackend { path: "/a".into(), volume: None });
782/// let fs_b = Backend::Filesystem(FilesystemBackend { path: "/b".into(), volume: None });
783/// // Different paths → differ.
784/// assert!(replication_destination_differs(&fs_a, &fs_b));
785/// // Same path → same target (would be a self-replication).
786/// assert!(!replication_destination_differs(&fs_a, &fs_a));
787/// // Different backend kinds always differ.
788/// let s3 = Backend::S3(S3Backend { bucket: "b".into(), prefix: None, endpoint: None, region: None, auth: None, tls: None });
789/// assert!(replication_destination_differs(&fs_a, &s3));
790/// // Same bucket name at two DIFFERENT S3 endpoints → distinct targets (#248).
791/// let s3_nas = Backend::S3(S3Backend { bucket: "kopiur".into(), prefix: None, endpoint: Some("nas.example:3000".into()), region: None, auth: None, tls: None });
792/// 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 });
793/// assert!(replication_destination_differs(&s3_nas, &s3_e2));
794/// ```
795pub fn replication_destination_differs(
796    source: &crate::backend::Backend,
797    dest: &crate::backend::Backend,
798) -> bool {
799    backend_target_key(source) != backend_target_key(dest)
800}
801
802/// A structural identity key for a backend (kind + identifying target), used by
803/// [`replication_destination_differs`] to decide whether two backends point at the
804/// same storage. Exhaustive over [`crate::backend::Backend`] so a new backend cannot
805/// compile until its key is defined.
806///
807/// Each arm must fold in EVERY field that distinguishes the storage *target*
808/// (never credentials) — dropping one makes two genuinely-distinct destinations
809/// collide onto the same key, so [`replication_destination_differs`] wrongly
810/// reports a self-replication and the webhook rejects a valid `RepositoryReplication`
811/// (issue #248): two different S3 providers sharing the bucket name `kopiur`
812/// resolved to the same `s3:kopiur/` key when only `bucket`+`prefix` were keyed.
813/// Fields are labelled (`endpoint=…;region=…`) so distinct tuples can't concatenate
814/// into an identical string. Auth/TLS are deliberately excluded: the same bucket
815/// reached with different credentials is still the same storage.
816fn backend_target_key(backend: &crate::backend::Backend) -> String {
817    use crate::backend::{Backend, RepoVolume};
818    let kind = backend.kind_str();
819    let target = match backend {
820        Backend::Filesystem(f) => {
821            // `path` is a mount path INSIDE the mover pod and is commonly the
822            // same default (`/repo`) across repositories, so the backing volume
823            // (a distinct PVC or NFS export) is what actually distinguishes two
824            // filesystem targets.
825            let vol = match &f.volume {
826                None => "none".to_string(),
827                Some(RepoVolume::Pvc(p)) => format!("pvc={}", p.name),
828                Some(RepoVolume::Nfs(n)) => format!("nfs={}:{}", n.server, n.path),
829            };
830            format!("path={};volume={vol}", f.path)
831        }
832        Backend::S3(s) => format!(
833            "endpoint={};region={};bucket={};prefix={}",
834            s.endpoint.clone().unwrap_or_default(),
835            s.region.clone().unwrap_or_default(),
836            s.bucket,
837            s.prefix.clone().unwrap_or_default(),
838        ),
839        Backend::Azure(a) => format!(
840            "account={};container={};prefix={}",
841            a.storage_account.clone().unwrap_or_default(),
842            a.container,
843            a.prefix.clone().unwrap_or_default(),
844        ),
845        // GCS/B2 bucket names are globally unique (no endpoint/account to key),
846        // so bucket+prefix is the complete target identity.
847        Backend::Gcs(g) => format!(
848            "bucket={};prefix={}",
849            g.bucket,
850            g.prefix.clone().unwrap_or_default()
851        ),
852        Backend::B2(b) => format!(
853            "bucket={};prefix={}",
854            b.bucket,
855            b.prefix.clone().unwrap_or_default()
856        ),
857        Backend::Sftp(s) => format!(
858            "host={};port={};path={}",
859            s.host,
860            s.port.map(|p| p.to_string()).unwrap_or_default(),
861            s.path,
862        ),
863        Backend::WebDav(w) => format!("url={}", w.url),
864        Backend::Rclone(r) => format!("remotePath={}", r.remote_path),
865        Backend::Gdrive(g) => format!("folderId={}", g.folder_id),
866    };
867    format!("{kind}:{target}")
868}
869
870/// Validate a `Maintenance` spec, accumulating all problems (ADR §3.7).
871pub fn validate_maintenance(spec: &MaintenanceSpec) -> Vec<ValidationError> {
872    let mut errs = Vec::new();
873    if let Err(e) = validate_repository_ref(&spec.repository) {
874        errs.push(e);
875    }
876    // `ownership.ownerAliases` become kopia identity components once run
877    // through `kopia_lease_identity` (M6), so each alias gets the identity
878    // shape rule — the same validator the resolved hostname/username go
879    // through. `owner` itself is deliberately NOT tightened here: it predates
880    // this rule, stored CRs may carry arbitrary strings the lease sanitizer
881    // already handles, and the controller re-validates defensively on every
882    // reconcile — a new rejection would hard-stop a working Maintenance.
883    // Aliases are new with this rule, so no stored object can regress.
884    for (i, alias) in spec.ownership.owner_aliases.iter().enumerate() {
885        if let Err(e) = validate_identity_component(&format!("ownership.ownerAliases[{i}]"), alias)
886        {
887            errs.push(e);
888        }
889    }
890    if let Err(e) = validate_cron(&spec.schedule.quick.cron) {
891        errs.push(e);
892    }
893    if let Err(e) = validate_cron(&spec.schedule.full.cron) {
894        errs.push(e);
895    }
896    for tz in [
897        spec.schedule.timezone.as_deref(),
898        spec.schedule.quick.timezone.as_deref(),
899        spec.schedule.full.timezone.as_deref(),
900    ] {
901        if let Err(e) = validate_timezone(tz) {
902            errs.push(e);
903        }
904    }
905    if let Some(m) = &spec.mover {
906        if let Err(e) = forbid_pvc_consumer(
907            m,
908            "maintenance",
909            "Use an explicit mover.securityContext instead.",
910        ) {
911            errs.push(e);
912        }
913        if let Err(e) = forbid_snapshot_inherit(
914            m,
915            "maintenance",
916            "a maintenance mover operates on the repository, not on a snapshot's data, so \
917             there is no recorded identity to reproduce; `snapshot` is restore-only. Use an \
918             explicit mover.securityContext instead.",
919        ) {
920            errs.push(e);
921        }
922        if let Err(e) = validate_mover(m, "Maintenance mover") {
923            errs.push(e);
924        }
925    }
926    if let Some(fp) = &spec.failure_policy
927        && let Err(e) = validate_failure_policy(fp, "Maintenance")
928    {
929        errs.push(e);
930    }
931    errs
932}
933
934/// Validate a `ClusterRepository` spec, accumulating all problems (ADR §3.2).
935///
936/// `All(false)` is rejected as meaningless (SKILL: "`false` is rejected by webhook").
937pub fn validate_cluster_repository(spec: &ClusterRepositorySpec) -> Vec<ValidationError> {
938    let mut errs = Vec::new();
939    if let AllowedNamespaces::All(false) = spec.allowed_namespaces {
940        errs.push(ValidationError::MissingRequiredField {
941            field: "allowedNamespaces.all must be true to grant access (false is meaningless)"
942                .to_string(),
943        });
944    }
945    if let Err(e) = validate_backend(&spec.backend) {
946        errs.push(e);
947    }
948    if let Some(m) = &spec.maintenance {
949        errs.extend(validate_repository_maintenance(m, true));
950    }
951    // Identity CEL expressions must compile + trial-evaluate to a string at admission
952    // (ADR-0004 §5), so a typo / out-of-scope variable is rejected on `kubectl apply`.
953    if let Some(id) = &spec.identity_defaults {
954        if let Some(expr) = &id.hostname_expr
955            && let Err(e) = crate::identity::validate_identity_expr(expr)
956        {
957            errs.push(e);
958        }
959        if let Some(expr) = &id.username_expr
960            && let Err(e) = crate::identity::validate_identity_expr(expr)
961        {
962            errs.push(e);
963        }
964        // `cluster` becomes part of the default hostname (`<namespace>.<cluster>`)
965        // and `classify_hostname` splits on the first `.`, so it must be a clean
966        // RFC 1123 label with no dot of its own (M1).
967        if let Some(cluster) = &id.cluster
968            && let Err(e) = validate_cluster_name(cluster)
969        {
970            errs.push(e);
971        }
972    }
973    if let Some(c) = &spec.catalog {
974        errs.extend(validate_catalog_bounds(c, true));
975    }
976    errs.extend(validate_foreign_snapshots_cluster_coupling(
977        spec.catalog.as_ref(),
978        spec.identity_defaults
979            .as_ref()
980            .and_then(|id| id.cluster.as_deref()),
981    ));
982    if let Some(md) = &spec.mover_defaults
983        && let Some(res) = &md.resources
984        && let Err(e) = validate_resources(res, "ClusterRepository moverDefaults")
985    {
986        errs.push(e);
987    }
988    if let Some(server) = &spec.server {
989        if server.namespace.trim().is_empty() {
990            errs.push(ValidationError::ServerNamespaceRequired);
991        }
992        errs.extend(validate_server(&server.server, spec.mode));
993    }
994    errs.extend(validate_repository_parameters(
995        spec.parameters.as_ref(),
996        spec.mode,
997        "ClusterRepository",
998    ));
999    if let Err(e) = validate_repository_health(spec.health.as_ref(), "ClusterRepository") {
1000        errs.push(e);
1001    }
1002    if let Some(b) = &spec.bootstrap
1003        && let Some(fp) = &b.failure_policy
1004        && let Err(e) = validate_failure_policy(fp, "ClusterRepository spec.bootstrap")
1005    {
1006        errs.push(e);
1007    }
1008    if let Err(e) = validate_timezone(
1009        spec.schedule_defaults
1010            .as_ref()
1011            .and_then(|d| d.timezone.as_deref()),
1012    ) {
1013        errs.push(e);
1014    }
1015    errs
1016}