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}