Skip to main content

devboy_storage/
router_resolve.rs

1//! Path resolution algorithm per [ADR-021] §2.
2//!
3//! Given a [`RouterConfig`] and a [`SecretPath`], decide *which*
4//! source serves the path and what context the source needs to
5//! produce a reference. The decision is pure data — no source
6//! plugin is invoked here. Turning the decision into an actual
7//! `(source, reference)` pair is the next layer's job (P5.4 wires
8//! the cache + plugin lookup; P6 ships the concrete sources).
9//!
10//! Splitting the resolver from the source-call layer lets tests
11//! exercise the routing rules with TOML fixtures alone, without
12//! standing up real backends. `doctor` (P7.2 / P10.1) reuses the
13//! same pure function to pre-flight a project's manifest against
14//! the active config.
15//!
16//! ## Algorithm
17//!
18//! Per ADR-021 §2:
19//!
20//! 1. **Per-secret override.** If `[secret."<path>"]` matches the
21//!    queried path, return [`RouteDecision::Explicit`] with the
22//!    user-supplied source + reference. Wins over everything else.
23//! 2. **Longest prefix.** Walk `[[route]]` blocks; pick the one
24//!    whose `prefix` is the longest string that the queried path
25//!    starts with. Return [`RouteDecision::Prefix`] with the
26//!    route's source name + verbatim settings. Source-specific
27//!    path-tail-to-reference mapping (Vault joins mount + tail,
28//!    1Password builds an `op://` URL, …) is delegated to the
29//!    plugin.
30//! 3. **Default route.** No `[secret]` and no `[[route]]` matched.
31//!    Return [`RouteDecision::Default`] carrying the default
32//!    source + the optional fallback hint (used by the caller's
33//!    `is_available() == NotInstalled` retry — see ADR-021 §2
34//!    step 3).
35//! 4. **No fall-through.** No matching `[secret]`, no matching
36//!    `[[route]]`, and no `[default]` either: fail with
37//!    [`ResolveError::NoRoute`].
38//!
39//! Prefix `[[route]]` already enforces a trailing `/` at config
40//! load (`router_config::RouterConfigError::BadRoutePrefix`), so
41//! the resolver does not have to worry about partial-segment
42//! matches like `team/` vs `teamfoo/x`.
43//!
44//! Ties between routes of equal length resolve by declaration
45//! order — earlier `[[route]]` wins. That's deterministic and
46//! matches what the user sees when they read the file top to
47//! bottom.
48//!
49//! [ADR-021]: https://github.com/meteora-pro/devboy-tools/blob/main/docs/architecture/adr/ADR-021-external-secret-sources.md
50//! [`RouterConfig`]: crate::router_config::RouterConfig
51
52use std::collections::BTreeMap;
53
54use thiserror::Error;
55
56use crate::router_config::RouterConfig;
57use crate::secret_path::SecretPath;
58
59// =============================================================================
60// Public types
61// =============================================================================
62
63/// Outcome of resolving a path against a [`RouterConfig`].
64///
65/// All variants borrow from the input config (lifetime `'a`); the
66/// queried path is owned by the caller and is not copied into the
67/// decision. The caller computes the path-tail (`path.strip_prefix
68/// (decision.prefix())`) only when it needs to call into a source
69/// plugin.
70#[derive(Debug, Clone, PartialEq)]
71pub enum RouteDecision<'a> {
72    /// `[secret."<path>"]` matched — both the source and the
73    /// backend-specific reference are user-supplied.
74    Explicit {
75        /// Source name to dispatch to.
76        source: &'a str,
77        /// Backend-specific reference handed to the source's `get`
78        /// (e.g. `op://Work/Acme Jira/credential`).
79        reference: &'a str,
80    },
81    /// One of the `[[route]]` prefixes matched. The source plugin
82    /// is responsible for joining `settings` with the path tail to
83    /// produce its actual reference.
84    Prefix {
85        /// Source name to dispatch to.
86        source: &'a str,
87        /// The matched prefix verbatim (always ends with `/`).
88        prefix: &'a str,
89        /// Source-specific extras from the `[[route]]` block
90        /// (`mount`, `vault`, …). Pass-through to the source.
91        settings: &'a BTreeMap<String, toml::Value>,
92    },
93    /// No `[secret]` and no `[[route]]` matched — falls back to
94    /// `[default].source`. The optional `fallback` hint lets the
95    /// caller retry against `[default].fallback` when the primary
96    /// reports `is_available() == NotInstalled` (per ADR-021 §2
97    /// step 3).
98    Default {
99        /// `[default].source`.
100        source: &'a str,
101        /// `[default].fallback`, if configured.
102        fallback: Option<&'a str>,
103    },
104}
105
106impl<'a> RouteDecision<'a> {
107    /// The source name selected for this path. Consumers that just
108    /// want to know "who serves it?" don't have to match on the
109    /// variant.
110    pub fn source(&self) -> &'a str {
111        match self {
112            RouteDecision::Explicit { source, .. }
113            | RouteDecision::Prefix { source, .. }
114            | RouteDecision::Default { source, .. } => source,
115        }
116    }
117}
118
119/// Failure modes for [`PathResolver::resolve`].
120#[derive(Debug, Clone, PartialEq, Eq, Error)]
121pub enum ResolveError {
122    /// No `[secret]` matched, no `[[route]]` matched, and the
123    /// config has no `[default]` section. The path is unroutable.
124    #[error(
125        "no route for path '{path}' — config has no matching [secret], no matching [[route]], and no [default]"
126    )]
127    NoRoute {
128        /// The path that failed to route.
129        path: String,
130    },
131}
132
133// =============================================================================
134// Resolver
135// =============================================================================
136
137/// Read-only view over a [`RouterConfig`] that answers
138/// "which source serves this path?" without invoking any source
139/// plugin.
140///
141/// Cheap to construct (just borrows the config), so call sites can
142/// re-create one per query if the config might have been reloaded
143/// in the meantime. The resolver itself caches nothing; the
144/// adaptive cache lands in P5.4.
145pub struct PathResolver<'a> {
146    config: &'a RouterConfig,
147}
148
149impl<'a> PathResolver<'a> {
150    /// Build a resolver borrowing from `config`.
151    pub fn new(config: &'a RouterConfig) -> Self {
152        Self { config }
153    }
154
155    /// Run the algorithm from the module docs against `path`.
156    pub fn resolve(&self, path: &SecretPath) -> Result<RouteDecision<'a>, ResolveError> {
157        // 1) Per-secret override — exact match wins outright.
158        if let Some(ovr) = self.config.secret_overrides.get(path) {
159            return Ok(RouteDecision::Explicit {
160                source: ovr.source.as_str(),
161                reference: ovr.reference.as_str(),
162            });
163        }
164
165        // 2) Longest prefix wins. Iterate in declaration order so
166        //    ties go to the earlier `[[route]]`.
167        let path_str = path.as_str();
168        let mut best: Option<&'a crate::router_config::RouteRule> = None;
169        for r in &self.config.routes {
170            if !path_str.starts_with(&r.prefix) {
171                continue;
172            }
173            match best {
174                None => best = Some(r),
175                Some(prev) if r.prefix.len() > prev.prefix.len() => best = Some(r),
176                Some(_) => {} // earlier-declared shorter or equal-length prefix already won
177            }
178        }
179        if let Some(r) = best {
180            return Ok(RouteDecision::Prefix {
181                source: r.source.as_str(),
182                prefix: r.prefix.as_str(),
183                settings: &r.settings,
184            });
185        }
186
187        // 3) Default route, with fallback hint.
188        if let Some(d) = &self.config.default {
189            return Ok(RouteDecision::Default {
190                source: d.source.as_str(),
191                fallback: d.fallback.as_deref(),
192            });
193        }
194
195        // 4) Nothing left. Fail with a structured error.
196        Err(ResolveError::NoRoute {
197            path: path_str.to_owned(),
198        })
199    }
200}
201
202// =============================================================================
203// Tests
204// =============================================================================
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::router_config::RouterConfig;
210
211    fn cfg(toml: &str) -> RouterConfig {
212        RouterConfig::parse(toml).expect("fixture config must parse")
213    }
214
215    fn p(s: &str) -> SecretPath {
216        SecretPath::parse(s).unwrap()
217    }
218
219    // -- 1) Per-secret override --------------------------------------
220
221    #[test]
222    fn explicit_secret_override_wins_over_prefix_and_default() {
223        let c = cfg(r#"
224            [[source]]
225            name = "keychain"
226            type = "keychain"
227
228            [[source]]
229            name = "vault-team"
230            type = "vault"
231
232            [[source]]
233            name = "1p-personal"
234            type = "1password"
235
236            [default]
237            source = "keychain"
238
239            [[route]]
240            prefix = "team/"
241            source = "vault-team"
242
243            [secret."team/gitlab/token-deploy"]
244            source = "1p-personal"
245            reference = "op://Work/Gitlab/credential"
246            "#);
247        let r = PathResolver::new(&c);
248
249        // Path is also covered by the team/ prefix and the default,
250        // but the [secret."..."] override beats both.
251        let d = r.resolve(&p("team/gitlab/token-deploy")).unwrap();
252        match d {
253            RouteDecision::Explicit { source, reference } => {
254                assert_eq!(source, "1p-personal");
255                assert_eq!(reference, "op://Work/Gitlab/credential");
256            }
257            other => panic!("expected Explicit, got {other:?}"),
258        }
259    }
260
261    // -- 2) Prefix routing -------------------------------------------
262
263    #[test]
264    fn matching_prefix_returns_route_with_settings() {
265        let c = cfg(r#"
266            [[source]]
267            name = "vault-team"
268            type = "vault"
269
270            [[route]]
271            prefix = "team/"
272            source = "vault-team"
273            mount  = "secret/data/team"
274            "#);
275        let r = PathResolver::new(&c);
276        let d = r.resolve(&p("team/gitlab/token-deploy")).unwrap();
277        match d {
278            RouteDecision::Prefix {
279                source,
280                prefix,
281                settings,
282            } => {
283                assert_eq!(source, "vault-team");
284                assert_eq!(prefix, "team/");
285                assert_eq!(
286                    settings.get("mount").unwrap().as_str().unwrap(),
287                    "secret/data/team"
288                );
289            }
290            other => panic!("expected Prefix, got {other:?}"),
291        }
292    }
293
294    #[test]
295    fn longest_matching_prefix_wins_over_shorter() {
296        let c = cfg(r#"
297            [[source]]
298            name = "vault-team"
299            type = "vault"
300
301            [[source]]
302            name = "vault-acme"
303            type = "vault"
304
305            [[route]]
306            prefix = "team/"
307            source = "vault-team"
308
309            [[route]]
310            prefix = "team/acme/"
311            source = "vault-acme"
312            "#);
313        let r = PathResolver::new(&c);
314        // `team/acme/x` matches both prefixes; the longer wins.
315        let d = r.resolve(&p("team/acme/database/url")).unwrap();
316        assert_eq!(d.source(), "vault-acme");
317        match d {
318            RouteDecision::Prefix { prefix, .. } => assert_eq!(prefix, "team/acme/"),
319            other => panic!("expected Prefix, got {other:?}"),
320        }
321
322        // `team/foo/x` only matches the shorter one.
323        let d = r.resolve(&p("team/foo/x")).unwrap();
324        assert_eq!(d.source(), "vault-team");
325    }
326
327    #[test]
328    fn duplicate_prefix_is_rejected_at_config_load_so_resolver_never_sees_it() {
329        // The resolver's "ties go to the earlier `[[route]]`" rule
330        // would only matter if config load let two `[[route]]`
331        // entries share a prefix — confirm here that it doesn't,
332        // so we can rely on the invariant.
333        let err = RouterConfig::parse(
334            r#"
335            [[source]]
336            name = "src-a"
337            type = "x"
338            [[source]]
339            name = "src-b"
340            type = "x"
341
342            [[route]]
343            prefix = "tea/"
344            source = "src-a"
345
346            [[route]]
347            prefix = "tea/"
348            source = "src-b"
349            "#,
350        )
351        .unwrap_err();
352        assert!(matches!(
353            err,
354            crate::router_config::RouterConfigError::DuplicateRoutePrefix { .. }
355        ));
356    }
357
358    #[test]
359    fn earlier_route_wins_when_prefixes_have_different_length_but_one_starts_the_other_short() {
360        // Reversal of "longest wins" — earlier declaration order
361        // matters only when lengths are equal. Here `team/foo/` is
362        // longer than `team/`; the long one wins regardless of
363        // which was declared first.
364        let c = cfg(r#"
365            [[source]]
366            name = "long"
367            type = "x"
368            [[source]]
369            name = "short"
370            type = "x"
371
372            [[route]]
373            prefix = "team/foo/"
374            source = "long"
375
376            [[route]]
377            prefix = "team/"
378            source = "short"
379            "#);
380        let r = PathResolver::new(&c);
381        let d = r.resolve(&p("team/foo/secret")).unwrap();
382        assert_eq!(d.source(), "long");
383    }
384
385    #[test]
386    fn prefix_must_match_at_segment_boundary() {
387        // `team/` is required to end in `/` at config load. So a
388        // path like `teamfoo/x` cannot accidentally match. Verify.
389        let c = cfg(r#"
390            [[source]]
391            name = "vault-team"
392            type = "vault"
393
394            [default]
395            source = "vault-team"
396
397            [[route]]
398            prefix = "team/"
399            source = "vault-team"
400            "#);
401        let r = PathResolver::new(&c);
402        // `teamfoo/sub/key` does NOT start with `team/` (3-segment
403        // path because ADR-020 requires ≥3).
404        let d = r.resolve(&p("teamfoo/sub/key")).unwrap();
405        match d {
406            RouteDecision::Default { .. } => {}
407            other => panic!("teamfoo/sub/key must NOT match the team/ prefix; got {other:?}"),
408        }
409    }
410
411    // -- 3) Default route --------------------------------------------
412
413    #[test]
414    fn unmatched_path_falls_back_to_default() {
415        let c = cfg(r#"
416            [[source]]
417            name = "keychain"
418            type = "keychain"
419
420            [default]
421            source = "keychain"
422            "#);
423        let r = PathResolver::new(&c);
424        let d = r.resolve(&p("personal/random/key")).unwrap();
425        match d {
426            RouteDecision::Default { source, fallback } => {
427                assert_eq!(source, "keychain");
428                assert!(fallback.is_none());
429            }
430            other => panic!("expected Default, got {other:?}"),
431        }
432    }
433
434    #[test]
435    fn default_fallback_is_carried_through() {
436        let c = cfg(r#"
437            [[source]]
438            name = "keychain"
439            type = "keychain"
440            [[source]]
441            name = "local-vault"
442            type = "local-vault"
443
444            [default]
445            source   = "keychain"
446            fallback = "local-vault"
447            "#);
448        let r = PathResolver::new(&c);
449        let d = r.resolve(&p("anything/goes/here")).unwrap();
450        match d {
451            RouteDecision::Default { source, fallback } => {
452                assert_eq!(source, "keychain");
453                assert_eq!(fallback, Some("local-vault"));
454            }
455            other => panic!("expected Default, got {other:?}"),
456        }
457    }
458
459    // -- 4) No-route error -------------------------------------------
460
461    #[test]
462    fn no_secret_no_prefix_no_default_returns_no_route_error() {
463        let c = cfg(r#"
464            [[source]]
465            name = "vault-team"
466            type = "vault"
467
468            [[route]]
469            prefix = "team/"
470            source = "vault-team"
471            "#);
472        let r = PathResolver::new(&c);
473        let err = r.resolve(&p("personal/random/key")).unwrap_err();
474        match err {
475            ResolveError::NoRoute { path } => assert_eq!(path, "personal/random/key"),
476        }
477    }
478
479    // -- Helpers / smoke ---------------------------------------------
480
481    #[test]
482    fn route_decision_source_helper_returns_the_dispatch_target() {
483        let c = cfg(r#"
484            [[source]]
485            name = "keychain"
486            type = "keychain"
487
488            [default]
489            source = "keychain"
490            "#);
491        let r = PathResolver::new(&c);
492        assert_eq!(r.resolve(&p("a/b/c")).unwrap().source(), "keychain");
493    }
494
495    /// Table-driven fixture covering all four cases — mirrors the
496    /// "tests with table of fixtures" requirement in the task
497    /// description.
498    #[test]
499    fn fixture_table_exercises_every_branch() {
500        let c = cfg(r#"
501            [[source]]
502            name = "keychain"
503            type = "keychain"
504            [[source]]
505            name = "local-vault"
506            type = "local-vault"
507            [[source]]
508            name = "vault-team"
509            type = "vault"
510            [[source]]
511            name = "1p-personal"
512            type = "1password"
513
514            [default]
515            source   = "keychain"
516            fallback = "local-vault"
517
518            [[route]]
519            prefix = "team/"
520            source = "vault-team"
521
522            [[route]]
523            prefix = "team/acme/"
524            source = "1p-personal"
525
526            [secret."client-acme/jira/api-key"]
527            source = "1p-personal"
528            reference = "op://Work/Acme Jira/credential"
529            "#);
530        let r = PathResolver::new(&c);
531
532        // (path, expected source, expected variant tag for sanity)
533        let cases: &[(&str, &str, &str)] = &[
534            // explicit > prefix > default
535            ("client-acme/jira/api-key", "1p-personal", "Explicit"),
536            // longest prefix
537            ("team/acme/db/url", "1p-personal", "Prefix"),
538            ("team/foo/bar", "vault-team", "Prefix"),
539            // default + fallback
540            ("personal/x/y", "keychain", "Default"),
541        ];
542        for (input, expected_source, expected_variant) in cases {
543            let d = r
544                .resolve(&p(input))
545                .unwrap_or_else(|e| panic!("resolve('{input}') failed: {e}"));
546            assert_eq!(
547                d.source(),
548                *expected_source,
549                "fixture for '{input}' picked wrong source"
550            );
551            let actual_variant = match &d {
552                RouteDecision::Explicit { .. } => "Explicit",
553                RouteDecision::Prefix { .. } => "Prefix",
554                RouteDecision::Default { .. } => "Default",
555            };
556            assert_eq!(
557                actual_variant, *expected_variant,
558                "fixture for '{input}' picked wrong variant"
559            );
560        }
561    }
562}