Skip to main content

kopiur_api/
expand.rs

1//! Selector expansion: turning one `SnapshotPolicy` source into N concrete
2//! per-PVC backups (#346).
3//!
4//! # The model
5//!
6//! A `SnapshotPolicy` source is exactly one of `pvc`, `nfs`, or `pvcSelector`.
7//! The first two name a single thing; the third matches many. Kopiur's whole
8//! data model is built on **one `Snapshot` CR = one mover Job = one kopia
9//! source path = one kopia manifest**, owned via a finalizer — retention,
10//! restore, the catalog and the deletion policy all rest on that 1:1. So a
11//! selector is expanded into N *ordinary* `Snapshot` CRs, one per matched PVC,
12//! each of which is then indistinguishable from a hand-written single-PVC
13//! backup.
14//!
15//! Expansion happens **once, at mint time**, in whichever component creates the
16//! CR — a `SnapshotSchedule` fire or `kubectl kopiur snapshot now`. A `Snapshot`
17//! never expands itself: a CR minting sibling CRs would re-enter the same
18//! reconciler for each child and break the one-shot `run_decision` discipline,
19//! and a `SnapshotPolicy` that minted invocations would collapse the
20//! recipe/invocation/schedule split the project is built around.
21//!
22//! # What was here before
23//!
24//! Nothing. `pvcSelector` was schema-valid, admission-accepted, documented on
25//! six pages and shipped as `deploy/examples/04-multi-pvc-selector.yaml` — and
26//! no expansion code existed anywhere, so `build_backup_run` hit its
27//! `_ =>` arm and returned `invariant violated … This is likely a bug in
28//! kopiur`. That is #346.
29//!
30//! # The restore side (#443)
31//!
32//! Expansion is only half the story. A selector policy writes N kopia sources,
33//! one per member path; a RESTORE against that policy must read exactly ONE of
34//! them, chosen by the PVC it is filling. [`restore_source_path`] is that
35//! inverse: it re-derives a member's path from the policy's own
36//! `sourcePathStrategy`, so the restore's kopia filter names the same path the
37//! backup wrote. Without it the filter is `user@host:` with no path, which
38//! matches EVERY member — and the newest of them wins, so one PVC could be
39//! filled with another PVC's data.
40//!
41//! It lives here, next to [`EffectiveSource::kopia_source_path`] and
42//! [`strategy_for`], on purpose: the backup path and the restore path are then
43//! literally the same code, and the two strings cannot drift.
44
45use std::collections::BTreeMap;
46
47use crate::error::ValidationError;
48use crate::snapshot::{PvcTargetRef, SnapshotSourceGroup, SnapshotSourceRef, SnapshotSourceTarget};
49use crate::snapshot_policy::SnapshotPolicy;
50use crate::snapshot_policy::{self, Source, SourcePathStrategy};
51use kube::ResourceExt;
52
53/// Max name length for a `Snapshot` CR produced by expansion.
54///
55/// **63, not 253.** The CR's name becomes the mover `Job`'s name, and
56/// `io::cleanup_staged_source` finds that Job's pods by the
57/// `batch.kubernetes.io/job-name` **label value**, which Kubernetes caps at 63
58/// bytes. A longer name silently breaks the pvc-protection release step and
59/// wedges staged-PVC teardown. Fan-out makes long names routine, so this is
60/// enforced here rather than discovered in production.
61const MAX_CHILD_NAME: usize = 63;
62
63/// The marker segment that makes a fanned-out name unambiguous.
64const FANOUT_MARKER: &str = "-pvc-";
65
66// --- the effective source for one run --------------------------------------
67
68/// The single source one `Snapshot` actually backs up, after resolving
69/// `spec.source` (a fanned-out child) against the policy's `sources[]`.
70// `PartialEq` only, never `Eq`: `stream` carries a `StreamExec`, which embeds
71// k8s-openapi's `LabelSelector` — a `PartialEq`-only type (api-conventions §3).
72#[derive(Debug, Clone, PartialEq)]
73pub struct EffectiveSource {
74    /// Index into `policy.spec.sources` these knobs came from.
75    pub index: usize,
76    /// The concrete PVC, when this is a PVC-shaped source.
77    pub pvc: Option<PvcTargetRef>,
78    /// The NFS export path, when this is an NFS source.
79    pub nfs_path: Option<String>,
80    /// The stream producer, when this is a `stream` source. Mounts nothing: the mover
81    /// execs a command and pipes its stdout into kopia, so no `pvc`/`nfs_path` is set
82    /// and `read_only` is meaningless (see [`EffectiveSource::read_only`]).
83    pub stream: Option<crate::snapshot_policy::StreamSource>,
84    /// `sourcePathOverride` from the governing source.
85    pub source_path_override: Option<String>,
86    /// Whether the mount is read-only.
87    pub read_only: bool,
88}
89
90impl EffectiveSource {
91    /// The kopia source path (and mount path) for this run.
92    ///
93    /// `sourcePathOverride` wins. Otherwise a PVC yields `/pvc/<name>` or
94    /// `/pvc/<namespace>/<name>` depending on the governing source's
95    /// [`SourcePathStrategy`], and an NFS source yields its export path.
96    ///
97    /// The `pvc`-before-`stream` order is safe because the combination is
98    /// UNREPRESENTABLE, not because PVC is the better guess: a `Source` is
99    /// exactly-one-of, and [`effective_source_in`] refuses a PVC pin against a
100    /// source that is not PVC-shaped (see the comment there for what the divergence
101    /// used to cost). Every other constructor sets `stream: None` wherever it sets
102    /// `pvc`. Anything that starts building an `EffectiveSource` with both must
103    /// decide which path is authoritative FIRST — the two consumers
104    /// ([`identity_source_path`], which records the manifest, and
105    /// `snapshot::build`, which fills the work spec) do not read this function the
106    /// same way.
107    pub fn kopia_source_path(&self, strategy: SourcePathStrategy) -> Option<String> {
108        if let Some(o) = &self.source_path_override {
109            return Some(o.clone());
110        }
111        if let Some(p) = &self.pvc {
112            return Some(match strategy {
113                SourcePathStrategy::PvcName => format!("/pvc/{}", p.name),
114                SourcePathStrategy::PvcNamespacedName => {
115                    format!("/pvc/{}/{}", p.namespace, p.name)
116                }
117            });
118        }
119        // A stream source records `/stream/<fileName>` — deliberately a different
120        // root from `/pvc/...` so a streamed artifact can never share a kopia
121        // identity with a volume backup.
122        if let Some(stream) = &self.stream {
123            return Some(crate::snapshot_policy::stream_source_path(stream));
124        }
125        self.nfs_path.clone()
126    }
127}
128
129/// Resolve which source this `Snapshot` covers.
130///
131/// * `pin: None` — the ordinary single-source case: `sources[0]` governs, and
132///   its own `pvc`/`nfs` is the target. Byte-for-byte the old behavior.
133/// * `pin: Some(_)` — a fanned-out child: `sourceIndex` selects the governing
134///   source's knobs, and the pinned `target` is the concrete PVC.
135///
136/// An out-of-range `sourceIndex` (the policy shrank mid-run) is a named,
137/// terminal error. It is emphatically NOT a fallback to `sources[0]`: silently
138/// backing up a different volume than the one the CR names is the worst
139/// possible failure mode for a backup operator.
140pub fn effective_source(
141    policy: &SnapshotPolicy,
142    pin: Option<&SnapshotSourceRef>,
143) -> Result<EffectiveSource, ValidationError> {
144    effective_source_in(
145        &policy.spec.sources,
146        &policy.namespace().unwrap_or_default(),
147        &policy.name_any(),
148        pin,
149    )
150}
151
152/// [`effective_source`] over a bare `sources[]` + namespace + name.
153///
154/// The webhook validates a `SnapshotPolicySpec` it has no full object for, so the
155/// derivation has to be reachable without one. `effective_source` is a thin
156/// wrapper over this, which keeps the two on ONE code path — a spec-level
157/// derivation that drifted from the object-level one is exactly the class of bug
158/// [`identity_source_path_in`] exists to make impossible.
159pub fn effective_source_in(
160    sources: &[Source],
161    policy_namespace: &str,
162    policy_name: &str,
163    pin: Option<&SnapshotSourceRef>,
164) -> Result<EffectiveSource, ValidationError> {
165    let index = pin.map(|p| p.source_index as usize).unwrap_or(0);
166    let Some(source) = sources.get(index) else {
167        return Err(ValidationError::InvalidFieldValue {
168            field: "spec.source.sourceIndex".to_string(),
169            reason: format!(
170                "is {index} but SnapshotPolicy `{policy_name}` now has {} source(s) (it was \
171                 edited). Fix: delete this Snapshot and let the schedule re-fire",
172                sources.len()
173            ),
174        });
175    };
176    let read_only = snapshot_policy::source_read_only(source);
177    let common = |pvc: Option<PvcTargetRef>| EffectiveSource {
178        index,
179        pvc,
180        nfs_path: source.nfs.as_ref().map(|n| n.path.clone()),
181        stream: source.stream.clone(),
182        source_path_override: source.source_path_override.clone(),
183        read_only,
184    };
185    match pin.map(|p| &p.target) {
186        // A fanned-out child names its own PVC; the policy source it came from
187        // is a selector and has no `pvc` of its own.
188        //
189        // The governing source must be PVC-SHAPED for that to mean anything, and the
190        // check is here rather than downstream so the bad state is unrepresentable:
191        // an `EffectiveSource` carrying BOTH a `pvc` target and a `stream` producer
192        // has two different kopia paths, and the two consumers pick different ones.
193        // `kopia_source_path` checks `pvc` first, so `identity_source_path` (hence the
194        // mover's `--override-source`, hence the RECORDED manifest) said `/pvc/data`,
195        // while `snapshot::build`'s stream arm derives `/stream/<fileName>` for the
196        // work spec — and verification and every `fromPolicy` restore look at the
197        // stream path, which no snapshot was ever written under. The quick verify then
198        // fails TERMINALLY on a policy whose snapshots are perfectly real.
199        //
200        // Nothing upstream rejects the input: `validate::snapshot` does not validate
201        // `Snapshot.spec.source` at all, and the webhook's pin refusals only fire for a
202        // selector policy MISSING a pin and for multi-repo membership. So a
203        // hand-written `spec.source: {sourceIndex: 0, target: {pvc: …}}` against a
204        // `stream` (or `nfs`) policy is admitted, and this is where it stops.
205        //
206        // Exhaustive over the wire form rather than an `is_some()` test, so a fifth
207        // source form must decide whether a PVC pin means anything for it before it
208        // compiles. A malformed source propagates its own error instead of silently
209        // accepting the pin.
210        Some(SnapshotSourceTarget::Pvc(t)) => match snapshot_policy::source_shape(source)? {
211            // Both PVC-shaped: a selector's fanned-out member (the ordinary case), and
212            // a plain `pvc:` source addressed by an explicit pin (the pre-existing
213            // one-shot shape, unchanged).
214            snapshot_policy::SourceShape::PvcSelector(_) | snapshot_policy::SourceShape::Pvc(_) => {
215                Ok(common(Some(t.clone())))
216            }
217            // A stream source's bytes come from a command's stdout and an NFS source's
218            // from an export; neither has a PVC to pin, and neither can be MOUNTED as
219            // the pinned claim either.
220            shape @ (snapshot_policy::SourceShape::Stream(_)
221            | snapshot_policy::SourceShape::Nfs(_)) => Err(ValidationError::InvalidFieldValue {
222                field: "spec.source.target.pvc".to_string(),
223                reason: format!(
224                    "pins PVC `{}/{}`, but SnapshotPolicy `{policy_name}` sources[{index}] is a \
225                     `{}` source with no PVC. Fix: drop `spec.source`, or point this Snapshot at \
226                     a policy whose sources[{index}] is a PVC source",
227                    t.namespace,
228                    t.name,
229                    shape.kind_str(),
230                ),
231            }),
232        },
233        None => Ok(common(source.pvc.as_ref().map(|p| PvcTargetRef {
234            // A non-selector `pvc:` source is always same-namespace.
235            namespace: policy_namespace.to_string(),
236            name: p.name.clone(),
237        }))),
238    }
239}
240
241/// **THE** kopia source path a `SnapshotPolicy`'s governing source records —
242/// the single derivation every identity site must use (#451).
243///
244/// # Why this function exists
245///
246/// Six places resolve a policy's kopia identity: the backup mover
247/// (`snapshot::build::resolve_identity_for`), the policy reconciler's
248/// `config_identity` / `config_identity_for_path` / `resolve_config_identity`,
249/// the verification reconciler's `verify_identity`, and the webhook's
250/// `identity_collision::resolve_policy_identity`. Each of them used to re-derive
251/// the path inline from `sources.first()`'s `pvc`/`nfs` — a pattern that is
252/// *correct only for the two source kinds that existed when it was written*.
253///
254/// A `stream` source has neither a `pvc` nor an `nfs`, so five of the six
255/// resolved a PATHLESS (or `/data`) identity while the backup side recorded
256/// `/stream/<fileName>`. Every consequence was silent: a `fromPolicy` `Restore`
257/// looked for `user@host:/data` and reported `SnapshotNotFound`; quick
258/// verification verified a path with no manifests and **exited 0**; the
259/// collision webhook collapsed every stream policy onto one pathless identity,
260/// raising false collisions and missing real ones; and `identities_match`
261/// compares source paths field-by-field, so a discovered stream snapshot could
262/// never be adopted.
263///
264/// Routing every site through here — and removing the raw `pvc`/`nfs` pieces
265/// from [`crate::IdentityInputs`], so no caller *can* re-derive a path — makes a
266/// seventh site structurally unable to drift.
267///
268/// # Semantics
269///
270/// Exactly [`EffectiveSource::kopia_source_path`] under the governing source's
271/// [`strategy_for`], which is byte-identical to the old inline derivation for
272/// every `pvc`/`nfs`/selector/override shape and additionally correct for
273/// `stream`. `Ok(None)` is kopia's identity-only `username@hostname` form:
274/// a selector source with no fan-out pin, or a **zero-source** legacy policy,
275/// which is deliberately tolerated rather than turned into a terminal error
276/// (admission forbids it; a hand-patched object may still carry it, and a
277/// working restore must not break on upgrade).
278pub fn identity_source_path(
279    policy: &SnapshotPolicy,
280    pin: Option<&SnapshotSourceRef>,
281) -> Result<Option<String>, ValidationError> {
282    identity_source_path_in(
283        &policy.spec.sources,
284        &policy.namespace().unwrap_or_default(),
285        &policy.name_any(),
286        pin,
287    )
288}
289
290/// The kopia source path ONE policy source records, by index — the per-source
291/// form of [`identity_source_path`], for the `status.resolved.sources[]` mirror.
292///
293/// Same derivation, so a human reading status sees exactly what kopia stores:
294/// `/pvc/<name>`, the NFS export path, `/stream/<fileName>`, or an explicit
295/// `sourcePathOverride`. `None` only for a selector source, whose path is
296/// per-member and therefore not a property of the source alone.
297pub fn source_kopia_path(source: &Source, index: usize, policy_namespace: &str) -> Option<String> {
298    let eff = EffectiveSource {
299        index,
300        pvc: source.pvc.as_ref().map(|p| PvcTargetRef {
301            // A non-selector `pvc:` source is always same-namespace.
302            namespace: policy_namespace.to_string(),
303            name: p.name.clone(),
304        }),
305        nfs_path: source.nfs.as_ref().map(|n| n.path.clone()),
306        stream: source.stream.clone(),
307        source_path_override: source.source_path_override.clone(),
308        read_only: snapshot_policy::source_read_only(source),
309    };
310    eff.kopia_source_path(strategy_for(source))
311}
312
313/// [`identity_source_path`] over a bare `sources[]` (the webhook's spec-only shape).
314pub fn identity_source_path_in(
315    sources: &[Source],
316    policy_namespace: &str,
317    policy_name: &str,
318    pin: Option<&SnapshotSourceRef>,
319) -> Result<Option<String>, ValidationError> {
320    // Zero-source legacy tolerance, checked BEFORE `effective_source_in` so it
321    // stays a pathless identity rather than the out-of-range error. A pin on a
322    // zero-source policy is still the real error it has always been.
323    if sources.is_empty() && pin.is_none() {
324        return Ok(None);
325    }
326    let eff = effective_source_in(sources, policy_namespace, policy_name, pin)?;
327    let strategy = sources.get(eff.index).map(strategy_for).unwrap_or_default();
328    Ok(eff.kopia_source_path(strategy))
329}
330
331/// The `sourcePathStrategy` governing a source.
332///
333/// Deliberately only consulted for **selector-expanded** sources. A plain
334/// `pvc:` source keeps `/pvc/<name>` unconditionally: changing the path of an
335/// existing single-PVC policy would re-identify its kopia source and orphan
336/// every manifest it has ever taken.
337pub fn strategy_for(source: &Source) -> SourcePathStrategy {
338    if source.pvc_selector.is_some() {
339        source
340            .source_path_strategy
341            .unwrap_or(SourcePathStrategy::PvcName)
342    } else {
343        SourcePathStrategy::PvcName
344    }
345}
346
347// --- the restore side: which member path fills THIS pvc (#443) ---------------
348
349/// Where a restore's kopia source path came from — the result of
350/// [`restore_source_path`].
351///
352/// The provenance is kept (rather than collapsed to an `Option<String>`) because
353/// the three cases mean different things to a human reading `status`, and to the
354/// reconciler: an [`Override`](Self::Override) is what the user asked for and is
355/// never second-guessed; a [`PolicySource`](Self::PolicySource) reproduces the
356/// pre-#443 behavior byte-for-byte; and a
357/// [`DerivedFromTarget`](Self::DerivedFromTarget) is the new per-PVC derivation
358/// that makes a fan-out restore read the right member.
359#[derive(Debug, Clone, PartialEq, Eq)]
360pub enum RestoreSourcePath {
361    /// `source.fromPolicy.sourcePath` — the user named the path explicitly.
362    Override(String),
363    /// The governing plain (`pvc:`/`nfs`) source's own path.
364    ///
365    /// `None` is the identity-only form (kopia `username@hostname`, matching any
366    /// path): a policy with **zero** sources, which admission forbids but a
367    /// hand-patched legacy object may still carry. Tolerated exactly as
368    /// `config_identity` tolerates it — a working restore must not become a
369    /// terminal error on an upgrade.
370    PolicySource(Option<String>),
371    /// The selector `sourcePathStrategy` applied to the PVC being restored — the
372    /// same code path the backup used, so the strings cannot drift.
373    DerivedFromTarget(String),
374}
375
376impl RestoreSourcePath {
377    /// The path to filter kopia snapshots by, if any. Exhaustive.
378    pub fn path(&self) -> Option<&str> {
379        match self {
380            Self::Override(p) | Self::DerivedFromTarget(p) => Some(p),
381            Self::PolicySource(p) => p.as_deref(),
382        }
383    }
384
385    /// Consume into the owned path, if any. Exhaustive.
386    pub fn into_path(self) -> Option<String> {
387        match self {
388            Self::Override(p) | Self::DerivedFromTarget(p) => Some(p),
389            Self::PolicySource(p) => p,
390        }
391    }
392}
393
394/// A total classification of one `SnapshotPolicy` source, **for the restore-path
395/// derivation only**.
396///
397/// Deliberately a DIFFERENT type from the wire-form resolver
398/// [`snapshot_policy::SourceShape`](crate::snapshot_policy::SourceShape), which
399/// answers "which one of the mutually-exclusive keys is set?" and borrows each
400/// form's whole sub-object for the backup side. This one answers the narrower
401/// restore question "can this source's path be derived from the target PVC?",
402/// so it carries only the fields that decision needs and adds an `Invalid` arm
403/// the wire-form resolver reports as an error instead. Keep the two in step:
404/// every form added there needs an arm here.
405///
406/// The point of the enum is that "not a selector" is not one thing: a plain
407/// `pvc:` source can match the restore target by NAME (and then contributes its
408/// own path, override included), while `nfs`, `stream` and a malformed source
409/// contribute nothing at all. Matched exhaustively, so a fifth source shape has
410/// to decide what it means for a restore before it compiles.
411#[derive(Debug, Clone, PartialEq, Eq)]
412enum SourceShape<'a> {
413    /// A plain `pvc:` source, with the PVC name it addresses (always in the
414    /// policy's own namespace).
415    Pvc { name: &'a str },
416    /// A `pvcSelector` source: the knobs that decide each member's path.
417    Selector {
418        /// The strategy this selector derives member paths with.
419        strategy: SourcePathStrategy,
420        /// A `sourcePathOverride` that would apply to EVERY member — which is
421        /// exactly what makes the selector non-per-PVC.
422        source_path_override: Option<&'a str>,
423    },
424    /// An `nfs` export: never addressed by a PVC target.
425    Nfs,
426    /// A `stream` source: its bytes come from a command's stdout and its kopia
427    /// path is `/stream/<fileName>`, which no PVC target can derive. Never
428    /// addressed by a PVC target, exactly like [`SourceShape::Nfs`] — but named
429    /// rather than folded into [`SourceShape::Invalid`], because a stream source
430    /// is perfectly VALID and reporting it as malformed would send a future
431    /// reader hunting a spec bug that isn't there.
432    Stream,
433    /// The source is not a singular, recognized wire form: none of
434    /// `pvc`/`pvcSelector`/`nfs`/`stream` is set, or several are — i.e. exactly the
435    /// cases [`snapshot_policy::source_shape`] reports as a
436    /// [`ValidationError`]. Admission (the CRD's own CEL rule AND the webhook)
437    /// forbids both; a hand-patched object, or one written against an older CRD
438    /// schema, may carry either.
439    ///
440    /// Contributes nothing to a derivation, and fails the derivation CLOSED when it is
441    /// the governing source — see [`restore_source_path`] step (3). Guessing which of
442    /// several set forms "wins" is how a malformed policy came to resolve a real path
443    /// holding ANOTHER volume's data.
444    Invalid,
445}
446
447/// Classify one source. Pure and total.
448///
449/// **Driven off [`snapshot_policy::source_shape`], not off the raw `Option` fields.**
450/// That is what makes the two classifiers compile-coupled: a fifth wire form adds a
451/// [`snapshot_policy::SourceShape`] variant, this `match` stops being exhaustive, and
452/// the build fails until someone decides what the new form means for a restore. The
453/// old `match (&source.pvc, &source.nfs, &source.stream)` could not do that — a fifth
454/// `Source` FIELD simply folded into [`SourceShape::Invalid`], which
455/// [`restore_source_path`] then answered for by accident. (A hardcoded list in a test
456/// cannot close that gap either: the test's inputs would just never name the new form.)
457///
458/// A malformed source — no form set, or several — becomes [`SourceShape::Invalid`]
459/// rather than whichever field an ad-hoc precedence happened to check first.
460fn source_shape(source: &Source) -> SourceShape<'_> {
461    match snapshot_policy::source_shape(source) {
462        Ok(snapshot_policy::SourceShape::PvcSelector(_)) => SourceShape::Selector {
463            strategy: strategy_for(source),
464            source_path_override: source.source_path_override.as_deref(),
465        },
466        Ok(snapshot_policy::SourceShape::Pvc(p)) => SourceShape::Pvc { name: &p.name },
467        Ok(snapshot_policy::SourceShape::Nfs(_)) => SourceShape::Nfs,
468        Ok(snapshot_policy::SourceShape::Stream(_)) => SourceShape::Stream,
469        // Nothing set, or several set. Either way there is no honest answer, and
470        // `restore_source_path` step (3) fails closed rather than deriving from it.
471        Err(_) => SourceShape::Invalid,
472    }
473}
474
475/// The kopia source path a restore of `target` should read from `policy` (#443).
476///
477/// This is the cross-volume fix. `RestoreSelector.source_path: None` becomes the
478/// kopia filter `username@hostname:` — an EMPTY path, which matches every member
479/// path of a selector policy — so before this, restoring one PVC of a multi-PVC
480/// policy took the newest snapshot of *any* member and could fill a volume with
481/// another volume's data.
482///
483/// The rule, in order:
484///
485/// 1. `override_` (`source.fromPolicy.sourcePath`) wins outright.
486/// 2. A plain `pvc:` source addressing EXACTLY this target (same name, same
487///    namespace) ⇒ that source's own path. An exact match beats every
488///    derivation AND the first-source fallback.
489/// 3. The policy has **no selector sources** ⇒ [`RestoreSourcePath::PolicySource`]
490///    of `sources[0]`'s own path — byte-identical to what `config_identity` +
491///    `resolve_identity` produced before this function existed, for the plain
492///    `pvc:`, `nfs` and `sourcePathOverride` shapes alike (and `None` for a
493///    zero-source legacy object).
494/// 4. Every selector source agrees on `(sourcePathStrategy, sourcePathOverride)`
495///    **and that override is `None`** ⇒
496///    [`RestoreSourcePath::DerivedFromTarget`], built through
497///    [`EffectiveSource::kopia_source_path`] — the same call the backup side
498///    makes, so the two strings cannot drift.
499/// 5. Anything else ⇒ a named error telling the user to set
500///    `fromPolicy.sourcePath`.
501///
502/// **(2) is deliberately ahead of (3).** Validation admits N plain `pvc:`
503/// sources on one policy (`validate::snapshot` only requires "at least one"),
504/// but today only the FIRST is ever captured: [`expand_sources`] returns `None`
505/// unless some source carries a `pvcSelector`, so a selector-free policy mints
506/// one unpinned child and `effective_source(policy, None)` resolves index 0.
507/// (A pre-existing backup-side limitation, tracked separately — not something
508/// this function can fix.) That is precisely why the fallback must not answer
509/// for every target: `sources: [pvc: a, pvc: b]` restoring into PVC `b` used to
510/// resolve `/pvc/a`, a path that is real but holds ANOTHER volume's data, and
511/// filled `b` with it under a green `Completed`. With the exact match first, `b`
512/// resolves `/pvc/b` — never written — so the restore fails honestly with
513/// `SnapshotNotFound`, or comes up empty under `Continue`. The same applies to
514/// `[nfs, pvc: a]` restoring `a`, which used to read the NFS export path.
515///
516/// Putting the exact match first is byte-identical for every SINGLE-source
517/// shape: a lone plain `pvc:` source whose name equals the target builds the
518/// same `EffectiveSource` (same index, same `PvcTargetRef`, same override, same
519/// strategy — `strategy_for` is `PvcName` for any non-selector source) and so
520/// the same string; an `nfs` source, a differently-named target and a
521/// cross-namespace target all miss (2) and fall through to (3) untouched.
522///
523/// **A target matching NO plain source still falls back to `sources[0]`** under
524/// (3) — e.g. `[pvc: a, pvc: b]` restoring into a PVC named `c` reads `/pvc/a`.
525/// That is deliberate, not an oversight: it is the pre-#443 answer, it is what
526/// makes "restore this policy's data into a differently-named scratch volume"
527/// keep working, and (5) is reserved for the shapes where a path genuinely
528/// cannot be derived (disagreeing selectors, a flattening selector override) —
529/// not for a multi-source policy where `sources[0]` is a defined, if arbitrary,
530/// answer. Set `fromPolicy.sourcePath` to name the member you want.
531///
532/// A selector carrying `sourcePathOverride: Some(o)` is deliberately NOT
533/// per-PVC: `kopia_source_path` returns the override before it ever looks at the
534/// PVC, so every member was backed up under the one path `o` and no derivation
535/// can tell them apart. That falls to (5) rather than silently returning `o`,
536/// because "which member is this?" genuinely has no answer.
537///
538/// Matching is by **strategy rule, not by claimant labels**: a `target.pvc` has
539/// no labels to match against, and a claimant's labels may have changed since the
540/// backup was taken. This function is therefore pure over the policy + target
541/// alone.
542///
543/// The namespace in `target` is the TARGET's namespace. For a cross-namespace
544/// `target.pvcRef` under a `pvcNamespacedName` strategy that derives
545/// `/pvc/<target-ns>/<name>`, which may be a path the repository never saw — use
546/// the override there.
547///
548/// ```
549/// # use kopiur_api::expand::{restore_source_path, RestoreSourcePath};
550/// # use kopiur_api::snapshot::PvcTargetRef;
551/// # use kopiur_api::SnapshotPolicy;
552/// let policy: SnapshotPolicy = serde_json::from_value(serde_json::json!({
553///     "apiVersion": "kopiur.home-operations.com/v1alpha1",
554///     "kind": "SnapshotPolicy",
555///     "metadata": { "name": "app", "namespace": "db" },
556///     "spec": {
557///         "repository": { "name": "r" },
558///         "sources": [{
559///             "pvcSelector": { "matchLabels": { "app": "web" } },
560///             "sourcePathStrategy": "PvcName",
561///         }],
562///     },
563/// }))
564/// .unwrap();
565/// let target = PvcTargetRef { namespace: "db".into(), name: "data-1".into() };
566/// assert_eq!(
567///     restore_source_path(&policy, None, &target).unwrap(),
568///     RestoreSourcePath::DerivedFromTarget("/pvc/data-1".into())
569/// );
570/// ```
571pub fn restore_source_path(
572    policy: &SnapshotPolicy,
573    override_: Option<&str>,
574    target: &PvcTargetRef,
575) -> Result<RestoreSourcePath, ValidationError> {
576    // (1) An explicit override is never second-guessed.
577    if let Some(o) = override_ {
578        return Ok(RestoreSourcePath::Override(o.to_string()));
579    }
580
581    let policy_ns = policy.namespace().unwrap_or_default();
582    let shapes: Vec<SourceShape<'_>> = policy.spec.sources.iter().map(source_shape).collect();
583
584    // (2) An exact plain-`pvc:` match, BEFORE the first-source fallback: a policy
585    // may carry several plain `pvc:` sources, and `sources[0]` would then be
586    // another volume's path — wrong-but-real data, rather than the honest
587    // `SnapshotNotFound` the unwritten path yields. Same namespace is required:
588    // a plain source always addresses the POLICY's namespace, so a same-named
589    // PVC in another namespace is a different volume.
590    if policy_ns == target.namespace
591        && let Some(index) = shapes.iter().position(|s| match s {
592            SourceShape::Pvc { name } => *name == target.name,
593            SourceShape::Selector { .. }
594            | SourceShape::Nfs
595            | SourceShape::Stream
596            | SourceShape::Invalid => false,
597        })
598    {
599        let source = &policy.spec.sources[index];
600        let eff = EffectiveSource {
601            index,
602            pvc: Some(target.clone()),
603            nfs_path: None,
604            // Matched by its own plain `pvc:` source, so PVC-shaped by construction.
605            stream: None,
606            source_path_override: source.source_path_override.clone(),
607            read_only: snapshot_policy::source_read_only(source),
608        };
609        return Ok(RestoreSourcePath::PolicySource(
610            eff.kopia_source_path(strategy_for(source)),
611        ));
612    }
613
614    // (3) No selector anywhere: the pre-#443 answer, verbatim (including the
615    // zero-source legacy tolerance, where `sources.first()` is `None`). Reached
616    // only when (2) did not match, so a target that names one of the policy's
617    // plain sources never lands here.
618    let has_selector = shapes
619        .iter()
620        .any(|s| matches!(s, SourceShape::Selector { .. }));
621    if !has_selector {
622        let Some(first) = policy.spec.sources.first() else {
623            return Ok(RestoreSourcePath::PolicySource(None));
624        };
625        // …but only when `sources[0]` is a source this build can classify. A
626        // MALFORMED governing source is the #451 failure mode reached from the
627        // other end: it is not a selector, so it lands here, and
628        // `effective_source` hands back an all-`None` source whose
629        // `kopia_source_path` is `None` — the PATHLESS identity `user@host:`,
630        // which kopia reads as a relative filesystem path and matches against
631        // EVERY member of the repository. A multi-set source (say `pvc` beside
632        // `pvcSelector`) was worse still under the old field-order precedence:
633        // it answered with `sources[0]`'s own `/pvc/<name>`, a real path holding
634        // another volume's data, under a green `Completed`. Fail closed instead.
635        //
636        // Deliberately scoped to the GOVERNING source, not to any source: a
637        // malformed sibling alongside a working selector is irrelevant to (4)'s
638        // derivation and must not break a restore that has an honest answer
639        // (`restore_path_ignores_nfs_and_malformed_siblings_of_a_selector`). And
640        // a ZERO-source legacy policy keeps its tolerance above — it has no
641        // source to be malformed.
642        if shapes.first() == Some(&SourceShape::Invalid) {
643            return Err(malformed_source_path(policy));
644        }
645        let eff = effective_source(policy, None)?;
646        return Ok(RestoreSourcePath::PolicySource(
647            eff.kopia_source_path(strategy_for(first)),
648        ));
649    }
650
651    // (4) Every selector agrees, and none of them flattens its members onto one
652    // shared path.
653    let mut agreed: Option<(SourcePathStrategy, Option<&str>)> = None;
654    for shape in &shapes {
655        let (strategy, over) = match shape {
656            SourceShape::Selector {
657                strategy,
658                source_path_override,
659            } => (*strategy, *source_path_override),
660            SourceShape::Pvc { .. }
661            | SourceShape::Nfs
662            | SourceShape::Stream
663            | SourceShape::Invalid => continue,
664        };
665        match agreed {
666            None => agreed = Some((strategy, over)),
667            Some(prev) if prev == (strategy, over) => {}
668            Some(_) => return Err(ambiguous_source_path(policy)),
669        }
670    }
671    match agreed {
672        Some((strategy, None)) => {
673            let eff = EffectiveSource {
674                // The index is inert here: the path comes from the target PVC and
675                // the agreed strategy, not from the source's own `pvc`/`nfs`.
676                index: 0,
677                pvc: Some(target.clone()),
678                nfs_path: None,
679                // A selector-derived path is PVC-shaped; a stream source never
680                // participates in a selector derivation.
681                stream: None,
682                source_path_override: None,
683                read_only: true,
684            };
685            // A PVC-bearing `EffectiveSource` with no override always yields a
686            // path, so the `None` arm is unreachable — but returning the fail-
687            // closed error rather than unwrapping keeps the function total.
688            eff.kopia_source_path(strategy)
689                .map(RestoreSourcePath::DerivedFromTarget)
690                .ok_or_else(|| ambiguous_source_path(policy))
691        }
692        // (5) A shared `sourcePathOverride`, or (defensively) no selector at all
693        // after the `has_selector` check.
694        Some((_, Some(_))) | None => Err(ambiguous_source_path(policy)),
695    }
696}
697
698/// The fail-closed error for the malformed-governing-source case of
699/// [`restore_source_path`] step (3): the source does not set exactly one of the four
700/// forms, so no path can be derived from it — what / why / fix.
701fn malformed_source_path(policy: &SnapshotPolicy) -> ValidationError {
702    ValidationError::InvalidFieldValue {
703        field: "spec.source.fromPolicy.sourcePath".to_string(),
704        reason: format!(
705            "SnapshotPolicy `{}` has a source that does not set exactly one of \
706             pvc/pvcSelector/nfs/stream, so the path to restore is unknown. Fix: repair the \
707             policy's sources[], or set source.fromPolicy.sourcePath",
708            policy.name_any()
709        ),
710    }
711}
712
713/// The fail-closed error for (5) of [`restore_source_path`] — what / why / fix.
714fn ambiguous_source_path(policy: &SnapshotPolicy) -> ValidationError {
715    ValidationError::InvalidFieldValue {
716        field: "spec.source.fromPolicy.sourcePath".to_string(),
717        reason: format!(
718            "SnapshotPolicy `{}` selector sources have no single per-PVC path (they differ in \
719             sourcePathStrategy/sourcePathOverride, or share one sourcePathOverride), so \
720             kopiur cannot tell which volume to restore. Fix: set source.fromPolicy.sourcePath \
721             (e.g. /pvc/<name>)",
722            policy.name_any()
723        ),
724    }
725}
726
727/// The deterministic name of the populate mover `Job` for one claiming PVC.
728///
729/// `<restore>-populate-<h8>`, capped at [`MAX_CHILD_NAME`] (63) — a Job name
730/// becomes a `batch.kubernetes.io/job-name` label value, which Kubernetes caps
731/// at 63 bytes.
732///
733/// `<h8>` is 8 hex of FNV-1a over the claiming PVC's `metadata.uid` and is
734/// **never** clipped: it is what makes N claimants of one `Restore` produce N
735/// distinct Jobs, and what makes a DELETED-and-re-created claim (a new uid) get a
736/// fresh Job instead of adopting the dead claim's. The pre-#443 name was the
737/// bare `<restore>-populate`, which is why only one claimant could ever be
738/// populated; that legacy name is still driven for an adopted in-flight claim
739/// (see the controller's `JobNameReuse`), never minted anew.
740///
741/// ```
742/// # use kopiur_api::expand::populate_job_name;
743/// let n = populate_job_name("restore-pg", "9f1c-uid");
744/// assert!(n.starts_with("restore-pg-populate-"));
745/// assert!(n.len() <= 63);
746/// // Distinct claimants never collide.
747/// assert_ne!(n, populate_job_name("restore-pg", "other-uid"));
748/// ```
749pub fn populate_job_name(restore: &str, consumer_uid: &str) -> String {
750    const MARKER: &str = "-populate-";
751    let tag = fnv8(consumer_uid);
752    let keep = MAX_CHILD_NAME.saturating_sub(MARKER.len() + tag.len());
753    format!("{}{MARKER}{tag}", clip(restore, keep).trim_end_matches('-'))
754        .trim_matches('-')
755        .to_string()
756}
757
758/// Render a `LabelSelector` as the API server's selector string.
759///
760/// Lives here so the controller and the CLI build byte-identical queries: a
761/// divergence would make `kubectl kopiur snapshot now` and the schedule expand
762/// to DIFFERENT PVC sets for the same recipe, which is the kind of discrepancy
763/// nobody notices until a restore is missing a volume.
764pub fn label_selector_string(
765    sel: &k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector,
766) -> String {
767    use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelectorRequirement;
768    let mut terms: Vec<String> = Vec::new();
769    if let Some(labels) = &sel.match_labels {
770        for (k, v) in labels {
771            terms.push(format!("{k}={v}"));
772        }
773    }
774    if let Some(exprs) = &sel.match_expressions {
775        for LabelSelectorRequirement {
776            key,
777            operator,
778            values,
779        } in exprs
780        {
781            let vals = values.clone().unwrap_or_default().join(",");
782            match operator.as_str() {
783                "In" => terms.push(format!("{key} in ({vals})")),
784                "NotIn" => terms.push(format!("{key} notin ({vals})")),
785                "Exists" => terms.push(key.clone()),
786                "DoesNotExist" => terms.push(format!("!{key}")),
787                // Unknown operator: skip (the webhook/schema constrain the set).
788                _ => {}
789            }
790        }
791    }
792    terms.join(",")
793}
794
795/// Whether an object with these `labels` matches `selector` — the client-side
796/// counterpart to [`label_selector_string`].
797///
798/// Implements `matchLabels` (every key present with the required value) plus the
799/// `matchExpressions` operators `In`/`NotIn`/`Exists`/`DoesNotExist`. An empty
800/// selector matches everything, as Kubernetes defines it.
801///
802/// Lives here rather than in a consumer because three of them need the same
803/// answer and a partial re-implementation is worse than none: the schedule
804/// reconciler decides which policies to fire, its watch mapper decides which
805/// schedules a policy edit wakes, and the web UI shows a policy which schedules
806/// fire it. A copy that handled only `matchLabels` would silently claim a
807/// `matchExpressions` selector matched every policy in the namespace.
808///
809/// An unknown operator is treated as *no constraint* rather than as no match:
810/// the CRD schema constrains the set, so an unrecognized one can only come from
811/// a newer API than this build, and refusing to match would silently drop a
812/// policy the operator itself is scheduling.
813///
814/// ```
815/// use std::collections::BTreeMap;
816/// use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector;
817/// use kopiur_api::expand::labels_match_selector;
818///
819/// let labels = BTreeMap::from([("tier".to_string(), "gold".to_string())]);
820/// // An empty selector selects everything.
821/// assert!(labels_match_selector(&labels, &LabelSelector::default()));
822///
823/// let gold: LabelSelector = serde_json::from_value(serde_json::json!({
824///     "matchLabels": { "tier": "gold" }
825/// }))
826/// .unwrap();
827/// assert!(labels_match_selector(&labels, &gold));
828/// ```
829pub fn labels_match_selector(
830    labels: &std::collections::BTreeMap<String, String>,
831    selector: &k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector,
832) -> bool {
833    use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelectorRequirement;
834    if let Some(ml) = &selector.match_labels {
835        for (k, v) in ml {
836            if labels.get(k) != Some(v) {
837                return false;
838            }
839        }
840    }
841    if let Some(exprs) = &selector.match_expressions {
842        for LabelSelectorRequirement {
843            key,
844            operator,
845            values,
846        } in exprs
847        {
848            let vals = values.clone().unwrap_or_default();
849            let present = labels.get(key);
850            let ok = match operator.as_str() {
851                "In" => present.is_some_and(|v| vals.iter().any(|x| x == v)),
852                "NotIn" => present.is_none_or(|v| !vals.iter().any(|x| x == v)),
853                "Exists" => present.is_some(),
854                "DoesNotExist" => present.is_none(),
855                // Unknown operator: the schema constrains the set; treat as no constraint.
856                _ => true,
857            };
858            if !ok {
859                return false;
860            }
861        }
862    }
863    true
864}
865
866// --- naming -----------------------------------------------------------------
867
868/// The deterministic name of the fanned-out `Snapshot` for one PVC.
869///
870/// `<base>-pvc-<slug>-<h8>`, capped at [`MAX_CHILD_NAME`].
871///
872/// * `<slug>` is human-legible (`<name>`, or `<namespace>-<name>` when the PVC
873///   is outside the policy's namespace) and may be clipped.
874/// * `<h8>` is 8 hex of FNV-1a over the exact string `"<namespace>/<name>"` and
875///   is **never** clipped — it is the injectivity guarantee.
876///
877/// Collision-free against the three existing schemes (`<schedule>-<slot>`,
878/// `<schedule>-<policy>-<slot>`, `<policy>-manual-<slot>`): each ends in a
879/// dash-free 14-digit slot stamp, while a fanned name's tail after `-pvc-`
880/// always contains a `-`.
881///
882/// This does NOT delegate to `io::staging::staged_child_name`, whose
883/// `MAX_NAME_LEN - tag.len() - suffix.len() - 2` is unchecked `usize`
884/// subtraction: its callers pass 4-char suffixes (`snap`, `src`), and a PVC
885/// name up to 253 chars would underflow it.
886///
887/// ```
888/// # use kopiur_api::expand::fanout_child_name;
889/// let n = fanout_child_name("nightly-20260805020000", "db", "db", "pgdata");
890/// assert!(n.starts_with("nightly-20260805020000-pvc-pgdata-"));
891/// assert!(n.len() <= 63);
892/// ```
893pub fn fanout_child_name(base: &str, policy_ns: &str, pvc_ns: &str, pvc_name: &str) -> String {
894    fanout_child_name_for(base, policy_ns, Some((pvc_ns, pvc_name)), None)
895}
896
897/// The marker segment naming the target repository in a multi-repo child name.
898const REPO_MARKER: &str = "-repo-";
899
900/// 8 hex chars of FNV-1a over `input` — the never-clipped injectivity tag
901/// shared by every fan-out naming scheme in this module. One definition so
902/// the hash function can never drift between the schemes.
903fn fnv8(input: &str) -> String {
904    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
905    for b in input.as_bytes() {
906        hash ^= *b as u64;
907        hash = hash.wrapping_mul(0x100_0000_01b3);
908    }
909    format!("{:08x}", (hash & 0xffff_ffff) as u32)
910}
911
912/// The deterministic child `Snapshot` name for one (source-member, repository)
913/// cell of a fan-out — the general form behind [`fanout_child_name`].
914///
915/// * `member`: `Some((pvc_ns, pvc_name))` for a selector-expanded member,
916///   `None` for a policy whose single source needs no per-PVC expansion.
917/// * `repo`: `Some(ref)` for a multi-repository fan-out child, `None` for the
918///   classic single-repo shapes.
919///
920/// The four combinations produce:
921///
922/// | member | repo | name |
923/// |--------|------|------|
924/// | `None` | `None` | `<base>` — the legacy non-fanned name, byte-identical |
925/// | `Some` | `None` | `<base>-pvc-<pslug>-<h8>` — byte-identical to the legacy [`fanout_child_name`] |
926/// | `None` | `Some` | `<base>-repo-<rslug>-<h8>` |
927/// | `Some` | `Some` | `<base>-pvc-<pslug>-repo-<rslug>-<h8>` |
928///
929/// `<h8>` is FNV-1a over the newline-framed FULL unclipped tuple — `base`,
930/// then (when present) `"<pvc_ns>/<pvc_name>"`, then (when present) the
931/// normalized [`repo_key`](crate::common::repo_key) — so clipping any legible
932/// segment never merges two distinct cells, and a policy slug that happens to
933/// contain `-repo-` (pslug `x-repo-y` vs pslug `x` + rslug `y`) still yields
934/// distinct names because the hash inputs differ. Markers and the tag are
935/// NEVER clipped; when over budget the clip order is base → pslug → rslug.
936/// Always ≤ [`MAX_CHILD_NAME`] (63).
937///
938/// The legacy hash input for `repo: None` is exactly the pre-multi-repo
939/// `"{base}\n{pvc_ns}/{pvc_name}"` — pinned by golden tests.
940pub fn fanout_child_name_for(
941    base: &str,
942    policy_ns: &str,
943    member: Option<(&str, &str)>,
944    repo: Option<&crate::common::RepositoryRef>,
945) -> String {
946    // (normalized repo_key for the hash, legible slug for the name).
947    let repo = repo.map(|r| (crate::common::repo_key(r, policy_ns), r.name.as_str()));
948    match (member, repo) {
949        // Legacy single child: the name IS the base (already ≤63 for every
950        // caller-produced base; clipped defensively, identical when in budget).
951        (None, None) => clip(base, MAX_CHILD_NAME).trim_matches('-').to_string(),
952        (Some((pvc_ns, pvc_name)), repo) => {
953            member_child_name(base, policy_ns, pvc_ns, pvc_name, repo.as_ref())
954        }
955        (None, Some((rkey, repo_name))) => {
956            // <base>-repo-<rslug>-<h8>
957            let tag = fnv8(&format!("{base}\n{rkey}"));
958            let rslug = sanitize_dns1123(repo_name);
959            let fixed = REPO_MARKER.len() + 1 + tag.len();
960            let mut base_keep = base.len();
961            let mut rslug_keep = rslug.len();
962            if fixed + base_keep + rslug_keep > MAX_CHILD_NAME {
963                let room = MAX_CHILD_NAME.saturating_sub(fixed);
964                // Clip base first (every sibling shares it), rslug second.
965                rslug_keep = rslug_keep.min(room / 3);
966                base_keep = base_keep.min(room.saturating_sub(rslug_keep));
967            }
968            format!(
969                "{}{REPO_MARKER}{}-{tag}",
970                clip(base, base_keep),
971                clip(&rslug, rslug_keep)
972            )
973            .trim_matches('-')
974            .to_string()
975        }
976    }
977}
978
979/// The `member: Some(..)` arm of [`fanout_child_name_for`]: the legacy
980/// `-pvc-` form (`rkey: None`, byte-identical to the pre-multi-repo
981/// [`fanout_child_name`]) and the combined `-pvc-…-repo-…` form.
982fn member_child_name(
983    base: &str,
984    policy_ns: &str,
985    pvc_ns: &str,
986    pvc_name: &str,
987    repo: Option<&(String, &str)>,
988) -> String {
989    // The tag hashes the BASE as well as the PVC (and, for a multi-repo child,
990    // the repo key). That is not belt-and-braces: `base` is
991    // `<schedule>-<YYYYMMDDHHMMSS>` and it is the part that gets CLIPPED when
992    // the name is too long, so a tag over the PVC alone leaves two different
993    // slots of the same schedule+PVC with byte-identical names. The schedule
994    // server-side-applies with force and only skips *terminating* twins, so
995    // the second fire would re-apply onto the already-`Succeeded` first
996    // Snapshot, `run_decision` would return `SucceededSteadyState`, and no
997    // mover Job would ever launch — a whole backup slot vanishing with no
998    // error anywhere.
999    let tag = match repo {
1000        None => fnv8(&format!("{base}\n{pvc_ns}/{pvc_name}")),
1001        Some((rkey, _)) => fnv8(&format!("{base}\n{pvc_ns}/{pvc_name}\n{rkey}")),
1002    };
1003
1004    let slug_full = if pvc_ns == policy_ns {
1005        pvc_name.to_string()
1006    } else {
1007        format!("{pvc_ns}-{pvc_name}")
1008    };
1009    let slug = sanitize_dns1123(&slug_full);
1010
1011    match repo {
1012        None => {
1013            // Budget: base + "-pvc-" + slug + "-" + tag. Clip `base` first (it
1014            // is the most redundant part — every sibling shares it), then the
1015            // slug. Never the tag or the marker.
1016            let fixed = FANOUT_MARKER.len() + 1 + tag.len();
1017            let mut base_keep = base.len();
1018            let mut slug_keep = slug.len();
1019            if fixed + base_keep + slug_keep > MAX_CHILD_NAME {
1020                let room = MAX_CHILD_NAME.saturating_sub(fixed);
1021                // Give the slug up to a third of the room, the base the rest.
1022                slug_keep = slug_keep.min(room / 3);
1023                base_keep = base_keep.min(room.saturating_sub(slug_keep));
1024            }
1025            let name = format!(
1026                "{}{FANOUT_MARKER}{}-{tag}",
1027                clip(base, base_keep),
1028                clip(&slug, slug_keep)
1029            );
1030            // A clip can leave a trailing '-', which is not a legal DNS-1123 name.
1031            name.trim_matches('-').to_string()
1032        }
1033        Some((_, repo_name)) => {
1034            // Combined form: base + "-pvc-" + pslug + "-repo-" + rslug + "-" +
1035            // tag. Overhead 5+6+1+8 = 20, room 43. Clip base → pslug → rslug.
1036            let rslug = sanitize_dns1123(repo_name);
1037            let fixed = FANOUT_MARKER.len() + REPO_MARKER.len() + 1 + tag.len();
1038            let mut base_keep = base.len();
1039            let mut pslug_keep = slug.len();
1040            let mut rslug_keep = rslug.len();
1041            if fixed + base_keep + pslug_keep + rslug_keep > MAX_CHILD_NAME {
1042                let room = MAX_CHILD_NAME.saturating_sub(fixed);
1043                rslug_keep = rslug_keep.min(room / 3);
1044                pslug_keep = pslug_keep.min(room.saturating_sub(rslug_keep) / 2);
1045                base_keep = base_keep.min(room.saturating_sub(rslug_keep + pslug_keep));
1046            }
1047            format!(
1048                "{}{FANOUT_MARKER}{}{REPO_MARKER}{}-{tag}",
1049                clip(base, base_keep),
1050                clip(&slug, pslug_keep),
1051                clip(&rslug, rslug_keep)
1052            )
1053            .trim_matches('-')
1054            .to_string()
1055        }
1056    }
1057}
1058
1059/// The kopia-cache PVC name for one (policy, repository) cell.
1060///
1061/// A kopia client cache is REPOSITORY-SPECIFIC state (indexes, metadata, owned
1062/// blobs of ONE repository), and the mover mounts it at a fixed
1063/// `KOPIA_CACHE_DIRECTORY` — so a multi-repo policy's children must not share
1064/// one PVC or they poison each other's cache. Hence:
1065///
1066/// * `repo: None` — the classic single-repo cache: `kopiur-cache-<policy>`,
1067///   byte-identical to the pre-multi-repo name (including its historical lack
1068///   of a length cap — an existing PVC must keep matching by name).
1069/// * `repo: Some(_)` — a pinned child of a multi-repo policy:
1070///   `kopiur-cache-<policy>-<rslug>-<h6>`, where `<h6>` is 6 hex of FNV-1a
1071///   over the normalized [`repo_key`](crate::common::repo_key) (never
1072///   clipped — the injectivity guarantee, same family as
1073///   [`fanout_child_name_for`]) and the whole name is capped at 63 (RFC 1123
1074///   label, the same Job-label bound child names honor). Clip order:
1075///   policy slug first, then rslug; marker dashes and the tag never.
1076///
1077/// Ownership stays the policy in both shapes (the caller's concern).
1078pub fn cache_pvc_name(
1079    policy_name: &str,
1080    policy_ns: &str,
1081    repo: Option<&crate::common::RepositoryRef>,
1082) -> String {
1083    const PREFIX: &str = "kopiur-cache-";
1084    let Some(repo) = repo else {
1085        return format!("{PREFIX}{policy_name}");
1086    };
1087    let rkey = crate::common::repo_key(repo, policy_ns);
1088    // h6: the fnv8 family hash, truncated to 6 hex — enough to disambiguate a
1089    // policy's ≤8 repositories while keeping the legible slugs roomy.
1090    let tag: String = fnv8(&rkey).chars().take(6).collect();
1091    let pslug = sanitize_dns1123(policy_name);
1092    let rslug = sanitize_dns1123(&repo.name);
1093    let fixed = PREFIX.len() + 2 + tag.len(); // two joining dashes + tag
1094    let mut pslug_keep = pslug.len();
1095    let mut rslug_keep = rslug.len();
1096    if fixed + pslug_keep + rslug_keep > MAX_CHILD_NAME {
1097        let room = MAX_CHILD_NAME.saturating_sub(fixed);
1098        rslug_keep = rslug_keep.min(room / 3);
1099        pslug_keep = pslug_keep.min(room.saturating_sub(rslug_keep));
1100    }
1101    // Trim any clip-produced trailing '-' per segment (a doubled dash is not a
1102    // legal-name problem, but keeping segments clean avoids `--` cosmetics; the
1103    // never-clipped tag carries injectivity regardless).
1104    format!(
1105        "{PREFIX}{}-{}-{tag}",
1106        clip(&pslug, pslug_keep).trim_end_matches('-'),
1107        clip(&rslug, rslug_keep).trim_end_matches('-')
1108    )
1109    .trim_matches('-')
1110    .to_string()
1111}
1112
1113/// Truncate on a char boundary.
1114fn clip(s: &str, keep: usize) -> &str {
1115    if keep >= s.len() {
1116        return s;
1117    }
1118    let mut end = keep;
1119    while end > 0 && !s.is_char_boundary(end) {
1120        end -= 1;
1121    }
1122    &s[..end]
1123}
1124
1125/// Lowercase, replacing anything outside `[a-z0-9-]` with `-`.
1126fn sanitize_dns1123(s: &str) -> String {
1127    s.chars()
1128        .map(|c| {
1129            if c.is_ascii_alphanumeric() {
1130                c.to_ascii_lowercase()
1131            } else {
1132                '-'
1133            }
1134        })
1135        .collect()
1136}
1137
1138// --- expansion ---------------------------------------------------------------
1139
1140/// One member of an expanded selector: the child's name and the `spec.source`
1141/// to stamp on it.
1142#[derive(Debug, Clone, PartialEq, Eq)]
1143pub struct ExpandedMember {
1144    /// Deterministic child `Snapshot` name.
1145    pub name: String,
1146    /// The pin recording which PVC this child covers.
1147    pub source: SnapshotSourceRef,
1148}
1149
1150/// The shared `VolumeGroupSnapshot` name for one expansion in one namespace.
1151///
1152/// Derived from the per-invocation base name, NOT from any one member: every
1153/// member must compute the identical name without talking to its siblings,
1154/// which is what makes their racing server-side-applies converge on one object
1155/// instead of N.
1156///
1157/// Bounded to 63 chars for the same reason child names are — see
1158/// [`fanout_child_name`].
1159/// `repo_key` adds the repository dimension for multi-repo fan-out — one
1160/// VolumeGroupSnapshot per (repository, slot), because each repo's members are
1161/// an independent capture wave (independent-captures semantics: the N groups
1162/// are N separate point-in-time CSI snapshots, at N× the CSI quota/load).
1163/// Existing callers pass `None` and get the byte-identical legacy name.
1164pub fn group_name(
1165    base: &str,
1166    namespace: &str,
1167    source_index: usize,
1168    repo_key: Option<&str>,
1169) -> String {
1170    let suffix = "-grp";
1171    // The SOURCE INDEX is part of the key, not just the namespace. Two selector
1172    // sources in one policy have DIFFERENT label selectors, and
1173    // `resolve_group_stage` builds the VolumeGroupSnapshot's `source.selector`
1174    // from `sources[sourceIndex]`. Sharing a name across them would have the two
1175    // members force-SSA conflicting selectors onto one object, so the loser's
1176    // PVC is never captured and it fails with `GroupMemberMissing`.
1177    let tag = match repo_key {
1178        None => fnv8(&format!("{namespace}#{source_index}")),
1179        Some(k) => fnv8(&format!("{namespace}#{source_index}#{k}")),
1180    };
1181    let room = MAX_CHILD_NAME - suffix.len() - tag.len() - 1;
1182    format!("{}-{tag}{suffix}", clip(base, room))
1183        .trim_matches('-')
1184        .to_string()
1185}
1186
1187/// One `Snapshot` to mint for a slot/invocation: its deterministic name, the
1188/// source pin (for a `pvcSelector` member) and the repository pin (for a
1189/// multi-repo fan-out child, NORMALIZED via
1190/// [`normalized_repository_ref`](crate::common::normalized_repository_ref)).
1191#[derive(Debug, Clone, PartialEq, Eq)]
1192pub struct MintCell {
1193    /// Deterministic child `Snapshot` name.
1194    pub name: String,
1195    /// The `spec.source` pin recording which PVC this child covers.
1196    pub source: Option<SnapshotSourceRef>,
1197    /// The `spec.repository` pin recording which repository this child targets
1198    /// (multi-repo fan-out only; `None` keeps the legacy unpinned wire).
1199    pub repository: Option<crate::common::RepositoryRef>,
1200}
1201
1202/// **Pure.** Cross one policy's expanded source members with its repository
1203/// dimension (#368) into the exact set of `Snapshot`s to mint for one slot or
1204/// one `snapshot now` invocation. Lives here so the `SnapshotSchedule`
1205/// reconciler and `kubectl kopiur snapshot now` mint byte-identical sets — a
1206/// divergence would give the same recipe different children depending on who
1207/// fired it.
1208///
1209/// * Single-repo policy: byte-identical to the pre-multi-repo behavior — the
1210///   bare `base_name` child (no pins) for a selector-less recipe, or one
1211///   unpinned per-member child.
1212/// * Multi-repo policy: one child per (member × repository), named via
1213///   [`fanout_child_name_for`] with the repo dimension, `spec.repository`
1214///   stamped NORMALIZED; a grouped member's shared `VolumeGroupSnapshot` name
1215///   is re-derived PER REPOSITORY ([`group_name`] with the repo key) — each
1216///   repo's members are an independent capture wave, so N repos = N groups.
1217///
1218/// `members: Some(vec![])` (a selector that matched nothing) yields no cells —
1219/// the caller warns, as before.
1220pub fn mint_cells(
1221    policy: &SnapshotPolicy,
1222    base_name: &str,
1223    members: Option<Vec<ExpandedMember>>,
1224) -> Vec<MintCell> {
1225    use crate::common::{normalized_repository_ref, repo_key};
1226    let policy_ns = policy.namespace().unwrap_or_default();
1227    let repos: Option<Vec<&crate::common::RepositoryRef>> =
1228        crate::snapshot_policy::is_multi_repo(&policy.spec)
1229            .then(|| policy.spec.repositories.iter().collect());
1230    match (members, repos) {
1231        // Single-repo, no selector: the legacy bare child.
1232        (None, None) => vec![MintCell {
1233            name: base_name.to_string(),
1234            source: None,
1235            repository: None,
1236        }],
1237        // Single-repo selector fan-out: unpinned members, byte-identical.
1238        (Some(members), None) => members
1239            .into_iter()
1240            .map(|m| MintCell {
1241                name: m.name,
1242                source: Some(m.source),
1243                repository: None,
1244            })
1245            .collect(),
1246        // Multi-repo, no selector: one child per repository.
1247        (None, Some(repos)) => repos
1248            .into_iter()
1249            .map(|r| MintCell {
1250                name: fanout_child_name_for(base_name, &policy_ns, None, Some(r)),
1251                source: None,
1252                repository: Some(normalized_repository_ref(r, &policy_ns)),
1253            })
1254            .collect(),
1255        // Multi-repo selector fan-out: members × repositories.
1256        (Some(members), Some(repos)) => members
1257            .iter()
1258            .flat_map(|m| {
1259                let policy_ns = policy_ns.clone();
1260                let target = match &m.source.target {
1261                    SnapshotSourceTarget::Pvc(t) => t.clone(),
1262                };
1263                repos.iter().map(move |r| {
1264                    let rkey = repo_key(r, &policy_ns);
1265                    let mut source = m.source.clone();
1266                    if let Some(group) = source.group.as_mut() {
1267                        group.volume_group_snapshot_name = group_name(
1268                            base_name,
1269                            &group.namespace,
1270                            source.source_index as usize,
1271                            Some(&rkey),
1272                        );
1273                    }
1274                    MintCell {
1275                        name: fanout_child_name_for(
1276                            base_name,
1277                            &policy_ns,
1278                            Some((&target.namespace, &target.name)),
1279                            Some(r),
1280                        ),
1281                        source: Some(source),
1282                        repository: Some(normalized_repository_ref(r, &policy_ns)),
1283                    }
1284                })
1285            })
1286            .collect(),
1287    }
1288}
1289
1290/// **Pure.** Expand one policy's sources against an already-matched PVC set.
1291///
1292/// `matched` is `(namespace, name)` per source index — the caller does the
1293/// cluster IO ([`match_pvcs`]), this decides the names and pins.
1294///
1295/// Returns `Ok(None)` when the policy has no selector source at all, meaning
1296/// "mint exactly one child with no `spec.source`", i.e. today's behavior. That
1297/// distinction matters: an empty `Vec` would mean "a selector matched nothing",
1298/// which is a different and much louder situation.
1299///
1300/// # The collision guard
1301///
1302/// `sourcePathStrategy` defaults to `PvcName` → `/pvc/<name>`. A selector with
1303/// a cross-namespace `namespaceSelector` matching a PVC called `data` in two
1304/// namespaces therefore yields **two kopia sources at the identical
1305/// `user@host:/pvc/data`**, silently merging two volumes' histories into one
1306/// stream — under a single `KOPIA_KEEP_MAX` retention pin, so they also prune
1307/// each other. `detect_identity_collision` cannot catch this: it compares
1308/// across policies and skips self. So it is caught here, before anything is
1309/// created.
1310pub fn expand_sources(
1311    policy: &SnapshotPolicy,
1312    base_name: &str,
1313    matched: &BTreeMap<usize, Vec<PvcTargetRef>>,
1314) -> Result<Option<Vec<ExpandedMember>>, ValidationError> {
1315    let policy_ns = policy.namespace().unwrap_or_default();
1316    if !policy.spec.sources.iter().any(|s| s.pvc_selector.is_some()) {
1317        return Ok(None);
1318    }
1319    let mut members: Vec<ExpandedMember> = Vec::new();
1320    // path -> (target, source index) that first produced it.
1321    let mut paths: BTreeMap<String, (PvcTargetRef, usize)> = BTreeMap::new();
1322    let grouped =
1323        policy.spec.group_by == Some(crate::snapshot_policy::GroupBy::VolumeGroupSnapshot);
1324    // How many members land in each namespace, computed up front: a
1325    // VolumeGroupSnapshot is namespaced and its `source.selector` is
1326    // namespace-local, so a selector spanning namespaces yields ONE GROUP PER
1327    // NAMESPACE — the consistency guarantee is per-namespace, not global.
1328    // Keyed by SOURCE too, not just namespace: one VolumeGroupSnapshot is built
1329    // from ONE source's label selector, so two selector sources in the same
1330    // namespace are two separate captures, and counting them together would
1331    // wrongly promote a pair of one-PVC sources into a "group".
1332    let mut per_group: BTreeMap<(&str, usize), usize> = BTreeMap::new();
1333    if grouped {
1334        for (index, source) in policy.spec.sources.iter().enumerate() {
1335            if source.pvc_selector.is_none() {
1336                continue;
1337            }
1338            for t in matched.get(&index).into_iter().flatten() {
1339                *per_group.entry((t.namespace.as_str(), index)).or_default() += 1;
1340            }
1341        }
1342    }
1343
1344    for (index, source) in policy.spec.sources.iter().enumerate() {
1345        if source.pvc_selector.is_none() {
1346            continue;
1347        }
1348        let strategy = strategy_for(source);
1349        for target in matched.get(&index).into_iter().flatten() {
1350            // Collision check against every path this expansion has produced.
1351            let eff = EffectiveSource {
1352                index,
1353                pvc: Some(target.clone()),
1354                nfs_path: None,
1355                // A selector source is always PVC-shaped; a stream source never
1356                // expands (it is one artifact per Snapshot, and admission requires
1357                // it to be its policy's only source).
1358                stream: None,
1359                source_path_override: source.source_path_override.clone(),
1360                read_only: snapshot_policy::source_read_only(source),
1361            };
1362            let path = eff
1363                .kopia_source_path(strategy)
1364                .unwrap_or_else(|| "/data".to_string());
1365            // ANY repeated path is refused, not just one produced by two
1366            // DIFFERENT PVCs. Two selector sources that both match the same PVC
1367            // land on one path AND one child name, so the second would
1368            // force-server-side-apply over the first and one backup would
1369            // vanish with no error — the same silent-overwrite class as a
1370            // clipped slot stamp.
1371            if let Some((prev, prev_index)) = paths.insert(path.clone(), (target.clone(), index)) {
1372                let same_pvc = prev.namespace == target.namespace && prev.name == target.name;
1373                // The same PVC listed twice by the SAME source is a listing
1374                // artifact (`match_pvcs` already dedupes; this keeps
1375                // `expand_sources` idempotent for any caller). Skip it rather
1376                // than reporting a configuration error that isn't one.
1377                if same_pvc && prev_index == index {
1378                    continue;
1379                }
1380                let reason = if same_pvc {
1381                    format!(
1382                        "SnapshotPolicy `{}` has two `pvcSelector` sources that both match \
1383                         `{}/{}` (path `{path}`). Fix: narrow the selectors so each PVC matches \
1384                         only one source",
1385                        policy.name_any(),
1386                        target.namespace,
1387                        target.name,
1388                    )
1389                } else if let Some(i) = [index, prev_index].into_iter().find(|i| {
1390                    policy
1391                        .spec
1392                        .sources
1393                        .get(*i)
1394                        .and_then(|s| s.source_path_override.as_deref())
1395                        == Some(path.as_str())
1396                }) {
1397                    // A `sourcePathOverride` PRODUCED this path: it wins at
1398                    // `kopia_source_path`'s first branch, so the strategy is
1399                    // never consulted and "set sourcePathStrategy" would be a
1400                    // remedy that does nothing. Name the real cause instead.
1401                    format!(
1402                        "SnapshotPolicy `{}` pvcSelector matches both `{}/{}` and `{}/{}`, but \
1403                         `spec.sources[{i}].sourcePathOverride` gives them the same path \
1404                         `{path}`, so their backups would overwrite each other. Fix: remove \
1405                         that `sourcePathOverride` and use `sourcePathStrategy` (`PvcName` or \
1406                         `PvcNamespacedName`), or move the override onto its own `pvc:` \
1407                         source",
1408                        policy.name_any(),
1409                        prev.namespace,
1410                        prev.name,
1411                        target.namespace,
1412                        target.name,
1413                    )
1414                } else {
1415                    format!(
1416                        "SnapshotPolicy `{}` pvcSelector matches both `{}/{}` and `{}/{}`, \
1417                         which get the same path `{path}` under `sourcePathStrategy: PvcName`, \
1418                         so their backups would overwrite each other. Fix: set \
1419                         `sourcePathStrategy: PvcNamespacedName` on that source",
1420                        policy.name_any(),
1421                        prev.namespace,
1422                        prev.name,
1423                        target.namespace,
1424                        target.name,
1425                    )
1426                };
1427                return Err(ValidationError::InvalidFieldValue {
1428                    field: "spec.sources[].pvcSelector".to_string(),
1429                    reason,
1430                });
1431            }
1432            members.push(ExpandedMember {
1433                name: fanout_child_name(base_name, &policy_ns, &target.namespace, &target.name),
1434                source: SnapshotSourceRef {
1435                    source_index: index as u32,
1436                    target: SnapshotSourceTarget::Pvc(target.clone()),
1437                    // A one-member "group" buys nothing and costs a
1438                    // VolumeGroupSnapshotClass requirement (and a Beta API
1439                    // group many clusters do not serve), so it degrades to the
1440                    // ordinary per-PVC VolumeSnapshot path.
1441                    group: (grouped
1442                        && per_group
1443                            .get(&(target.namespace.as_str(), index))
1444                            .copied()
1445                            .unwrap_or(0)
1446                            > 1)
1447                    .then(|| SnapshotSourceGroup {
1448                        namespace: target.namespace.clone(),
1449                        // Repo dimension: none HERE — `expand_sources` emits
1450                        // repo-agnostic members, and `mint_cells` re-derives
1451                        // this name with `Some(repo_key)` per repository for a
1452                        // multi-repo policy. Passing None keeps single-repo
1453                        // names byte-identical to the legacy form.
1454                        volume_group_snapshot_name: group_name(
1455                            base_name,
1456                            &target.namespace,
1457                            index,
1458                            None,
1459                        ),
1460                    }),
1461                },
1462            });
1463        }
1464    }
1465    Ok(Some(members))
1466}
1467
1468#[cfg(test)]
1469mod tests;