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