Skip to main content

kopiur_api/validate/
snapshot.rs

1use super::*;
2use crate::common::ScheduleDeletePolicy;
3use crate::error::{ValidationError, ValidationResult};
4use crate::snapshot::{Origin, SnapshotSpec};
5use crate::snapshot_policy::{CopyMethod, Hook, HttpRequestHook, SnapshotPolicySpec};
6use crate::snapshot_schedule::SnapshotScheduleSpec;
7
8/// Validate the `spec.repository` / `spec.repositories` surface of a
9/// `SnapshotPolicy`, accumulating every independent problem:
10///
11///   1. **Exactly one of** the two shapes (mirrors the spec-level CEL rule, so
12///      the refusal also covers objects stored before the CRD carried it).
13///   2. Per-ref shape validity ([`validate_repository_ref`] over whatever
14///      refs exist — tolerant iteration, one error per bad ref).
15///   3. **Duplicate rejection** over `spec.repositories`, normalized via
16///      [`crate::common::repo_key`]. Validators are spec-only (no owner
17///      namespace here), so the key uses a fixed `""` owner-namespace
18///      sentinel: it is identical for every entry of ONE policy, which is all
19///      duplicate detection needs. (A `namespace`-omitted and an explicit
20///      same-namespace ref to one repository are not caught here; the webhook
21///      guards that identity-level collision separately.)
22///   4. `hooks` × `repositories` mutual exclusion
23///      ([`ValidationError::PolicyHooksWithRepositories`]) — hooks quiesce a
24///      workload around ONE capture; N concurrent fan-out children void that
25///      contract (use a single-repo policy + `SnapshotReplication` instead).
26///
27/// A well-formed multi-repo spec is ACCEPTED — the M7 "not yet enabled"
28/// feature gate was lifted once the fan-out data path (per-child pins,
29/// per-repo retention/cache/verification) landed end-to-end.
30fn validate_policy_repositories(spec: &SnapshotPolicySpec) -> Vec<ValidationError> {
31    let mut errs = Vec::new();
32    if let Err(e) = crate::snapshot_policy::policy_repositories(spec) {
33        errs.push(e);
34    }
35    for r in crate::snapshot_policy::repository_refs(spec) {
36        if let Err(e) = validate_repository_ref(r) {
37            errs.push(e);
38        }
39    }
40    let mut seen: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
41    for (i, r) in spec.repositories.iter().enumerate() {
42        let key = crate::common::repo_key(r, "");
43        if let Some(&first) = seen.get(&key) {
44            errs.push(ValidationError::PolicyRepositoriesDuplicate {
45                key,
46                first,
47                second: i,
48            });
49        } else {
50            seen.insert(key, i);
51        }
52    }
53    if crate::snapshot_policy::is_multi_repo(spec) && spec.hooks.is_some() {
54        errs.push(ValidationError::PolicyHooksWithRepositories);
55    }
56    errs
57}
58
59/// Validate a `SnapshotPolicy` spec, accumulating all problems.
60pub fn validate_backup_config(spec: &SnapshotPolicySpec) -> Vec<ValidationError> {
61    let mut errs = Vec::new();
62    errs.extend(validate_policy_repositories(spec));
63    if spec.sources.is_empty() {
64        errs.push(ValidationError::MissingRequiredField {
65            field: "spec.sources (at least one source required)".to_string(),
66        });
67    }
68    for source in &spec.sources {
69        if let Err(e) = validate_source(source) {
70            errs.push(e);
71        }
72    }
73    // A CSI volume CLONE has no group counterpart — there is no
74    // "VolumeGroupClone" — so `copyMethod: Clone` can only ever capture each PVC
75    // independently. Since `groupBy` server-side-defaults to
76    // `VolumeGroupSnapshot`, an untouched selector policy with `Clone` would
77    // silently get N unrelated live-volume clones while every member CR still
78    // named a VolumeGroupSnapshot that is never created. Refuse instead: a
79    // consistency guarantee that is quietly not honored is worse than none.
80    if spec.sources.iter().any(|s| s.pvc_selector.is_some())
81        && spec.copy_method == crate::snapshot_policy::CopyMethod::Clone
82        && spec.group_by != Some(crate::snapshot_policy::GroupBy::None)
83    {
84        errs.push(ValidationError::InvalidFieldValue {
85            field: "spec.groupBy".to_string(),
86            reason: "`copyMethod: Clone` clones each PVC independently and has no group \
87                     equivalent, so it cannot honor `groupBy: VolumeGroupSnapshot` (the \
88                     default). Use `copyMethod: Snapshot` for a consistency group, or set \
89                     `groupBy: None` to accept independent clones"
90                .to_string(),
91        });
92    }
93    // Identity shape (kopia's username@hostname:path contract). The explicit
94    // overrides are validated here — client-free, so this runs on every admission
95    // even when the webhook has no kube client. CEL-resolved values and the
96    // name/namespace defaults are validated where they are resolved
97    // (`resolve_identity`), and again defensively at reconcile time.
98    if let Some(id) = &spec.identity {
99        if let Some(u) = &id.username
100            && let Err(e) = validate_identity_component("spec.identity.username", u)
101        {
102            errs.push(e);
103        }
104        if let Some(h) = &id.hostname
105            && let Err(e) = validate_identity_component("spec.identity.hostname", h)
106        {
107            errs.push(e);
108        }
109    }
110    for (i, source) in spec.sources.iter().enumerate() {
111        if let Some(p) = &source.source_path_override
112            && let Err(e) =
113                validate_source_path(&format!("spec.sources[{i}].sourcePathOverride"), p)
114        {
115            errs.push(e);
116        }
117    }
118    // `copyMethod: Direct` + `readOnly: false` is the one combination that reaches the
119    // workload's own volume. `readOnly: false` is only ever set to make `fsGroup` apply,
120    // and the kubelet applies it by recursively chgrp-ing the mount and adding
121    // group-write. Under Snapshot/Clone that walk rewrites a throwaway staged PVC and is
122    // free; under Direct it permanently rewrites live production data while the workload
123    // runs — and the mover ships `fsGroup: 65532` by DEFAULT, so a user who sets one bool
124    // to fix a permissions error would have their data re-grouped with no other signal.
125    // Databases that refuse an over-permissive data directory (postgres, redis) fail to
126    // restart afterwards. Require the intent to be stated; it is not inferable.
127    for (i, source) in spec.sources.iter().enumerate() {
128        if crate::snapshot_policy::source_mutates_live_volume(spec.copy_method, source)
129            && !source.acknowledge_live_mutation.unwrap_or(false)
130        {
131            errs.push(ValidationError::InvalidFieldValue {
132                field: format!("spec.sources[{i}].readOnly"),
133                reason: "copyMethod: Direct with readOnly: false mounts the LIVE source volume \
134                         read-write, so the kubelet will recursively chgrp its contents to the \
135                         mover's fsGroup (65532 by default) and make them group-writable — \
136                         permanently, while the workload is running. Prefer copyMethod: \
137                         Snapshot/Clone, which applies fsGroup to a throwaway staged copy and \
138                         never touches your data. If you do mean to rewrite the live volume, \
139                         set acknowledgeLiveMutation: true on this source"
140                    .to_string(),
141            });
142        }
143    }
144    // `volumeSnapshotClassName` only applies when a PVC source is CSI-snapshotted/cloned
145    // (`copyMethod: Snapshot`/`Clone`). An NFS source has no PVC to snapshot, so pairing
146    // it with an explicit class is a configuration mistake — reject it at admission with
147    // an actionable message rather than silently ignoring the class. (`copyMethod` itself
148    // can't be rejected for NFS: it defaults to `Snapshot` implicitly and an NFS source
149    // is simply read directly.)
150    if spec.volume_snapshot_class_name.is_some() && spec.sources.iter().any(|s| s.nfs.is_some()) {
151        errs.push(ValidationError::InvalidFieldValue {
152            field: "spec.volumeSnapshotClassName".to_string(),
153            reason: "an NFS source cannot be CSI-snapshotted, so volumeSnapshotClassName is \
154                     meaningless with it; remove volumeSnapshotClassName (NFS is read directly), \
155                     or use a PVC source for copyMethod: Snapshot/Clone"
156                .to_string(),
157        });
158    }
159    if let Some(m) = &spec.mover {
160        // `inheritSecurityContextFrom.snapshot` replays a backup's RECORDED identity;
161        // a backup has no recorded identity to replay — it is the run that records one.
162        if let Err(e) = forbid_snapshot_inherit(
163            m,
164            "snapshotPolicy",
165            "a backup mover's identity is read from the live workload \
166             (pvcConsumer/workloadSelector), not from a snapshot; `snapshot` is restore-only",
167        ) {
168            errs.push(e);
169        }
170        if let Err(e) = validate_mover(m, "SnapshotPolicy mover") {
171            errs.push(e);
172        }
173    }
174    // `snapshot create --upload-limit-mb` (M4 flag sweep, issue #216): a count
175    // knob, must be at least 1 (0 or negative disables the flag's own purpose).
176    if let Some(u) = &spec.upload
177        && let Some(mb) = u.limit_mb
178        && let Some(e) = require_min(
179            "SnapshotPolicy spec.upload.limitMb",
180            mb,
181            NumericBound::Megabytes,
182        )
183    {
184        errs.push(e);
185    }
186    // Data-loss guard: a retention that selects no snapshots prunes EVERY Snapshot the
187    // moment it runs. `retention: None` means "don't prune" (safe) and is not flagged;
188    // only an explicit but empty/all-zero retention is the trap.
189    if let Some(r) = &spec.retention
190        && retention_keeps_nothing(r)
191    {
192        errs.push(ValidationError::InvalidFieldValue {
193            field: "spec.retention".to_string(),
194            reason: "keeps no snapshots — every keep* bucket is unset or 0, so GFS retention \
195                     would prune every Snapshot immediately (data loss). Set at least one bucket \
196                     (e.g. keepLatest: 1), or omit spec.retention entirely to disable pruning."
197                .to_string(),
198        });
199    }
200    // Verification (ADR-0005 §4): override schedules must parse, and the optional
201    // `successExpr` (ADR-0005 §15) must compile + trial-evaluate to a bool with no
202    // out-of-scope variable — rejected at admission rather than at first verify run.
203    if let Some(v) = &spec.verification {
204        if let Some(q) = &v.quick {
205            // The flat `verification.quick.cron` shape moved under `quick.schedule`
206            // (GitHub #174) — a required schedule would break decode of old persisted
207            // objects, so `schedule` is Option and this validator is the gate that
208            // rejects a re-apply of the old shape with an actionable pointer.
209            match &q.schedule {
210                None => errs.push(ValidationError::InvalidFieldValue {
211                    field: "spec.verification.quick.schedule".to_string(),
212                    reason: "the flat `verification.quick.cron` shape moved to \
213                             `verification.quick.schedule.cron` (matching `deep.schedule`). \
214                             Move your cron/jitter/timezone fields under `schedule:`."
215                        .to_string(),
216                }),
217                Some(s) => {
218                    if let Err(e) = validate_cron(&s.cron) {
219                        errs.push(e);
220                    }
221                    if let Err(e) = validate_timezone(s.timezone.as_deref()) {
222                        errs.push(e);
223                    }
224                    if let Err(e) = validate_jitter(
225                        "spec.verification.quick.schedule.jitter",
226                        s.jitter.as_deref(),
227                    ) {
228                        errs.push(e);
229                    }
230                }
231            }
232            // `kopia snapshot verify` tuning knobs: counts must be at least 1.
233            // `maxErrors` is deliberately unconstrained — 0 is a valid, meaningful
234            // value (kopia's own default, "stop at the first error").
235            if let Some(p) = q.parallel
236                && let Some(e) = require_min(
237                    "SnapshotPolicy spec.verification.quick.parallel",
238                    p.into(),
239                    NumericBound::Count,
240                )
241            {
242                errs.push(e);
243            }
244            if let Some(p) = q.file_parallelism
245                && let Some(e) = require_min(
246                    "SnapshotPolicy spec.verification.quick.fileParallelism",
247                    p.into(),
248                    NumericBound::Count,
249                )
250            {
251                errs.push(e);
252            }
253            if let Some(p) = q.file_queue_length
254                && let Some(e) = require_min(
255                    "SnapshotPolicy spec.verification.quick.fileQueueLength",
256                    p.into(),
257                    NumericBound::Count,
258                )
259            {
260                errs.push(e);
261            }
262        }
263        if let Some(d) = &v.deep {
264            if let Err(e) = validate_cron(&d.schedule.cron) {
265                errs.push(e);
266            }
267            if let Err(e) = validate_timezone(d.schedule.timezone.as_deref()) {
268                errs.push(e);
269            }
270            if let Err(e) = validate_jitter(
271                "spec.verification.deep.schedule.jitter",
272                d.schedule.jitter.as_deref(),
273            ) {
274                errs.push(e);
275            }
276            // `restore --parallel` under the hood (deep verify IS a restore).
277            if let Some(p) = d.parallel
278                && let Some(e) = require_min(
279                    "SnapshotPolicy spec.verification.deep.parallel",
280                    p.into(),
281                    NumericBound::Count,
282                )
283            {
284                errs.push(e);
285            }
286        }
287        if let Some(expr) = &v.success_expr
288            && let Err(e) = crate::success_expr::validate_success_expr(expr)
289        {
290            errs.push(e);
291        }
292    }
293    errs.extend(validate_staging(spec));
294    // Preflight: the timeout must parse, check names must be unique + non-blank, and
295    // each check expression must compile + trial-evaluate to a bool with no
296    // out-of-scope variable — rejected at admission rather than at the first run.
297    if let Some(pf) = &spec.preflight {
298        if let Some(t) = &pf.timeout
299            && crate::duration::parse_go_duration(t).is_none()
300        {
301            errs.push(ValidationError::InvalidFieldValue {
302                field: "spec.preflight.timeout".to_string(),
303                reason: format!(
304                    "{t:?} is not a valid duration. Use a Go-style duration like 10m or 1h; omit \
305                     for the default (10m), or 0 to hold indefinitely"
306                ),
307            });
308        }
309        let mut seen = std::collections::BTreeSet::new();
310        for (i, c) in pf.checks.iter().enumerate() {
311            let name = c.name.trim();
312            if name.is_empty() {
313                errs.push(ValidationError::MissingRequiredField {
314                    field: format!("spec.preflight.checks[{i}].name"),
315                });
316            } else if !seen.insert(name.to_string()) {
317                errs.push(ValidationError::InvalidFieldValue {
318                    field: format!("spec.preflight.checks[{i}].name"),
319                    reason: format!(
320                        "duplicate preflight check name {name:?}; names must be unique"
321                    ),
322                });
323            }
324            if let Err(e) = crate::preflight::validate_preflight_expr(&c.expr) {
325                errs.push(e);
326            }
327        }
328    }
329    // Hooks (ADR §4.8): per-hook shape problems are caught at admission rather
330    // than at the first backup run (where a quiesce hook failing on a typo would
331    // abort the backup).
332    if let Some(h) = &spec.hooks {
333        for (list, hooks) in [
334            ("beforeSnapshot", &h.before_snapshot),
335            ("afterSnapshot", &h.after_snapshot),
336        ] {
337            for (i, hook) in hooks.iter().enumerate() {
338                if let Err(e) = validate_hook(list, i, hook) {
339                    errs.push(e);
340                }
341            }
342        }
343    }
344    errs
345}
346
347/// Validate `spec.staging` (+ its interplay with `copyMethod` and the sources):
348///
349///   * `timeout` must parse — rejected at admission rather than silently falling
350///     back to the default at the first backup run.
351///   * `accessModes` entries must be canonical/unique, and `ReadWriteOncePod`
352///     sole ([`validate_access_modes`]).
353///   * The staged-PVC override fields (`storageClassName`/`accessModes`) must have
354///     a staged PVC to act on — rejected for `copyMethod: Direct` (no staged PVC
355///     at all), an NFS source (never staged), and `pvcSelector` sources (staging
356///     is skipped for selector expansion). The pre-existing `timeout` and
357///     `volumeSnapshotClassName` stay deliberately lenient in those combinations —
358///     tightening them now would reject already-persisted objects on re-apply.
359fn validate_staging(spec: &SnapshotPolicySpec) -> Vec<ValidationError> {
360    let mut errs = Vec::new();
361    let Some(st) = &spec.staging else {
362        return errs;
363    };
364    if let Some(t) = &st.timeout
365        && crate::duration::parse_go_duration(t).is_none()
366    {
367        errs.push(ValidationError::InvalidFieldValue {
368            field: "spec.staging.timeout".to_string(),
369            reason: format!(
370                "{t:?} is not a valid duration. Use a Go-style duration like 10m or 1h; omit \
371                 for the default (10m), or 0 to wait for the VolumeSnapshot indefinitely"
372            ),
373        });
374    }
375    errs.extend(validate_access_modes(
376        "spec.staging.accessModes",
377        &st.access_modes,
378    ));
379    // A ReadOnlyMany staged PVC cannot be mounted read-write: the kubelet fails the
380    // mount and the backup dies at run time with an opaque error. Catch it here.
381    if st.access_modes.contains(&PvcAccessMode::ReadOnlyMany)
382        && let Some(i) = spec
383            .sources
384            .iter()
385            .position(|s| !crate::snapshot_policy::source_read_only(s))
386    {
387        errs.push(ValidationError::InvalidFieldValue {
388            field: format!("spec.sources[{i}].readOnly"),
389            reason: "readOnly: false cannot be honored when spec.staging.accessModes is \
390                     [ReadOnlyMany]: the staged PVC is read-only, so mounting it read-write \
391                     fails at the kubelet and the backup never starts. Drop ReadOnlyMany (a \
392                     read-write staged PVC is what lets the kubelet apply fsGroup), or drop \
393                     readOnly: false"
394                .to_string(),
395        });
396    }
397    let overrides: Vec<&str> = [
398        (
399            "spec.staging.storageClassName",
400            st.storage_class_name.is_some(),
401        ),
402        ("spec.staging.accessModes", !st.access_modes.is_empty()),
403    ]
404    .into_iter()
405    .filter_map(|(name, present)| present.then_some(name))
406    .collect();
407    if overrides.is_empty() {
408        return errs;
409    }
410    let overrides = overrides.join(" / ");
411    match spec.copy_method {
412        CopyMethod::Direct => errs.push(ValidationError::InvalidFieldValue {
413            field: overrides.clone(),
414            reason: "copyMethod: Direct mounts the live source PVC — there is no staged PVC \
415                     to override. Remove the staged-PVC override(s), or use copyMethod: \
416                     Snapshot/Clone."
417                .to_string(),
418        }),
419        CopyMethod::Snapshot | CopyMethod::Clone => {}
420    }
421    if spec.sources.iter().any(|s| s.nfs.is_some()) {
422        errs.push(ValidationError::InvalidFieldValue {
423            field: overrides.clone(),
424            reason: "an NFS source is read directly and never staged, so a staged-PVC \
425                     override is meaningless with it; remove the override(s) or use a PVC \
426                     source for copyMethod: Snapshot/Clone"
427                .to_string(),
428        });
429    }
430    // NOTE: `pvcSelector` sources used to be rejected here on the grounds that
431    // they "are not CSI-staged". That was true only because the selector was
432    // never implemented (#346). Each expanded member is now an ordinary
433    // single-PVC Snapshot and stages exactly like one, so the override applies
434    // to every member and the rejection is gone.
435    let _ = overrides;
436    errs
437}
438
439/// Validate one hook entry — the controller executes these with the SAME parsers
440/// (Go-style `timeout`, URL/method for `httpRequest`), so a value admitted here
441/// can never fail to parse at run time. Exhaustive over [`Hook`].
442fn validate_hook(list: &str, index: usize, hook: &Hook) -> ValidationResult {
443    let field = |leaf: &str| format!("spec.hooks.{list}[{index}].{leaf}");
444    let check_timeout = |leaf: &str, t: Option<&str>| -> ValidationResult {
445        if let Some(t) = t
446            && crate::duration::parse_go_duration(t).is_none()
447        {
448            return Err(ValidationError::InvalidFieldValue {
449                field: field(leaf),
450                reason: format!(
451                    "{t:?} is not a valid Go-style duration; use a positive number with an \
452                     s/m/h suffix (e.g. 90s, 2m) — how long the hook may run before it is \
453                     treated as failed"
454                ),
455            });
456        }
457        Ok(())
458    };
459    match hook {
460        Hook::WorkloadExec(h) => {
461            if h.command.is_empty() {
462                return Err(ValidationError::MissingRequiredField {
463                    field: field("workloadExec.command"),
464                });
465            }
466            check_timeout("workloadExec.timeout", h.timeout.as_deref())
467        }
468        Hook::RunJob(h) => check_timeout("runJob.timeout", h.timeout.as_deref()),
469        Hook::HttpRequest(h) => {
470            if !(h.url.starts_with("http://") || h.url.starts_with("https://")) {
471                return Err(ValidationError::InvalidFieldValue {
472                    field: field("httpRequest.url"),
473                    reason: format!(
474                        "{:?} must be an absolute http:// or https:// URL the controller can \
475                         reach (e.g. http://notifier.tools.svc:8080/fire)",
476                        h.url
477                    ),
478                });
479            }
480            if let Some(m) = &h.method {
481                const METHODS: [&str; 7] =
482                    ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
483                if !METHODS.contains(&m.to_ascii_uppercase().as_str()) {
484                    return Err(ValidationError::InvalidFieldValue {
485                        field: field("httpRequest.method"),
486                        reason: format!(
487                            "{m:?} is not an HTTP method; use one of GET, POST (default), PUT, \
488                             PATCH, DELETE, HEAD, OPTIONS"
489                        ),
490                    });
491                }
492            }
493            if let Some(e) = validate_http_hook_headers(h, list, index) {
494                return Err(e);
495            }
496            check_timeout("httpRequest.timeout", h.timeout.as_deref())
497        }
498    }
499}
500
501/// Largest header name `http::HeaderName::from_bytes` will accept: `http` 1.4.2
502/// rejects anything longer via its `MAX_HEADER_NAME_LEN = 65535` guard. Mirrored
503/// EXACTLY here so a name admitted at the webhook can never fail to parse at run
504/// time (the branch's "anything admitted never fails at runtime" guarantee).
505const MAX_HEADER_NAME_LEN: usize = 65_535;
506
507/// RFC 7230 token — exactly the character set `http::HeaderName` accepts. This
508/// is the token check only; `http` ALSO caps the byte length at
509/// [`MAX_HEADER_NAME_LEN`], enforced separately in [`header_name_error`].
510fn is_valid_header_name(name: &str) -> bool {
511    !name.is_empty()
512        && name.bytes().all(|b| {
513            matches!(b,
514                b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9'
515                | b'!' | b'#' | b'$' | b'%' | b'&' | b'\'' | b'*' | b'+'
516                | b'-' | b'.' | b'^' | b'_' | b'`' | b'|' | b'~')
517        })
518}
519
520/// Validate a single header name against everything `http::HeaderName` enforces
521/// — the RFC 7230 token set AND the [`MAX_HEADER_NAME_LEN`] byte cap — returning
522/// the first problem as a ready-to-return [`ValidationError`]. A length problem
523/// is not a token problem, so each carries its own what/why/fix message. Split
524/// out of [`validate_http_hook_headers`] to keep that function's branch count
525/// (and cognitive complexity) unchanged.
526fn header_name_error(name: &str, list: &str, i: usize, j: usize) -> Option<ValidationError> {
527    if !is_valid_header_name(name) {
528        return Some(ValidationError::InvalidFieldValue {
529            field: format!("spec.hooks.{list}[{i}].httpRequest.headers[{j}].name"),
530            reason: format!(
531                "{name:?} is not a valid HTTP header name — names are case-insensitive \
532                 RFC 7230 tokens (letters, digits, and !#$%&'*+-.^_`|~); remove \
533                 spaces and other separators"
534            ),
535        });
536    }
537    if name.len() > MAX_HEADER_NAME_LEN {
538        return Some(ValidationError::InvalidFieldValue {
539            field: format!("spec.hooks.{list}[{i}].httpRequest.headers[{j}].name"),
540            reason: format!(
541                "header name is {} bytes — HTTP header names are limited to \
542                 {MAX_HEADER_NAME_LEN} bytes; use a shorter name",
543                name.len()
544            ),
545        });
546    }
547    None
548}
549
550/// Field-content bytes `http::HeaderValue::from_str` accepts: HTAB, or any
551/// byte >= 0x20 except DEL (0x7F). Blocks CR/LF header injection.
552fn is_valid_header_value(value: &str) -> bool {
553    value
554        .bytes()
555        .all(|b| b == b'\t' || (b >= 0x20 && b != 0x7f))
556}
557
558/// True when the URL's authority component carries `user[:pass]@` credentials.
559fn url_has_userinfo(url: &str) -> bool {
560    let rest = url.split_once("://").map_or(url, |(_, r)| r);
561    let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
562    authority.contains('@')
563}
564
565/// Validate an `httpRequest` hook's headers so a value admitted here can never
566/// fail `http::HeaderName`/`HeaderValue` parsing at run time. Extracted from
567/// [`validate_hook`] to keep that match arm below the cognitive-complexity
568/// ratchet. Returns the first problem found, matching the arm's early-return
569/// style; the field paths mirror the CRD shape
570/// (`spec.hooks.{list}[{i}].httpRequest.headers[{j}].{name|value}`).
571fn validate_http_hook_headers(
572    h: &HttpRequestHook,
573    list: &str,
574    i: usize,
575) -> Option<ValidationError> {
576    let mut seen: Vec<String> = Vec::new();
577    for (j, header) in h.headers.iter().enumerate() {
578        if let Some(e) = header_name_error(&header.name, list, i, j) {
579            return Some(e);
580        }
581        if !is_valid_header_value(&header.value) {
582            return Some(ValidationError::InvalidFieldValue {
583                field: format!("spec.hooks.{list}[{i}].httpRequest.headers[{j}].value"),
584                reason: "control characters (including CR/LF) are not allowed in header \
585                         values — put multi-line payloads in `body`, not a header"
586                    .into(),
587            });
588        }
589        let lower = header.name.to_ascii_lowercase();
590        if seen.contains(&lower) {
591            return Some(ValidationError::InvalidFieldValue {
592                field: format!("spec.hooks.{list}[{i}].httpRequest.headers[{j}].name"),
593                reason: format!(
594                    "duplicate header {:?} — each header may be set once; combine values \
595                     into a single comma-separated header if the endpoint expects repeats",
596                    header.name
597                ),
598            });
599        }
600        seen.push(lower);
601    }
602    if url_has_userinfo(&h.url)
603        && h.headers
604            .iter()
605            .any(|hd| hd.name.eq_ignore_ascii_case("authorization"))
606    {
607        return Some(ValidationError::InvalidFieldValue {
608            field: format!("spec.hooks.{list}[{i}].httpRequest.headers"),
609            reason: "an explicit Authorization header conflicts with credentials in the \
610                     URL (user:pass@…) — use one auth source, not both"
611                .into(),
612        });
613    }
614    None
615}
616
617/// Validate a `Snapshot` spec for a given origin, accumulating all problems.
618///
619/// `origin` is supplied by the caller because the canonical value lives in
620/// `status.origin` / the `kopiur.home-operations.com/origin` label, not in
621/// `spec` (ADR §3.4). `None` means the row carries an origin marker the caller
622/// could not parse (`Origin::parse` returned `None`): the origin-GATED rules
623/// (deletionPolicy legality, onScheduleDelete) are **skipped, not failed** —
624/// refusing would wedge metadata-only writes (finalizer removal above all) on
625/// rows written under a newer operator during version skew, while skipping is
626/// safe because the controller's conservative resolution of an unparseable
627/// origin (forced `Retain`, never scheduled, never run) makes both gated
628/// fields inert at reconcile time. The origin-independent rules still run.
629pub fn validate_backup(spec: &SnapshotSpec, origin: Option<Origin>) -> Vec<ValidationError> {
630    let mut errs = Vec::new();
631    if let Some(origin) = origin {
632        if let Err(e) = validate_backup_deletion_policy(origin, spec.deletion_policy) {
633            errs.push(e);
634        }
635        if let Err(e) = validate_backup_on_schedule_delete(origin, spec.on_schedule_delete) {
636            errs.push(e);
637        }
638    }
639    if let Some(fp) = &spec.failure_policy
640        && let Err(e) = validate_failure_policy(fp, "Snapshot")
641    {
642        errs.push(e);
643    }
644    errs.extend(validate_snapshot_tags(spec.tags.as_ref()));
645    errs
646}
647
648/// At most this many user tags per `Snapshot` — unbounded user tags would
649/// inflate every kopia manifest AND the catalog result wire.
650pub const MAX_SNAPSHOT_TAGS: usize = 10;
651/// Longest admissible user tag key, in bytes.
652pub const MAX_SNAPSHOT_TAG_KEY_LEN: usize = 63;
653/// Longest admissible user tag value, in bytes.
654pub const MAX_SNAPSHOT_TAG_VALUE_LEN: usize = 256;
655
656/// Why one `spec.tags` key/value pair is invalid, or `None` when it is clean.
657///
658/// One predicate, two callers (the shared-validator pattern): the admission
659/// validator ([`validate_snapshot_tags`]) rejects NEW objects with these
660/// reasons, and the controller's build path defensively SKIPS (warn, never
661/// fail) the same keys on already-stored pre-feature objects.
662pub fn snapshot_tag_error(key: &str, value: &str) -> Option<String> {
663    if key.is_empty() {
664        return Some("tag keys must be non-empty; remove the empty key".to_string());
665    }
666    if key.contains(':') {
667        return Some(format!(
668            "tag key {key:?} contains a colon — kopia splits each `--tags` arg on the first \
669             colon, so the text after it becomes the value and can collide with the reserved \
670             `kopiur:config` tag, failing snapshot create with a duplicate-tag error. Use a \
671             colon-free key."
672        ));
673    }
674    if key.starts_with("kopiur") {
675        return Some(format!(
676            "tag key {key:?} uses the reserved `kopiur` prefix — kopiur writes its own tags \
677             there (`kopiur:config`, `kopiur-meta`) and a user tag under that prefix would \
678             collide with or spoof them. Pick a key that does not start with `kopiur`."
679        ));
680    }
681    if key.len() > MAX_SNAPSHOT_TAG_KEY_LEN {
682        return Some(format!(
683            "tag key is {} bytes; keys are limited to {MAX_SNAPSHOT_TAG_KEY_LEN} bytes — use a \
684             shorter key",
685            key.len()
686        ));
687    }
688    if value.len() > MAX_SNAPSHOT_TAG_VALUE_LEN {
689        return Some(format!(
690            "tag value is {} bytes; values are limited to {MAX_SNAPSHOT_TAG_VALUE_LEN} bytes — \
691             every tag is stored on the kopia manifest and read back by every catalog scan, so \
692             unbounded values inflate the repository and the scan wire. Use a shorter value.",
693            value.len()
694        ));
695    }
696    None
697}
698
699/// Validate `Snapshot.spec.tags` (admission): every key/value must pass
700/// [`snapshot_tag_error`] and the map is bounded to [`MAX_SNAPSHOT_TAGS`]
701/// entries. Accumulates every problem, one error per offending tag.
702pub fn validate_snapshot_tags(
703    tags: Option<&std::collections::BTreeMap<String, String>>,
704) -> Vec<ValidationError> {
705    let mut errs = Vec::new();
706    let Some(tags) = tags else {
707        return errs;
708    };
709    if tags.len() > MAX_SNAPSHOT_TAGS {
710        errs.push(ValidationError::InvalidFieldValue {
711            field: "spec.tags".to_string(),
712            reason: format!(
713                "{} tags; at most {MAX_SNAPSHOT_TAGS} user tags are allowed per Snapshot — \
714                 every tag is stored on the kopia manifest and read back by every catalog \
715                 scan. Remove tags until at most {MAX_SNAPSHOT_TAGS} remain.",
716                tags.len()
717            ),
718        });
719    }
720    for (key, value) in tags {
721        if let Some(reason) = snapshot_tag_error(key, value) {
722            errs.push(ValidationError::InvalidFieldValue {
723                field: format!("spec.tags[{key:?}]"),
724                reason,
725            });
726        }
727    }
728    errs
729}
730
731/// `origin: discovered` Snapshots carry an empty spec; a stamped cascade
732/// policy on one is meaningless (their owner is a repository, not a schedule)
733/// and forbidden, like a non-Retain deletionPolicy. `origin: adopted` is
734/// forbidden for the same reason: an adopted row's owner is the
735/// `SnapshotPolicy` it was re-attached to, never a `SnapshotSchedule`. So is
736/// `origin: replicated`: a copy CR belongs to its `SnapshotReplication`, and
737/// no `SnapshotSchedule` ever fires (or cascades onto) one.
738pub fn validate_backup_on_schedule_delete(
739    origin: Origin,
740    value: Option<ScheduleDeletePolicy>,
741) -> ValidationResult {
742    match origin {
743        Origin::Discovered | Origin::Adopted | Origin::Replicated => match value {
744            None => Ok(()),
745            Some(v) => Err(ValidationError::DiscoveredCannotSetOnScheduleDelete {
746                origin: origin.label_value(),
747                got: format!("{v:?}"),
748            }),
749        },
750        Origin::Scheduled | Origin::Manual => Ok(()),
751    }
752}
753
754/// Exactly one of `policyRef` / `policySelector` is set on a `SnapshotSchedule`
755/// (ADR-0005 §10). Neither ⇒ `MissingRequiredField`; both ⇒ `MutuallyExclusive`.
756/// Pure so the XOR decision is unit-tested directly.
757pub fn validate_schedule_policy_target(spec: &SnapshotScheduleSpec) -> ValidationResult {
758    match (spec.policy_ref.is_some(), spec.policy_selector.is_some()) {
759        (true, true) => Err(ValidationError::MutuallyExclusive {
760            a: "policyRef".to_string(),
761            b: "policySelector".to_string(),
762            context: "SnapshotSchedule".to_string(),
763        }),
764        (false, false) => Err(ValidationError::MissingRequiredField {
765            field: "exactly one of spec.policyRef or spec.policySelector".to_string(),
766        }),
767        _ => Ok(()),
768    }
769}
770
771/// Validate a `SnapshotSchedule` spec, accumulating all problems.
772pub fn validate_backup_schedule(spec: &SnapshotScheduleSpec) -> Vec<ValidationError> {
773    let mut errs = Vec::new();
774    if let Err(e) = validate_schedule_policy_target(spec) {
775        errs.push(e);
776    }
777    if let Err(e) = validate_cron(&spec.schedule.cron) {
778        errs.push(e);
779    }
780    if let Err(e) = validate_timezone(spec.schedule.timezone.as_deref()) {
781        errs.push(e);
782    }
783    if let Err(e) = validate_jitter("spec.schedule.jitter", spec.schedule.jitter.as_deref()) {
784        errs.push(e);
785    }
786    errs
787}