Skip to main content

kovra_core/
coordinate.rs

1//! The secret coordinate URI: a three-segment address (spec §1.2, §4.2).
2//!
3//! Grammar (this layer parses only the `secret:` coordinate, **not** the
4//! `.env.refs` grammar — that is L4):
5//!
6//! ```text
7//! secret:<env>/<component>/<key>
8//! secret://global/<env>/<component>/<key>   # scope selector: ignore project override
9//! secret:<env>/<component>/<key>#public     # keypair half selector (KOV-12)
10//! secret:<env>/<component>/<key>#private     #   "
11//! ```
12//!
13//! - Always exactly three path segments; no short form (removes env-vs-component
14//!   ambiguity).
15//! - The only interpolation allowed is `${ENV}` in the **environment** segment,
16//!   substituted with the `--env` value at run time (L4). `${COMPONENT}` and any
17//!   other `${...}` are rejected here, never silently passed through.
18//! - An optional trailing `#public` / `#private` **fragment** (KOV-12) selects
19//!   which half of a keypair to act on. It is meaningful only for the `Keypair`
20//!   modality (injecting/using a key); a literal/reference ignores it. The
21//!   fragment is part of the *resolution* request, not the stored address, so it
22//!   does not change the storage id (a coordinate and its `#half` forms file
23//!   under the same record).
24
25use core::fmt;
26use core::str::FromStr;
27
28use crate::error::CoreError;
29
30/// Scope selector: whether the project vault may override the global vault.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum Scope {
33    /// Project vault overrides global at the exact coordinate (spec §1.1).
34    Default,
35    /// `secret://global/...` — resolve only against the global vault.
36    Global,
37}
38
39/// Which half of a keypair a coordinate refers to (KOV-12). Only meaningful for
40/// the `Keypair` modality; for a literal/reference it is always [`KeyHalf::Unspecified`].
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
42pub enum KeyHalf {
43    /// No `#public`/`#private` fragment given. For a keypair, resolution
44    /// defaults to the **public** half (the safe, non-secret default); a face
45    /// that needs the private half must ask for it explicitly.
46    #[default]
47    Unspecified,
48    /// `#public` — the public key (free, non-secret; a `Metadata`-class op).
49    Public,
50    /// `#private` — the private key (a private-key op: routed as `Inject`,
51    /// broker-gated for high/prod, never returned to the caller's context).
52    Private,
53}
54
55/// The environment segment: a fixed literal or the `${ENV}` placeholder.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum EnvSegment {
58    /// A fixed environment, e.g. `prod`.
59    Literal(String),
60    /// `${ENV}` — resolved from `--env` at run time (L4).
61    Placeholder,
62}
63
64impl fmt::Display for EnvSegment {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        match self {
67            EnvSegment::Literal(s) => f.write_str(s),
68            EnvSegment::Placeholder => f.write_str("${ENV}"),
69        }
70    }
71}
72
73/// A parsed, canonical secret coordinate. Carries only the address — never a
74/// value (I6: the command line carries the coordinate, the value enters
75/// separately as a [`crate::SecretValue`]).
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct Coordinate {
78    /// Override scope.
79    pub scope: Scope,
80    /// Environment segment (literal or `${ENV}`).
81    pub environment: EnvSegment,
82    /// Component segment.
83    pub component: String,
84    /// Key segment.
85    pub key: String,
86    /// Keypair half selector (`#public`/`#private`). [`KeyHalf::Unspecified`]
87    /// for a plain coordinate; meaningful only for the `Keypair` modality.
88    pub half: KeyHalf,
89}
90
91impl Coordinate {
92    /// The canonical storage path `<env>/<component>/<key>` — the address a
93    /// secret is filed under on disk. The scope selector is a *resolution*
94    /// concern (which vault to read), never stored, so it is excluded here:
95    /// `secret:prod/db/pw` and `secret://global/prod/db/pw` map to the same
96    /// path in whichever vault is chosen.
97    ///
98    /// Fails for an unresolved `${ENV}` placeholder — placeholders are
99    /// substituted at resolution time (L4); only concrete coordinates are
100    /// storable.
101    pub fn canonical_path(&self) -> Result<String, CoreError> {
102        match &self.environment {
103            EnvSegment::Literal(env) => Ok(format!("{}/{}/{}", env, self.component, self.key)),
104            EnvSegment::Placeholder => Err(CoreError::NotStorable(
105                "coordinate has an unresolved `${ENV}` placeholder".to_string(),
106            )),
107        }
108    }
109
110    /// Substitute the `${ENV}` placeholder with a concrete environment, for
111    /// resolution at launch (spec §4.2/§4.3). A coordinate whose environment is
112    /// already literal is returned unchanged; only the `Placeholder` is replaced.
113    pub fn with_env(&self, env: &str) -> Coordinate {
114        match &self.environment {
115            EnvSegment::Placeholder => Coordinate {
116                scope: self.scope,
117                environment: EnvSegment::Literal(env.to_string()),
118                component: self.component.clone(),
119                key: self.key.clone(),
120                half: self.half,
121            },
122            EnvSegment::Literal(_) => self.clone(),
123        }
124    }
125
126    /// The opaque on-disk record id: lowercase-hex `BLAKE3(canonical_path)`
127    /// (ADR-0001 §A.1). Hashing the coordinate keeps the address off disk as a
128    /// filename while giving an O(1) point lookup. Inherits the placeholder
129    /// rejection from [`Coordinate::canonical_path`].
130    pub fn storage_id(&self) -> Result<String, CoreError> {
131        Ok(blake3::hash(self.canonical_path()?.as_bytes())
132            .to_hex()
133            .to_string())
134    }
135}
136
137impl FromStr for Coordinate {
138    type Err = CoreError;
139
140    fn from_str(s: &str) -> Result<Self, Self::Err> {
141        let invalid = |msg: &str| CoreError::InvalidCoordinate(msg.to_string());
142
143        let rest = s
144            .strip_prefix("secret:")
145            .ok_or_else(|| invalid("must start with `secret:`"))?;
146
147        // Split off an optional `#public`/`#private` keypair half selector
148        // (KOV-12) before parsing the path. Only those two fragments are valid.
149        let (rest, half) = match rest.split_once('#') {
150            Some((before, "public")) => (before, KeyHalf::Public),
151            Some((before, "private")) => (before, KeyHalf::Private),
152            Some((_, other)) => {
153                return Err(invalid(&format!(
154                    "unknown coordinate fragment `#{other}` (only `#public`/`#private` are valid)"
155                )));
156            }
157            None => (rest, KeyHalf::Unspecified),
158        };
159
160        let (scope, path) = match rest.strip_prefix("//") {
161            Some(authority_and_path) => {
162                let (authority, path) = authority_and_path
163                    .split_once('/')
164                    .ok_or_else(|| invalid("scope form requires `//<authority>/<path>`"))?;
165                if authority != "global" {
166                    return Err(invalid("only `//global/` scope selector is supported"));
167                }
168                (Scope::Global, path)
169            }
170            None => (Scope::Default, rest),
171        };
172
173        let segments: Vec<&str> = path.split('/').collect();
174        if segments.len() != 3 {
175            return Err(invalid("coordinate must have exactly three segments"));
176        }
177        if segments.iter().any(|seg| seg.is_empty()) {
178            return Err(invalid("segments must be non-empty"));
179        }
180
181        let environment = parse_env_segment(segments[0])?;
182        let component = parse_plain_segment(segments[1], "component")?;
183        let key = parse_plain_segment(segments[2], "key")?;
184
185        Ok(Coordinate {
186            scope,
187            environment,
188            component,
189            key,
190            half,
191        })
192    }
193}
194
195/// The environment segment is either exactly `${ENV}` or a literal with no
196/// interpolation. Any other `${...}` is rejected.
197fn parse_env_segment(seg: &str) -> Result<EnvSegment, CoreError> {
198    if seg == "${ENV}" {
199        return Ok(EnvSegment::Placeholder);
200    }
201    if seg.contains("${") {
202        return Err(CoreError::InvalidCoordinate(
203            "only `${ENV}` interpolation is allowed in the environment segment".to_string(),
204        ));
205    }
206    Ok(EnvSegment::Literal(seg.to_string()))
207}
208
209/// Component and key segments admit no interpolation at all (only `${ENV}`
210/// interpolates, and only in the environment segment).
211fn parse_plain_segment(seg: &str, what: &str) -> Result<String, CoreError> {
212    if seg.contains("${") {
213        return Err(CoreError::InvalidCoordinate(format!(
214            "interpolation is not allowed in the {what} segment"
215        )));
216    }
217    Ok(seg.to_string())
218}
219
220impl fmt::Display for Coordinate {
221    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
222        match self.scope {
223            Scope::Default => write!(
224                f,
225                "secret:{}/{}/{}",
226                self.environment, self.component, self.key
227            )?,
228            Scope::Global => write!(
229                f,
230                "secret://global/{}/{}/{}",
231                self.environment, self.component, self.key
232            )?,
233        }
234        match self.half {
235            KeyHalf::Unspecified => Ok(()),
236            KeyHalf::Public => f.write_str("#public"),
237            KeyHalf::Private => f.write_str("#private"),
238        }
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use proptest::prelude::*;
246
247    #[test]
248    fn parses_literal_three_segments() {
249        let c: Coordinate = "secret:prod/db/password".parse().unwrap();
250        assert_eq!(c.scope, Scope::Default);
251        assert_eq!(c.environment, EnvSegment::Literal("prod".to_string()));
252        assert_eq!(c.component, "db");
253        assert_eq!(c.key, "password");
254    }
255
256    #[test]
257    fn parses_env_placeholder() {
258        let c: Coordinate = "secret:${ENV}/db/password".parse().unwrap();
259        assert_eq!(c.environment, EnvSegment::Placeholder);
260    }
261
262    #[test]
263    fn parses_global_scope_selector() {
264        let c: Coordinate = "secret://global/prod/db/password".parse().unwrap();
265        assert_eq!(c.scope, Scope::Global);
266        assert_eq!(c.environment, EnvSegment::Literal("prod".to_string()));
267        assert_eq!(c.key, "password");
268    }
269
270    #[test]
271    fn rejects_two_segments() {
272        assert!("secret:prod/db".parse::<Coordinate>().is_err());
273    }
274
275    #[test]
276    fn rejects_four_segments() {
277        assert!(
278            "secret:prod/db/password/extra"
279                .parse::<Coordinate>()
280                .is_err()
281        );
282    }
283
284    #[test]
285    fn rejects_missing_scheme() {
286        assert!("prod/db/password".parse::<Coordinate>().is_err());
287    }
288
289    #[test]
290    fn rejects_empty_segment() {
291        assert!("secret:prod//password".parse::<Coordinate>().is_err());
292    }
293
294    #[test]
295    fn rejects_non_env_interpolation() {
296        assert!("secret:${FOO}/db/password".parse::<Coordinate>().is_err());
297        assert!(
298            "secret:prod/${COMPONENT}/password"
299                .parse::<Coordinate>()
300                .is_err()
301        );
302        assert!("secret:prod/db/${KEY}".parse::<Coordinate>().is_err());
303    }
304
305    #[test]
306    fn storage_id_ignores_scope() {
307        // Default and global scope of the same address file under the same id;
308        // scope only chooses which vault to read.
309        let default: Coordinate = "secret:prod/db/password".parse().unwrap();
310        let global: Coordinate = "secret://global/prod/db/password".parse().unwrap();
311        assert_eq!(default.canonical_path().unwrap(), "prod/db/password");
312        assert_eq!(default.storage_id().unwrap(), global.storage_id().unwrap());
313    }
314
315    #[test]
316    fn storage_id_is_blake3_hex_of_path() {
317        let c: Coordinate = "secret:prod/db/password".parse().unwrap();
318        let expected = blake3::hash(b"prod/db/password").to_hex().to_string();
319        assert_eq!(c.storage_id().unwrap(), expected);
320    }
321
322    #[test]
323    fn with_env_substitutes_placeholder_only() {
324        let ph: Coordinate = "secret:${ENV}/db/password".parse().unwrap();
325        let resolved = ph.with_env("prod");
326        assert_eq!(
327            resolved.environment,
328            EnvSegment::Literal("prod".to_string())
329        );
330        assert_eq!(resolved.canonical_path().unwrap(), "prod/db/password");
331
332        // A literal env is unchanged by with_env.
333        let lit: Coordinate = "secret:dev/db/password".parse().unwrap();
334        assert_eq!(lit.with_env("prod"), lit);
335    }
336
337    #[test]
338    fn placeholder_is_not_storable() {
339        let c: Coordinate = "secret:${ENV}/db/password".parse().unwrap();
340        assert!(matches!(c.canonical_path(), Err(CoreError::NotStorable(_))));
341        assert!(matches!(c.storage_id(), Err(CoreError::NotStorable(_))));
342    }
343
344    #[test]
345    fn parses_keypair_half_selector() {
346        let pubc: Coordinate = "secret:dev/ssh/deploy#public".parse().unwrap();
347        assert_eq!(pubc.half, KeyHalf::Public);
348        assert_eq!(pubc.key, "deploy");
349        let privc: Coordinate = "secret:dev/ssh/deploy#private".parse().unwrap();
350        assert_eq!(privc.half, KeyHalf::Private);
351        // a plain coordinate has no half selector
352        let plain: Coordinate = "secret:dev/ssh/deploy".parse().unwrap();
353        assert_eq!(plain.half, KeyHalf::Unspecified);
354    }
355
356    #[test]
357    fn half_selector_does_not_change_storage_id() {
358        // A coordinate and its #public/#private forms file under the same record:
359        // the half is a resolution concern, not part of the stored address.
360        let plain: Coordinate = "secret:dev/ssh/deploy".parse().unwrap();
361        let pubc: Coordinate = "secret:dev/ssh/deploy#public".parse().unwrap();
362        let privc: Coordinate = "secret:dev/ssh/deploy#private".parse().unwrap();
363        assert_eq!(plain.storage_id().unwrap(), pubc.storage_id().unwrap());
364        assert_eq!(plain.storage_id().unwrap(), privc.storage_id().unwrap());
365    }
366
367    #[test]
368    fn half_selector_round_trips_through_display() {
369        for uri in [
370            "secret:dev/ssh/deploy#public",
371            "secret:dev/ssh/deploy#private",
372            "secret://global/dev/ssh/deploy#private",
373        ] {
374            let c: Coordinate = uri.parse().unwrap();
375            assert_eq!(c.to_string(), uri);
376            assert_eq!(c.to_string().parse::<Coordinate>().unwrap(), c);
377        }
378    }
379
380    #[test]
381    fn rejects_unknown_fragment() {
382        assert!("secret:dev/ssh/deploy#frag".parse::<Coordinate>().is_err());
383        assert!("secret:dev/a/b#".parse::<Coordinate>().is_err());
384    }
385
386    #[test]
387    fn rejects_unknown_scope_authority() {
388        assert!(
389            "secret://local/prod/db/password"
390                .parse::<Coordinate>()
391                .is_err()
392        );
393    }
394
395    #[test]
396    fn display_round_trips() {
397        for uri in [
398            "secret:prod/db/password",
399            "secret:${ENV}/db/password",
400            "secret://global/prod/db/password",
401        ] {
402            let c: Coordinate = uri.parse().unwrap();
403            assert_eq!(c.to_string(), uri);
404            // and re-parsing the rendered form yields the same coordinate
405            assert_eq!(c.to_string().parse::<Coordinate>().unwrap(), c);
406        }
407    }
408
409    proptest! {
410        // A parse never panics, and any accepted coordinate round-trips through
411        // Display -> parse. Malformed input must error, never silently resolve.
412        #[test]
413        fn parse_never_panics_and_round_trips(s in ".*") {
414            if let Ok(c) = s.parse::<Coordinate>() {
415                prop_assert_eq!(c.to_string().parse::<Coordinate>().unwrap(), c);
416            }
417        }
418
419        // Well-formed literal coordinates (no slashes / `${` in segments) always parse.
420        #[test]
421        fn well_formed_literals_parse(
422            env in "[a-z][a-z0-9_-]{0,12}",
423            comp in "[a-z][a-z0-9_-]{0,12}",
424            key in "[a-z][a-z0-9_-]{0,12}",
425        ) {
426            let uri = format!("secret:{env}/{comp}/{key}");
427            let c = uri.parse::<Coordinate>().unwrap();
428            prop_assert_eq!(c.environment, EnvSegment::Literal(env));
429            prop_assert_eq!(c.component, comp);
430            prop_assert_eq!(c.key, key);
431        }
432    }
433
434    // ---- KOV-28 hardening: structured near-miss fuzzing ----
435    //
436    // `parse_never_panics_and_round_trips` above uses `.*`, which rarely lands on
437    // the `secret:`/`//`/`#`/`${ENV}` boundaries where parser bugs hide. These
438    // generators concatenate "interesting" tokens so coverage concentrates right
439    // on those boundaries (scheme, scope authority, keypair fragment, segment
440    // separators, the interpolation sigil, and embedded control/unicode bytes).
441
442    /// Tokens that have historically tripped URI-like grammars.
443    fn near_miss_token() -> impl Strategy<Value = String> {
444        prop_oneof![
445            Just("secret:".to_string()),
446            Just("//".to_string()),
447            Just("global".to_string()),
448            Just("/".to_string()),
449            Just("#".to_string()),
450            Just("#public".to_string()),
451            Just("#private".to_string()),
452            Just("${ENV}".to_string()),
453            Just("${FOO}".to_string()),
454            Just("${".to_string()),
455            Just("\0".to_string()),
456            Just("\n".to_string()),
457            Just("é".to_string()),
458            "[a-z0-9_-]{0,6}",
459        ]
460    }
461
462    proptest! {
463        // Assembled near-miss inputs never panic the parser; any accepted
464        // coordinate still round-trips through Display -> parse. Malformed input
465        // errors, never silently resolves to a (wrong) coordinate.
466        #[test]
467        fn near_miss_never_panics_and_round_trips(
468            toks in proptest::collection::vec(near_miss_token(), 0..8)
469        ) {
470            let s = toks.concat();
471            if let Ok(c) = s.parse::<Coordinate>() {
472                prop_assert_eq!(c.to_string().parse::<Coordinate>().unwrap(), c);
473            }
474        }
475
476        // `${...}` interpolation is legal ONLY as exactly `${ENV}` in the
477        // environment segment. The same sigil in the component or key segment —
478        // even `${ENV}` itself — is always rejected, never passed through to
479        // storage (the §4.2 cross-interpolation footgun).
480        #[test]
481        fn interpolation_outside_env_segment_is_rejected(
482            env in "[a-z][a-z0-9_-]{0,8}",
483            comp in "[a-z][a-z0-9_-]{0,8}",
484            key in "[a-z][a-z0-9_-]{0,8}",
485            inject in prop_oneof![Just("${X}"), Just("${ENV}"), Just("${COMPONENT}")],
486        ) {
487            // poison the component segment
488            let poisoned_comp = format!("secret:{env}/{comp}{inject}/{key}");
489            prop_assert!(poisoned_comp.parse::<Coordinate>().is_err());
490            // poison the key segment
491            let poisoned_key = format!("secret:{env}/{comp}/{key}{inject}");
492            prop_assert!(poisoned_key.parse::<Coordinate>().is_err());
493        }
494
495        // A `${ENV}` placeholder coordinate is never storable: both
496        // `canonical_path` and `storage_id` error, so an on-disk id can never be
497        // derived from an unresolved placeholder (it must be resolved first, L4).
498        #[test]
499        fn placeholder_never_yields_storage_id(
500            comp in "[a-z][a-z0-9_-]{0,8}",
501            key in "[a-z][a-z0-9_-]{0,8}",
502        ) {
503            let c: Coordinate = format!("secret:${{ENV}}/{comp}/{key}").parse().unwrap();
504            prop_assert!(matches!(c.canonical_path(), Err(CoreError::NotStorable(_))));
505            prop_assert!(matches!(c.storage_id(), Err(CoreError::NotStorable(_))));
506        }
507    }
508}