Skip to main content

kopiur_api/
secctx_compat.rs

1//! Pure, controller-free reasoning about whether a mover's resolved security context can
2//! **read** a backup *source* PVC, and the inverse — whether a future workload can read
3//! what a restore mover **writes** to a *target* PVC.
4//!
5//! ## Why this is deliberately conservative
6//!
7//! A predicate that reasons *only* from security contexts cannot see file **mode bits**,
8//! and world-readable `0644` data is everywhere. So it can almost never be *certain* a
9//! backup will fail — the only honest verdicts from the spec alone are:
10//!
11//! - **`Compatible`** — provably fine: the mover is root, or its UID exactly matches every
12//!   writer's UID. (We never claim `Compatible` on a group/`fsGroup` basis — see below.)
13//! - **`Unknown`** — we cannot tell from the spec (the common case). The mover-side
14//!   readability preflight (in `crates/mover`) is the layer that *certainly* validates this
15//!   at runtime, where the files are actually mounted.
16//! - **`LikelyIncompatible`** — reserved for near-certainty (the mover shares neither UID
17//!   nor any group with the writers). Used only for a best-effort, advisory admission
18//!   warning; the reconcile loop maps it to a non-blocking `Unknown` condition (the
19//!   certain `False` comes from the mover preflight), so a `0644` tree never produces a
20//!   false alarm on a successful backup.
21//!
22//! ## `fsGroup` is excluded from *backup-source* reasoning
23//!
24//! A backup mounts the source PVC **read-only** by default, so the kubelet never recursively
25//! chgrp's it and `fsGroup` grants nothing for readability there. We therefore never treat an
26//! `fsGroup` match as a path to `Compatible` for backups. (`fsGroup` is only counted toward
27//! the mover's *process* group set, which can only ever *soften* a mismatch to `Unknown` —
28//! the safe direction.) On **restore** the target is a fresh read-write volume where the
29//! kubelet *does* apply `fsGroup`, so [`assess_restore_compat`] treats an `fsGroup` match as
30//! a positive signal — the predicates are intentionally asymmetric.
31//!
32//! `Source::readOnly: false` (#254) exists precisely to re-enable that walk on the source, so
33//! it partially unwinds this: with a writable source and an `fsGroup`, the kubelet MAY chgrp
34//! the tree to the mover's own group, which would make the source readable no matter who wrote
35//! it. That is not enough for `Compatible` — whether the walk happens depends on the
36//! CSIDriver's `fsGroupPolicy` and on `fsGroupChangePolicy`, neither of which is in the spec —
37//! but it IS enough to invalidate every workload-ownership comparison. So
38//! [`assess_read_compat`] takes the effective `readOnly` and short-circuits to
39//! `Unknown { FsGroupMayApply }`. Without that, the one configuration the flag exists to
40//! enable would be reported `LikelyIncompatible` while working fine.
41
42use std::collections::BTreeSet;
43
44use k8s_openapi::api::core::v1::{Pod, PodSecurityContext, SecurityContext};
45
46use crate::common::effective_run_as_user;
47
48/// The mover's effective identity for **read** reasoning (backup source), built from the
49/// resolved, post-invariant container + pod security contexts.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct MoverIdentity {
52    /// Effective `runAsUser` (`container ?? pod`); `None` when image-determined.
53    pub uid: Option<i64>,
54    /// Groups the mover *process* holds: container/pod `runAsGroup`, pod
55    /// `supplementalGroups`, and pod `fsGroup` (the kubelet adds `fsGroup` to the pod's
56    /// supplementary GIDs). Sorted for determinism. Used only to *soften* a UID mismatch.
57    pub groups: BTreeSet<i64>,
58    /// Effective pod `fsGroup`, kept separately from [`Self::groups`] (which folds it in
59    /// among the process's GIDs and so cannot answer *which* one it was). Load-bearing
60    /// only on a **read-write** source mount, where the kubelet may recursively chgrp the
61    /// tree to it — on the read-only default it grants nothing. Symmetric with
62    /// [`MoverWriteIdentity::fs_group`].
63    pub fs_group: Option<i64>,
64}
65
66/// The mover's effective identity for **write** reasoning (restore target).
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct MoverWriteIdentity {
69    /// Effective write `runAsUser` (`container ?? pod`); `None` when image-determined.
70    /// Non-root kopia cannot `chown`, so restored files end up owned by this UID.
71    pub uid: Option<i64>,
72    /// Effective `fsGroup` for the target pod — load-bearing on a fresh read-write volume
73    /// (the kubelet setgid-owns the restored tree to it). `None` when unset.
74    pub fs_group: Option<i64>,
75}
76
77/// A workload pod's identity, for comparing against a mover. One per consuming pod.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct WorkloadIdentity {
80    /// Pod namespace — for deterministic selection + messages.
81    pub namespace: String,
82    /// Pod name — for deterministic selection + messages.
83    pub name: String,
84    /// Effective UIDs of every writer (init + main containers, with pod fallback), pinned
85    /// values only. A file could be owned by any of these.
86    pub writer_uids: BTreeSet<i64>,
87    /// True if any container's effective UID is image-determined (unpinned) — then the
88    /// true writer set is unknowable and the workload can never be `ExactUidMatch`.
89    pub has_unpinned_writer: bool,
90    /// Candidate *file* group IDs: the pod `fsGroup` (setgid file group on the workload's
91    /// volume), every container/pod `runAsGroup`, and `supplementalGroups`. Sorted.
92    pub file_groups: BTreeSet<i64>,
93    /// Effective `runAsUser` of the *primary* (first non-init) container, with pod
94    /// fallback; `None` when unpinned. Used as the restore "future consumer" UID.
95    pub primary_uid: Option<i64>,
96    /// The pod's `fsGroup`, if set — used as the restore "future consumer" group.
97    pub fs_group: Option<i64>,
98}
99
100/// Basis on which a backup mover was found read-compatible. Exhaustive (thesis §5.5).
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum CompatBasis {
103    /// The mover runs as root (UID 0) — reads everything.
104    RootMover,
105    /// The mover's UID exactly matches every writer's UID — owner-reads everything.
106    ExactUidMatch,
107}
108
109/// Why a backup read-compatibility verdict is undecidable from the spec alone.
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum UnknownReason {
112    /// The mover's UID is image-determined (no `runAsUser`); nothing to compare.
113    MoverUidUnpinned,
114    /// At least one workload writer's UID is image-determined.
115    WorkloadUidUnpinned,
116    /// UIDs differ but the mover shares a group with the file group — might read via the
117    /// group bit (which we can't see), so we abstain.
118    OnlyGroupOverlap,
119    /// No pod currently mounts the source PVC (offline workload / mid-rollout).
120    NoConsumerPod,
121    /// The source is NFS (or has no single PVC) — ownership is NAS-determined and `fsGroup`
122    /// is ignored; nothing to assess.
123    NfsOrNoPvc,
124    /// The source is mounted read-write and the mover declares an `fsGroup`, so the kubelet
125    /// **may** recursively chgrp the tree to it before the mover reads — which would make
126    /// the source readable regardless of who wrote it. Whether it actually does is not
127    /// knowable from the spec (the CSIDriver's `fsGroupPolicy` may be `None`, or the default
128    /// `ReadWriteOnceWithFSType`, which skips RWX volumes; and `fsGroupChangePolicy:
129    /// OnRootMismatch` skips the walk when the root group already matches), so we abstain.
130    FsGroupMayApply,
131}
132
133/// Whether a backup mover can read the source PVC's files. See the module docs for why
134/// `Compatible` is rare and `LikelyIncompatible` rarer still.
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub enum MoverReadCompat {
137    /// Provably readable.
138    Compatible {
139        /// Why the mover is read-compatible.
140        basis: CompatBasis,
141    },
142    /// Undecidable from the spec — the mover preflight is the runtime arbiter.
143    Unknown {
144        /// Why the verdict is undecidable.
145        why: UnknownReason,
146    },
147    /// Near-certain mismatch: the mover shares neither UID nor any group with the
148    /// (fully-pinned) writers. Advisory only.
149    LikelyIncompatible {
150        /// The mover's effective UID.
151        mover_uid: i64,
152        /// The writer UIDs the mover matches none of (sorted).
153        workload_uids: Vec<i64>,
154    },
155}
156
157/// Basis on which a restore was found write-compatible with the future consumer.
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159pub enum RestoreBasis {
160    /// The future workload's UID equals the mover's write UID — it owns the restored files.
161    WorkloadOwnsFiles,
162    /// The mover's `fsGroup` equals the future workload's `fsGroup`; on a fresh read-write
163    /// volume the kubelet setgid-owns the tree to that group, so the workload group-reads it.
164    FsGroupMatch,
165}
166
167/// Why a restore write-compatibility verdict is undecidable from the spec alone.
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub enum RestoreUnknown {
170    /// No pod consumes the target PVC yet (the common case — e.g. a populator target).
171    ConsumerAbsent,
172    /// The future workload's UID is image-determined.
173    WorkloadUidUnpinned,
174    /// UID and `fsGroup` both differ, but file modes are unknown so we can't be certain.
175    ModeUnknown,
176}
177
178/// Whether a future workload can read what a restore mover writes. The restore counterpart
179/// of [`MoverReadCompat`] — `fsGroup` IS load-bearing here (fresh read-write volume).
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub enum RestoreWriteCompat {
182    /// Provably (or near-certainly) readable by the future workload.
183    Compatible {
184        /// Why the restore is write-compatible.
185        basis: RestoreBasis,
186    },
187    /// Undecidable from the spec.
188    Unknown {
189        /// Why the verdict is undecidable.
190        why: RestoreUnknown,
191    },
192    /// Near-certain mismatch: the mover writes as a UID and `fsGroup` the future workload
193    /// shares neither of. Advisory only.
194    LikelyIncompatible {
195        /// The mover's write UID, if pinned.
196        mover_uid: Option<i64>,
197        /// The future workload's UID, if pinned.
198        workload_uid: Option<i64>,
199    },
200}
201
202/// Build the mover's read identity from its resolved container + pod security contexts.
203pub 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
223/// Build the mover's *write* identity (restore) from its resolved contexts.
224pub 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
234/// Extract a [`WorkloadIdentity`] from a live pod: the union of init + main container
235/// effective UIDs (pod fallback) as writers, and the file-group candidates.
236pub 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    // No containers at all → the writer set is unknowable.
272    if init.is_empty() && main.is_empty() {
273        has_unpinned_writer = true;
274    }
275    // The "primary" container is the first non-init container (the long-running app).
276    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
293/// Does this pod mount `claim_name` via a `persistentVolumeClaim` volume? The canonical
294/// scanner — the controller's colocation logic reuses this so the two never drift.
295pub 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
309/// Pods (in stable input order) that mount `claim_name`.
310pub 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
316/// The **container** within `pod` that mounts `claim_name`: resolve the pod volume(s) backed
317/// by the claim, then find the container whose `volumeMounts` reference one.
318///
319/// [`pod_mounts_claim`] is pod-level and cannot answer "which container is the app?" — a
320/// question that matters because `inheritSecurityContextFrom` copies ONE container's
321/// `securityContext`. Falling back to the pod's first container picks whatever the manifest or
322/// a sidecar injector listed first, which on an istio-injected pod is `istio-proxy` (uid 1337),
323/// not the app. The container actually mounting the data is a far better guess at whose
324/// identity wrote it.
325///
326/// Returns `None` when nothing mounts the claim or when **several** containers do — ambiguous
327/// is not a guess worth making, and the caller falls back to the first container as before.
328/// Init containers are excluded: they are not the long-running writer.
329pub 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    // Pod volume names backed by this claim (usually exactly one).
335    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    // More than one container mounts it → ambiguous; let the caller decide.
359    if mounters.next().is_some() {
360        return None;
361    }
362    Some(first)
363}
364
365/// Whether a pod is a kopiur-managed object (carries `app.kubernetes.io/managed-by=kopiur`).
366/// A mover Job's pod mounts the source PVC too, so consumer-discovery and compatibility
367/// reasoning must exclude these — otherwise the mover would be compared against (or inherit
368/// from) itself. The single definition, shared by the controller and webhook.
369pub 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
378/// Build [`WorkloadIdentity`] for every **workload** pod mounting `claim_name` — i.e. those
379/// mounting the claim, minus kopiur-managed (mover) pods. The shared core for backup,
380/// restore, and webhook compatibility checks (one definition, no per-caller duplication).
381pub 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
389/// Assess whether a backup mover can read the source PVC's files, given the workload pods
390/// mounting it. See the module docs for the conservative posture; the result is deterministic
391/// (independent of `workloads` ordering).
392///
393/// `source_read_only` is the source mount's effective `readOnly` (`Source::readOnly`, default
394/// `true`). It is load-bearing rather than incidental: the module's whole `fsGroup` posture
395/// rests on the mount being read-only, and a writable source invalidates every group
396/// comparison below — see [`UnknownReason::FsGroupMayApply`].
397pub fn assess_read_compat(
398    mover: &MoverIdentity,
399    workloads: &[WorkloadIdentity],
400    source_read_only: bool,
401) -> MoverReadCompat {
402    // Root reads everything — independent of any workload (so this holds even with no pod).
403    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    // Any workload with an unpinned writer makes the whole assessment undecidable.
420    if workloads.iter().any(|w| w.has_unpinned_writer) {
421        return MoverReadCompat::Unknown {
422            why: UnknownReason::WorkloadUidUnpinned,
423        };
424    }
425    // Compatible only if EVERY writer across all pods is exactly the mover's UID.
426    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    // Everything below reasons about the ownership the WORKLOAD left on the volume. A
436    // read-write mount plus an `fsGroup` breaks that premise at the root: the kubelet may
437    // chgrp the whole tree to the mover's own `fsGroup` and add group-write before the
438    // mover ever reads, at which point who wrote the files stops mattering. Bail out here
439    // rather than after the group chain — `mover.groups` already contains `fs_group`, so
440    // `shares_group` below would otherwise catch this first and blame "a shared group with
441    // the workload", which is not what happened. Still `Unknown`, never `Compatible`: the
442    // module reserves that for provable verdicts, and whether the kubelet performs the walk
443    // is not a property of the spec (see `FsGroupMayApply`).
444    if !source_read_only && mover.fs_group.is_some() {
445        return MoverReadCompat::Unknown {
446            why: UnknownReason::FsGroupMayApply,
447        };
448    }
449    // UIDs differ somewhere. If the mover shares any group with any file group, a group-read
450    // (mode we can't see) might let it through — abstain.
451    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    // No UID match, no group bridge, every writer pinned → near-certain unreadable.
460    MoverReadCompat::LikelyIncompatible {
461        mover_uid,
462        workload_uids: all_writers.into_iter().collect(),
463    }
464}
465
466/// Assess whether the *future* consumer of a restore target can read what the mover writes.
467/// `fsGroup` IS a positive signal here (fresh read-write volume). `future` is `None` when no
468/// pod consumes the target yet (the common case → `ConsumerAbsent`).
469pub 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    // UID ownership: the workload runs as the UID that owns the restored files.
479    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    // fsGroup bridge: setgid group ownership on the fresh volume + the workload joins it.
487    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    // Both UID and fsGroup differ; a pinned mover UID vs a pinned, distinct workload UID with
500    // no fsGroup bridge is the near-certain case. Otherwise modes are unknown — abstain.
501    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    /// A stable, deterministic one-line summary for a status condition message / admission
514    /// warning. No timestamps / volatile content — sorted UID lists only.
515    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    /// A stable label for the reason (status condition `reason` / messages).
545    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
560/// Render a sorted UID list deterministically, e.g. `[999, 1000]`.
561fn 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    /// Build a pod with one main container running as `uid`/`gid`, pod fsGroup `fs_group`,
593    /// mounting `claim`.
594    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        // Container UID unset, pod UID 0 → effective root (the precedence bug guard).
639        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    /// The exact reported bug, end-to-end through the real merge + assessment: a
653    /// `pvcConsumer` mover inheriting from a workload whose container securityContext EXISTS
654    /// but pins no `runAsUser` (its UID comes from the image's `USER` line).
655    ///
656    /// Inherit "succeeds" — the block is non-empty — but contributes no UID, so the merged
657    /// mover has none either and runs as the mover image's 65532. The reconciler used to
658    /// short-circuit this to `SecurityContextCompatible=True` ("matches the workload by
659    /// construction") without ever asking this engine; the backup then failed with
660    /// `permission denied`. The engine's answer has always been `Unknown` — it was simply
661    /// never consulted on the pvcConsumer path.
662    #[test]
663    fn inheriting_a_uidless_workload_is_never_compatible() {
664        // The workload: hardened container context, no runAsUser at either level.
665        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        // What `inheritSecurityContextFrom` copies off it, through the real merge ladder.
673        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    /// The companion: when the workload DOES pin a UID, inheriting it is provably compatible.
706    /// Guards against over-correcting the fix above into never confirming anything.
707    #[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    /// Cause (b): a sidecar-injected pod. `containers.first()` may hand the mover the
733    /// sidecar's UID while the app writes the files as its own. The whole-namespace writer
734    /// set catches the mismatch — "matches by construction" never could.
735    #[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        // istio-proxy injected ahead of the app container, running as 1337.
739        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        // The mover inherited the SIDECAR (containers.first()).
748        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    /// `container_mounting_claim` exists because `containers.first()` is a coin flip on any
774    /// pod with an injected sidecar — and inheriting the sidecar's UID yields a mover that
775    /// cannot read the app's files.
776    #[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        // The app container mounts the claim.
782        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        // istio-proxy is injected FIRST and mounts nothing of ours.
788        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        // Nothing mounts the claim -> None (caller keeps its old first-container behavior).
823        let p = pod("app-1", "app", Some(1000), None, "app-data");
824        assert!(container_mounting_claim(&p, "app-data").is_none());
825
826        // A DIFFERENT claim -> None.
827        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        // TWO containers mount it -> ambiguous, abstain rather than guess.
832        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        // Hardened mover: runAsNonRoot:true, no runAsUser → UID unpinned → Unknown.
849        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        // Mover non-root 65532 with supplementalGroup 2000; workload writes as 999 with
888        // fsGroup 2000 → shared group 2000 → abstain (might group-read).
889        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        // Main container 999, init container root → writer set {0, 999}; mover 999 ≠ {999}
905        // exactly, and root-owned files might be 0644 → Unknown (no group bridge), not False.
906        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        // mover 999 doesn't match the {0,999} set exactly, no shared group → LikelyIncompatible.
916        assert!(matches!(
917            assess_read_compat(&m, &[w], true),
918            MoverReadCompat::LikelyIncompatible { .. }
919        ));
920    }
921
922    /// #254: the exact configuration `Source::readOnly: false` exists to enable must not
923    /// be reported as a near-certain failure.
924    #[test]
925    fn a_writable_source_with_an_fsgroup_softens_likely_incompatible_to_unknown() {
926        // The workload writes as 1000 and shares no group with the mover. Read-only, that
927        // is the textbook LikelyIncompatible — and it is the verdict a user gets today.
928        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        // Writable, the kubelet MAY chgrp the whole tree to the mover's own fsGroup before
941        // it reads, at which point who wrote the files stops mattering. Abstain.
942        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    /// The verdict is `Unknown`, never `Compatible` — whether the kubelet performs the walk
954    /// is not a property of the spec (CSIDriver `fsGroupPolicy: None`, or the default
955    /// `ReadWriteOnceWithFSType` skipping RWX; `fsGroupChangePolicy: OnRootMismatch`).
956    #[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    /// The softening is keyed on the mover actually HAVING an fsGroup — without one there
970    /// is no walk to hope for, and a writable mount changes nothing.
971    #[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    /// The fsGroup short-circuit sits AFTER the provable verdicts, so it can never mask a
982    /// real `Compatible` into an abstention.
983    #[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        // ...and an exact UID match likewise survives.
997        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        // Container with no runAsUser and no pod-level UID → unpinned writer.
1012        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        // A kopiur mover pod mounts the source PVC too — it must never be treated as a
1048        // workload (else the mover compares against / inherits from itself).
1049        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    // --- restore-direction ---
1062
1063    #[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        // Mover writes as 65532 with fsGroup 2500; future workload runs as 1000 with fsGroup
1089        // 2500 → on a fresh RW volume the kubelet setgid-owns to 2500 → group-readable.
1090        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}