kopiur_api/identity.rs
1//! Kopia identity resolution (ADR §4.2).
2//!
3//! Kopia records every snapshot under `username@hostname:sourcePath`. Kopiur makes
4//! that identity an explicit, overridable part of the API rather than an accident
5//! of `metadata.name`/`metadata.namespace` (ADR §2.2 principle 9). This module is
6//! the single place the defaulting + templating rules live. The webhook calls it
7//! at admission (so a bad expression/component is rejected on `kubectl apply`) and
8//! the controller calls it again on every reconcile, resolving from the LIVE
9//! `SnapshotPolicy.spec.identity` and the LIVE referenced repository's
10//! `identityDefaults` — **not** a value pinned once and frozen. `status.resolved.identity`
11//! mirrors the most recent resolution for observability; it is not the source of
12//! truth a later run reads back. What actually keeps an already-snapshotted policy
13//! stable is the fork guard (`ValidationError::IdentityWouldFork`/
14//! `RepositoryIdentityWouldFork`): an edit that would change the resolved identity
15//! on a policy (or repository) with existing history is rejected at admission
16//! unless acknowledged with the `allow-identity-change` annotation.
17//!
18//! ## Defaults (ADR §4.2)
19//! - `username` ← `SnapshotPolicy.metadata.name`
20//! - `hostname` ← namespace
21//! - `sourcePath` ← `/pvc/<pvcName>`
22//!
23//! ## Repository/ClusterRepository identity expressions (CEL)
24//!
25//! A [`crate::common::IdentityDefaults`] (`Repository.spec.identityDefaults` or
26//! `ClusterRepository.spec.identityDefaults`) supplies `hostnameExpr`/
27//! `usernameExpr`, **CEL** expressions ([`cel`]) validated (compiled + trial-evaluated
28//! via [`validate_identity_expr`]) at admission, then actually rendered against the
29//! LIVE consumer + LIVE repository on every reconcile (ADR-0004 §5) — so editing
30//! `identityDefaults` re-renders every consumer that resolves through it on its
31//! next backup (guarded by [`ValidationError::RepositoryIdentityWouldFork`], see
32//! the module intro above). A consumer's explicit [`Identity`] override **always
33//! wins** over the expression.
34//!
35//! ### CEL environment
36//!
37//! Each expression returns a **string** and is evaluated against:
38//! `namespace` (the consumer's namespace), `policyName` (the `SnapshotPolicy`'s
39//! name), `labels` and `annotations` (its metadata maps), and `cluster`
40//! ([`IdentityDefaults::cluster`], or `""` when unset — see [`identity_context`]).
41//! Examples: `hostnameExpr: "namespace"`,
42//! `usernameExpr: "namespace + '-' + policyName"`,
43//! `"'team' in labels ? labels['team'] : namespace"`,
44//! `"namespace + '.' + cluster"`. CEL is sandboxed (no I/O, no arbitrary code); a
45//! syntax error or out-of-scope variable is rejected at `kubectl apply` via
46//! [`validate_identity_expr`], and a non-string result is a typed error.
47//! Expressions are length-capped ([`MAX_EXPR_LEN`]) as the cost-budget surrogate.
48//!
49//! ### Multi-cluster hostname default
50//!
51//! When a repository's [`IdentityDefaults::cluster`] is set, the default
52//! (no override, no `hostnameExpr`) kopia identity hostname becomes
53//! `<namespace>.<cluster>` instead of bare `<namespace>`, so N clusters sharing one
54//! repository never collide on a same-named namespace. [`classify_hostname`]
55//! recovers the namespace/cluster split on the read path (retention, discovered
56//! `Snapshot` placement).
57
58use std::collections::BTreeMap;
59
60use cel::{Context, Program, Value};
61
62use crate::common::{Identity, IdentityDefaults, ResolvedIdentity};
63use crate::error::{ValidationError, ValidationResult};
64
65/// Maximum CEL expression length accepted at admission (the cost-budget surrogate;
66/// `cel` 0.13 has no built-in cost API). 1 KiB is far beyond any real identity
67/// expression and bounds parse/eval work on adversarial input.
68pub const MAX_EXPR_LEN: usize = 1024;
69
70/// Inputs to identity resolution. Grouped into a struct so call sites are readable
71/// and future inputs slot in without churning the signature.
72#[derive(Debug, Clone)]
73pub struct IdentityInputs<'a> {
74 /// The consumer object's `metadata.name` (default `username`; the `policyName`
75 /// CEL variable).
76 pub object_name: &'a str,
77 /// The consumer object's namespace (default `hostname`; the `namespace` CEL
78 /// variable).
79 pub namespace: &'a str,
80 /// Explicit overrides from `SnapshotPolicy.spec.identity`, if any.
81 pub overrides: Option<&'a Identity>,
82 /// The referenced repository's `spec.identityDefaults` — `Repository` or
83 /// `ClusterRepository`, whichever the consumer targets. Carries the CEL
84 /// `*Expr` pair (`hostnameExpr`/`usernameExpr`) AND `cluster`, the
85 /// multi-cluster hostname-default suffix (see [`IdentityDefaults::cluster`]
86 /// and the module's "Multi-cluster hostname default" section).
87 pub defaults: Option<&'a IdentityDefaults>,
88 /// The consumer's `metadata.labels`, exposed to CEL as `labels`.
89 pub labels: Option<&'a BTreeMap<String, String>>,
90 /// The consumer's `metadata.annotations`, exposed to CEL as `annotations`.
91 pub annotations: Option<&'a BTreeMap<String, String>>,
92 /// The kopia `sourcePath` to record, fully derived by the caller.
93 ///
94 /// Deliberately ONE field rather than the `pvc_name` / `default_source_path`
95 /// / `source_path_override` triple it replaces. Those three invited every
96 /// identity site to re-derive the path inline from a source's `pvc`/`nfs`,
97 /// and five of them therefore resolved a pathless identity for a `stream`
98 /// source while the backup side recorded `/stream/<fileName>` (#451). With
99 /// only the answer accepted here, a caller MUST go through
100 /// [`crate::expand::identity_source_path`] — the single derivation — so a new
101 /// source kind cannot silently resolve the wrong path at some sites and the
102 /// right one at others.
103 ///
104 /// `None` leaves `sourcePath` unset: kopia's identity-only
105 /// `username@hostname` form, which matches any path. Used by surfaces that
106 /// genuinely have no single source path (a maintenance identity, a selector
107 /// policy with no fan-out pin, a zero-source legacy policy).
108 pub source_path: Option<&'a str>,
109}
110
111/// Compile a CEL identity expression, enforcing the [`MAX_EXPR_LEN`] budget first.
112/// Maps a parse failure to [`ValidationError::IdentityExprCompile`].
113fn compile_expr(expr: &str) -> ValidationResult<Program> {
114 if expr.len() > MAX_EXPR_LEN {
115 return Err(ValidationError::IdentityExprCompile {
116 expr: expr.to_string(),
117 reason: format!(
118 "expression is {} bytes; the maximum is {MAX_EXPR_LEN}",
119 expr.len()
120 ),
121 });
122 }
123 let aliased = alias_namespace_ident(expr);
124 Program::compile(&aliased).map_err(|e| ValidationError::IdentityExprCompile {
125 expr: expr.to_string(),
126 reason: restore_source(&e.to_string(), expr, &aliased),
127 })
128}
129
130/// The name the `namespace` variable is actually bound under.
131///
132/// `namespace` is a CEL reserved word: the spec reserves it, and from `cel`
133/// 0.14.5 the parser enforces that (as cel-go always has) — `namespace` alone is
134/// a parse error. But it is also this API's documented, longest-standing
135/// variable, in every example and in live `identityDefaults`; refusing it would
136/// break each of those expressions on upgrade, and with them every backup that
137/// resolves its identity through one. So it keeps working: before compiling,
138/// [`alias_namespace_ident`] rewrites the bare identifier to this name, which is
139/// what [`identity_context`] binds.
140///
141/// Exactly as long as `namespace`, so the rewritten source lines up with the
142/// user's column for column: a `cel` error's `line:col` and `^` caret stay
143/// true, and [`restore_source`] can map its quoted source back exactly.
144const NAMESPACE_ALIAS: &str = "__nsvar__";
145const _: () = assert!(NAMESPACE_ALIAS.len() == "namespace".len());
146
147/// Rewrite every bare `namespace` identifier in `expr` to [`NAMESPACE_ALIAS`].
148///
149/// Token-aware, so only a variable reference changes: string and bytes literals
150/// (every quoting and prefix form), `//` comments, and field selectors
151/// (`labels.namespace` — the spec permits reserved words there) are copied
152/// through untouched. Anything this scan does not understand is copied through
153/// too, leaving the parser to report it against the user's own text.
154fn alias_namespace_ident(expr: &str) -> String {
155 let b = expr.as_bytes();
156 let mut out = String::with_capacity(expr.len());
157 let mut i = 0;
158 // Whether the last significant token was `.`, i.e. an identifier here is a
159 // field selector rather than a variable.
160 let mut after_dot = false;
161 while i < b.len() {
162 let c = b[i];
163 if c == b'/' && b.get(i + 1) == Some(&b'/') {
164 let end = expr[i..].find('\n').map_or(b.len(), |n| i + n);
165 out.push_str(&expr[i..end]);
166 i = end;
167 } else if c == b'"' || c == b'\'' {
168 let end = string_end(b, i, false);
169 out.push_str(&expr[i..end]);
170 i = end;
171 after_dot = false;
172 } else if c.is_ascii_alphabetic() || c == b'_' {
173 let start = i;
174 while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'_') {
175 i += 1;
176 }
177 let word = &expr[start..i];
178 let is_prefix = word.len() <= 2 && word.bytes().all(|p| b"rRbB".contains(&p));
179 if is_prefix && matches!(b.get(i), Some(b'"' | b'\'')) {
180 // A string prefix: `r'…'`, `b"…"`, `rb'''…'''`. Raw strings take
181 // no escapes.
182 let raw = word.bytes().any(|p| p == b'r' || p == b'R');
183 let end = string_end(b, i, raw);
184 out.push_str(&expr[start..end]);
185 i = end;
186 } else if word == "namespace" && !after_dot {
187 out.push_str(NAMESPACE_ALIAS);
188 } else {
189 out.push_str(word);
190 }
191 after_dot = false;
192 } else if c.is_ascii_digit() {
193 // A number (`42`, `0xFF`, `3u`, `1e9`): its letters are not identifiers.
194 let start = i;
195 while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'_') {
196 i += 1;
197 }
198 out.push_str(&expr[start..i]);
199 after_dot = false;
200 } else {
201 let ch = expr[i..].chars().next().unwrap_or_default();
202 if !ch.is_whitespace() {
203 after_dot = ch == '.';
204 }
205 out.push(ch);
206 i += ch.len_utf8();
207 }
208 }
209 out
210}
211
212/// Index just past the string literal whose opening quote is at `open`, handling
213/// `'`/`"` and their tripled forms. An unterminated literal runs to the end.
214fn string_end(b: &[u8], open: usize, raw: bool) -> usize {
215 let q = b[open];
216 let triple = b.get(open + 1) == Some(&q) && b.get(open + 2) == Some(&q);
217 let mut i = open + if triple { 3 } else { 1 };
218 while i < b.len() {
219 if b[i] == b'\\' && !raw {
220 i += 2;
221 } else if triple {
222 if b[i] == q && b.get(i + 1) == Some(&q) && b.get(i + 2) == Some(&q) {
223 return i + 3;
224 }
225 i += 1;
226 } else if b[i] == q {
227 return i + 1;
228 } else {
229 i += 1;
230 }
231 }
232 b.len()
233}
234
235/// Show a `cel` error against the source the user wrote rather than the
236/// [`alias_namespace_ident`] rewrite it parsed.
237///
238/// `cel` reports each error as `ERROR: <input>:LINE:COL: …` followed by
239/// `| <source line LINE>` and a caret line. Only that quoted line is restored,
240/// and only when it is exactly the rewritten line LINE: nothing is searched
241/// for, so no other text in the message — a user's own `'__nsvar__'` literal,
242/// another error's quote — can be touched. The rewrite never adds or removes a
243/// line, and the alias is the length of `namespace`, so LINE:COL and the caret
244/// need no adjusting. Anything not in that shape is passed through unchanged.
245fn restore_source(message: &str, original: &str, aliased: &str) -> String {
246 let original: Vec<&str> = original.lines().collect();
247 let aliased: Vec<&str> = aliased.lines().collect();
248 let mut line = None;
249 message
250 .split('\n')
251 .map(|m| {
252 if let Some(n) = error_line(m) {
253 line = Some(n);
254 } else if let (Some(quoted), Some(n)) = (m.strip_prefix("| "), line.take()) {
255 let idx = n.wrapping_sub(1);
256 if aliased.get(idx) == Some("ed)
257 && let Some(o) = original.get(idx)
258 {
259 return format!("| {o}");
260 }
261 }
262 m.to_string()
263 })
264 .collect::<Vec<_>>()
265 .join("\n")
266}
267
268/// The 1-based LINE of a `cel` `ERROR: <input>:LINE:COL: …` header line.
269fn error_line(m: &str) -> Option<usize> {
270 let rest = m.strip_prefix("ERROR: <input>:")?;
271 rest.split(':').next()?.parse().ok()
272}
273
274/// Build the CEL evaluation context for identity expressions: `namespace`,
275/// `policyName`, `labels`, `annotations`, `cluster`. `cluster` is **always**
276/// declared (so an expression referencing it never hits an undeclared-variable
277/// error), taking `inputs.defaults.and_then(|d| d.cluster.as_deref())` or `""`
278/// when the consumer's repository has no `identityDefaults.cluster` set (bare
279/// single-cluster deployments never see a `cluster` variable value).
280fn identity_context<'a>(inputs: &IdentityInputs<'_>) -> Context<'a> {
281 let mut ctx = Context::default();
282 // `add_variable` only errors if a value cannot serialize; these are
283 // `&str`/`BTreeMap<String,String>`, which always serialize.
284 let empty = BTreeMap::<String, String>::new();
285 // Bound under its alias: `namespace` itself cannot be referenced in CEL.
286 let _ = ctx.add_variable(NAMESPACE_ALIAS, inputs.namespace);
287 let _ = ctx.add_variable("policyName", inputs.object_name);
288 let _ = ctx.add_variable("labels", inputs.labels.unwrap_or(&empty));
289 let _ = ctx.add_variable("annotations", inputs.annotations.unwrap_or(&empty));
290 let cluster = inputs
291 .defaults
292 .and_then(|d| d.cluster.as_deref())
293 .unwrap_or("");
294 let _ = ctx.add_variable("cluster", cluster);
295 ctx
296}
297
298/// Evaluate a compiled identity [`Program`] against `inputs`, requiring a string
299/// result. Maps evaluation failure to [`ValidationError::IdentityExprEval`] and a
300/// non-string result to [`ValidationError::IdentityExprType`].
301fn eval_expr(
302 expr: &str,
303 program: &Program,
304 inputs: &IdentityInputs<'_>,
305) -> ValidationResult<String> {
306 let ctx = identity_context(inputs);
307 match program.execute(&ctx) {
308 Ok(Value::String(s)) => Ok(s.to_string()),
309 Ok(other) => Err(ValidationError::IdentityExprType {
310 expr: expr.to_string(),
311 got: other.type_of().to_string(),
312 }),
313 Err(e) => Err(ValidationError::IdentityExprEval {
314 expr: expr.to_string(),
315 reason: restore_source(&e.to_string(), expr, &alias_namespace_ident(expr)),
316 }),
317 }
318}
319
320/// Compile + evaluate an identity expression in one step.
321fn render_expr(expr: &str, inputs: &IdentityInputs<'_>) -> ValidationResult<String> {
322 let program = compile_expr(expr)?;
323 eval_expr(expr, &program, inputs)
324}
325
326/// Validate a `Repository`/`ClusterRepository` `identityDefaults` CEL expression
327/// at admission (ADR-0004 §5): it must compile, and — because CEL reports an
328/// out-of-scope variable only at *evaluation* time — it must also evaluate
329/// against a representative context without referencing an undeclared variable.
330/// A non-string result is rejected. Missing *map keys* (e.g. `labels['env']`
331/// when the trial data lacks `env`) are tolerated: they are data-dependent, not
332/// a structural error.
333pub fn validate_identity_expr(expr: &str) -> ValidationResult {
334 let program = compile_expr(expr)?;
335 // Representative trial context: non-empty maps so `'k' in labels`-style guards
336 // behave, plus placeholder scalars. `cluster` gets a non-empty placeholder too
337 // (rather than the "unset" empty string [`identity_context`] would otherwise
338 // supply) so an expression referencing it — e.g. `namespace + '.' + cluster` —
339 // trial-evaluates the same way regardless of whether *this particular*
340 // repository happens to set `identityDefaults.cluster`.
341 let labels = BTreeMap::from([("app".to_string(), "trial".to_string())]);
342 let annotations = BTreeMap::from([("note".to_string(), "trial".to_string())]);
343 let trial_defaults = IdentityDefaults {
344 cluster: Some("trial-cluster".to_string()),
345 hostname_expr: None,
346 username_expr: None,
347 };
348 let inputs = IdentityInputs {
349 object_name: "policy",
350 namespace: "namespace",
351 overrides: None,
352 defaults: Some(&trial_defaults),
353 labels: Some(&labels),
354 annotations: Some(&annotations),
355 source_path: None,
356 };
357 let ctx = identity_context(&inputs);
358 match program.execute(&ctx) {
359 Ok(Value::String(_)) => Ok(()),
360 Ok(other) => Err(ValidationError::IdentityExprType {
361 expr: expr.to_string(),
362 got: other.type_of().to_string(),
363 }),
364 // An undeclared-variable reference (a typo / out-of-scope var) is a hard
365 // rejection. Other runtime errors (e.g. NoSuchKey on a label the trial data
366 // lacks) are data-dependent and tolerated — the real object may supply them.
367 Err(cel::ExecutionError::UndeclaredReference(name)) => {
368 Err(ValidationError::IdentityExprEval {
369 expr: expr.to_string(),
370 reason: format!("undeclared reference to '{name}'"),
371 })
372 }
373 Err(_) => Ok(()),
374 }
375}
376
377/// Resolve a [`ResolvedIdentity`] from defaults, an optional `Repository`/
378/// `ClusterRepository` identity expression set, and explicit consumer overrides
379/// (ADR §4.2 / ADR-0004 §5).
380///
381/// Precedence per component: **explicit override, then expression, then default**.
382/// For `hostname` specifically, the default step itself has two tiers: with
383/// [`IdentityDefaults::cluster`] set, the default is `<namespace>.<cluster>`
384/// (M1, multi-cluster shared repositories); otherwise it's the bare `namespace`
385/// (ADR §4.2). So the full hostname chain, highest precedence first, is:
386/// explicit override, `hostnameExpr`, `<namespace>.<cluster>` (cluster set),
387/// `namespace`. `username` is unaffected — `cluster` is not part of its default.
388/// Returns a
389/// [`ValidationError::IdentityExprCompile`]/`IdentityExprEval`/`IdentityExprType` if a
390/// supplied expression fails (so the webhook rejects it at admission rather than
391/// pinning garbage).
392///
393/// ```
394/// use kopiur_api::{IdentityInputs, resolve_identity, identity_string};
395///
396/// // Bare defaults: username <- object name, hostname <- namespace,
397/// // sourcePath <- /pvc/<pvcName> (ADR §4.2).
398/// let inputs = IdentityInputs {
399/// object_name: "postgres-data",
400/// namespace: "billing",
401/// overrides: None,
402/// defaults: None,
403/// labels: None,
404/// annotations: None,
405/// source_path: Some("/pvc/postgres-data"),
406/// };
407/// let id = resolve_identity(&inputs).unwrap();
408/// assert_eq!(id.username, "postgres-data");
409/// assert_eq!(id.hostname, "billing");
410/// assert_eq!(id.source_path.as_deref(), Some("/pvc/postgres-data"));
411/// assert_eq!(identity_string(&id), "postgres-data@billing:/pvc/postgres-data");
412/// ```
413pub fn resolve_identity(inputs: &IdentityInputs<'_>) -> ValidationResult<ResolvedIdentity> {
414 let override_username = inputs.overrides.and_then(|o| o.username.as_deref());
415 let override_hostname = inputs.overrides.and_then(|o| o.hostname.as_deref());
416
417 let username = match override_username {
418 Some(u) => u.to_string(),
419 None => match inputs.defaults.and_then(|t| t.username_expr.as_deref()) {
420 Some(expr) => render_expr(expr, inputs)?,
421 None => inputs.object_name.to_string(),
422 },
423 };
424
425 let hostname = match override_hostname {
426 Some(h) => h.to_string(),
427 None => match inputs.defaults.and_then(|t| t.hostname_expr.as_deref()) {
428 Some(expr) => render_expr(expr, inputs)?,
429 // No explicit expression: fall back to `<namespace>.<cluster>` when this
430 // repository has a cluster identity configured (M1 — distinguishes
431 // same-named namespaces across clusters sharing one repository), else the
432 // plain namespace (ADR §4.2).
433 None => match inputs.defaults.and_then(|d| d.cluster.as_deref()) {
434 Some(cluster) => format!("{}.{cluster}", inputs.namespace),
435 None => inputs.namespace.to_string(),
436 },
437 },
438 };
439
440 let source_path = inputs.source_path.map(String::from);
441
442 // Shape-check the fully-resolved identity, regardless of where each component came
443 // from (explicit override, CEL expression result, or the name/namespace/PVC
444 // default). kopia parses `username@hostname:path` on the first `@`/`:` with no
445 // escaping, so a component carrying a delimiter/whitespace/control char would
446 // silently misparse or become un-findable. Rejecting here means the controller
447 // never PINS a bad identity into status, and the webhook surfaces it at admission.
448 crate::validate::validate_identity_component("resolved username", &username)?;
449 crate::validate::validate_identity_component("resolved hostname", &hostname)?;
450 if let Some(p) = &source_path {
451 crate::validate::validate_source_path("resolved sourcePath", p)?;
452 }
453
454 Ok(ResolvedIdentity {
455 username,
456 hostname,
457 source_path,
458 })
459}
460
461/// Format a kopia identity string. With a source path: `username@hostname:path`;
462/// without one: `username@hostname` (kopia's identity-only form, used for catalog
463/// queries that aren't pinned to a path).
464///
465/// ```
466/// use kopiur_api::{IdentityInputs, resolve_identity, identity_string};
467///
468/// // No PVC => no sourcePath => kopia's identity-only `username@hostname` form.
469/// let inputs = IdentityInputs {
470/// object_name: "cfg",
471/// namespace: "ns",
472/// overrides: None,
473/// defaults: None,
474/// labels: None,
475/// annotations: None,
476/// source_path: None,
477/// };
478/// let id = resolve_identity(&inputs).unwrap();
479/// assert_eq!(id.source_path, None);
480/// assert_eq!(identity_string(&id), "cfg@ns");
481/// ```
482pub fn identity_string(id: &ResolvedIdentity) -> String {
483 match &id.source_path {
484 Some(p) => format!("{}@{}:{}", id.username, id.hostname, p),
485 None => format!("{}@{}", id.username, id.hostname),
486 }
487}
488
489/// Classification of a snapshot identity hostname relative to THIS cluster.
490/// Kubernetes namespace names cannot contain `.`, so the FIRST `.` unambiguously
491/// ends the namespace part of a `<namespace>.<cluster>` hostname. Total: with no
492/// cluster identity every hostname is `Bare`; an empty namespace part (".east")
493/// or empty suffix ("ns.") also classifies `Bare` (never a panic, never an
494/// invalid empty namespace). Suffix comparison is case-sensitive.
495#[derive(Debug, Clone, Copy, PartialEq, Eq)]
496pub enum HostClass<'a> {
497 /// No dot (or cluster mode off): the legacy `hostname == namespace` form.
498 Bare {
499 /// The hostname verbatim (there is no separate suffix to strip).
500 namespace: &'a str,
501 },
502 /// `<namespace>.<suffix>` where suffix == this cluster: written by us.
503 OwnCluster {
504 /// The part of the hostname before the first `.`.
505 namespace: &'a str,
506 },
507 /// `<namespace>.<suffix>` with a different suffix: another cluster's.
508 ForeignCluster {
509 /// Everything after the first `.` (may itself contain further dots).
510 suffix: &'a str,
511 },
512}
513
514/// Classify a kopia identity `hostname` against `my_cluster`
515/// ([`IdentityDefaults::cluster`], resolved from the consuming repository), so a
516/// reader (retention pruning, discovered-`Snapshot` placement) can tell whether a
517/// hostname it sees was written by this cluster, another cluster sharing the same
518/// repository, or predates cluster identity entirely.
519///
520/// Total and pure — never panics, never fabricates an empty namespace:
521/// - `my_cluster` is `None` or `""` (no cluster identity configured): always
522/// [`HostClass::Bare`].
523/// - No `.` in `hostname`: [`HostClass::Bare`].
524/// - The part before the first `.` is empty (e.g. `".east"`), or the part after
525/// is empty (e.g. `"ns."`): [`HostClass::Bare`] with the **whole** hostname as
526/// `namespace` — these aren't well-formed `<namespace>.<cluster>` hostnames, so
527/// classification declines to guess which part is which.
528/// - The suffix (everything after the first `.`) exactly equals `my_cluster`:
529/// [`HostClass::OwnCluster`].
530/// - Otherwise: [`HostClass::ForeignCluster`] — note `"ns.east.x"` under cluster
531/// `"east"` is `ForeignCluster { suffix: "east.x" }`, not `OwnCluster`; only an
532/// **exact** suffix match is ours.
533///
534/// ```
535/// use kopiur_api::identity::{HostClass, classify_hostname};
536///
537/// assert_eq!(
538/// classify_hostname("billing.east", Some("east")),
539/// HostClass::OwnCluster { namespace: "billing" }
540/// );
541/// assert_eq!(
542/// classify_hostname("billing.west", Some("east")),
543/// HostClass::ForeignCluster { suffix: "west" }
544/// );
545/// // No cluster identity configured => every hostname reads as legacy/bare.
546/// assert_eq!(
547/// classify_hostname("billing.east", None),
548/// HostClass::Bare { namespace: "billing.east" }
549/// );
550/// ```
551pub fn classify_hostname<'a>(hostname: &'a str, my_cluster: Option<&str>) -> HostClass<'a> {
552 let my_cluster = match my_cluster {
553 Some(c) if !c.is_empty() => c,
554 _ => {
555 return HostClass::Bare {
556 namespace: hostname,
557 };
558 }
559 };
560 match hostname.split_once('.') {
561 None => HostClass::Bare {
562 namespace: hostname,
563 },
564 Some((namespace, suffix)) if namespace.is_empty() || suffix.is_empty() => HostClass::Bare {
565 namespace: hostname,
566 },
567 Some((namespace, suffix)) if suffix == my_cluster => HostClass::OwnCluster { namespace },
568 Some((_, suffix)) => HostClass::ForeignCluster { suffix },
569 }
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575
576 fn inputs<'a>(
577 name: &'a str,
578 ns: &'a str,
579 overrides: Option<&'a Identity>,
580 defaults: Option<&'a IdentityDefaults>,
581 source_path: Option<&'a str>,
582 ) -> IdentityInputs<'a> {
583 IdentityInputs {
584 object_name: name,
585 namespace: ns,
586 overrides,
587 defaults,
588 labels: None,
589 annotations: None,
590 source_path,
591 }
592 }
593
594 fn defaults(host: Option<&str>, user: Option<&str>) -> IdentityDefaults {
595 IdentityDefaults {
596 cluster: None,
597 hostname_expr: host.map(String::from),
598 username_expr: user.map(String::from),
599 }
600 }
601
602 /// Like [`defaults`] but also sets `identityDefaults.cluster` (M1 precedence tests).
603 fn defaults_with_cluster(
604 host: Option<&str>,
605 user: Option<&str>,
606 cluster: Option<&str>,
607 ) -> IdentityDefaults {
608 IdentityDefaults {
609 cluster: cluster.map(String::from),
610 hostname_expr: host.map(String::from),
611 username_expr: user.map(String::from),
612 }
613 }
614
615 #[test]
616 fn nfs_source_uses_default_source_path() {
617 // No PVC, but an NFS export supplies the sourcePath (derived by
618 // `expand::identity_source_path` from the export path).
619 let mut i = inputs("media", "default", None, None, None);
620 i.source_path = Some("/mnt/eros/Media");
621 let r = resolve_identity(&i).unwrap();
622 assert_eq!(r.source_path.as_deref(), Some("/mnt/eros/Media"));
623 assert_eq!(identity_string(&r), "media@default:/mnt/eros/Media");
624 }
625
626 #[test]
627 fn override_beats_default_source_path() {
628 // `sourcePathOverride` precedence now lives in
629 // `EffectiveSource::kopia_source_path`, which is what supplies this field —
630 // so the kernel simply records what it is handed.
631 let mut i = inputs("media", "default", None, None, None);
632 i.source_path = Some("/data");
633 let r = resolve_identity(&i).unwrap();
634 assert_eq!(r.source_path.as_deref(), Some("/data"));
635 }
636
637 #[test]
638 fn defaults_use_name_namespace_and_pvc_path() {
639 let r = resolve_identity(&inputs(
640 "postgres-data",
641 "billing",
642 None,
643 None,
644 Some("/pvc/postgres-data"),
645 ))
646 .unwrap();
647 assert_eq!(r.username, "postgres-data");
648 assert_eq!(r.hostname, "billing");
649 assert_eq!(r.source_path.as_deref(), Some("/pvc/postgres-data"));
650 }
651
652 // --- M1: identityDefaults.cluster hostname precedence -------------------
653 // explicit override > hostnameExpr > `<namespace>.<cluster>` (cluster set) > namespace.
654
655 #[test]
656 fn cluster_default_is_used_when_no_override_or_expr() {
657 let d = defaults_with_cluster(None, None, Some("east"));
658 let r = resolve_identity(&inputs(
659 "postgres-data",
660 "billing",
661 None,
662 Some(&d),
663 Some("data"),
664 ))
665 .unwrap();
666 assert_eq!(r.hostname, "billing.east");
667 // username is unaffected — cluster is not part of its default.
668 assert_eq!(r.username, "postgres-data");
669 }
670
671 #[test]
672 fn no_cluster_no_expr_no_override_falls_back_to_bare_namespace() {
673 // The `defaults_use_name_namespace_and_pvc_path` test above already covers
674 // `defaults: None`; this covers `defaults: Some(..)` with `cluster: None`.
675 let d = defaults_with_cluster(None, None, None);
676 let r = resolve_identity(&inputs("cfg", "billing", None, Some(&d), Some("data"))).unwrap();
677 assert_eq!(r.hostname, "billing");
678 }
679
680 #[test]
681 fn explicit_hostname_override_beats_cluster_default() {
682 let d = defaults_with_cluster(None, None, Some("east"));
683 let ovr = Identity {
684 username: None,
685 hostname: Some("pinned-host".to_string()),
686 };
687 let r = resolve_identity(&inputs(
688 "postgres-data",
689 "billing",
690 Some(&ovr),
691 Some(&d),
692 Some("data"),
693 ))
694 .unwrap();
695 assert_eq!(r.hostname, "pinned-host");
696 }
697
698 #[test]
699 fn hostname_expr_beats_cluster_default() {
700 // Both a hostnameExpr AND a cluster default are set; the expr wins (the
701 // cluster tier only applies when there is no expression at all).
702 let d = defaults_with_cluster(Some("namespace"), None, Some("east"));
703 let r = resolve_identity(&inputs(
704 "postgres-data",
705 "billing",
706 None,
707 Some(&d),
708 Some("data"),
709 ))
710 .unwrap();
711 assert_eq!(r.hostname, "billing"); // NOT "billing.east"
712 }
713
714 #[test]
715 fn hostname_expr_can_reference_cluster_variable() {
716 // hostnameExpr explicitly renders using `cluster` — the brief's example.
717 let d = defaults_with_cluster(Some("namespace + '.' + cluster"), None, Some("east"));
718 let r = resolve_identity(&inputs(
719 "postgres-data",
720 "billing",
721 None,
722 Some(&d),
723 Some("data"),
724 ))
725 .unwrap();
726 assert_eq!(r.hostname, "billing.east");
727 }
728
729 #[test]
730 fn cluster_variable_is_empty_string_when_defaults_lack_cluster() {
731 // Same expression, but `identityDefaults.cluster` isn't set: `cluster`
732 // evaluates to "" (identity_context's documented "unset" value).
733 let d = defaults_with_cluster(Some("namespace + '.' + cluster"), None, None);
734 let r = resolve_identity(&inputs(
735 "postgres-data",
736 "billing",
737 None,
738 Some(&d),
739 Some("data"),
740 ))
741 .unwrap();
742 assert_eq!(r.hostname, "billing.");
743 }
744
745 #[test]
746 fn validate_identity_expr_admits_cluster_reference_even_without_a_real_cluster_default() {
747 // The *admission-time trial* always declares `cluster` (a placeholder),
748 // independent of whether the real ClusterRepository sets
749 // identityDefaults.cluster — so this must admit even though no `defaults`
750 // is threaded through validate_identity_expr's caller in this test.
751 assert!(validate_identity_expr("namespace + '.' + cluster").is_ok());
752 assert!(validate_identity_expr("cluster").is_ok());
753 }
754
755 #[test]
756 fn adr_cluster_repository_cel_example() {
757 // ADR-0004 §5 example:
758 // hostnameExpr: "namespace"
759 // usernameExpr: "namespace + '-' + policyName"
760 // For namespace `billing`, policy `postgres-data`, must evaluate to
761 // username = billing-postgres-data, hostname = billing.
762 let d = defaults(Some("namespace"), Some("namespace + '-' + policyName"));
763 let r = resolve_identity(&inputs(
764 "postgres-data",
765 "billing",
766 None,
767 Some(&d),
768 Some("data"),
769 ))
770 .unwrap();
771 assert_eq!(r.username, "billing-postgres-data");
772 assert_eq!(r.hostname, "billing");
773 }
774
775 #[test]
776 fn cel_conditional_on_labels_resolves() {
777 // ADR-0004 §5 conditional: 'team' in labels ? labels['team'] : namespace.
778 let d = defaults(
779 Some("'team' in labels ? labels['team'] : namespace"),
780 Some("namespace + '-' + policyName"),
781 );
782 let labels = BTreeMap::from([("team".to_string(), "payments".to_string())]);
783 let mut i = inputs("atuin", "default", None, Some(&d), Some("data"));
784 i.labels = Some(&labels);
785 let r = resolve_identity(&i).unwrap();
786 assert_eq!(r.hostname, "payments"); // label present → label value
787 assert_eq!(r.username, "default-atuin");
788
789 // No `team` label → falls back to namespace.
790 let mut i2 = inputs("atuin", "default", None, Some(&d), Some("data"));
791 i2.labels = None;
792 assert_eq!(resolve_identity(&i2).unwrap().hostname, "default");
793 }
794
795 #[test]
796 fn override_beats_expr() {
797 let d = defaults(Some("namespace"), Some("namespace + '-' + policyName"));
798 let ovr = Identity {
799 username: Some("custom-user".to_string()),
800 hostname: Some("custom-host".to_string()),
801 };
802 let r = resolve_identity(&inputs("cfg", "ns", Some(&ovr), Some(&d), Some("p"))).unwrap();
803 assert_eq!(r.username, "custom-user");
804 assert_eq!(r.hostname, "custom-host");
805 }
806
807 #[test]
808 fn partial_override_falls_through_to_expr_for_the_other_field() {
809 let d = defaults(Some("namespace"), Some("namespace + '-' + policyName"));
810 // Only hostname overridden; username still comes from the expression.
811 let ovr = Identity {
812 username: None,
813 hostname: Some("pinned-host".to_string()),
814 };
815 let r = resolve_identity(&inputs(
816 "postgres-data",
817 "billing",
818 Some(&ovr),
819 Some(&d),
820 Some("d"),
821 ))
822 .unwrap();
823 assert_eq!(r.hostname, "pinned-host");
824 assert_eq!(r.username, "billing-postgres-data");
825 }
826
827 #[test]
828 /// The kernel records `source_path` VERBATIM — it never re-derives, never
829 /// prepends `/pvc/`, never substitutes a default. That is the property that
830 /// lets `expand::identity_source_path` be the single derivation (#451): if the
831 /// kernel massaged the value, a stream path could still come out wrong here.
832 fn source_path_is_recorded_verbatim() {
833 for path in [
834 "/data",
835 "/pvc/vol",
836 "/stream/postgres.sql",
837 "/mnt/eros/Media",
838 ] {
839 let mut i = inputs("cfg", "ns", None, None, None);
840 i.source_path = Some(path);
841 let r = resolve_identity(&i).unwrap();
842 assert_eq!(r.source_path.as_deref(), Some(path));
843 }
844 }
845
846 #[test]
847 fn no_pvc_yields_no_source_path() {
848 let r = resolve_identity(&inputs("cfg", "ns", None, None, None)).unwrap();
849 assert_eq!(r.source_path, None);
850 }
851
852 #[test]
853 fn identity_string_with_and_without_path() {
854 let with = ResolvedIdentity {
855 username: "postgres-data".into(),
856 hostname: "billing".into(),
857 source_path: Some("/data".into()),
858 };
859 assert_eq!(identity_string(&with), "postgres-data@billing:/data");
860 let without = ResolvedIdentity {
861 source_path: None,
862 ..with
863 };
864 assert_eq!(identity_string(&without), "postgres-data@billing");
865 }
866
867 #[test]
868 fn resolve_rejects_override_with_kopia_delimiter() {
869 // An explicit override carrying kopia's '@' delimiter would misparse — rejected
870 // at resolution so the controller never pins it.
871 let ovr = Identity {
872 username: Some("bad@user".to_string()),
873 hostname: None,
874 };
875 let err = resolve_identity(&inputs("c", "n", Some(&ovr), None, Some("p"))).unwrap_err();
876 assert!(matches!(
877 err,
878 ValidationError::IdentityComponentInvalid { .. }
879 ));
880 }
881
882 #[test]
883 fn resolve_rejects_cel_result_with_delimiter() {
884 // A CEL expression that resolves to a string with ':' is caught at resolution
885 // (the static override validator can't see CEL results).
886 let d = defaults(None, Some("'a:b'"));
887 let err = resolve_identity(&inputs("c", "n", None, Some(&d), Some("p"))).unwrap_err();
888 assert!(matches!(
889 err,
890 ValidationError::IdentityComponentInvalid { .. }
891 ));
892 }
893
894 #[test]
895 fn malformed_expr_is_rejected_at_resolve() {
896 let d = defaults(Some("namespace +"), None); // syntax error
897 let err = resolve_identity(&inputs("c", "n", None, Some(&d), Some("p"))).unwrap_err();
898 assert!(matches!(err, ValidationError::IdentityExprCompile { .. }));
899 }
900
901 // --- validate_identity_expr (admission-time check, ADR-0004 §5) ---
902
903 #[test]
904 fn validate_accepts_valid_string_exprs() {
905 assert!(validate_identity_expr("namespace").is_ok());
906 assert!(validate_identity_expr("namespace + '-' + policyName").is_ok());
907 assert!(validate_identity_expr("'team' in labels ? labels['team'] : namespace").is_ok());
908 // Data-dependent map index is tolerated (the real object may supply the key).
909 assert!(
910 validate_identity_expr("namespace + (labels['env'] == 'prod' ? '-prod' : '')").is_ok()
911 );
912 }
913
914 #[test]
915 fn validate_rejects_syntax_error() {
916 let err = validate_identity_expr("namespace +").unwrap_err();
917 assert!(matches!(err, ValidationError::IdentityExprCompile { .. }));
918 }
919
920 #[test]
921 fn validate_rejects_out_of_scope_variable() {
922 // `namspace` is a typo — an undeclared reference, caught at trial-eval.
923 let err = validate_identity_expr("namspace").unwrap_err();
924 assert!(matches!(err, ValidationError::IdentityExprEval { .. }));
925 }
926
927 #[test]
928 fn validate_rejects_non_string_result() {
929 // A bool/int result is not a valid hostname/username.
930 let err = validate_identity_expr("1 + 1").unwrap_err();
931 assert!(matches!(err, ValidationError::IdentityExprType { .. }));
932 }
933
934 #[test]
935 fn validate_rejects_over_length_expr() {
936 let long = format!("'{}'", "a".repeat(MAX_EXPR_LEN));
937 let err = validate_identity_expr(&long).unwrap_err();
938 assert!(matches!(err, ValidationError::IdentityExprCompile { .. }));
939 }
940
941 // --- classify_hostname (M1) ---
942
943 #[test]
944 fn classify_hostname_table() {
945 let cases: &[(&str, Option<&str>, HostClass<'_>)] = &[
946 // No cluster identity configured at all: always Bare, dot or no dot.
947 (
948 "billing",
949 None,
950 HostClass::Bare {
951 namespace: "billing",
952 },
953 ),
954 (
955 "billing.east",
956 None,
957 HostClass::Bare {
958 namespace: "billing.east",
959 },
960 ),
961 // Empty cluster string behaves like None (defensive; shouldn't occur post
962 // admission-validation, but classify_hostname is total).
963 (
964 "billing",
965 Some(""),
966 HostClass::Bare {
967 namespace: "billing",
968 },
969 ),
970 // No dot in the hostname at all.
971 (
972 "billing",
973 Some("east"),
974 HostClass::Bare {
975 namespace: "billing",
976 },
977 ),
978 // Exact suffix match => ours.
979 (
980 "ns.east",
981 Some("east"),
982 HostClass::OwnCluster { namespace: "ns" },
983 ),
984 // Different suffix => another cluster's.
985 (
986 "ns.west",
987 Some("east"),
988 HostClass::ForeignCluster { suffix: "west" },
989 ),
990 // Only an EXACT suffix match is "own" — "east.x" != "east".
991 (
992 "ns.east.x",
993 Some("east"),
994 HostClass::ForeignCluster { suffix: "east.x" },
995 ),
996 // Empty namespace part or empty suffix: Bare, whole hostname verbatim.
997 (
998 ".east",
999 Some("east"),
1000 HostClass::Bare { namespace: ".east" },
1001 ),
1002 ("ns.", Some("east"), HostClass::Bare { namespace: "ns." }),
1003 // Case-sensitive suffix comparison.
1004 (
1005 "ns.East",
1006 Some("east"),
1007 HostClass::ForeignCluster { suffix: "East" },
1008 ),
1009 // Hostname exactly equal to the cluster name, no dot => Bare, never
1010 // OwnCluster (there is no namespace/cluster split to make).
1011 ("east", Some("east"), HostClass::Bare { namespace: "east" }),
1012 ];
1013 for (hostname, cluster, expected) in cases {
1014 assert_eq!(
1015 classify_hostname(hostname, *cluster),
1016 *expected,
1017 "classify_hostname({hostname:?}, {cluster:?})"
1018 );
1019 }
1020 }
1021
1022 #[test]
1023 fn classify_hostname_round_trips_the_rendered_default() {
1024 // For any valid namespace + cluster, classifying the hostname
1025 // resolve_identity's own default would render must come back OwnCluster
1026 // with the original namespace — the write path and the read path agree.
1027 for (ns, cluster) in [
1028 ("billing", "east"),
1029 ("a", "b"),
1030 ("kube-system", "prod-1"),
1031 ("default", "us-west-2"),
1032 ] {
1033 let rendered = format!("{ns}.{cluster}");
1034 assert_eq!(
1035 classify_hostname(&rendered, Some(cluster)),
1036 HostClass::OwnCluster { namespace: ns },
1037 "rendered={rendered:?} cluster={cluster:?}"
1038 );
1039 }
1040 }
1041
1042 /// `namespace` is a CEL reserved word (enforced from `cel` 0.14.5); only its
1043 /// bare variable references may be aliased — never text the user quoted, a
1044 /// comment, or a field selector, all of which CEL already accepts as written.
1045 #[test]
1046 fn alias_rewrites_only_the_namespace_variable() {
1047 let cases = [
1048 ("namespace", "__nsvar__"),
1049 (
1050 "namespace + '-' + policyName",
1051 "__nsvar__ + '-' + policyName",
1052 ),
1053 ("namespace+namespace", "__nsvar__+__nsvar__"),
1054 // Not a variable: string/bytes literals in every quoting form.
1055 ("'namespace'", "'namespace'"),
1056 (r#""namespace""#, r#""namespace""#),
1057 ("'''a namespace'''", "'''a namespace'''"),
1058 (r#""""namespace""""#, r#""""namespace""""#),
1059 (r"r'namespace\'", r"r'namespace\'"),
1060 ("b'namespace'", "b'namespace'"),
1061 ("rb'namespace'", "rb'namespace'"),
1062 // An escaped quote does not end a literal early.
1063 (
1064 r"'it\'s namespace' + namespace",
1065 r"'it\'s namespace' + __nsvar__",
1066 ),
1067 // Field selectors may be reserved words, and stay so.
1068 ("labels.namespace", "labels.namespace"),
1069 ("labels . namespace", "labels . namespace"),
1070 // A comment.
1071 (
1072 "namespace // the namespace\n",
1073 "__nsvar__ // the namespace\n",
1074 ),
1075 // Longer identifiers that merely contain it.
1076 ("namespaces + my_namespace", "namespaces + my_namespace"),
1077 // Number suffixes are not identifiers.
1078 ("1e3 + 0xFF", "1e3 + 0xFF"),
1079 ("'ü' + namespace", "'ü' + __nsvar__"),
1080 ];
1081 for (input, want) in cases {
1082 assert_eq!(alias_namespace_ident(input), want, "input: {input:?}");
1083 }
1084 }
1085
1086 #[test]
1087 fn namespace_variable_resolves_and_quoted_namespace_stays_text() {
1088 let labels = BTreeMap::from([("namespace".to_string(), "from-label".to_string())]);
1089 let d = defaults(
1090 Some("'namespace=' + namespace"),
1091 Some("labels.namespace + '/' + labels['namespace']"),
1092 );
1093 let mut i = inputs("p", "billing", None, Some(&d), None);
1094 i.labels = Some(&labels);
1095 let r = resolve_identity(&i).unwrap();
1096 assert_eq!(r.hostname, "namespace=billing");
1097 assert_eq!(r.username, "from-label/from-label");
1098 }
1099
1100 #[test]
1101 fn compile_errors_quote_the_expression_the_user_wrote() {
1102 let err = validate_identity_expr("namespace +")
1103 .unwrap_err()
1104 .to_string();
1105 assert!(!err.contains(NAMESPACE_ALIAS), "alias leaked: {err}");
1106 assert!(err.contains("namespace +"), "{err}");
1107 }
1108
1109 /// The error must be exactly what `cel` reports for an expression of the
1110 /// same shape with no reserved word in it — position and caret included.
1111 #[test]
1112 fn compile_error_positions_match_the_users_text() {
1113 let ours = validate_identity_expr("policyName + namespace + )")
1114 .unwrap_err()
1115 .to_string();
1116 let same_shape = Program::compile("policyName + xxxxxxxxx + )")
1117 .unwrap_err()
1118 .to_string()
1119 .replace("xxxxxxxxx", "namespace");
1120 assert!(
1121 ours.contains(&same_shape),
1122 "ours: {ours}\nwant: {same_shape}"
1123 );
1124 }
1125
1126 /// A literal the user wrote is never rewritten in an error, even when it
1127 /// spells the alias.
1128 #[test]
1129 fn compile_errors_keep_a_literal_that_spells_the_alias() {
1130 let err = validate_identity_expr("'__nsvar__' + namespace +")
1131 .unwrap_err()
1132 .to_string();
1133 assert!(err.contains("'__nsvar__' + namespace +"), "{err}");
1134 }
1135
1136 /// Each quoted line is restored by its own line number, so restoring one
1137 /// can never rewrite text in another — here, line 1's literal that spells
1138 /// the alias, while line 2 is nothing but the alias.
1139 #[test]
1140 fn multiline_errors_restore_each_line_independently() {
1141 let err = validate_identity_expr("'__nsvar__' + namespace +\nnamespace + )")
1142 .unwrap_err()
1143 .to_string();
1144 assert!(err.contains("<input>:2:13"), "{err}");
1145 assert!(err.contains("| namespace + )\n"), "{err}");
1146
1147 // The case a plain search gets wrong: line 2 is the alias and nothing
1148 // else, which a search would also find inside line 1's literal.
1149 let expr = "'__nsvar__' + namespace +\nnamespace";
1150 let restored = restore_source(
1151 "ERROR: <input>:1:1: x\n| '__nsvar__' + __nsvar__ +\n| ^\n\
1152 ERROR: <input>:2:1: y\n| __nsvar__\n| ^",
1153 expr,
1154 &alias_namespace_ident(expr),
1155 );
1156 assert_eq!(
1157 restored,
1158 "ERROR: <input>:1:1: x\n| '__nsvar__' + namespace +\n| ^\n\
1159 ERROR: <input>:2:1: y\n| namespace\n| ^"
1160 );
1161 }
1162
1163 /// A message that is not cel's quoted-source shape passes through as is.
1164 #[test]
1165 fn restore_source_leaves_other_messages_alone() {
1166 let msg = "No such key: __nsvar__";
1167 assert_eq!(restore_source(msg, "namespace", "__nsvar__"), msg);
1168 }
1169
1170 #[test]
1171 fn other_reserved_words_are_still_rejected() {
1172 assert!(validate_identity_expr("package").is_err());
1173 }
1174}