Skip to main content

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(&quoted)
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}