Skip to main content

kopiur_api/
validate.rs

1//! Cross-field validation the type system can't express (ADR §2.2 principle 8).
2//!
3//! These are the rules a single struct's types can't enforce: "field X is
4//! forbidden only when sibling Y has a particular variant," "this string must
5//! parse as a cron," "a discovered backup may only Retain." They live here as pure
6//! functions so the **webhook calls them at admission and the controller calls them
7//! defensively** — one validator, two callers (SKILL hard-rule 4). No `kube::Client`,
8//! no `tokio`.
9//!
10//! ## Fail-fast vs. accumulate (see [`crate::error`])
11//!
12//! Single-rule helpers return [`ValidationResult`] (fail-fast — first problem).
13//! The per-CRD aggregate validators (`validate_backup_config`, …) return
14//! `Vec<ValidationError>` so a user sees every independent problem in one apply.
15//! An empty vec means valid.
16
17use crate::backup::{BackupSpec, Origin};
18use crate::backup_config::{BackupConfigSpec, Source};
19use crate::backup_schedule::BackupScheduleSpec;
20use crate::cluster_repository::{AllowedNamespaces, ClusterRepositorySpec};
21use crate::common::{DeletionPolicy, RepositoryKind, RepositoryRef};
22use crate::error::{ValidationError, ValidationResult};
23use crate::maintenance::{MaintenanceSpec, RepositoryMaintenanceSpec};
24use crate::repository::RepositorySpec;
25use crate::restore::{RestoreSource, RestoreSpec, RestoreTarget};
26use std::collections::BTreeMap;
27
28/// A `RepositoryRef` is well-formed: a `ClusterRepository` reference is by name
29/// only, so `namespace` MUST be absent (ADR §3.2/§3.3). A namespaced `Repository`
30/// reference may carry a namespace (cross-namespace references are allowed).
31///
32/// ```
33/// use kopiur_api::common::RepositoryRef;
34/// use kopiur_api::validate::validate_repository_ref;
35/// use kopiur_api::ValidationError;
36///
37/// // OK: a namespaced Repository reference may name a namespace.
38/// let ok: RepositoryRef = serde_json::from_value(serde_json::json!({
39///     "kind": "Repository", "name": "nas-primary", "namespace": "backups",
40/// }))
41/// .unwrap();
42/// assert!(validate_repository_ref(&ok).is_ok());
43///
44/// // Err: a ClusterRepository is referenced by name alone — a namespace is forbidden.
45/// let bad: RepositoryRef = serde_json::from_value(serde_json::json!({
46///     "kind": "ClusterRepository", "name": "shared", "namespace": "oops",
47/// }))
48/// .unwrap();
49/// assert_eq!(
50///     validate_repository_ref(&bad).unwrap_err(),
51///     ValidationError::ClusterRepoNamespaceForbidden { namespace: "oops".to_string() },
52/// );
53/// ```
54pub fn validate_repository_ref(r: &RepositoryRef) -> ValidationResult {
55    match r.kind {
56        RepositoryKind::ClusterRepository => match &r.namespace {
57            Some(ns) => Err(ValidationError::ClusterRepoNamespaceForbidden {
58                namespace: ns.clone(),
59            }),
60            None => Ok(()),
61        },
62        RepositoryKind::Repository => Ok(()),
63    }
64}
65
66/// A consumer namespace is permitted by a `ClusterRepository`'s tenancy gate
67/// (ADR §3.2/§4.3).
68///
69/// - `List`     → membership test.
70/// - `All(true)`→ always allowed; `All(false)` is meaningless and denies.
71/// - `Selector` → matched against `labels` (the consumer namespace's labels). The
72///   `crates/api` crate cannot fetch a `Namespace` object, so the caller (webhook)
73///   must supply the labels. **If `labels` is `None` we fail closed** with
74///   [`ValidationError::SelectorLabelsUnavailable`] rather than guess — the webhook
75///   never trusts unfiltered input (ADR §3.2). Selector matching here is a simple
76///   `matchLabels` superset test (the common case); `matchExpressions` is treated
77///   as "no constraint" for now and documented as such.
78pub fn validate_consumer_against_cluster_repo(
79    consumer_namespace: &str,
80    repo_name: &str,
81    allowed: &AllowedNamespaces,
82    labels: Option<&BTreeMap<String, String>>,
83) -> ValidationResult {
84    match allowed {
85        AllowedNamespaces::All(true) => Ok(()),
86        AllowedNamespaces::All(false) => Err(ValidationError::ConsumerNamespaceNotAllowed {
87            namespace: consumer_namespace.to_string(),
88            repo: repo_name.to_string(),
89        }),
90        AllowedNamespaces::List(names) => {
91            if names.iter().any(|n| n == consumer_namespace) {
92                Ok(())
93            } else {
94                Err(ValidationError::ConsumerNamespaceNotAllowed {
95                    namespace: consumer_namespace.to_string(),
96                    repo: repo_name.to_string(),
97                })
98            }
99        }
100        AllowedNamespaces::Selector(sel) => {
101            let Some(labels) = labels else {
102                return Err(ValidationError::SelectorLabelsUnavailable {
103                    namespace: consumer_namespace.to_string(),
104                    repo: repo_name.to_string(),
105                });
106            };
107            let match_labels = sel.match_labels.clone().unwrap_or_default();
108            // Every required label must be present with the required value.
109            let matches = match_labels
110                .iter()
111                .all(|(k, v)| labels.get(k).map(|got| got == v).unwrap_or(false));
112            if matches {
113                Ok(())
114            } else {
115                Err(ValidationError::ConsumerNamespaceNotAllowed {
116                    namespace: consumer_namespace.to_string(),
117                    repo: repo_name.to_string(),
118                })
119            }
120        }
121    }
122}
123
124/// A `Backup`'s `deletionPolicy` is legal for its origin (ADR §4.5).
125///
126/// `origin: discovered` forces `Retain`: `None` (defaults to `Retain`) and an
127/// explicit `Retain` pass; `Delete`/`Orphan` are rejected. Other origins accept any
128/// policy.
129pub fn validate_backup_deletion_policy(
130    origin: Origin,
131    policy: Option<DeletionPolicy>,
132) -> ValidationResult {
133    if origin != Origin::Discovered {
134        return Ok(());
135    }
136    match policy {
137        None | Some(DeletionPolicy::Retain) => Ok(()),
138        Some(other) => Err(ValidationError::DiscoveredMustRetain {
139            got: format!("{other:?}"),
140        }),
141    }
142}
143
144/// A single backup `Source` is well-formed: `pvc` and `pvcSelector` are mutually
145/// exclusive, and at least one must be set (ADR §3.3 — modeled as sibling Options
146/// because both forms share `sourcePath*` keys, so it's a webhook check, not an
147/// enum).
148pub fn validate_source(source: &Source) -> ValidationResult {
149    match (source.pvc.is_some(), source.pvc_selector.is_some()) {
150        (true, true) => Err(ValidationError::MutuallyExclusive {
151            a: "pvc".to_string(),
152            b: "pvcSelector".to_string(),
153            context: "backup source".to_string(),
154        }),
155        (false, false) => Err(ValidationError::MissingRequiredField {
156            field: "source.pvc or source.pvcSelector".to_string(),
157        }),
158        _ => Ok(()),
159    }
160}
161
162/// A `Restore` spec is internally consistent (ADR §3.6/§4.6).
163///
164/// The externally-tagged `RestoreSource`/`RestoreTarget` enums already guarantee
165/// **exactly one** variant — that is a compile-time/serde invariant, not re-checked
166/// here. We validate the cross-field rules the enums can't express:
167/// - `source.identity` requires `spec.repository` (nothing else can derive it).
168/// - if `target: pvc`, the template must name the PVC (`name` non-empty).
169pub fn validate_restore(spec: &RestoreSpec) -> ValidationResult {
170    // Exactly-one-variant on `source`/`target` is guaranteed by the enum + Option;
171    // see RestoreSource / Option<RestoreTarget>.
172    if matches!(spec.source, RestoreSource::Identity(_)) && spec.repository.is_none() {
173        return Err(ValidationError::RestoreSourceRepositoryRequired);
174    }
175    if let Some(RestoreTarget::Pvc(t)) = &spec.target
176        && t.name.trim().is_empty()
177    {
178        return Err(ValidationError::MissingRequiredField {
179            field: "restore.target.pvc.name".to_string(),
180        });
181    }
182    Ok(())
183}
184
185/// A `Repository` spec does not carry kopia-side (repo-level) retention policy,
186/// which would conflict with CR-driven GFS retention (ADR §4.4 exclusivity).
187///
188/// The current [`RepositorySpec`] deliberately models no inline retention field, so
189/// this **always passes today**. It exists as the enforcement hook so that if a
190/// future field (e.g. `spec.policy.keepDaily`) is ever added, wiring it here is the
191/// one obvious place — and the rule is already named and tested. Be pragmatic: we
192/// do not invent a field to reject.
193pub fn validate_repository_no_inline_retention(_spec: &RepositorySpec) -> ValidationResult {
194    // No inline-retention field exists on RepositorySpec. If one is added later,
195    // return Err(ValidationError::InlineRetentionForbidden { field: "<name>" }) here.
196    Ok(())
197}
198
199/// Validate a `spec.maintenance` block on a `Repository`/`ClusterRepository`,
200/// accumulating problems (ADR §3.7):
201/// - any override schedule's quick/full crons must parse (same parser as runtime);
202/// - `namespace` is **cluster-scope only** — it selects where the namespaced
203///   managed `Maintenance` lands for a `ClusterRepository`, and is forbidden on a
204///   namespaced `Repository` (whose `Maintenance` always lives in its own ns).
205///
206/// `cluster_scoped` is the only thing that differs between the two repository
207/// kinds, so one validator serves both call sites.
208pub fn validate_repository_maintenance(
209    maintenance: &RepositoryMaintenanceSpec,
210    cluster_scoped: bool,
211) -> Vec<ValidationError> {
212    let mut errs = Vec::new();
213    if let Some(schedule) = &maintenance.schedule {
214        if let Err(e) = validate_cron(&schedule.quick.cron) {
215            errs.push(e);
216        }
217        if let Err(e) = validate_cron(&schedule.full.cron) {
218            errs.push(e);
219        }
220    }
221    if !cluster_scoped && let Some(ns) = &maintenance.namespace {
222        errs.push(ValidationError::MaintenanceNamespaceOnNamespacedRepo {
223            namespace: ns.clone(),
224        });
225    }
226    errs
227}
228
229/// A cron expression parses with the same parser the controller uses at runtime, so
230/// bad expressions are rejected at apply time, not at first reconcile (ADR §4.1).
231///
232/// `croner` 2.x does not implement Jenkins-style `H`. Since kopiur resolves `H`
233/// deterministically in [`crate::jitter::substitute_h`] (not in the parser), we
234/// substitute every `H` field with the fixed placeholder `0` purely to validate the
235/// expression's *shape* here. The real `H` spread is produced at scheduling time.
236///
237/// ```
238/// use kopiur_api::validate::validate_cron;
239/// use kopiur_api::ValidationError;
240///
241/// // Valid 5-field crons pass — including Jenkins-style `H` (resolved later).
242/// assert!(validate_cron("0 2 * * *").is_ok());
243/// assert!(validate_cron("H 2 * * *").is_ok());
244///
245/// // Garbage is rejected at apply time, not at first reconcile (ADR §4.1).
246/// assert!(matches!(
247///     validate_cron("not a cron"),
248///     Err(ValidationError::InvalidCron { .. }),
249/// ));
250/// ```
251pub fn validate_cron(expr: &str) -> ValidationResult {
252    let probe = expr
253        .split_whitespace()
254        .map(|f| if f == "H" { "0" } else { f })
255        .collect::<Vec<_>>()
256        .join(" ");
257    match croner::Cron::new(&probe).parse() {
258        Ok(_) => Ok(()),
259        Err(e) => Err(ValidationError::InvalidCron {
260            expr: expr.to_string(),
261            reason: e.to_string(),
262        }),
263    }
264}
265
266// --- Per-CRD aggregate validators (accumulate every problem) ----------------
267
268/// Validate a `BackupConfig` spec, accumulating all problems.
269pub fn validate_backup_config(spec: &BackupConfigSpec) -> Vec<ValidationError> {
270    let mut errs = Vec::new();
271    if let Err(e) = validate_repository_ref(&spec.repository) {
272        errs.push(e);
273    }
274    if spec.sources.is_empty() {
275        errs.push(ValidationError::MissingRequiredField {
276            field: "spec.sources (at least one source required)".to_string(),
277        });
278    }
279    for source in &spec.sources {
280        if let Err(e) = validate_source(source) {
281            errs.push(e);
282        }
283    }
284    errs
285}
286
287/// Validate a `Backup` spec for a given origin, accumulating all problems.
288///
289/// `origin` is supplied by the caller because the canonical value lives in
290/// `status.origin` / the `kopiur.home-operations.com/origin` label, not in `spec` (ADR §3.4).
291pub fn validate_backup(spec: &BackupSpec, origin: Origin) -> Vec<ValidationError> {
292    let mut errs = Vec::new();
293    if let Err(e) = validate_backup_deletion_policy(origin, spec.deletion_policy) {
294        errs.push(e);
295    }
296    errs
297}
298
299/// Validate a `BackupSchedule` spec, accumulating all problems.
300pub fn validate_backup_schedule(spec: &BackupScheduleSpec) -> Vec<ValidationError> {
301    let mut errs = Vec::new();
302    if let Err(e) = validate_cron(&spec.schedule.cron) {
303        errs.push(e);
304    }
305    errs
306}
307
308/// Validate a `Repository` spec, accumulating all problems (ADR §3.1).
309pub fn validate_repository(spec: &RepositorySpec) -> Vec<ValidationError> {
310    let mut errs = Vec::new();
311    if let Err(e) = validate_repository_no_inline_retention(spec) {
312        errs.push(e);
313    }
314    if let Some(m) = &spec.maintenance {
315        errs.extend(validate_repository_maintenance(m, false));
316    }
317    errs
318}
319
320/// Validate a `Maintenance` spec, accumulating all problems (ADR §3.7).
321pub fn validate_maintenance(spec: &MaintenanceSpec) -> Vec<ValidationError> {
322    let mut errs = Vec::new();
323    if let Err(e) = validate_repository_ref(&spec.repository) {
324        errs.push(e);
325    }
326    if let Err(e) = validate_cron(&spec.schedule.quick.cron) {
327        errs.push(e);
328    }
329    if let Err(e) = validate_cron(&spec.schedule.full.cron) {
330        errs.push(e);
331    }
332    errs
333}
334
335/// Validate a `ClusterRepository` spec, accumulating all problems (ADR §3.2).
336///
337/// `All(false)` is rejected as meaningless (SKILL: "`false` is rejected by webhook").
338pub fn validate_cluster_repository(spec: &ClusterRepositorySpec) -> Vec<ValidationError> {
339    let mut errs = Vec::new();
340    if let AllowedNamespaces::All(false) = spec.allowed_namespaces {
341        errs.push(ValidationError::MissingRequiredField {
342            field: "allowedNamespaces.all must be true to grant access (false is meaningless)"
343                .to_string(),
344        });
345    }
346    if let Some(m) = &spec.maintenance {
347        errs.extend(validate_repository_maintenance(m, true));
348    }
349    errs
350}
351
352/// Validate a `Restore` spec, accumulating all problems (wraps the fail-fast
353/// [`validate_restore`] for caller symmetry).
354pub fn validate_restore_spec(spec: &RestoreSpec) -> Vec<ValidationError> {
355    let mut errs = Vec::new();
356    if let Some(r) = &spec.repository
357        && let Err(e) = validate_repository_ref(r)
358    {
359        errs.push(e);
360    }
361    if let Err(e) = validate_restore(spec) {
362        errs.push(e);
363    }
364    errs
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370    use crate::common::Identity;
371    use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector;
372
373    fn repo_ref(kind: RepositoryKind, ns: Option<&str>) -> RepositoryRef {
374        RepositoryRef {
375            kind,
376            name: "r".to_string(),
377            namespace: ns.map(String::from),
378        }
379    }
380
381    // --- validate_repository_ref ---
382
383    #[test]
384    fn cluster_repo_ref_forbids_namespace() {
385        let err = validate_repository_ref(&repo_ref(RepositoryKind::ClusterRepository, Some("x")))
386            .unwrap_err();
387        assert_eq!(
388            err,
389            ValidationError::ClusterRepoNamespaceForbidden {
390                namespace: "x".to_string()
391            }
392        );
393    }
394
395    #[test]
396    fn cluster_repo_ref_without_namespace_ok() {
397        assert!(
398            validate_repository_ref(&repo_ref(RepositoryKind::ClusterRepository, None)).is_ok()
399        );
400    }
401
402    #[test]
403    fn namespaced_repo_ref_allows_namespace() {
404        assert!(
405            validate_repository_ref(&repo_ref(RepositoryKind::Repository, Some("other"))).is_ok()
406        );
407        assert!(validate_repository_ref(&repo_ref(RepositoryKind::Repository, None)).is_ok());
408    }
409
410    // --- validate_consumer_against_cluster_repo ---
411
412    #[test]
413    fn consumer_allowed_via_list() {
414        let allowed = AllowedNamespaces::List(vec!["billing".into(), "staging".into()]);
415        assert!(validate_consumer_against_cluster_repo("billing", "repo", &allowed, None).is_ok());
416    }
417
418    #[test]
419    fn consumer_denied_via_list() {
420        let allowed = AllowedNamespaces::List(vec!["billing".into()]);
421        let err =
422            validate_consumer_against_cluster_repo("evil", "repo", &allowed, None).unwrap_err();
423        assert_eq!(
424            err,
425            ValidationError::ConsumerNamespaceNotAllowed {
426                namespace: "evil".to_string(),
427                repo: "repo".to_string()
428            }
429        );
430    }
431
432    #[test]
433    fn consumer_allowed_via_all_true_denied_via_all_false() {
434        assert!(
435            validate_consumer_against_cluster_repo(
436                "any",
437                "repo",
438                &AllowedNamespaces::All(true),
439                None
440            )
441            .is_ok()
442        );
443        assert!(
444            validate_consumer_against_cluster_repo(
445                "any",
446                "repo",
447                &AllowedNamespaces::All(false),
448                None
449            )
450            .is_err()
451        );
452    }
453
454    #[test]
455    fn consumer_allowed_via_selector_match() {
456        let sel = LabelSelector {
457            match_labels: Some(BTreeMap::from([(
458                "kopiur.home-operations.com/tier".to_string(),
459                "enterprise".to_string(),
460            )])),
461            ..Default::default()
462        };
463        let allowed = AllowedNamespaces::Selector(sel);
464        let labels = BTreeMap::from([(
465            "kopiur.home-operations.com/tier".to_string(),
466            "enterprise".to_string(),
467        )]);
468        assert!(
469            validate_consumer_against_cluster_repo("ns", "repo", &allowed, Some(&labels)).is_ok()
470        );
471    }
472
473    #[test]
474    fn consumer_denied_via_selector_mismatch() {
475        let sel = LabelSelector {
476            match_labels: Some(BTreeMap::from([(
477                "kopiur.home-operations.com/tier".to_string(),
478                "enterprise".to_string(),
479            )])),
480            ..Default::default()
481        };
482        let allowed = AllowedNamespaces::Selector(sel);
483        let labels = BTreeMap::from([(
484            "kopiur.home-operations.com/tier".to_string(),
485            "free".to_string(),
486        )]);
487        assert!(
488            validate_consumer_against_cluster_repo("ns", "repo", &allowed, Some(&labels)).is_err()
489        );
490    }
491
492    #[test]
493    fn selector_without_labels_fails_closed() {
494        let allowed = AllowedNamespaces::Selector(LabelSelector::default());
495        let err = validate_consumer_against_cluster_repo("ns", "repo", &allowed, None).unwrap_err();
496        assert_eq!(
497            err,
498            ValidationError::SelectorLabelsUnavailable {
499                namespace: "ns".to_string(),
500                repo: "repo".to_string()
501            }
502        );
503    }
504
505    // --- validate_backup_deletion_policy ---
506
507    #[test]
508    fn discovered_accepts_none_and_retain() {
509        assert!(validate_backup_deletion_policy(Origin::Discovered, None).is_ok());
510        assert!(
511            validate_backup_deletion_policy(Origin::Discovered, Some(DeletionPolicy::Retain))
512                .is_ok()
513        );
514    }
515
516    #[test]
517    fn discovered_rejects_delete_and_orphan() {
518        assert_eq!(
519            validate_backup_deletion_policy(Origin::Discovered, Some(DeletionPolicy::Delete))
520                .unwrap_err(),
521            ValidationError::DiscoveredMustRetain {
522                got: "Delete".to_string()
523            }
524        );
525        assert!(matches!(
526            validate_backup_deletion_policy(Origin::Discovered, Some(DeletionPolicy::Orphan)),
527            Err(ValidationError::DiscoveredMustRetain { .. })
528        ));
529    }
530
531    #[test]
532    fn produced_origins_accept_any_policy() {
533        for o in [Origin::Scheduled, Origin::Manual] {
534            for p in [
535                None,
536                Some(DeletionPolicy::Delete),
537                Some(DeletionPolicy::Retain),
538                Some(DeletionPolicy::Orphan),
539            ] {
540                assert!(validate_backup_deletion_policy(o, p).is_ok());
541            }
542        }
543    }
544
545    // --- validate_source ---
546
547    #[test]
548    fn source_with_both_pvc_and_selector_is_rejected() {
549        use crate::backup_config::{PvcSelector, PvcSource};
550        let src = Source {
551            pvc: Some(PvcSource { name: "p".into() }),
552            pvc_selector: Some(PvcSelector {
553                namespace_selector: None,
554                label_selector: None,
555            }),
556            source_path_override: None,
557            source_path_strategy: None,
558        };
559        assert!(matches!(
560            validate_source(&src),
561            Err(ValidationError::MutuallyExclusive { .. })
562        ));
563    }
564
565    #[test]
566    fn source_with_neither_is_rejected() {
567        let src = Source {
568            pvc: None,
569            pvc_selector: None,
570            source_path_override: None,
571            source_path_strategy: None,
572        };
573        assert!(matches!(
574            validate_source(&src),
575            Err(ValidationError::MissingRequiredField { .. })
576        ));
577    }
578
579    // --- validate_restore ---
580
581    fn restore_with(source: RestoreSource, repo: Option<RepositoryRef>) -> RestoreSpec {
582        RestoreSpec {
583            repository: repo,
584            source,
585            target: None,
586            options: None,
587            policy: None,
588        }
589    }
590
591    #[test]
592    fn restore_identity_requires_repository() {
593        use crate::restore::IdentitySource;
594        let spec = restore_with(
595            RestoreSource::Identity(IdentitySource {
596                username: "u".into(),
597                hostname: "h".into(),
598                source_path: None,
599                snapshot_id: None,
600                as_of: None,
601                offset: None,
602            }),
603            None,
604        );
605        assert_eq!(
606            validate_restore(&spec).unwrap_err(),
607            ValidationError::RestoreSourceRepositoryRequired
608        );
609    }
610
611    #[test]
612    fn restore_identity_with_repository_ok() {
613        use crate::restore::IdentitySource;
614        let spec = restore_with(
615            RestoreSource::Identity(IdentitySource {
616                username: "u".into(),
617                hostname: "h".into(),
618                source_path: None,
619                snapshot_id: None,
620                as_of: None,
621                offset: None,
622            }),
623            Some(repo_ref(RepositoryKind::Repository, Some("backups"))),
624        );
625        assert!(validate_restore(&spec).is_ok());
626    }
627
628    #[test]
629    fn restore_backup_ref_does_not_require_repository() {
630        use crate::common::ObjectRef;
631        let spec = restore_with(
632            RestoreSource::BackupRef(ObjectRef {
633                name: "b".into(),
634                namespace: None,
635            }),
636            None,
637        );
638        assert!(validate_restore(&spec).is_ok());
639    }
640
641    #[test]
642    fn restore_pvc_target_requires_name() {
643        use crate::common::ObjectRef;
644        use crate::restore::PvcTemplate;
645        let mut spec = restore_with(
646            RestoreSource::BackupRef(ObjectRef {
647                name: "b".into(),
648                namespace: None,
649            }),
650            None,
651        );
652        spec.target = Some(RestoreTarget::Pvc(PvcTemplate {
653            name: "  ".into(),
654            storage_class_name: None,
655            capacity: None,
656            access_modes: vec![],
657        }));
658        assert!(matches!(
659            validate_restore(&spec),
660            Err(ValidationError::MissingRequiredField { .. })
661        ));
662    }
663
664    // --- validate_cron ---
665
666    #[test]
667    fn valid_cron_expressions_pass() {
668        for expr in ["0 2 * * *", "*/15 * * * *", "0 0 1 1 *", "0 */6 * * *"] {
669            assert!(validate_cron(expr).is_ok(), "{expr} should be valid");
670        }
671    }
672
673    #[test]
674    fn jenkins_h_cron_passes_via_placeholder() {
675        // H is substituted to 0 for shape-validation; real spread is in jitter.
676        assert!(validate_cron("H 2 * * *").is_ok());
677        assert!(validate_cron("H H * * *").is_ok());
678    }
679
680    #[test]
681    fn malformed_cron_is_rejected() {
682        for expr in ["not a cron", "99 99 99 99 99", ""] {
683            assert!(
684                matches!(
685                    validate_cron(expr),
686                    Err(ValidationError::InvalidCron { .. })
687                ),
688                "{expr} should be rejected"
689            );
690        }
691    }
692
693    // --- validate_repository_no_inline_retention ---
694
695    #[test]
696    fn repository_inline_retention_hook_passes_today() {
697        use crate::backend::{Backend, FilesystemBackend};
698        use crate::common::{Encryption, SecretKeyRef};
699        let spec = RepositorySpec {
700            backend: Backend::Filesystem(FilesystemBackend {
701                path: "/repo".into(),
702                pvc_name: None,
703            }),
704            encryption: Encryption {
705                password_secret_ref: SecretKeyRef {
706                    name: "s".into(),
707                    namespace: None,
708                    key: None,
709                },
710            },
711            create: None,
712            cache_defaults: None,
713            catalog: None,
714            maintenance: None,
715        };
716        assert!(validate_repository_no_inline_retention(&spec).is_ok());
717    }
718
719    // --- aggregate validators ---
720
721    #[test]
722    fn backup_config_aggregate_collects_multiple_errors() {
723        let spec = BackupConfigSpec {
724            repository: repo_ref(RepositoryKind::ClusterRepository, Some("forbidden")),
725            identity: Some(Identity::default()),
726            sources: vec![], // missing required
727            copy_method: None,
728            volume_snapshot_class_name: None,
729            group_by: None,
730            retention: None,
731            default_deletion_policy: None,
732            policy: None,
733            hooks: None,
734            mover: None,
735        };
736        let errs = validate_backup_config(&spec);
737        // Both: ClusterRepo namespace forbidden + missing sources.
738        assert_eq!(errs.len(), 2);
739        assert!(
740            errs.iter()
741                .any(|e| matches!(e, ValidationError::ClusterRepoNamespaceForbidden { .. }))
742        );
743        assert!(
744            errs.iter()
745                .any(|e| matches!(e, ValidationError::MissingRequiredField { .. }))
746        );
747    }
748
749    #[test]
750    fn backup_config_valid_spec_has_no_errors() {
751        use crate::backup_config::{PvcSource, Source};
752        let spec = BackupConfigSpec {
753            repository: repo_ref(RepositoryKind::Repository, Some("backups")),
754            identity: None,
755            sources: vec![Source {
756                pvc: Some(PvcSource {
757                    name: "data".into(),
758                }),
759                pvc_selector: None,
760                source_path_override: None,
761                source_path_strategy: None,
762            }],
763            copy_method: None,
764            volume_snapshot_class_name: None,
765            group_by: None,
766            retention: None,
767            default_deletion_policy: None,
768            policy: None,
769            hooks: None,
770            mover: None,
771        };
772        assert!(validate_backup_config(&spec).is_empty());
773    }
774
775    #[test]
776    fn backup_aggregate_rejects_discovered_delete() {
777        let spec = BackupSpec {
778            config_ref: None,
779            tags: None,
780            failure_policy: None,
781            deletion_policy: Some(DeletionPolicy::Delete),
782        };
783        let errs = validate_backup(&spec, Origin::Discovered);
784        assert_eq!(errs.len(), 1);
785        assert!(matches!(
786            errs[0],
787            ValidationError::DiscoveredMustRetain { .. }
788        ));
789    }
790
791    #[test]
792    fn backup_schedule_aggregate_rejects_bad_cron() {
793        use crate::backup_schedule::ScheduleSpec;
794        use crate::common::ConfigRef;
795        let spec = BackupScheduleSpec {
796            config_ref: ConfigRef {
797                name: "c".into(),
798                namespace: None,
799            },
800            schedule: ScheduleSpec {
801                cron: "totally bad".into(),
802                jitter: None,
803                timezone: None,
804                run_on_create: false,
805                suspend: false,
806                concurrency_policy: Default::default(),
807                starting_deadline_seconds: None,
808            },
809            failed_jobs_history_limit: None,
810        };
811        let errs = validate_backup_schedule(&spec);
812        assert!(
813            errs.iter()
814                .any(|e| matches!(e, ValidationError::InvalidCron { .. }))
815        );
816    }
817
818    // --- validate_repository_maintenance / validate_repository ---
819
820    fn repo_spec_with_maintenance(m: Option<RepositoryMaintenanceSpec>) -> RepositorySpec {
821        use crate::backend::{Backend, FilesystemBackend};
822        use crate::common::{Encryption, SecretKeyRef};
823        RepositorySpec {
824            backend: Backend::Filesystem(FilesystemBackend {
825                path: "/repo".into(),
826                pvc_name: None,
827            }),
828            encryption: Encryption {
829                password_secret_ref: SecretKeyRef {
830                    name: "s".into(),
831                    namespace: None,
832                    key: None,
833                },
834            },
835            create: None,
836            cache_defaults: None,
837            catalog: None,
838            maintenance: m,
839        }
840    }
841
842    #[test]
843    fn repository_default_managed_maintenance_is_valid() {
844        // Absent `maintenance` (default-on) and an empty block both pass.
845        assert!(validate_repository(&repo_spec_with_maintenance(None)).is_empty());
846        assert!(
847            validate_repository(&repo_spec_with_maintenance(Some(
848                RepositoryMaintenanceSpec::default()
849            )))
850            .is_empty()
851        );
852    }
853
854    #[test]
855    fn repository_maintenance_namespace_rejected_on_namespaced_repo() {
856        let m = RepositoryMaintenanceSpec {
857            namespace: Some("kopia-system".into()),
858            ..Default::default()
859        };
860        let errs = validate_repository(&repo_spec_with_maintenance(Some(m)));
861        assert_eq!(
862            errs,
863            vec![ValidationError::MaintenanceNamespaceOnNamespacedRepo {
864                namespace: "kopia-system".into()
865            }]
866        );
867    }
868
869    #[test]
870    fn repository_maintenance_namespace_allowed_on_cluster_repo() {
871        let m = RepositoryMaintenanceSpec {
872            namespace: Some("kopia-system".into()),
873            ..Default::default()
874        };
875        // cluster_scoped = true: the namespace field is the placement selector.
876        assert!(validate_repository_maintenance(&m, true).is_empty());
877    }
878
879    #[test]
880    fn repository_maintenance_bad_override_cron_is_rejected() {
881        use crate::common::CronSpec;
882        use crate::maintenance::MaintenanceSchedule;
883        let m = RepositoryMaintenanceSpec {
884            schedule: Some(MaintenanceSchedule {
885                quick: CronSpec {
886                    cron: "totally bad".into(),
887                    jitter: None,
888                },
889                full: CronSpec {
890                    cron: "0 3 * * *".into(),
891                    jitter: None,
892                },
893                timezone: None,
894            }),
895            ..Default::default()
896        };
897        let errs = validate_repository_maintenance(&m, false);
898        assert!(
899            errs.iter()
900                .any(|e| matches!(e, ValidationError::InvalidCron { .. }))
901        );
902    }
903
904    #[test]
905    fn cluster_repository_rejects_all_false() {
906        use crate::backend::{Backend, FilesystemBackend};
907        use crate::common::{Encryption, SecretKeyRef};
908        let spec = ClusterRepositorySpec {
909            backend: Backend::Filesystem(FilesystemBackend {
910                path: "/r".into(),
911                pvc_name: None,
912            }),
913            encryption: Encryption {
914                password_secret_ref: SecretKeyRef {
915                    name: "s".into(),
916                    namespace: Some("kopia-system".into()),
917                    key: None,
918                },
919            },
920            create: None,
921            cache_defaults: None,
922            catalog: None,
923            allowed_namespaces: AllowedNamespaces::All(false),
924            identity_defaults: None,
925            maintenance: None,
926        };
927        assert!(!validate_cluster_repository(&spec).is_empty());
928    }
929}