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
8fn 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
59pub 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 if spec.sources.iter().any(|s| s.pvc_selector.is_some())
81 && spec.copy_method == crate::snapshot_policy::CopyMethod::Clone
82 && spec.group_by != Some(crate::snapshot_policy::GroupBy::None)
83 {
84 errs.push(ValidationError::InvalidFieldValue {
85 field: "spec.groupBy".to_string(),
86 reason: "`copyMethod: Clone` clones each PVC independently and has no group \
87 equivalent, so it cannot honor `groupBy: VolumeGroupSnapshot` (the \
88 default). Use `copyMethod: Snapshot` for a consistency group, or set \
89 `groupBy: None` to accept independent clones"
90 .to_string(),
91 });
92 }
93 if let Some(id) = &spec.identity {
99 if let Some(u) = &id.username
100 && let Err(e) = validate_identity_component("spec.identity.username", u)
101 {
102 errs.push(e);
103 }
104 if let Some(h) = &id.hostname
105 && let Err(e) = validate_identity_component("spec.identity.hostname", h)
106 {
107 errs.push(e);
108 }
109 }
110 for (i, source) in spec.sources.iter().enumerate() {
111 if let Some(p) = &source.source_path_override
112 && let Err(e) =
113 validate_source_path(&format!("spec.sources[{i}].sourcePathOverride"), p)
114 {
115 errs.push(e);
116 }
117 }
118 for (i, source) in spec.sources.iter().enumerate() {
128 if crate::snapshot_policy::source_mutates_live_volume(spec.copy_method, source)
129 && !source.acknowledge_live_mutation.unwrap_or(false)
130 {
131 errs.push(ValidationError::InvalidFieldValue {
132 field: format!("spec.sources[{i}].readOnly"),
133 reason: "copyMethod: Direct with readOnly: false mounts the LIVE source volume \
134 read-write, so the kubelet will recursively chgrp its contents to the \
135 mover's fsGroup (65532 by default) and make them group-writable — \
136 permanently, while the workload is running. Prefer copyMethod: \
137 Snapshot/Clone, which applies fsGroup to a throwaway staged copy and \
138 never touches your data. If you do mean to rewrite the live volume, \
139 set acknowledgeLiveMutation: true on this source"
140 .to_string(),
141 });
142 }
143 }
144 if spec.volume_snapshot_class_name.is_some() && spec.sources.iter().any(|s| s.nfs.is_some()) {
151 errs.push(ValidationError::InvalidFieldValue {
152 field: "spec.volumeSnapshotClassName".to_string(),
153 reason: "an NFS source cannot be CSI-snapshotted, so volumeSnapshotClassName is \
154 meaningless with it; remove volumeSnapshotClassName (NFS is read directly), \
155 or use a PVC source for copyMethod: Snapshot/Clone"
156 .to_string(),
157 });
158 }
159 if let Some(m) = &spec.mover {
160 if let Err(e) = forbid_snapshot_inherit(
163 m,
164 "snapshotPolicy",
165 "a backup mover's identity is read from the live workload \
166 (pvcConsumer/workloadSelector), not from a snapshot; `snapshot` is restore-only",
167 ) {
168 errs.push(e);
169 }
170 if let Err(e) = validate_mover(m, "SnapshotPolicy mover") {
171 errs.push(e);
172 }
173 }
174 if let Some(u) = &spec.upload
177 && let Some(mb) = u.limit_mb
178 && let Some(e) = require_min(
179 "SnapshotPolicy spec.upload.limitMb",
180 mb,
181 NumericBound::Megabytes,
182 )
183 {
184 errs.push(e);
185 }
186 if let Some(r) = &spec.retention
190 && retention_keeps_nothing(r)
191 {
192 errs.push(ValidationError::InvalidFieldValue {
193 field: "spec.retention".to_string(),
194 reason: "keeps no snapshots — every keep* bucket is unset or 0, so GFS retention \
195 would prune every Snapshot immediately (data loss). Set at least one bucket \
196 (e.g. keepLatest: 1), or omit spec.retention entirely to disable pruning."
197 .to_string(),
198 });
199 }
200 if let Some(v) = &spec.verification {
204 if let Some(q) = &v.quick {
205 match &q.schedule {
210 None => errs.push(ValidationError::InvalidFieldValue {
211 field: "spec.verification.quick.schedule".to_string(),
212 reason: "the flat `verification.quick.cron` shape moved to \
213 `verification.quick.schedule.cron` (matching `deep.schedule`). \
214 Move your cron/jitter/timezone fields under `schedule:`."
215 .to_string(),
216 }),
217 Some(s) => {
218 if let Err(e) = validate_cron(&s.cron) {
219 errs.push(e);
220 }
221 if let Err(e) = validate_timezone(s.timezone.as_deref()) {
222 errs.push(e);
223 }
224 if let Err(e) = validate_jitter(
225 "spec.verification.quick.schedule.jitter",
226 s.jitter.as_deref(),
227 ) {
228 errs.push(e);
229 }
230 }
231 }
232 if let Some(p) = q.parallel
236 && let Some(e) = require_min(
237 "SnapshotPolicy spec.verification.quick.parallel",
238 p.into(),
239 NumericBound::Count,
240 )
241 {
242 errs.push(e);
243 }
244 if let Some(p) = q.file_parallelism
245 && let Some(e) = require_min(
246 "SnapshotPolicy spec.verification.quick.fileParallelism",
247 p.into(),
248 NumericBound::Count,
249 )
250 {
251 errs.push(e);
252 }
253 if let Some(p) = q.file_queue_length
254 && let Some(e) = require_min(
255 "SnapshotPolicy spec.verification.quick.fileQueueLength",
256 p.into(),
257 NumericBound::Count,
258 )
259 {
260 errs.push(e);
261 }
262 }
263 if let Some(d) = &v.deep {
264 if let Err(e) = validate_cron(&d.schedule.cron) {
265 errs.push(e);
266 }
267 if let Err(e) = validate_timezone(d.schedule.timezone.as_deref()) {
268 errs.push(e);
269 }
270 if let Err(e) = validate_jitter(
271 "spec.verification.deep.schedule.jitter",
272 d.schedule.jitter.as_deref(),
273 ) {
274 errs.push(e);
275 }
276 if let Some(p) = d.parallel
278 && let Some(e) = require_min(
279 "SnapshotPolicy spec.verification.deep.parallel",
280 p.into(),
281 NumericBound::Count,
282 )
283 {
284 errs.push(e);
285 }
286 }
287 if let Some(expr) = &v.success_expr
288 && let Err(e) = crate::success_expr::validate_success_expr(expr)
289 {
290 errs.push(e);
291 }
292 }
293 errs.extend(validate_staging(spec));
294 if let Some(pf) = &spec.preflight {
298 if let Some(t) = &pf.timeout
299 && crate::duration::parse_go_duration(t).is_none()
300 {
301 errs.push(ValidationError::InvalidFieldValue {
302 field: "spec.preflight.timeout".to_string(),
303 reason: format!(
304 "{t:?} is not a valid duration. Use a Go-style duration like 10m or 1h; omit \
305 for the default (10m), or 0 to hold indefinitely"
306 ),
307 });
308 }
309 let mut seen = std::collections::BTreeSet::new();
310 for (i, c) in pf.checks.iter().enumerate() {
311 let name = c.name.trim();
312 if name.is_empty() {
313 errs.push(ValidationError::MissingRequiredField {
314 field: format!("spec.preflight.checks[{i}].name"),
315 });
316 } else if !seen.insert(name.to_string()) {
317 errs.push(ValidationError::InvalidFieldValue {
318 field: format!("spec.preflight.checks[{i}].name"),
319 reason: format!(
320 "duplicate preflight check name {name:?}; names must be unique"
321 ),
322 });
323 }
324 if let Err(e) = crate::preflight::validate_preflight_expr(&c.expr) {
325 errs.push(e);
326 }
327 }
328 }
329 if let Some(h) = &spec.hooks {
333 for (list, hooks) in [
334 ("beforeSnapshot", &h.before_snapshot),
335 ("afterSnapshot", &h.after_snapshot),
336 ] {
337 for (i, hook) in hooks.iter().enumerate() {
338 if let Err(e) = validate_hook(list, i, hook) {
339 errs.push(e);
340 }
341 }
342 }
343 }
344 errs
345}
346
347fn validate_staging(spec: &SnapshotPolicySpec) -> Vec<ValidationError> {
360 let mut errs = Vec::new();
361 let Some(st) = &spec.staging else {
362 return errs;
363 };
364 if let Some(t) = &st.timeout
365 && crate::duration::parse_go_duration(t).is_none()
366 {
367 errs.push(ValidationError::InvalidFieldValue {
368 field: "spec.staging.timeout".to_string(),
369 reason: format!(
370 "{t:?} is not a valid duration. Use a Go-style duration like 10m or 1h; omit \
371 for the default (10m), or 0 to wait for the VolumeSnapshot indefinitely"
372 ),
373 });
374 }
375 errs.extend(validate_access_modes(
376 "spec.staging.accessModes",
377 &st.access_modes,
378 ));
379 if st.access_modes.contains(&PvcAccessMode::ReadOnlyMany)
382 && let Some(i) = spec
383 .sources
384 .iter()
385 .position(|s| !crate::snapshot_policy::source_read_only(s))
386 {
387 errs.push(ValidationError::InvalidFieldValue {
388 field: format!("spec.sources[{i}].readOnly"),
389 reason: "readOnly: false cannot be honored when spec.staging.accessModes is \
390 [ReadOnlyMany]: the staged PVC is read-only, so mounting it read-write \
391 fails at the kubelet and the backup never starts. Drop ReadOnlyMany (a \
392 read-write staged PVC is what lets the kubelet apply fsGroup), or drop \
393 readOnly: false"
394 .to_string(),
395 });
396 }
397 let overrides: Vec<&str> = [
398 (
399 "spec.staging.storageClassName",
400 st.storage_class_name.is_some(),
401 ),
402 ("spec.staging.accessModes", !st.access_modes.is_empty()),
403 ]
404 .into_iter()
405 .filter_map(|(name, present)| present.then_some(name))
406 .collect();
407 if overrides.is_empty() {
408 return errs;
409 }
410 let overrides = overrides.join(" / ");
411 match spec.copy_method {
412 CopyMethod::Direct => errs.push(ValidationError::InvalidFieldValue {
413 field: overrides.clone(),
414 reason: "copyMethod: Direct mounts the live source PVC — there is no staged PVC \
415 to override. Remove the staged-PVC override(s), or use copyMethod: \
416 Snapshot/Clone."
417 .to_string(),
418 }),
419 CopyMethod::Snapshot | CopyMethod::Clone => {}
420 }
421 if spec.sources.iter().any(|s| s.nfs.is_some()) {
422 errs.push(ValidationError::InvalidFieldValue {
423 field: overrides.clone(),
424 reason: "an NFS source is read directly and never staged, so a staged-PVC \
425 override is meaningless with it; remove the override(s) or use a PVC \
426 source for copyMethod: Snapshot/Clone"
427 .to_string(),
428 });
429 }
430 let _ = overrides;
436 errs
437}
438
439fn validate_hook(list: &str, index: usize, hook: &Hook) -> ValidationResult {
443 let field = |leaf: &str| format!("spec.hooks.{list}[{index}].{leaf}");
444 let check_timeout = |leaf: &str, t: Option<&str>| -> ValidationResult {
445 if let Some(t) = t
446 && crate::duration::parse_go_duration(t).is_none()
447 {
448 return Err(ValidationError::InvalidFieldValue {
449 field: field(leaf),
450 reason: format!(
451 "{t:?} is not a valid Go-style duration; use a positive number with an \
452 s/m/h suffix (e.g. 90s, 2m) — how long the hook may run before it is \
453 treated as failed"
454 ),
455 });
456 }
457 Ok(())
458 };
459 match hook {
460 Hook::WorkloadExec(h) => {
461 if h.command.is_empty() {
462 return Err(ValidationError::MissingRequiredField {
463 field: field("workloadExec.command"),
464 });
465 }
466 check_timeout("workloadExec.timeout", h.timeout.as_deref())
467 }
468 Hook::RunJob(h) => check_timeout("runJob.timeout", h.timeout.as_deref()),
469 Hook::HttpRequest(h) => {
470 if !(h.url.starts_with("http://") || h.url.starts_with("https://")) {
471 return Err(ValidationError::InvalidFieldValue {
472 field: field("httpRequest.url"),
473 reason: format!(
474 "{:?} must be an absolute http:// or https:// URL the controller can \
475 reach (e.g. http://notifier.tools.svc:8080/fire)",
476 h.url
477 ),
478 });
479 }
480 if let Some(m) = &h.method {
481 const METHODS: [&str; 7] =
482 ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
483 if !METHODS.contains(&m.to_ascii_uppercase().as_str()) {
484 return Err(ValidationError::InvalidFieldValue {
485 field: field("httpRequest.method"),
486 reason: format!(
487 "{m:?} is not an HTTP method; use one of GET, POST (default), PUT, \
488 PATCH, DELETE, HEAD, OPTIONS"
489 ),
490 });
491 }
492 }
493 if let Some(e) = validate_http_hook_headers(h, list, index) {
494 return Err(e);
495 }
496 check_timeout("httpRequest.timeout", h.timeout.as_deref())
497 }
498 }
499}
500
501const MAX_HEADER_NAME_LEN: usize = 65_535;
506
507fn is_valid_header_name(name: &str) -> bool {
511 !name.is_empty()
512 && name.bytes().all(|b| {
513 matches!(b,
514 b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9'
515 | b'!' | b'#' | b'$' | b'%' | b'&' | b'\'' | b'*' | b'+'
516 | b'-' | b'.' | b'^' | b'_' | b'`' | b'|' | b'~')
517 })
518}
519
520fn header_name_error(name: &str, list: &str, i: usize, j: usize) -> Option<ValidationError> {
527 if !is_valid_header_name(name) {
528 return Some(ValidationError::InvalidFieldValue {
529 field: format!("spec.hooks.{list}[{i}].httpRequest.headers[{j}].name"),
530 reason: format!(
531 "{name:?} is not a valid HTTP header name — names are case-insensitive \
532 RFC 7230 tokens (letters, digits, and !#$%&'*+-.^_`|~); remove \
533 spaces and other separators"
534 ),
535 });
536 }
537 if name.len() > MAX_HEADER_NAME_LEN {
538 return Some(ValidationError::InvalidFieldValue {
539 field: format!("spec.hooks.{list}[{i}].httpRequest.headers[{j}].name"),
540 reason: format!(
541 "header name is {} bytes — HTTP header names are limited to \
542 {MAX_HEADER_NAME_LEN} bytes; use a shorter name",
543 name.len()
544 ),
545 });
546 }
547 None
548}
549
550fn is_valid_header_value(value: &str) -> bool {
553 value
554 .bytes()
555 .all(|b| b == b'\t' || (b >= 0x20 && b != 0x7f))
556}
557
558fn url_has_userinfo(url: &str) -> bool {
560 let rest = url.split_once("://").map_or(url, |(_, r)| r);
561 let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
562 authority.contains('@')
563}
564
565fn validate_http_hook_headers(
572 h: &HttpRequestHook,
573 list: &str,
574 i: usize,
575) -> Option<ValidationError> {
576 let mut seen: Vec<String> = Vec::new();
577 for (j, header) in h.headers.iter().enumerate() {
578 if let Some(e) = header_name_error(&header.name, list, i, j) {
579 return Some(e);
580 }
581 if !is_valid_header_value(&header.value) {
582 return Some(ValidationError::InvalidFieldValue {
583 field: format!("spec.hooks.{list}[{i}].httpRequest.headers[{j}].value"),
584 reason: "control characters (including CR/LF) are not allowed in header \
585 values — put multi-line payloads in `body`, not a header"
586 .into(),
587 });
588 }
589 let lower = header.name.to_ascii_lowercase();
590 if seen.contains(&lower) {
591 return Some(ValidationError::InvalidFieldValue {
592 field: format!("spec.hooks.{list}[{i}].httpRequest.headers[{j}].name"),
593 reason: format!(
594 "duplicate header {:?} — each header may be set once; combine values \
595 into a single comma-separated header if the endpoint expects repeats",
596 header.name
597 ),
598 });
599 }
600 seen.push(lower);
601 }
602 if url_has_userinfo(&h.url)
603 && h.headers
604 .iter()
605 .any(|hd| hd.name.eq_ignore_ascii_case("authorization"))
606 {
607 return Some(ValidationError::InvalidFieldValue {
608 field: format!("spec.hooks.{list}[{i}].httpRequest.headers"),
609 reason: "an explicit Authorization header conflicts with credentials in the \
610 URL (user:pass@…) — use one auth source, not both"
611 .into(),
612 });
613 }
614 None
615}
616
617pub fn validate_backup(spec: &SnapshotSpec, origin: Option<Origin>) -> Vec<ValidationError> {
630 let mut errs = Vec::new();
631 if let Some(origin) = origin {
632 if let Err(e) = validate_backup_deletion_policy(origin, spec.deletion_policy) {
633 errs.push(e);
634 }
635 if let Err(e) = validate_backup_on_schedule_delete(origin, spec.on_schedule_delete) {
636 errs.push(e);
637 }
638 }
639 if let Some(fp) = &spec.failure_policy
640 && let Err(e) = validate_failure_policy(fp, "Snapshot")
641 {
642 errs.push(e);
643 }
644 errs.extend(validate_snapshot_tags(spec.tags.as_ref()));
645 errs
646}
647
648pub const MAX_SNAPSHOT_TAGS: usize = 10;
651pub const MAX_SNAPSHOT_TAG_KEY_LEN: usize = 63;
653pub const MAX_SNAPSHOT_TAG_VALUE_LEN: usize = 256;
655
656pub fn snapshot_tag_error(key: &str, value: &str) -> Option<String> {
663 if key.is_empty() {
664 return Some("tag keys must be non-empty; remove the empty key".to_string());
665 }
666 if key.contains(':') {
667 return Some(format!(
668 "tag key {key:?} contains a colon — kopia splits each `--tags` arg on the first \
669 colon, so the text after it becomes the value and can collide with the reserved \
670 `kopiur:config` tag, failing snapshot create with a duplicate-tag error. Use a \
671 colon-free key."
672 ));
673 }
674 if key.starts_with("kopiur") {
675 return Some(format!(
676 "tag key {key:?} uses the reserved `kopiur` prefix — kopiur writes its own tags \
677 there (`kopiur:config`, `kopiur-meta`) and a user tag under that prefix would \
678 collide with or spoof them. Pick a key that does not start with `kopiur`."
679 ));
680 }
681 if key.len() > MAX_SNAPSHOT_TAG_KEY_LEN {
682 return Some(format!(
683 "tag key is {} bytes; keys are limited to {MAX_SNAPSHOT_TAG_KEY_LEN} bytes — use a \
684 shorter key",
685 key.len()
686 ));
687 }
688 if value.len() > MAX_SNAPSHOT_TAG_VALUE_LEN {
689 return Some(format!(
690 "tag value is {} bytes; values are limited to {MAX_SNAPSHOT_TAG_VALUE_LEN} bytes — \
691 every tag is stored on the kopia manifest and read back by every catalog scan, so \
692 unbounded values inflate the repository and the scan wire. Use a shorter value.",
693 value.len()
694 ));
695 }
696 None
697}
698
699pub fn validate_snapshot_tags(
703 tags: Option<&std::collections::BTreeMap<String, String>>,
704) -> Vec<ValidationError> {
705 let mut errs = Vec::new();
706 let Some(tags) = tags else {
707 return errs;
708 };
709 if tags.len() > MAX_SNAPSHOT_TAGS {
710 errs.push(ValidationError::InvalidFieldValue {
711 field: "spec.tags".to_string(),
712 reason: format!(
713 "{} tags; at most {MAX_SNAPSHOT_TAGS} user tags are allowed per Snapshot — \
714 every tag is stored on the kopia manifest and read back by every catalog \
715 scan. Remove tags until at most {MAX_SNAPSHOT_TAGS} remain.",
716 tags.len()
717 ),
718 });
719 }
720 for (key, value) in tags {
721 if let Some(reason) = snapshot_tag_error(key, value) {
722 errs.push(ValidationError::InvalidFieldValue {
723 field: format!("spec.tags[{key:?}]"),
724 reason,
725 });
726 }
727 }
728 errs
729}
730
731pub fn validate_backup_on_schedule_delete(
739 origin: Origin,
740 value: Option<ScheduleDeletePolicy>,
741) -> ValidationResult {
742 match origin {
743 Origin::Discovered | Origin::Adopted | Origin::Replicated => match value {
744 None => Ok(()),
745 Some(v) => Err(ValidationError::DiscoveredCannotSetOnScheduleDelete {
746 origin: origin.label_value(),
747 got: format!("{v:?}"),
748 }),
749 },
750 Origin::Scheduled | Origin::Manual => Ok(()),
751 }
752}
753
754pub fn validate_schedule_policy_target(spec: &SnapshotScheduleSpec) -> ValidationResult {
758 match (spec.policy_ref.is_some(), spec.policy_selector.is_some()) {
759 (true, true) => Err(ValidationError::MutuallyExclusive {
760 a: "policyRef".to_string(),
761 b: "policySelector".to_string(),
762 context: "SnapshotSchedule".to_string(),
763 }),
764 (false, false) => Err(ValidationError::MissingRequiredField {
765 field: "exactly one of spec.policyRef or spec.policySelector".to_string(),
766 }),
767 _ => Ok(()),
768 }
769}
770
771pub fn validate_backup_schedule(spec: &SnapshotScheduleSpec) -> Vec<ValidationError> {
773 let mut errs = Vec::new();
774 if let Err(e) = validate_schedule_policy_target(spec) {
775 errs.push(e);
776 }
777 if let Err(e) = validate_cron(&spec.schedule.cron) {
778 errs.push(e);
779 }
780 if let Err(e) = validate_timezone(spec.schedule.timezone.as_deref()) {
781 errs.push(e);
782 }
783 if let Err(e) = validate_jitter("spec.schedule.jitter", spec.schedule.jitter.as_deref()) {
784 errs.push(e);
785 }
786 errs
787}