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    /// The mover runs as root AND can bypass file permission checks: `privileged: true`,
65    /// or `DAC_OVERRIDE`/`DAC_READ_SEARCH` added (which `privilegedMode` grants a backup,
66    /// #552). Root under the hardened `drop: [ALL]` without either is just uid 0 — the
67    /// kernel still applies owner/group/other checks, so it does NOT read everything.
68    pub reads_any_file: bool,
69}
70
71/// The mover's effective identity for **write** reasoning (restore target).
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct MoverWriteIdentity {
74    /// Effective write `runAsUser` (`container ?? pod`); `None` when image-determined.
75    /// Non-root kopia cannot `chown`, so restored files end up owned by this UID.
76    pub uid: Option<i64>,
77    /// Effective `fsGroup` for the target pod — load-bearing on a fresh read-write volume
78    /// (the kubelet setgid-owns the restored tree to it). `None` when unset.
79    pub fs_group: Option<i64>,
80}
81
82/// A workload pod's identity, for comparing against a mover. One per consuming pod.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct WorkloadIdentity {
85    /// Pod namespace — for deterministic selection + messages.
86    pub namespace: String,
87    /// Pod name — for deterministic selection + messages.
88    pub name: String,
89    /// Effective UIDs of every writer (init + main containers, with pod fallback), pinned
90    /// values only. A file could be owned by any of these.
91    pub writer_uids: BTreeSet<i64>,
92    /// True if any container's effective UID is image-determined (unpinned) — then the
93    /// true writer set is unknowable and the workload can never be `ExactUidMatch`.
94    pub has_unpinned_writer: bool,
95    /// Candidate *file* group IDs: the pod `fsGroup` (setgid file group on the workload's
96    /// volume), every container/pod `runAsGroup`, and `supplementalGroups`. Sorted.
97    pub file_groups: BTreeSet<i64>,
98    /// Effective `runAsUser` of the *primary* (first non-init) container, with pod
99    /// fallback; `None` when unpinned. Used as the restore "future consumer" UID.
100    pub primary_uid: Option<i64>,
101    /// The pod's `fsGroup`, if set — used as the restore "future consumer" group.
102    pub fs_group: Option<i64>,
103}
104
105/// Basis on which a backup mover was found read-compatible. Exhaustive (thesis §5.5).
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum CompatBasis {
108    /// The mover runs as root (UID 0) — reads everything.
109    RootMover,
110    /// The mover's UID exactly matches every writer's UID — owner-reads everything.
111    ExactUidMatch,
112}
113
114/// Why a backup read-compatibility verdict is undecidable from the spec alone.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum UnknownReason {
117    /// The mover's UID is image-determined (no `runAsUser`); nothing to compare.
118    MoverUidUnpinned,
119    /// At least one workload writer's UID is image-determined.
120    WorkloadUidUnpinned,
121    /// UIDs differ but the mover shares a group with the file group — might read via the
122    /// group bit (which we can't see), so we abstain.
123    OnlyGroupOverlap,
124    /// No pod currently mounts the source PVC (offline workload / mid-rollout).
125    NoConsumerPod,
126    /// The source is NFS (or has no single PVC) — ownership is NAS-determined and `fsGroup`
127    /// is ignored; nothing to assess.
128    NfsOrNoPvc,
129    /// The source is mounted read-write and the mover declares an `fsGroup`, so the kubelet
130    /// **may** recursively chgrp the tree to it before the mover reads — which would make
131    /// the source readable regardless of who wrote it. Whether it actually does is not
132    /// knowable from the spec (the CSIDriver's `fsGroupPolicy` may be `None`, or the default
133    /// `ReadWriteOnceWithFSType`, which skips RWX volumes; and `fsGroupChangePolicy:
134    /// OnRootMismatch` skips the walk when the root group already matches), so we abstain.
135    FsGroupMayApply,
136}
137
138/// Whether a backup mover can read the source PVC's files. See the module docs for why
139/// `Compatible` is rare and `LikelyIncompatible` rarer still.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub enum MoverReadCompat {
142    /// Provably readable.
143    Compatible {
144        /// Why the mover is read-compatible.
145        basis: CompatBasis,
146    },
147    /// Undecidable from the spec — the mover preflight is the runtime arbiter.
148    Unknown {
149        /// Why the verdict is undecidable.
150        why: UnknownReason,
151    },
152    /// Near-certain mismatch: the mover shares neither UID nor any group with the
153    /// (fully-pinned) writers. Advisory only.
154    LikelyIncompatible {
155        /// The mover's effective UID.
156        mover_uid: i64,
157        /// The writer UIDs the mover matches none of (sorted).
158        workload_uids: Vec<i64>,
159    },
160}
161
162/// Basis on which a restore was found write-compatible with the future consumer.
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum RestoreBasis {
165    /// The future workload's UID equals the mover's write UID — it owns the restored files.
166    WorkloadOwnsFiles,
167    /// The mover's `fsGroup` equals the future workload's `fsGroup`; on a fresh read-write
168    /// volume the kubelet setgid-owns the tree to that group, so the workload group-reads it.
169    FsGroupMatch,
170}
171
172/// Why a restore write-compatibility verdict is undecidable from the spec alone.
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum RestoreUnknown {
175    /// No pod consumes the target PVC yet (the common case — e.g. a populator target).
176    ConsumerAbsent,
177    /// The future workload's UID is image-determined.
178    WorkloadUidUnpinned,
179    /// UID and `fsGroup` both differ, but file modes are unknown so we can't be certain.
180    ModeUnknown,
181}
182
183/// Whether a future workload can read what a restore mover writes. The restore counterpart
184/// of [`MoverReadCompat`] — `fsGroup` IS load-bearing here (fresh read-write volume).
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub enum RestoreWriteCompat {
187    /// Provably (or near-certainly) readable by the future workload.
188    Compatible {
189        /// Why the restore is write-compatible.
190        basis: RestoreBasis,
191    },
192    /// Undecidable from the spec.
193    Unknown {
194        /// Why the verdict is undecidable.
195        why: RestoreUnknown,
196    },
197    /// Near-certain mismatch: the mover writes as a UID and `fsGroup` the future workload
198    /// shares neither of. Advisory only.
199    LikelyIncompatible {
200        /// The mover's write UID, if pinned.
201        mover_uid: Option<i64>,
202        /// The future workload's UID, if pinned.
203        workload_uid: Option<i64>,
204    },
205}
206
207/// Build the mover's read identity from its resolved container + pod security contexts.
208pub 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
230/// Build the mover's *write* identity (restore) from its resolved contexts.
231pub 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
241/// Extract a [`WorkloadIdentity`] from a live pod: the union of init + main container
242/// effective UIDs (pod fallback) as writers, and the file-group candidates.
243pub 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    // No containers at all → the writer set is unknowable.
279    if init.is_empty() && main.is_empty() {
280        has_unpinned_writer = true;
281    }
282    // The "primary" container is the first non-init container (the long-running app).
283    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
300/// Does this pod mount `claim_name` via a `persistentVolumeClaim` volume? The canonical
301/// scanner — the controller's colocation logic reuses this so the two never drift.
302pub 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
316/// Pods (in stable input order) that mount `claim_name`.
317pub 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
323/// The **container** within `pod` that mounts `claim_name`: resolve the pod volume(s) backed
324/// by the claim, then find the container whose `volumeMounts` reference one.
325///
326/// [`pod_mounts_claim`] is pod-level and cannot answer "which container is the app?" — a
327/// question that matters because `inheritSecurityContextFrom` copies ONE container's
328/// `securityContext`. Falling back to the pod's first container picks whatever the manifest or
329/// a sidecar injector listed first, which on an istio-injected pod is `istio-proxy` (uid 1337),
330/// not the app. The container actually mounting the data is a far better guess at whose
331/// identity wrote it.
332///
333/// Returns `None` when nothing mounts the claim or when **several** containers do — ambiguous
334/// is not a guess worth making, and the caller falls back to the first container as before.
335/// Init containers are excluded: they are not the long-running writer.
336pub 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    // Pod volume names backed by this claim (usually exactly one).
342    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    // More than one container mounts it → ambiguous; let the caller decide.
366    if mounters.next().is_some() {
367        return None;
368    }
369    Some(first)
370}
371
372/// Whether a pod is a kopiur-managed object (carries `app.kubernetes.io/managed-by=kopiur`).
373/// A mover Job's pod mounts the source PVC too, so consumer-discovery and compatibility
374/// reasoning must exclude these — otherwise the mover would be compared against (or inherit
375/// from) itself. The single definition, shared by the controller and webhook.
376pub 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
385/// Build [`WorkloadIdentity`] for every **workload** pod mounting `claim_name` — i.e. those
386/// mounting the claim, minus kopiur-managed (mover) pods. The shared core for backup,
387/// restore, and webhook compatibility checks (one definition, no per-caller duplication).
388pub 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
396/// Assess whether a backup mover can read the source PVC's files, given the workload pods
397/// mounting it. See the module docs for the conservative posture; the result is deterministic
398/// (independent of `workloads` ordering).
399///
400/// `source_read_only` is the source mount's effective `readOnly` (`Source::readOnly`, default
401/// `true`). It is load-bearing rather than incidental: the module's whole `fsGroup` posture
402/// rests on the mount being read-only, and a writable source invalidates every group
403/// comparison below — see [`UnknownReason::FsGroupMayApply`].
404pub fn assess_read_compat(
405    mover: &MoverIdentity,
406    workloads: &[WorkloadIdentity],
407    source_read_only: bool,
408) -> MoverReadCompat {
409    // Root that bypasses permission checks reads everything — independent of any workload
410    // (so this holds even with no pod). Plain root under `drop: [ALL]` does not (#552); it
411    // falls through to the ordinary uid/gid assessment below like any other UID.
412    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    // Any workload with an unpinned writer makes the whole assessment undecidable.
429    if workloads.iter().any(|w| w.has_unpinned_writer) {
430        return MoverReadCompat::Unknown {
431            why: UnknownReason::WorkloadUidUnpinned,
432        };
433    }
434    // Compatible only if EVERY writer across all pods is exactly the mover's UID.
435    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    // Everything below reasons about the ownership the WORKLOAD left on the volume. A
445    // read-write mount plus an `fsGroup` breaks that premise at the root: the kubelet may
446    // chgrp the whole tree to the mover's own `fsGroup` and add group-write before the
447    // mover ever reads, at which point who wrote the files stops mattering. Bail out here
448    // rather than after the group chain — `mover.groups` already contains `fs_group`, so
449    // `shares_group` below would otherwise catch this first and blame "a shared group with
450    // the workload", which is not what happened. Still `Unknown`, never `Compatible`: the
451    // module reserves that for provable verdicts, and whether the kubelet performs the walk
452    // is not a property of the spec (see `FsGroupMayApply`).
453    if !source_read_only && mover.fs_group.is_some() {
454        return MoverReadCompat::Unknown {
455            why: UnknownReason::FsGroupMayApply,
456        };
457    }
458    // UIDs differ somewhere. If the mover shares any group with any file group, a group-read
459    // (mode we can't see) might let it through — abstain.
460    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    // No UID match, no group bridge, every writer pinned → near-certain unreadable.
469    MoverReadCompat::LikelyIncompatible {
470        mover_uid,
471        workload_uids: all_writers.into_iter().collect(),
472    }
473}
474
475/// Assess whether the *future* consumer of a restore target can read what the mover writes.
476/// `fsGroup` IS a positive signal here (fresh read-write volume). `future` is `None` when no
477/// pod consumes the target yet (the common case → `ConsumerAbsent`).
478pub 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    // UID ownership: the workload runs as the UID that owns the restored files.
488    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    // fsGroup bridge: setgid group ownership on the fresh volume + the workload joins it.
496    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    // Both UID and fsGroup differ; a pinned mover UID vs a pinned, distinct workload UID with
509    // no fsGroup bridge is the near-certain case. Otherwise modes are unknown — abstain.
510    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    /// A stable, deterministic one-line summary for a status condition message / admission
523    /// warning. No timestamps / volatile content — sorted UID lists only.
524    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    /// A stable label for the reason (status condition `reason` / messages).
556    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
571/// Render a sorted UID list deterministically, e.g. `[999, 1000]`.
572fn 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    /// Build a pod with one main container running as `uid`/`gid`, pod fsGroup `fs_group`,
604    /// mounting `claim`.
605    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    /// A root container context adding `caps` on top of the hardened `drop: [ALL]`.
637    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    /// A root mover that can bypass permission checks (here: `privilegedMode`'s
648    /// `DAC_OVERRIDE`) reads everything, independent of any workload.
649    #[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    /// #552: root with `drop: [ALL]` and nothing added holds no `DAC_OVERRIDE`, so the
664    /// kernel applies normal permission checks to it. It must NOT be reported as
665    /// "reads everything" — it falls through to the ordinary uid/gid assessment.
666    #[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        // A workload writing as root: uid 0 matches every writer, so ownership reads.
677        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        // A workload writing as 1000 with no shared group: near-certain unreadable.
685        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    /// The full #552 path: `privilegedMode` on a root backup mover yields a `RootMover`
693    /// verdict; the same root mover without it does not.
694    #[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        // Container UID unset, pod UID 0 → effective root (the precedence bug guard).
720        // `privileged: true` is what lets that root bypass permission checks.
721        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    /// The exact reported bug, end-to-end through the real merge + assessment: a
738    /// `pvcConsumer` mover inheriting from a workload whose container securityContext EXISTS
739    /// but pins no `runAsUser` (its UID comes from the image's `USER` line).
740    ///
741    /// Inherit "succeeds" — the block is non-empty — but contributes no UID, so the merged
742    /// mover has none either and runs as the mover image's 65532. The reconciler used to
743    /// short-circuit this to `SecurityContextCompatible=True` ("matches the workload by
744    /// construction") without ever asking this engine; the backup then failed with
745    /// `permission denied`. The engine's answer has always been `Unknown` — it was simply
746    /// never consulted on the pvcConsumer path.
747    #[test]
748    fn inheriting_a_uidless_workload_is_never_compatible() {
749        // The workload: hardened container context, no runAsUser at either level.
750        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        // What `inheritSecurityContextFrom` copies off it, through the real merge ladder.
758        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    /// The companion: when the workload DOES pin a UID, inheriting it is provably compatible.
791    /// Guards against over-correcting the fix above into never confirming anything.
792    #[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    /// Cause (b): a sidecar-injected pod. `containers.first()` may hand the mover the
818    /// sidecar's UID while the app writes the files as its own. The whole-namespace writer
819    /// set catches the mismatch — "matches by construction" never could.
820    #[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        // istio-proxy injected ahead of the app container, running as 1337.
824        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        // The mover inherited the SIDECAR (containers.first()).
833        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    /// `container_mounting_claim` exists because `containers.first()` is a coin flip on any
859    /// pod with an injected sidecar — and inheriting the sidecar's UID yields a mover that
860    /// cannot read the app's files.
861    #[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        // The app container mounts the claim.
867        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        // istio-proxy is injected FIRST and mounts nothing of ours.
873        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        // Nothing mounts the claim -> None (caller keeps its old first-container behavior).
908        let p = pod("app-1", "app", Some(1000), None, "app-data");
909        assert!(container_mounting_claim(&p, "app-data").is_none());
910
911        // A DIFFERENT claim -> None.
912        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        // TWO containers mount it -> ambiguous, abstain rather than guess.
917        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        // Hardened mover: runAsNonRoot:true, no runAsUser → UID unpinned → Unknown.
934        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        // Mover non-root 65532 with supplementalGroup 2000; workload writes as 999 with
973        // fsGroup 2000 → shared group 2000 → abstain (might group-read).
974        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        // Main container 999, init container root → writer set {0, 999}; mover 999 ≠ {999}
990        // exactly, and root-owned files might be 0644 → Unknown (no group bridge), not False.
991        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        // mover 999 doesn't match the {0,999} set exactly, no shared group → LikelyIncompatible.
1001        assert!(matches!(
1002            assess_read_compat(&m, &[w], true),
1003            MoverReadCompat::LikelyIncompatible { .. }
1004        ));
1005    }
1006
1007    /// #254: the exact configuration `Source::readOnly: false` exists to enable must not
1008    /// be reported as a near-certain failure.
1009    #[test]
1010    fn a_writable_source_with_an_fsgroup_softens_likely_incompatible_to_unknown() {
1011        // The workload writes as 1000 and shares no group with the mover. Read-only, that
1012        // is the textbook LikelyIncompatible — and it is the verdict a user gets today.
1013        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        // Writable, the kubelet MAY chgrp the whole tree to the mover's own fsGroup before
1026        // it reads, at which point who wrote the files stops mattering. Abstain.
1027        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    /// The verdict is `Unknown`, never `Compatible` — whether the kubelet performs the walk
1039    /// is not a property of the spec (CSIDriver `fsGroupPolicy: None`, or the default
1040    /// `ReadWriteOnceWithFSType` skipping RWX; `fsGroupChangePolicy: OnRootMismatch`).
1041    #[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    /// The softening is keyed on the mover actually HAVING an fsGroup — without one there
1055    /// is no walk to hope for, and a writable mount changes nothing.
1056    #[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    /// The fsGroup short-circuit sits AFTER the provable verdicts, so it can never mask a
1067    /// real `Compatible` into an abstention.
1068    #[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        // ...and an exact UID match likewise survives.
1082        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        // Container with no runAsUser and no pod-level UID → unpinned writer.
1097        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        // A kopiur mover pod mounts the source PVC too — it must never be treated as a
1133        // workload (else the mover compares against / inherits from itself).
1134        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    // --- restore-direction ---
1147
1148    #[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        // Mover writes as 65532 with fsGroup 2500; future workload runs as 1000 with fsGroup
1174        // 2500 → on a fresh RW volume the kubelet setgid-owns to 2500 → group-readable.
1175        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}