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
8pub 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 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 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 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 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 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 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 if let Some(v) = &spec.verification {
131 if let Some(q) = &v.quick {
132 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 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 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 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 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
274fn 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 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
371fn 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
433const MAX_HEADER_NAME_LEN: usize = 65_535;
438
439fn 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
452fn 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
482fn is_valid_header_value(value: &str) -> bool {
485 value
486 .bytes()
487 .all(|b| b == b'\t' || (b >= 0x20 && b != 0x7f))
488}
489
490fn 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
497fn 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
549pub 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
570pub const MAX_SNAPSHOT_TAGS: usize = 10;
573pub const MAX_SNAPSHOT_TAG_KEY_LEN: usize = 63;
575pub const MAX_SNAPSHOT_TAG_VALUE_LEN: usize = 256;
577
578pub 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
622pub 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
654pub 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
675pub 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
692pub 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}