1use std::collections::BTreeSet;
43
44use k8s_openapi::api::core::v1::{Pod, PodSecurityContext, SecurityContext};
45
46use crate::common::effective_run_as_user;
47
48#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct MoverIdentity {
52 pub uid: Option<i64>,
54 pub groups: BTreeSet<i64>,
58 pub fs_group: Option<i64>,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct MoverWriteIdentity {
69 pub uid: Option<i64>,
72 pub fs_group: Option<i64>,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct WorkloadIdentity {
80 pub namespace: String,
82 pub name: String,
84 pub writer_uids: BTreeSet<i64>,
87 pub has_unpinned_writer: bool,
90 pub file_groups: BTreeSet<i64>,
93 pub primary_uid: Option<i64>,
96 pub fs_group: Option<i64>,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum CompatBasis {
103 RootMover,
105 ExactUidMatch,
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum UnknownReason {
112 MoverUidUnpinned,
114 WorkloadUidUnpinned,
116 OnlyGroupOverlap,
119 NoConsumerPod,
121 NfsOrNoPvc,
124 FsGroupMayApply,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq)]
136pub enum MoverReadCompat {
137 Compatible {
139 basis: CompatBasis,
141 },
142 Unknown {
144 why: UnknownReason,
146 },
147 LikelyIncompatible {
150 mover_uid: i64,
152 workload_uids: Vec<i64>,
154 },
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159pub enum RestoreBasis {
160 WorkloadOwnsFiles,
162 FsGroupMatch,
165}
166
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub enum RestoreUnknown {
170 ConsumerAbsent,
172 WorkloadUidUnpinned,
174 ModeUnknown,
176}
177
178#[derive(Debug, Clone, PartialEq, Eq)]
181pub enum RestoreWriteCompat {
182 Compatible {
184 basis: RestoreBasis,
186 },
187 Unknown {
189 why: RestoreUnknown,
191 },
192 LikelyIncompatible {
195 mover_uid: Option<i64>,
197 workload_uid: Option<i64>,
199 },
200}
201
202pub fn mover_identity(sc: &SecurityContext, psc: Option<&PodSecurityContext>) -> MoverIdentity {
204 let mut groups = BTreeSet::new();
205 if let Some(g) = sc.run_as_group.or_else(|| psc.and_then(|p| p.run_as_group)) {
206 groups.insert(g);
207 }
208 if let Some(p) = psc {
209 if let Some(fsg) = p.fs_group {
210 groups.insert(fsg);
211 }
212 if let Some(sup) = p.supplemental_groups.as_ref() {
213 groups.extend(sup.iter().copied());
214 }
215 }
216 MoverIdentity {
217 uid: effective_run_as_user(Some(sc), psc),
218 groups,
219 fs_group: psc.and_then(|p| p.fs_group),
220 }
221}
222
223pub fn mover_write_identity(
225 sc: &SecurityContext,
226 psc: Option<&PodSecurityContext>,
227) -> MoverWriteIdentity {
228 MoverWriteIdentity {
229 uid: effective_run_as_user(Some(sc), psc),
230 fs_group: psc.and_then(|p| p.fs_group),
231 }
232}
233
234pub fn workload_identity(pod: &Pod) -> WorkloadIdentity {
237 let spec = pod.spec.as_ref();
238 let pod_sc = spec.and_then(|s| s.security_context.as_ref());
239 let pod_uid = pod_sc.and_then(|p| p.run_as_user);
240 let pod_gid = pod_sc.and_then(|p| p.run_as_group);
241
242 let init = spec
243 .and_then(|s| s.init_containers.as_deref())
244 .unwrap_or(&[]);
245 let main = spec.map(|s| s.containers.as_slice()).unwrap_or(&[]);
246
247 let mut writer_uids = BTreeSet::new();
248 let mut has_unpinned_writer = false;
249 let mut file_groups = BTreeSet::new();
250 if let Some(g) = pod_gid {
251 file_groups.insert(g);
252 }
253 if let Some(fsg) = pod_sc.and_then(|p| p.fs_group) {
254 file_groups.insert(fsg);
255 }
256 if let Some(sup) = pod_sc.and_then(|p| p.supplemental_groups.as_ref()) {
257 file_groups.extend(sup.iter().copied());
258 }
259 for c in init.iter().chain(main.iter()) {
260 let csc = c.security_context.as_ref();
261 match csc.and_then(|s| s.run_as_user).or(pod_uid) {
262 Some(u) => {
263 writer_uids.insert(u);
264 }
265 None => has_unpinned_writer = true,
266 }
267 if let Some(g) = csc.and_then(|s| s.run_as_group) {
268 file_groups.insert(g);
269 }
270 }
271 if init.is_empty() && main.is_empty() {
273 has_unpinned_writer = true;
274 }
275 let primary_uid = main
277 .first()
278 .and_then(|c| c.security_context.as_ref())
279 .and_then(|s| s.run_as_user)
280 .or(pod_uid);
281
282 WorkloadIdentity {
283 namespace: pod.metadata.namespace.clone().unwrap_or_default(),
284 name: pod.metadata.name.clone().unwrap_or_default(),
285 writer_uids,
286 has_unpinned_writer,
287 file_groups,
288 primary_uid,
289 fs_group: pod_sc.and_then(|p| p.fs_group),
290 }
291}
292
293pub fn pod_mounts_claim(pod: &Pod, claim_name: &str) -> bool {
296 pod.spec
297 .as_ref()
298 .and_then(|s| s.volumes.as_deref())
299 .unwrap_or_default()
300 .iter()
301 .any(|v| {
302 v.persistent_volume_claim
303 .as_ref()
304 .map(|pvc| pvc.claim_name == claim_name)
305 .unwrap_or(false)
306 })
307}
308
309pub fn pods_mounting_pvc<'a>(pods: &'a [Pod], claim_name: &str) -> Vec<&'a Pod> {
311 pods.iter()
312 .filter(|p| pod_mounts_claim(p, claim_name))
313 .collect()
314}
315
316pub fn container_mounting_claim<'a>(
330 pod: &'a Pod,
331 claim_name: &str,
332) -> Option<&'a k8s_openapi::api::core::v1::Container> {
333 let spec = pod.spec.as_ref()?;
334 let volume_names: BTreeSet<&str> = spec
336 .volumes
337 .as_deref()
338 .unwrap_or_default()
339 .iter()
340 .filter(|v| {
341 v.persistent_volume_claim
342 .as_ref()
343 .is_some_and(|pvc| pvc.claim_name == claim_name)
344 })
345 .map(|v| v.name.as_str())
346 .collect();
347 if volume_names.is_empty() {
348 return None;
349 }
350 let mut mounters = spec.containers.iter().filter(|c| {
351 c.volume_mounts
352 .as_deref()
353 .unwrap_or_default()
354 .iter()
355 .any(|m| volume_names.contains(m.name.as_str()))
356 });
357 let first = mounters.next()?;
358 if mounters.next().is_some() {
360 return None;
361 }
362 Some(first)
363}
364
365pub fn is_managed_by_kopiur(pod: &Pod) -> bool {
370 pod.metadata
371 .labels
372 .as_ref()
373 .and_then(|l| l.get(crate::consts::MANAGED_BY_LABEL))
374 .map(|v| v == crate::consts::MANAGED_BY_VALUE)
375 .unwrap_or(false)
376}
377
378pub fn workload_identities(pods: &[Pod], claim_name: &str) -> Vec<WorkloadIdentity> {
382 pods_mounting_pvc(pods, claim_name)
383 .into_iter()
384 .filter(|p| !is_managed_by_kopiur(p))
385 .map(workload_identity)
386 .collect()
387}
388
389pub fn assess_read_compat(
398 mover: &MoverIdentity,
399 workloads: &[WorkloadIdentity],
400 source_read_only: bool,
401) -> MoverReadCompat {
402 if mover.uid == Some(0) {
404 return MoverReadCompat::Compatible {
405 basis: CompatBasis::RootMover,
406 };
407 }
408 if workloads.is_empty() {
409 return MoverReadCompat::Unknown {
410 why: UnknownReason::NoConsumerPod,
411 };
412 }
413 let Some(mover_uid) = mover.uid else {
414 return MoverReadCompat::Unknown {
415 why: UnknownReason::MoverUidUnpinned,
416 };
417 };
418
419 if workloads.iter().any(|w| w.has_unpinned_writer) {
421 return MoverReadCompat::Unknown {
422 why: UnknownReason::WorkloadUidUnpinned,
423 };
424 }
425 let all_writers: BTreeSet<i64> = workloads
427 .iter()
428 .flat_map(|w| w.writer_uids.iter().copied())
429 .collect();
430 if all_writers == BTreeSet::from([mover_uid]) {
431 return MoverReadCompat::Compatible {
432 basis: CompatBasis::ExactUidMatch,
433 };
434 }
435 if !source_read_only && mover.fs_group.is_some() {
445 return MoverReadCompat::Unknown {
446 why: UnknownReason::FsGroupMayApply,
447 };
448 }
449 let shares_group = workloads
452 .iter()
453 .any(|w| w.file_groups.iter().any(|g| mover.groups.contains(g)));
454 if shares_group {
455 return MoverReadCompat::Unknown {
456 why: UnknownReason::OnlyGroupOverlap,
457 };
458 }
459 MoverReadCompat::LikelyIncompatible {
461 mover_uid,
462 workload_uids: all_writers.into_iter().collect(),
463 }
464}
465
466pub fn assess_restore_compat(
470 mover: &MoverWriteIdentity,
471 future: Option<&WorkloadIdentity>,
472) -> RestoreWriteCompat {
473 let Some(w) = future else {
474 return RestoreWriteCompat::Unknown {
475 why: RestoreUnknown::ConsumerAbsent,
476 };
477 };
478 if let (Some(mu), Some(wu)) = (mover.uid, w.primary_uid)
480 && mu == wu
481 {
482 return RestoreWriteCompat::Compatible {
483 basis: RestoreBasis::WorkloadOwnsFiles,
484 };
485 }
486 if let (Some(mg), Some(wg)) = (mover.fs_group, w.fs_group)
488 && mg == wg
489 {
490 return RestoreWriteCompat::Compatible {
491 basis: RestoreBasis::FsGroupMatch,
492 };
493 }
494 if w.primary_uid.is_none() {
495 return RestoreWriteCompat::Unknown {
496 why: RestoreUnknown::WorkloadUidUnpinned,
497 };
498 }
499 match (mover.uid, w.primary_uid) {
502 (Some(_), Some(_)) => RestoreWriteCompat::LikelyIncompatible {
503 mover_uid: mover.uid,
504 workload_uid: w.primary_uid,
505 },
506 _ => RestoreWriteCompat::Unknown {
507 why: RestoreUnknown::ModeUnknown,
508 },
509 }
510}
511
512impl MoverReadCompat {
513 pub fn summary(&self, mover_uid_render: &str) -> String {
516 match self {
517 MoverReadCompat::Compatible {
518 basis: CompatBasis::RootMover,
519 } => format!("mover runs as root ({mover_uid_render}) and can read all source files"),
520 MoverReadCompat::Compatible {
521 basis: CompatBasis::ExactUidMatch,
522 } => format!(
523 "mover UID {mover_uid_render} matches the workload's UID; it can read the source"
524 ),
525 MoverReadCompat::Unknown { why } => format!(
526 "cannot determine source readability from securityContext alone ({}); the mover \
527 verifies it at runtime",
528 why.as_str()
529 ),
530 MoverReadCompat::LikelyIncompatible {
531 mover_uid,
532 workload_uids,
533 } => format!(
534 "mover UID {mover_uid} shares no UID or group with the workload writer UID(s) {} \
535 — the backup may fail with permission denied or silently skip unreadable files; \
536 set mover.inheritSecurityContextFrom.pvcConsumer, or a matching runAsUser/fsGroup",
537 render_uid_list(workload_uids)
538 ),
539 }
540 }
541}
542
543impl UnknownReason {
544 pub fn as_str(&self) -> &'static str {
546 match self {
547 UnknownReason::MoverUidUnpinned => "mover UID is image-determined",
548 UnknownReason::WorkloadUidUnpinned => "a workload writer UID is image-determined",
549 UnknownReason::OnlyGroupOverlap => "UIDs differ but a group is shared",
550 UnknownReason::NoConsumerPod => "no pod currently mounts the source PVC",
551 UnknownReason::NfsOrNoPvc => "source is NFS or has no single PVC",
552 UnknownReason::FsGroupMayApply => {
553 "the source is mounted read-write, so the kubelet may apply the mover's fsGroup \
554 to it"
555 }
556 }
557 }
558}
559
560fn render_uid_list(uids: &[i64]) -> String {
562 let parts: Vec<String> = uids.iter().map(|u| u.to_string()).collect();
563 format!("[{}]", parts.join(", "))
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569 use k8s_openapi::api::core::v1::{
570 Container, PersistentVolumeClaimVolumeSource, PodSpec, Volume,
571 };
572 use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
573
574 fn sc(uid: Option<i64>, gid: Option<i64>, non_root: Option<bool>) -> SecurityContext {
575 SecurityContext {
576 run_as_user: uid,
577 run_as_group: gid,
578 run_as_non_root: non_root,
579 ..Default::default()
580 }
581 }
582
583 fn psc(uid: Option<i64>, fs_group: Option<i64>, supp: Vec<i64>) -> PodSecurityContext {
584 PodSecurityContext {
585 run_as_user: uid,
586 fs_group,
587 supplemental_groups: if supp.is_empty() { None } else { Some(supp) },
588 ..Default::default()
589 }
590 }
591
592 fn pod(name: &str, ns: &str, uid: Option<i64>, fs_group: Option<i64>, claim: &str) -> Pod {
595 Pod {
596 metadata: ObjectMeta {
597 name: Some(name.into()),
598 namespace: Some(ns.into()),
599 ..Default::default()
600 },
601 spec: Some(PodSpec {
602 security_context: Some(PodSecurityContext {
603 fs_group,
604 ..Default::default()
605 }),
606 containers: vec![Container {
607 name: "app".into(),
608 security_context: Some(sc(uid, None, uid.map(|u| u != 0))),
609 ..Default::default()
610 }],
611 volumes: Some(vec![Volume {
612 name: "data".into(),
613 persistent_volume_claim: Some(PersistentVolumeClaimVolumeSource {
614 claim_name: claim.into(),
615 read_only: None,
616 }),
617 ..Default::default()
618 }]),
619 ..Default::default()
620 }),
621 ..Default::default()
622 }
623 }
624
625 #[test]
626 fn root_mover_is_compatible_even_with_no_consumer() {
627 let m = mover_identity(&sc(Some(0), None, Some(false)), None);
628 assert!(matches!(
629 assess_read_compat(&m, &[], true),
630 MoverReadCompat::Compatible {
631 basis: CompatBasis::RootMover
632 }
633 ));
634 }
635
636 #[test]
637 fn pod_level_root_mover_is_root() {
638 let m = mover_identity(
640 &SecurityContext::default(),
641 Some(&psc(Some(0), None, vec![])),
642 );
643 assert_eq!(m.uid, Some(0));
644 assert!(matches!(
645 assess_read_compat(&m, &[], true),
646 MoverReadCompat::Compatible {
647 basis: CompatBasis::RootMover
648 }
649 ));
650 }
651
652 #[test]
663 fn inheriting_a_uidless_workload_is_never_compatible() {
664 let mut w_pod = pod("app-7c9d8f5b6", "app", None, None, "app-data");
666 w_pod.spec.as_mut().unwrap().containers[0].security_context = Some(SecurityContext {
667 allow_privilege_escalation: Some(false),
668 ..Default::default()
669 });
670 w_pod.spec.as_mut().unwrap().security_context = None;
671
672 let inherited_sc = w_pod.spec.as_ref().unwrap().containers[0]
674 .security_context
675 .clone();
676 let inherited_psc = w_pod.spec.as_ref().unwrap().security_context.clone();
677 let resolved = crate::common::resolve_mover(
678 None,
679 inherited_sc.as_ref(),
680 inherited_psc.as_ref(),
681 None,
682 None,
683 None,
684 );
685
686 let m = mover_identity(
687 &resolved.security_context,
688 resolved.pod_security_context.as_ref(),
689 );
690 assert_eq!(
691 m.uid, None,
692 "inheriting pinned no UID — the mover silently runs as the image's 65532"
693 );
694
695 let ids = workload_identities(std::slice::from_ref(&w_pod), "app-data");
696 assert!(
697 !matches!(
698 assess_read_compat(&m, &ids, true),
699 MoverReadCompat::Compatible { .. }
700 ),
701 "must never be Compatible: nothing here proves the mover can read the source"
702 );
703 }
704
705 #[test]
708 fn inheriting_a_uid_pinning_workload_is_compatible() {
709 let w_pod = pod("app-7c9d8f5b6", "app", Some(1000), None, "app-data");
710 let inherited_sc = w_pod.spec.as_ref().unwrap().containers[0]
711 .security_context
712 .clone();
713 let resolved =
714 crate::common::resolve_mover(None, inherited_sc.as_ref(), None, None, None, None);
715 let m = mover_identity(
716 &resolved.security_context,
717 resolved.pod_security_context.as_ref(),
718 );
719 assert_eq!(m.uid, Some(1000));
720 assert!(matches!(
721 assess_read_compat(
722 &m,
723 &workload_identities(std::slice::from_ref(&w_pod), "app-data"),
724 true
725 ),
726 MoverReadCompat::Compatible {
727 basis: CompatBasis::ExactUidMatch
728 }
729 ));
730 }
731
732 #[test]
736 fn inheriting_a_sidecars_uid_is_not_compatible_with_the_apps_files() {
737 let mut w_pod = pod("app-7c9d8f5b6", "app", Some(1000), None, "app-data");
738 w_pod.spec.as_mut().unwrap().containers.insert(
740 0,
741 Container {
742 name: "istio-proxy".into(),
743 security_context: Some(sc(Some(1337), None, Some(true))),
744 ..Default::default()
745 },
746 );
747 let resolved = crate::common::resolve_mover(
749 None,
750 Some(&sc(Some(1337), None, Some(true))),
751 None,
752 None,
753 None,
754 None,
755 );
756 let m = mover_identity(
757 &resolved.security_context,
758 resolved.pod_security_context.as_ref(),
759 );
760 assert!(
761 !matches!(
762 assess_read_compat(
763 &m,
764 &workload_identities(std::slice::from_ref(&w_pod), "app-data"),
765 true
766 ),
767 MoverReadCompat::Compatible { .. }
768 ),
769 "the app writes as 1000; a 1337 mover is not provably able to read it"
770 );
771 }
772
773 #[test]
777 fn container_mounting_claim_picks_the_app_over_a_first_listed_sidecar() {
778 use k8s_openapi::api::core::v1::VolumeMount;
779
780 let mut p = pod("app-1", "app", Some(1000), None, "app-data");
781 p.spec.as_mut().unwrap().containers[0].volume_mounts = Some(vec![VolumeMount {
783 name: "data".into(),
784 mount_path: "/data".into(),
785 ..Default::default()
786 }]);
787 p.spec.as_mut().unwrap().containers.insert(
789 0,
790 Container {
791 name: "istio-proxy".into(),
792 security_context: Some(sc(Some(1337), None, Some(true))),
793 ..Default::default()
794 },
795 );
796 assert_eq!(
797 p.spec.as_ref().unwrap().containers.first().unwrap().name,
798 "istio-proxy",
799 "precondition: the naive pick would take the sidecar"
800 );
801 let picked = container_mounting_claim(&p, "app-data").expect("the app mounts the claim");
802 assert_eq!(picked.name, "app");
803 assert_eq!(
804 picked.security_context.as_ref().unwrap().run_as_user,
805 Some(1000),
806 "and it carries the identity that actually wrote the data"
807 );
808 }
809
810 #[test]
811 fn container_mounting_claim_abstains_when_ambiguous_or_absent() {
812 use k8s_openapi::api::core::v1::VolumeMount;
813
814 let mount = || {
815 Some(vec![VolumeMount {
816 name: "data".into(),
817 mount_path: "/data".into(),
818 ..Default::default()
819 }])
820 };
821
822 let p = pod("app-1", "app", Some(1000), None, "app-data");
824 assert!(container_mounting_claim(&p, "app-data").is_none());
825
826 let mut p = pod("app-1", "app", Some(1000), None, "app-data");
828 p.spec.as_mut().unwrap().containers[0].volume_mounts = mount();
829 assert!(container_mounting_claim(&p, "other-claim").is_none());
830
831 let mut p = pod("app-1", "app", Some(1000), None, "app-data");
833 p.spec.as_mut().unwrap().containers[0].volume_mounts = mount();
834 p.spec.as_mut().unwrap().containers.push(Container {
835 name: "sidecar-backup".into(),
836 security_context: Some(sc(Some(2000), None, Some(true))),
837 volume_mounts: mount(),
838 ..Default::default()
839 });
840 assert!(
841 container_mounting_claim(&p, "app-data").is_none(),
842 "two mounters: no basis to prefer either"
843 );
844 }
845
846 #[test]
847 fn stock_hardened_mover_is_unknown_never_warns() {
848 let resolved = crate::common::resolve_mover(None, None, None, None, None, None);
850 let m = mover_identity(
851 &resolved.security_context,
852 resolved.pod_security_context.as_ref(),
853 );
854 let w = workload_identity(&pod("pg-0", "db", Some(999), None, "data"));
855 assert!(matches!(
856 assess_read_compat(&m, &[w], true),
857 MoverReadCompat::Unknown {
858 why: UnknownReason::MoverUidUnpinned
859 }
860 ));
861 }
862
863 #[test]
864 fn exact_uid_match_is_compatible() {
865 let m = mover_identity(&sc(Some(999), None, Some(true)), None);
866 let w = workload_identity(&pod("pg-0", "db", Some(999), None, "data"));
867 assert!(matches!(
868 assess_read_compat(&m, &[w], true),
869 MoverReadCompat::Compatible {
870 basis: CompatBasis::ExactUidMatch
871 }
872 ));
873 }
874
875 #[test]
876 fn disjoint_uid_no_group_is_likely_incompatible() {
877 let m = mover_identity(&sc(Some(65532), None, Some(true)), None);
878 let w = workload_identity(&pod("pg-0", "db", Some(999), None, "data"));
879 assert!(matches!(
880 assess_read_compat(&m, &[w], true),
881 MoverReadCompat::LikelyIncompatible { .. }
882 ));
883 }
884
885 #[test]
886 fn group_overlap_softens_to_unknown() {
887 let m = mover_identity(
890 &sc(Some(65532), None, Some(true)),
891 Some(&psc(None, None, vec![2000])),
892 );
893 let w = workload_identity(&pod("pg-0", "db", Some(999), Some(2000), "data"));
894 assert!(matches!(
895 assess_read_compat(&m, &[w], true),
896 MoverReadCompat::Unknown {
897 why: UnknownReason::OnlyGroupOverlap
898 }
899 ));
900 }
901
902 #[test]
903 fn root_init_container_writer_is_unknown_not_compatible() {
904 let mut p = pod("app-0", "ns", Some(999), None, "data");
907 p.spec.as_mut().unwrap().init_containers = Some(vec![Container {
908 name: "init".into(),
909 security_context: Some(sc(Some(0), None, Some(false))),
910 ..Default::default()
911 }]);
912 let m = mover_identity(&sc(Some(999), None, Some(true)), None);
913 let w = workload_identity(&p);
914 assert!(w.writer_uids.contains(&0) && w.writer_uids.contains(&999));
915 assert!(matches!(
917 assess_read_compat(&m, &[w], true),
918 MoverReadCompat::LikelyIncompatible { .. }
919 ));
920 }
921
922 #[test]
925 fn a_writable_source_with_an_fsgroup_softens_likely_incompatible_to_unknown() {
926 let m = mover_identity(
929 &sc(Some(65532), None, Some(true)),
930 Some(&psc(None, Some(65532), vec![])),
931 );
932 let w = workload_identity(&pod("app-0", "ns", Some(1000), Some(1000), "data"));
933 assert!(
934 matches!(
935 assess_read_compat(&m, std::slice::from_ref(&w), true),
936 MoverReadCompat::LikelyIncompatible { .. }
937 ),
938 "read-only source: fsGroup grants nothing, so the mismatch stands"
939 );
940 assert!(
943 matches!(
944 assess_read_compat(&m, std::slice::from_ref(&w), false),
945 MoverReadCompat::Unknown {
946 why: UnknownReason::FsGroupMayApply
947 }
948 ),
949 "a writable source + an fsGroup invalidates the ownership comparison"
950 );
951 }
952
953 #[test]
957 fn a_writable_source_never_reaches_compatible_on_an_fsgroup_basis() {
958 let m = mover_identity(
959 &sc(Some(65532), None, Some(true)),
960 Some(&psc(None, Some(65532), vec![])),
961 );
962 let w = workload_identity(&pod("app-0", "ns", Some(1000), Some(1000), "data"));
963 assert!(!matches!(
964 assess_read_compat(&m, &[w], false),
965 MoverReadCompat::Compatible { .. }
966 ));
967 }
968
969 #[test]
972 fn a_writable_source_without_an_fsgroup_still_reports_likely_incompatible() {
973 let m = mover_identity(&sc(Some(65532), None, Some(true)), None);
974 let w = workload_identity(&pod("app-0", "ns", Some(1000), Some(1000), "data"));
975 assert!(matches!(
976 assess_read_compat(&m, &[w], false),
977 MoverReadCompat::LikelyIncompatible { .. }
978 ));
979 }
980
981 #[test]
984 fn a_writable_source_does_not_mask_a_provable_compatible() {
985 let root = mover_identity(
986 &sc(Some(0), None, Some(false)),
987 Some(&psc(None, Some(65532), vec![])),
988 );
989 let w = workload_identity(&pod("app-0", "ns", Some(1000), Some(1000), "data"));
990 assert!(matches!(
991 assess_read_compat(&root, std::slice::from_ref(&w), false),
992 MoverReadCompat::Compatible {
993 basis: CompatBasis::RootMover
994 }
995 ));
996 let exact = mover_identity(
998 &sc(Some(1000), None, Some(true)),
999 Some(&psc(None, Some(65532), vec![])),
1000 );
1001 assert!(matches!(
1002 assess_read_compat(&exact, &[w], false),
1003 MoverReadCompat::Compatible {
1004 basis: CompatBasis::ExactUidMatch
1005 }
1006 ));
1007 }
1008
1009 #[test]
1010 fn unpinned_workload_writer_is_unknown() {
1011 let mut p = pod("x", "ns", None, None, "data");
1013 p.spec.as_mut().unwrap().containers[0].security_context = None;
1014 p.spec.as_mut().unwrap().security_context = None;
1015 let m = mover_identity(&sc(Some(65532), None, Some(true)), None);
1016 let w = workload_identity(&p);
1017 assert!(matches!(
1018 assess_read_compat(&m, &[w], true),
1019 MoverReadCompat::Unknown {
1020 why: UnknownReason::WorkloadUidUnpinned
1021 }
1022 ));
1023 }
1024
1025 #[test]
1026 fn verdict_is_independent_of_pod_order() {
1027 let m = mover_identity(&sc(Some(65532), None, Some(true)), None);
1028 let a = workload_identity(&pod("a", "ns", Some(999), None, "data"));
1029 let b = workload_identity(&pod("b", "ns", Some(1000), None, "data"));
1030 let forward = assess_read_compat(&m, &[a.clone(), b.clone()], true);
1031 let reversed = assess_read_compat(&m, &[b, a], true);
1032 assert_eq!(forward, reversed, "verdict must not depend on input order");
1033 }
1034
1035 #[test]
1036 fn pods_mounting_pvc_filters_by_claim() {
1037 let p1 = pod("p1", "ns", Some(1), None, "wanted");
1038 let p2 = pod("p2", "ns", Some(1), None, "other");
1039 let pods = vec![p1, p2];
1040 let found = pods_mounting_pvc(&pods, "wanted");
1041 assert_eq!(found.len(), 1);
1042 assert_eq!(found[0].metadata.name.as_deref(), Some("p1"));
1043 }
1044
1045 #[test]
1046 fn workload_identities_excludes_kopiur_movers() {
1047 let workload = pod("pg-0", "db", Some(999), None, "data");
1050 let mut mover = pod("mover-x", "db", Some(65532), None, "data");
1051 mover.metadata.labels = Some(std::collections::BTreeMap::from([(
1052 crate::consts::MANAGED_BY_LABEL.to_string(),
1053 crate::consts::MANAGED_BY_VALUE.to_string(),
1054 )]));
1055 assert!(is_managed_by_kopiur(&mover) && !is_managed_by_kopiur(&workload));
1056 let ids = workload_identities(&[mover, workload], "data");
1057 assert_eq!(ids.len(), 1, "only the non-kopiur workload counts");
1058 assert_eq!(ids[0].name, "pg-0");
1059 }
1060
1061 #[test]
1064 fn restore_absent_consumer_is_unknown() {
1065 let m = mover_write_identity(&sc(Some(65532), None, Some(true)), None);
1066 assert!(matches!(
1067 assess_restore_compat(&m, None),
1068 RestoreWriteCompat::Unknown {
1069 why: RestoreUnknown::ConsumerAbsent
1070 }
1071 ));
1072 }
1073
1074 #[test]
1075 fn restore_uid_ownership_is_compatible() {
1076 let m = mover_write_identity(&sc(Some(2000), None, Some(true)), None);
1077 let w = workload_identity(&pod("app", "ns", Some(2000), None, "data"));
1078 assert!(matches!(
1079 assess_restore_compat(&m, Some(&w)),
1080 RestoreWriteCompat::Compatible {
1081 basis: RestoreBasis::WorkloadOwnsFiles
1082 }
1083 ));
1084 }
1085
1086 #[test]
1087 fn restore_fsgroup_match_is_compatible() {
1088 let m = mover_write_identity(
1091 &sc(Some(65532), None, Some(true)),
1092 Some(&psc(None, Some(2500), vec![])),
1093 );
1094 let w = workload_identity(&pod("app", "ns", Some(1000), Some(2500), "data"));
1095 assert!(matches!(
1096 assess_restore_compat(&m, Some(&w)),
1097 RestoreWriteCompat::Compatible {
1098 basis: RestoreBasis::FsGroupMatch
1099 }
1100 ));
1101 }
1102
1103 #[test]
1104 fn restore_disjoint_uid_and_fsgroup_is_likely_incompatible() {
1105 let m = mover_write_identity(
1106 &sc(Some(65532), None, Some(true)),
1107 Some(&psc(None, Some(65532), vec![])),
1108 );
1109 let w = workload_identity(&pod("app", "ns", Some(1000), Some(2500), "data"));
1110 assert!(matches!(
1111 assess_restore_compat(&m, Some(&w)),
1112 RestoreWriteCompat::LikelyIncompatible { .. }
1113 ));
1114 }
1115}