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