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 pub reads_any_file: bool,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct MoverWriteIdentity {
74 pub uid: Option<i64>,
77 pub fs_group: Option<i64>,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct WorkloadIdentity {
85 pub namespace: String,
87 pub name: String,
89 pub writer_uids: BTreeSet<i64>,
92 pub has_unpinned_writer: bool,
95 pub file_groups: BTreeSet<i64>,
98 pub primary_uid: Option<i64>,
101 pub fs_group: Option<i64>,
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum CompatBasis {
108 RootMover,
110 ExactUidMatch,
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum UnknownReason {
117 MoverUidUnpinned,
119 WorkloadUidUnpinned,
121 OnlyGroupOverlap,
124 NoConsumerPod,
126 NfsOrNoPvc,
129 FsGroupMayApply,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
141pub enum MoverReadCompat {
142 Compatible {
144 basis: CompatBasis,
146 },
147 Unknown {
149 why: UnknownReason,
151 },
152 LikelyIncompatible {
155 mover_uid: i64,
157 workload_uids: Vec<i64>,
159 },
160}
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum RestoreBasis {
165 WorkloadOwnsFiles,
167 FsGroupMatch,
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum RestoreUnknown {
175 ConsumerAbsent,
177 WorkloadUidUnpinned,
179 ModeUnknown,
181}
182
183#[derive(Debug, Clone, PartialEq, Eq)]
186pub enum RestoreWriteCompat {
187 Compatible {
189 basis: RestoreBasis,
191 },
192 Unknown {
194 why: RestoreUnknown,
196 },
197 LikelyIncompatible {
200 mover_uid: Option<i64>,
202 workload_uid: Option<i64>,
204 },
205}
206
207pub fn mover_identity(sc: &SecurityContext, psc: Option<&PodSecurityContext>) -> MoverIdentity {
209 let mut groups = BTreeSet::new();
210 if let Some(g) = sc.run_as_group.or_else(|| psc.and_then(|p| p.run_as_group)) {
211 groups.insert(g);
212 }
213 if let Some(p) = psc {
214 if let Some(fsg) = p.fs_group {
215 groups.insert(fsg);
216 }
217 if let Some(sup) = p.supplemental_groups.as_ref() {
218 groups.extend(sup.iter().copied());
219 }
220 }
221 let uid = effective_run_as_user(Some(sc), psc);
222 MoverIdentity {
223 uid,
224 groups,
225 fs_group: psc.and_then(|p| p.fs_group),
226 reads_any_file: uid == Some(0) && crate::common::bypasses_read_permission_checks(sc),
227 }
228}
229
230pub fn mover_write_identity(
232 sc: &SecurityContext,
233 psc: Option<&PodSecurityContext>,
234) -> MoverWriteIdentity {
235 MoverWriteIdentity {
236 uid: effective_run_as_user(Some(sc), psc),
237 fs_group: psc.and_then(|p| p.fs_group),
238 }
239}
240
241pub fn workload_identity(pod: &Pod) -> WorkloadIdentity {
244 let spec = pod.spec.as_ref();
245 let pod_sc = spec.and_then(|s| s.security_context.as_ref());
246 let pod_uid = pod_sc.and_then(|p| p.run_as_user);
247 let pod_gid = pod_sc.and_then(|p| p.run_as_group);
248
249 let init = spec
250 .and_then(|s| s.init_containers.as_deref())
251 .unwrap_or(&[]);
252 let main = spec.map(|s| s.containers.as_slice()).unwrap_or(&[]);
253
254 let mut writer_uids = BTreeSet::new();
255 let mut has_unpinned_writer = false;
256 let mut file_groups = BTreeSet::new();
257 if let Some(g) = pod_gid {
258 file_groups.insert(g);
259 }
260 if let Some(fsg) = pod_sc.and_then(|p| p.fs_group) {
261 file_groups.insert(fsg);
262 }
263 if let Some(sup) = pod_sc.and_then(|p| p.supplemental_groups.as_ref()) {
264 file_groups.extend(sup.iter().copied());
265 }
266 for c in init.iter().chain(main.iter()) {
267 let csc = c.security_context.as_ref();
268 match csc.and_then(|s| s.run_as_user).or(pod_uid) {
269 Some(u) => {
270 writer_uids.insert(u);
271 }
272 None => has_unpinned_writer = true,
273 }
274 if let Some(g) = csc.and_then(|s| s.run_as_group) {
275 file_groups.insert(g);
276 }
277 }
278 if init.is_empty() && main.is_empty() {
280 has_unpinned_writer = true;
281 }
282 let primary_uid = main
284 .first()
285 .and_then(|c| c.security_context.as_ref())
286 .and_then(|s| s.run_as_user)
287 .or(pod_uid);
288
289 WorkloadIdentity {
290 namespace: pod.metadata.namespace.clone().unwrap_or_default(),
291 name: pod.metadata.name.clone().unwrap_or_default(),
292 writer_uids,
293 has_unpinned_writer,
294 file_groups,
295 primary_uid,
296 fs_group: pod_sc.and_then(|p| p.fs_group),
297 }
298}
299
300pub fn pod_mounts_claim(pod: &Pod, claim_name: &str) -> bool {
303 pod.spec
304 .as_ref()
305 .and_then(|s| s.volumes.as_deref())
306 .unwrap_or_default()
307 .iter()
308 .any(|v| {
309 v.persistent_volume_claim
310 .as_ref()
311 .map(|pvc| pvc.claim_name == claim_name)
312 .unwrap_or(false)
313 })
314}
315
316pub fn pods_mounting_pvc<'a>(pods: &'a [Pod], claim_name: &str) -> Vec<&'a Pod> {
318 pods.iter()
319 .filter(|p| pod_mounts_claim(p, claim_name))
320 .collect()
321}
322
323pub fn container_mounting_claim<'a>(
337 pod: &'a Pod,
338 claim_name: &str,
339) -> Option<&'a k8s_openapi::api::core::v1::Container> {
340 let spec = pod.spec.as_ref()?;
341 let volume_names: BTreeSet<&str> = spec
343 .volumes
344 .as_deref()
345 .unwrap_or_default()
346 .iter()
347 .filter(|v| {
348 v.persistent_volume_claim
349 .as_ref()
350 .is_some_and(|pvc| pvc.claim_name == claim_name)
351 })
352 .map(|v| v.name.as_str())
353 .collect();
354 if volume_names.is_empty() {
355 return None;
356 }
357 let mut mounters = spec.containers.iter().filter(|c| {
358 c.volume_mounts
359 .as_deref()
360 .unwrap_or_default()
361 .iter()
362 .any(|m| volume_names.contains(m.name.as_str()))
363 });
364 let first = mounters.next()?;
365 if mounters.next().is_some() {
367 return None;
368 }
369 Some(first)
370}
371
372pub fn is_managed_by_kopiur(pod: &Pod) -> bool {
377 pod.metadata
378 .labels
379 .as_ref()
380 .and_then(|l| l.get(crate::consts::MANAGED_BY_LABEL))
381 .map(|v| v == crate::consts::MANAGED_BY_VALUE)
382 .unwrap_or(false)
383}
384
385pub fn workload_identities(pods: &[Pod], claim_name: &str) -> Vec<WorkloadIdentity> {
389 pods_mounting_pvc(pods, claim_name)
390 .into_iter()
391 .filter(|p| !is_managed_by_kopiur(p))
392 .map(workload_identity)
393 .collect()
394}
395
396pub fn assess_read_compat(
405 mover: &MoverIdentity,
406 workloads: &[WorkloadIdentity],
407 source_read_only: bool,
408) -> MoverReadCompat {
409 if mover.reads_any_file {
413 return MoverReadCompat::Compatible {
414 basis: CompatBasis::RootMover,
415 };
416 }
417 if workloads.is_empty() {
418 return MoverReadCompat::Unknown {
419 why: UnknownReason::NoConsumerPod,
420 };
421 }
422 let Some(mover_uid) = mover.uid else {
423 return MoverReadCompat::Unknown {
424 why: UnknownReason::MoverUidUnpinned,
425 };
426 };
427
428 if workloads.iter().any(|w| w.has_unpinned_writer) {
430 return MoverReadCompat::Unknown {
431 why: UnknownReason::WorkloadUidUnpinned,
432 };
433 }
434 let all_writers: BTreeSet<i64> = workloads
436 .iter()
437 .flat_map(|w| w.writer_uids.iter().copied())
438 .collect();
439 if all_writers == BTreeSet::from([mover_uid]) {
440 return MoverReadCompat::Compatible {
441 basis: CompatBasis::ExactUidMatch,
442 };
443 }
444 if !source_read_only && mover.fs_group.is_some() {
454 return MoverReadCompat::Unknown {
455 why: UnknownReason::FsGroupMayApply,
456 };
457 }
458 let shares_group = workloads
461 .iter()
462 .any(|w| w.file_groups.iter().any(|g| mover.groups.contains(g)));
463 if shares_group {
464 return MoverReadCompat::Unknown {
465 why: UnknownReason::OnlyGroupOverlap,
466 };
467 }
468 MoverReadCompat::LikelyIncompatible {
470 mover_uid,
471 workload_uids: all_writers.into_iter().collect(),
472 }
473}
474
475pub fn assess_restore_compat(
479 mover: &MoverWriteIdentity,
480 future: Option<&WorkloadIdentity>,
481) -> RestoreWriteCompat {
482 let Some(w) = future else {
483 return RestoreWriteCompat::Unknown {
484 why: RestoreUnknown::ConsumerAbsent,
485 };
486 };
487 if let (Some(mu), Some(wu)) = (mover.uid, w.primary_uid)
489 && mu == wu
490 {
491 return RestoreWriteCompat::Compatible {
492 basis: RestoreBasis::WorkloadOwnsFiles,
493 };
494 }
495 if let (Some(mg), Some(wg)) = (mover.fs_group, w.fs_group)
497 && mg == wg
498 {
499 return RestoreWriteCompat::Compatible {
500 basis: RestoreBasis::FsGroupMatch,
501 };
502 }
503 if w.primary_uid.is_none() {
504 return RestoreWriteCompat::Unknown {
505 why: RestoreUnknown::WorkloadUidUnpinned,
506 };
507 }
508 match (mover.uid, w.primary_uid) {
511 (Some(_), Some(_)) => RestoreWriteCompat::LikelyIncompatible {
512 mover_uid: mover.uid,
513 workload_uid: w.primary_uid,
514 },
515 _ => RestoreWriteCompat::Unknown {
516 why: RestoreUnknown::ModeUnknown,
517 },
518 }
519}
520
521impl MoverReadCompat {
522 pub fn summary(&self, mover_uid_render: &str) -> String {
525 match self {
526 MoverReadCompat::Compatible {
527 basis: CompatBasis::RootMover,
528 } => format!(
529 "mover runs as root ({mover_uid_render}) with DAC_OVERRIDE (or equivalent), so it can read all source files"
530 ),
531 MoverReadCompat::Compatible {
532 basis: CompatBasis::ExactUidMatch,
533 } => format!(
534 "mover UID {mover_uid_render} matches the workload's UID; it can read the source"
535 ),
536 MoverReadCompat::Unknown { why } => format!(
537 "cannot tell from securityContext whether the mover can read the source ({}); \
538 the mover checks at runtime",
539 why.as_str()
540 ),
541 MoverReadCompat::LikelyIncompatible {
542 mover_uid,
543 workload_uids,
544 } => format!(
545 "mover UID {mover_uid} shares no UID or group with the workload UID(s) {}, so \
546 the backup may fail or skip unreadable files. Fix: set \
547 mover.inheritSecurityContextFrom.pvcConsumer, or a matching runAsUser/fsGroup",
548 render_uid_list(workload_uids)
549 ),
550 }
551 }
552}
553
554impl UnknownReason {
555 pub fn as_str(&self) -> &'static str {
557 match self {
558 UnknownReason::MoverUidUnpinned => "mover UID is image-determined",
559 UnknownReason::WorkloadUidUnpinned => "a workload writer UID is image-determined",
560 UnknownReason::OnlyGroupOverlap => "UIDs differ but a group is shared",
561 UnknownReason::NoConsumerPod => "no pod currently mounts the source PVC",
562 UnknownReason::NfsOrNoPvc => "source is NFS or has no single PVC",
563 UnknownReason::FsGroupMayApply => {
564 "the source is mounted read-write, so the kubelet may apply the mover's fsGroup \
565 to it"
566 }
567 }
568 }
569}
570
571fn render_uid_list(uids: &[i64]) -> String {
573 let parts: Vec<String> = uids.iter().map(|u| u.to_string()).collect();
574 format!("[{}]", parts.join(", "))
575}
576
577#[cfg(test)]
578mod tests {
579 use super::*;
580 use k8s_openapi::api::core::v1::{
581 Container, PersistentVolumeClaimVolumeSource, PodSpec, Volume,
582 };
583 use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
584
585 fn sc(uid: Option<i64>, gid: Option<i64>, non_root: Option<bool>) -> SecurityContext {
586 SecurityContext {
587 run_as_user: uid,
588 run_as_group: gid,
589 run_as_non_root: non_root,
590 ..Default::default()
591 }
592 }
593
594 fn psc(uid: Option<i64>, fs_group: Option<i64>, supp: Vec<i64>) -> PodSecurityContext {
595 PodSecurityContext {
596 run_as_user: uid,
597 fs_group,
598 supplemental_groups: if supp.is_empty() { None } else { Some(supp) },
599 ..Default::default()
600 }
601 }
602
603 fn pod(name: &str, ns: &str, uid: Option<i64>, fs_group: Option<i64>, claim: &str) -> Pod {
606 Pod {
607 metadata: ObjectMeta {
608 name: Some(name.into()),
609 namespace: Some(ns.into()),
610 ..Default::default()
611 },
612 spec: Some(PodSpec {
613 security_context: Some(PodSecurityContext {
614 fs_group,
615 ..Default::default()
616 }),
617 containers: vec![Container {
618 name: "app".into(),
619 security_context: Some(sc(uid, None, uid.map(|u| u != 0))),
620 ..Default::default()
621 }],
622 volumes: Some(vec![Volume {
623 name: "data".into(),
624 persistent_volume_claim: Some(PersistentVolumeClaimVolumeSource {
625 claim_name: claim.into(),
626 read_only: None,
627 }),
628 ..Default::default()
629 }]),
630 ..Default::default()
631 }),
632 ..Default::default()
633 }
634 }
635
636 fn root_with(caps: &[&str]) -> SecurityContext {
638 SecurityContext {
639 capabilities: Some(k8s_openapi::api::core::v1::Capabilities {
640 add: Some(caps.iter().map(|c| c.to_string()).collect()),
641 drop: Some(vec!["ALL".into()]),
642 }),
643 ..sc(Some(0), None, Some(false))
644 }
645 }
646
647 #[test]
650 fn root_mover_with_dac_override_is_compatible_even_with_no_consumer() {
651 for caps in [&["DAC_OVERRIDE"][..], &["CAP_DAC_READ_SEARCH"][..]] {
652 let m = mover_identity(&root_with(caps), None);
653 assert!(m.reads_any_file, "{caps:?}");
654 assert!(matches!(
655 assess_read_compat(&m, &[], true),
656 MoverReadCompat::Compatible {
657 basis: CompatBasis::RootMover
658 }
659 ));
660 }
661 }
662
663 #[test]
667 fn root_mover_without_capabilities_is_not_root_compatible() {
668 let m = mover_identity(&root_with(&[]), None);
669 assert!(!m.reads_any_file);
670 assert!(matches!(
671 assess_read_compat(&m, &[], true),
672 MoverReadCompat::Unknown {
673 why: UnknownReason::NoConsumerPod
674 }
675 ));
676 let w = workload_identity(&pod("app-0", "ns", Some(0), None, "data"));
678 assert!(matches!(
679 assess_read_compat(&m, std::slice::from_ref(&w), true),
680 MoverReadCompat::Compatible {
681 basis: CompatBasis::ExactUidMatch
682 }
683 ));
684 let w = workload_identity(&pod("app-0", "ns", Some(1000), None, "data"));
686 assert!(matches!(
687 assess_read_compat(&m, &[w], true),
688 MoverReadCompat::LikelyIncompatible { mover_uid: 0, .. }
689 ));
690 }
691
692 #[test]
695 fn privileged_mode_is_what_makes_a_root_backup_read_everything() {
696 let root = sc(Some(0), None, Some(false));
697 for (pm, expect_root) in [(Some(true), true), (None, false)] {
698 let resolved = crate::common::resolve_mover(None, Some(&root), None, None, None, None)
699 .with_privileged_mode(pm, crate::common::MoverOperation::Backup);
700 let m = mover_identity(
701 &resolved.security_context,
702 resolved.pod_security_context.as_ref(),
703 );
704 assert_eq!(
705 matches!(
706 assess_read_compat(&m, &[], true),
707 MoverReadCompat::Compatible {
708 basis: CompatBasis::RootMover
709 }
710 ),
711 expect_root,
712 "{pm:?}"
713 );
714 }
715 }
716
717 #[test]
718 fn pod_level_root_mover_is_root() {
719 let m = mover_identity(
722 &SecurityContext {
723 privileged: Some(true),
724 ..Default::default()
725 },
726 Some(&psc(Some(0), None, vec![])),
727 );
728 assert_eq!(m.uid, Some(0));
729 assert!(matches!(
730 assess_read_compat(&m, &[], true),
731 MoverReadCompat::Compatible {
732 basis: CompatBasis::RootMover
733 }
734 ));
735 }
736
737 #[test]
748 fn inheriting_a_uidless_workload_is_never_compatible() {
749 let mut w_pod = pod("app-7c9d8f5b6", "app", None, None, "app-data");
751 w_pod.spec.as_mut().unwrap().containers[0].security_context = Some(SecurityContext {
752 allow_privilege_escalation: Some(false),
753 ..Default::default()
754 });
755 w_pod.spec.as_mut().unwrap().security_context = None;
756
757 let inherited_sc = w_pod.spec.as_ref().unwrap().containers[0]
759 .security_context
760 .clone();
761 let inherited_psc = w_pod.spec.as_ref().unwrap().security_context.clone();
762 let resolved = crate::common::resolve_mover(
763 None,
764 inherited_sc.as_ref(),
765 inherited_psc.as_ref(),
766 None,
767 None,
768 None,
769 );
770
771 let m = mover_identity(
772 &resolved.security_context,
773 resolved.pod_security_context.as_ref(),
774 );
775 assert_eq!(
776 m.uid, None,
777 "inheriting pinned no UID — the mover silently runs as the image's 65532"
778 );
779
780 let ids = workload_identities(std::slice::from_ref(&w_pod), "app-data");
781 assert!(
782 !matches!(
783 assess_read_compat(&m, &ids, true),
784 MoverReadCompat::Compatible { .. }
785 ),
786 "must never be Compatible: nothing here proves the mover can read the source"
787 );
788 }
789
790 #[test]
793 fn inheriting_a_uid_pinning_workload_is_compatible() {
794 let w_pod = pod("app-7c9d8f5b6", "app", Some(1000), None, "app-data");
795 let inherited_sc = w_pod.spec.as_ref().unwrap().containers[0]
796 .security_context
797 .clone();
798 let resolved =
799 crate::common::resolve_mover(None, inherited_sc.as_ref(), None, None, None, None);
800 let m = mover_identity(
801 &resolved.security_context,
802 resolved.pod_security_context.as_ref(),
803 );
804 assert_eq!(m.uid, Some(1000));
805 assert!(matches!(
806 assess_read_compat(
807 &m,
808 &workload_identities(std::slice::from_ref(&w_pod), "app-data"),
809 true
810 ),
811 MoverReadCompat::Compatible {
812 basis: CompatBasis::ExactUidMatch
813 }
814 ));
815 }
816
817 #[test]
821 fn inheriting_a_sidecars_uid_is_not_compatible_with_the_apps_files() {
822 let mut w_pod = pod("app-7c9d8f5b6", "app", Some(1000), None, "app-data");
823 w_pod.spec.as_mut().unwrap().containers.insert(
825 0,
826 Container {
827 name: "istio-proxy".into(),
828 security_context: Some(sc(Some(1337), None, Some(true))),
829 ..Default::default()
830 },
831 );
832 let resolved = crate::common::resolve_mover(
834 None,
835 Some(&sc(Some(1337), None, Some(true))),
836 None,
837 None,
838 None,
839 None,
840 );
841 let m = mover_identity(
842 &resolved.security_context,
843 resolved.pod_security_context.as_ref(),
844 );
845 assert!(
846 !matches!(
847 assess_read_compat(
848 &m,
849 &workload_identities(std::slice::from_ref(&w_pod), "app-data"),
850 true
851 ),
852 MoverReadCompat::Compatible { .. }
853 ),
854 "the app writes as 1000; a 1337 mover is not provably able to read it"
855 );
856 }
857
858 #[test]
862 fn container_mounting_claim_picks_the_app_over_a_first_listed_sidecar() {
863 use k8s_openapi::api::core::v1::VolumeMount;
864
865 let mut p = pod("app-1", "app", Some(1000), None, "app-data");
866 p.spec.as_mut().unwrap().containers[0].volume_mounts = Some(vec![VolumeMount {
868 name: "data".into(),
869 mount_path: "/data".into(),
870 ..Default::default()
871 }]);
872 p.spec.as_mut().unwrap().containers.insert(
874 0,
875 Container {
876 name: "istio-proxy".into(),
877 security_context: Some(sc(Some(1337), None, Some(true))),
878 ..Default::default()
879 },
880 );
881 assert_eq!(
882 p.spec.as_ref().unwrap().containers.first().unwrap().name,
883 "istio-proxy",
884 "precondition: the naive pick would take the sidecar"
885 );
886 let picked = container_mounting_claim(&p, "app-data").expect("the app mounts the claim");
887 assert_eq!(picked.name, "app");
888 assert_eq!(
889 picked.security_context.as_ref().unwrap().run_as_user,
890 Some(1000),
891 "and it carries the identity that actually wrote the data"
892 );
893 }
894
895 #[test]
896 fn container_mounting_claim_abstains_when_ambiguous_or_absent() {
897 use k8s_openapi::api::core::v1::VolumeMount;
898
899 let mount = || {
900 Some(vec![VolumeMount {
901 name: "data".into(),
902 mount_path: "/data".into(),
903 ..Default::default()
904 }])
905 };
906
907 let p = pod("app-1", "app", Some(1000), None, "app-data");
909 assert!(container_mounting_claim(&p, "app-data").is_none());
910
911 let mut p = pod("app-1", "app", Some(1000), None, "app-data");
913 p.spec.as_mut().unwrap().containers[0].volume_mounts = mount();
914 assert!(container_mounting_claim(&p, "other-claim").is_none());
915
916 let mut p = pod("app-1", "app", Some(1000), None, "app-data");
918 p.spec.as_mut().unwrap().containers[0].volume_mounts = mount();
919 p.spec.as_mut().unwrap().containers.push(Container {
920 name: "sidecar-backup".into(),
921 security_context: Some(sc(Some(2000), None, Some(true))),
922 volume_mounts: mount(),
923 ..Default::default()
924 });
925 assert!(
926 container_mounting_claim(&p, "app-data").is_none(),
927 "two mounters: no basis to prefer either"
928 );
929 }
930
931 #[test]
932 fn stock_hardened_mover_is_unknown_never_warns() {
933 let resolved = crate::common::resolve_mover(None, None, None, None, None, None);
935 let m = mover_identity(
936 &resolved.security_context,
937 resolved.pod_security_context.as_ref(),
938 );
939 let w = workload_identity(&pod("pg-0", "db", Some(999), None, "data"));
940 assert!(matches!(
941 assess_read_compat(&m, &[w], true),
942 MoverReadCompat::Unknown {
943 why: UnknownReason::MoverUidUnpinned
944 }
945 ));
946 }
947
948 #[test]
949 fn exact_uid_match_is_compatible() {
950 let m = mover_identity(&sc(Some(999), None, Some(true)), None);
951 let w = workload_identity(&pod("pg-0", "db", Some(999), None, "data"));
952 assert!(matches!(
953 assess_read_compat(&m, &[w], true),
954 MoverReadCompat::Compatible {
955 basis: CompatBasis::ExactUidMatch
956 }
957 ));
958 }
959
960 #[test]
961 fn disjoint_uid_no_group_is_likely_incompatible() {
962 let m = mover_identity(&sc(Some(65532), None, Some(true)), None);
963 let w = workload_identity(&pod("pg-0", "db", Some(999), None, "data"));
964 assert!(matches!(
965 assess_read_compat(&m, &[w], true),
966 MoverReadCompat::LikelyIncompatible { .. }
967 ));
968 }
969
970 #[test]
971 fn group_overlap_softens_to_unknown() {
972 let m = mover_identity(
975 &sc(Some(65532), None, Some(true)),
976 Some(&psc(None, None, vec![2000])),
977 );
978 let w = workload_identity(&pod("pg-0", "db", Some(999), Some(2000), "data"));
979 assert!(matches!(
980 assess_read_compat(&m, &[w], true),
981 MoverReadCompat::Unknown {
982 why: UnknownReason::OnlyGroupOverlap
983 }
984 ));
985 }
986
987 #[test]
988 fn root_init_container_writer_is_unknown_not_compatible() {
989 let mut p = pod("app-0", "ns", Some(999), None, "data");
992 p.spec.as_mut().unwrap().init_containers = Some(vec![Container {
993 name: "init".into(),
994 security_context: Some(sc(Some(0), None, Some(false))),
995 ..Default::default()
996 }]);
997 let m = mover_identity(&sc(Some(999), None, Some(true)), None);
998 let w = workload_identity(&p);
999 assert!(w.writer_uids.contains(&0) && w.writer_uids.contains(&999));
1000 assert!(matches!(
1002 assess_read_compat(&m, &[w], true),
1003 MoverReadCompat::LikelyIncompatible { .. }
1004 ));
1005 }
1006
1007 #[test]
1010 fn a_writable_source_with_an_fsgroup_softens_likely_incompatible_to_unknown() {
1011 let m = mover_identity(
1014 &sc(Some(65532), None, Some(true)),
1015 Some(&psc(None, Some(65532), vec![])),
1016 );
1017 let w = workload_identity(&pod("app-0", "ns", Some(1000), Some(1000), "data"));
1018 assert!(
1019 matches!(
1020 assess_read_compat(&m, std::slice::from_ref(&w), true),
1021 MoverReadCompat::LikelyIncompatible { .. }
1022 ),
1023 "read-only source: fsGroup grants nothing, so the mismatch stands"
1024 );
1025 assert!(
1028 matches!(
1029 assess_read_compat(&m, std::slice::from_ref(&w), false),
1030 MoverReadCompat::Unknown {
1031 why: UnknownReason::FsGroupMayApply
1032 }
1033 ),
1034 "a writable source + an fsGroup invalidates the ownership comparison"
1035 );
1036 }
1037
1038 #[test]
1042 fn a_writable_source_never_reaches_compatible_on_an_fsgroup_basis() {
1043 let m = mover_identity(
1044 &sc(Some(65532), None, Some(true)),
1045 Some(&psc(None, Some(65532), vec![])),
1046 );
1047 let w = workload_identity(&pod("app-0", "ns", Some(1000), Some(1000), "data"));
1048 assert!(!matches!(
1049 assess_read_compat(&m, &[w], false),
1050 MoverReadCompat::Compatible { .. }
1051 ));
1052 }
1053
1054 #[test]
1057 fn a_writable_source_without_an_fsgroup_still_reports_likely_incompatible() {
1058 let m = mover_identity(&sc(Some(65532), None, Some(true)), None);
1059 let w = workload_identity(&pod("app-0", "ns", Some(1000), Some(1000), "data"));
1060 assert!(matches!(
1061 assess_read_compat(&m, &[w], false),
1062 MoverReadCompat::LikelyIncompatible { .. }
1063 ));
1064 }
1065
1066 #[test]
1069 fn a_writable_source_does_not_mask_a_provable_compatible() {
1070 let root = mover_identity(
1071 &root_with(&["DAC_OVERRIDE"]),
1072 Some(&psc(None, Some(65532), vec![])),
1073 );
1074 let w = workload_identity(&pod("app-0", "ns", Some(1000), Some(1000), "data"));
1075 assert!(matches!(
1076 assess_read_compat(&root, std::slice::from_ref(&w), false),
1077 MoverReadCompat::Compatible {
1078 basis: CompatBasis::RootMover
1079 }
1080 ));
1081 let exact = mover_identity(
1083 &sc(Some(1000), None, Some(true)),
1084 Some(&psc(None, Some(65532), vec![])),
1085 );
1086 assert!(matches!(
1087 assess_read_compat(&exact, &[w], false),
1088 MoverReadCompat::Compatible {
1089 basis: CompatBasis::ExactUidMatch
1090 }
1091 ));
1092 }
1093
1094 #[test]
1095 fn unpinned_workload_writer_is_unknown() {
1096 let mut p = pod("x", "ns", None, None, "data");
1098 p.spec.as_mut().unwrap().containers[0].security_context = None;
1099 p.spec.as_mut().unwrap().security_context = None;
1100 let m = mover_identity(&sc(Some(65532), None, Some(true)), None);
1101 let w = workload_identity(&p);
1102 assert!(matches!(
1103 assess_read_compat(&m, &[w], true),
1104 MoverReadCompat::Unknown {
1105 why: UnknownReason::WorkloadUidUnpinned
1106 }
1107 ));
1108 }
1109
1110 #[test]
1111 fn verdict_is_independent_of_pod_order() {
1112 let m = mover_identity(&sc(Some(65532), None, Some(true)), None);
1113 let a = workload_identity(&pod("a", "ns", Some(999), None, "data"));
1114 let b = workload_identity(&pod("b", "ns", Some(1000), None, "data"));
1115 let forward = assess_read_compat(&m, &[a.clone(), b.clone()], true);
1116 let reversed = assess_read_compat(&m, &[b, a], true);
1117 assert_eq!(forward, reversed, "verdict must not depend on input order");
1118 }
1119
1120 #[test]
1121 fn pods_mounting_pvc_filters_by_claim() {
1122 let p1 = pod("p1", "ns", Some(1), None, "wanted");
1123 let p2 = pod("p2", "ns", Some(1), None, "other");
1124 let pods = vec![p1, p2];
1125 let found = pods_mounting_pvc(&pods, "wanted");
1126 assert_eq!(found.len(), 1);
1127 assert_eq!(found[0].metadata.name.as_deref(), Some("p1"));
1128 }
1129
1130 #[test]
1131 fn workload_identities_excludes_kopiur_movers() {
1132 let workload = pod("pg-0", "db", Some(999), None, "data");
1135 let mut mover = pod("mover-x", "db", Some(65532), None, "data");
1136 mover.metadata.labels = Some(std::collections::BTreeMap::from([(
1137 crate::consts::MANAGED_BY_LABEL.to_string(),
1138 crate::consts::MANAGED_BY_VALUE.to_string(),
1139 )]));
1140 assert!(is_managed_by_kopiur(&mover) && !is_managed_by_kopiur(&workload));
1141 let ids = workload_identities(&[mover, workload], "data");
1142 assert_eq!(ids.len(), 1, "only the non-kopiur workload counts");
1143 assert_eq!(ids[0].name, "pg-0");
1144 }
1145
1146 #[test]
1149 fn restore_absent_consumer_is_unknown() {
1150 let m = mover_write_identity(&sc(Some(65532), None, Some(true)), None);
1151 assert!(matches!(
1152 assess_restore_compat(&m, None),
1153 RestoreWriteCompat::Unknown {
1154 why: RestoreUnknown::ConsumerAbsent
1155 }
1156 ));
1157 }
1158
1159 #[test]
1160 fn restore_uid_ownership_is_compatible() {
1161 let m = mover_write_identity(&sc(Some(2000), None, Some(true)), None);
1162 let w = workload_identity(&pod("app", "ns", Some(2000), None, "data"));
1163 assert!(matches!(
1164 assess_restore_compat(&m, Some(&w)),
1165 RestoreWriteCompat::Compatible {
1166 basis: RestoreBasis::WorkloadOwnsFiles
1167 }
1168 ));
1169 }
1170
1171 #[test]
1172 fn restore_fsgroup_match_is_compatible() {
1173 let m = mover_write_identity(
1176 &sc(Some(65532), None, Some(true)),
1177 Some(&psc(None, Some(2500), vec![])),
1178 );
1179 let w = workload_identity(&pod("app", "ns", Some(1000), Some(2500), "data"));
1180 assert!(matches!(
1181 assess_restore_compat(&m, Some(&w)),
1182 RestoreWriteCompat::Compatible {
1183 basis: RestoreBasis::FsGroupMatch
1184 }
1185 ));
1186 }
1187
1188 #[test]
1189 fn restore_disjoint_uid_and_fsgroup_is_likely_incompatible() {
1190 let m = mover_write_identity(
1191 &sc(Some(65532), None, Some(true)),
1192 Some(&psc(None, Some(65532), vec![])),
1193 );
1194 let w = workload_identity(&pod("app", "ns", Some(1000), Some(2500), "data"));
1195 assert!(matches!(
1196 assess_restore_compat(&m, Some(&w)),
1197 RestoreWriteCompat::LikelyIncompatible { .. }
1198 ));
1199 }
1200}