Skip to main content

devboy_storage/
router_config.rs

1//! Router configuration loader per [ADR-021] §2.
2//!
3//! The router maps an ADR-020 path to a `(source, reference)` pair.
4//! Its configuration is global and lives at
5//! `<config_dir>/devboy-tools/secrets/sources.toml` (the ADR text
6//! abbreviates this to `~/.devboy/secrets/sources.toml`).
7//!
8//! This module parses and **validates** that file. The actual
9//! resolution algorithm — "which source serves this path?" — is
10//! the next phase (P5.3) and lives in `crate::router_resolve`.
11//! Splitting parse from resolve keeps the loader testable in
12//! isolation and lets the config be inspected by `doctor` without
13//! committing to a runtime decision.
14//!
15//! ## File layout
16//!
17//! ```toml
18//! # Source definitions — one per backend instance.
19//! [[source]]
20//! name = "keychain"
21//! type = "keychain"
22//!
23//! [[source]]
24//! name = "1p-personal"
25//! type = "1password"
26//! account = "personal.example.1password.com"
27//!
28//! [[source]]
29//! name = "vault-team"
30//! type = "vault"
31//! addr  = "https://vault.example.internal/"
32//! mount = "secret"
33//!
34//! # The default route — used when no [[route]] prefix matches.
35//! [default]
36//! source   = "keychain"
37//! fallback = "local-vault"          # optional, see ADR-021 §8
38//!
39//! # Prefix routes — longest match wins.
40//! [[route]]
41//! prefix = "team/"
42//! source = "vault-team"
43//! mount  = "secret/data/team"        # source-specific extra
44//!
45//! # Per-secret override — explicit (source, reference) for one path.
46//! [secret."client-acme/jira/api-key"]
47//! source    = "1p-personal"
48//! reference = "op://Work/Acme Jira/credential"
49//! ```
50//!
51//! Per-source and per-route extra fields are kept verbatim as
52//! `toml::Value`; concrete source plugins (P6) parse them into
53//! their own typed config when they're constructed. The router
54//! itself never inspects them.
55//!
56//! ## Validation
57//!
58//! [`RouterConfig::parse`] returns a typed config when:
59//!
60//! - source names are non-empty and `^[a-z0-9][a-z0-9_-]*$`,
61//! - no two `[[source]]` blocks share a name,
62//! - `default.source` and `default.fallback` (when set) reference
63//!   defined sources,
64//! - every `[[route]].source` references a defined source,
65//! - every `[[route]].prefix` ends with `/`,
66//! - no two `[[route]]` blocks share a prefix,
67//! - every `[secret."<path>"]` key parses as a [`SecretPath`],
68//! - every `[secret."<path>"].source` references a defined source.
69//!
70//! Anything else is left to P5.3 / P5.5 (e.g. the source-credential
71//! recursion check).
72//!
73//! [ADR-021]: https://github.com/meteora-pro/devboy-tools/blob/main/docs/architecture/adr/ADR-021-external-secret-sources.md
74
75use std::collections::{BTreeMap, HashMap, HashSet};
76use std::fs;
77use std::io;
78use std::path::{Path, PathBuf};
79
80use serde::{Deserialize, Serialize};
81use thiserror::Error;
82use tracing::debug;
83
84use crate::index::SECRETS_SUBDIR;
85use crate::secret_path::{PathError, SecretPath};
86use crate::source::Capabilities;
87
88/// Filename of the router config inside [`SECRETS_SUBDIR`].
89pub const SOURCES_FILENAME: &str = "sources.toml";
90
91// =============================================================================
92// Errors
93// =============================================================================
94
95/// Failure modes when loading or validating a [`RouterConfig`].
96#[derive(Debug, Error)]
97pub enum RouterConfigError {
98    /// `dirs::config_dir()` returned `None` — extremely unusual on
99    /// supported platforms but worth a typed variant rather than a
100    /// panic.
101    #[error("could not resolve the user's config directory")]
102    NoConfigDir,
103
104    /// I/O error reading the config file.
105    #[error("failed to read {}: {source}", path.display())]
106    Read {
107        /// Path the loader tried to read.
108        path: PathBuf,
109        /// Underlying I/O error.
110        #[source]
111        source: io::Error,
112    },
113
114    /// TOML deserialization error.
115    #[error("failed to parse {}: {source}", path.display())]
116    Parse {
117        /// Path the loader tried to parse.
118        path: PathBuf,
119        /// Underlying parse error.
120        #[source]
121        source: toml::de::Error,
122    },
123
124    /// Source name failed the identifier regex (kebab-case allowed).
125    #[error("invalid source name '{name}': {reason}")]
126    BadSourceName {
127        /// The offending name.
128        name: String,
129        /// Human-readable detail.
130        reason: String,
131    },
132
133    /// Two `[[source]]` blocks share a name.
134    #[error("source '{name}' is defined more than once")]
135    DuplicateSource {
136        /// The duplicated name.
137        name: String,
138    },
139
140    /// `[default].source` references an undefined source.
141    #[error("[default].source = '{name}' references an undefined source")]
142    UndefinedDefaultSource {
143        /// The unresolved source name.
144        name: String,
145    },
146
147    /// `[default].fallback` references an undefined source.
148    #[error("[default].fallback = '{name}' references an undefined source")]
149    UndefinedFallbackSource {
150        /// The unresolved source name.
151        name: String,
152    },
153
154    /// `[[route]].source` references an undefined source.
155    #[error("[[route]] for prefix '{prefix}' references undefined source '{name}'")]
156    UndefinedRouteSource {
157        /// Prefix of the route in question.
158        prefix: String,
159        /// The unresolved source name.
160        name: String,
161    },
162
163    /// `[secret."<path>"].source` references an undefined source.
164    #[error("[secret.\"{path}\"] references undefined source '{name}'")]
165    UndefinedSecretSource {
166        /// Path key of the override.
167        path: String,
168        /// The unresolved source name.
169        name: String,
170    },
171
172    /// `[[route]].prefix` does not end with `/`.
173    #[error("[[route]].prefix '{prefix}' must end with '/'")]
174    BadRoutePrefix {
175        /// The offending prefix.
176        prefix: String,
177    },
178
179    /// Two `[[route]]` blocks share a prefix.
180    #[error("[[route]].prefix '{prefix}' is declared more than once")]
181    DuplicateRoutePrefix {
182        /// The duplicated prefix.
183        prefix: String,
184    },
185
186    /// `[secret."<path>"]` key is not a valid ADR-020 path.
187    #[error("[secret.\"{path}\"] is not a valid secret path: {source}")]
188    BadSecretPath {
189        /// The offending key.
190        path: String,
191        /// Underlying path-validation error.
192        #[source]
193        source: PathError,
194    },
195}
196
197// =============================================================================
198// Public types
199// =============================================================================
200
201/// Parsed + validated router configuration.
202///
203/// Built by [`RouterConfig::load`] / [`RouterConfig::parse`]. The
204/// router (P5.3) consumes the typed view; `doctor` (P7.2) reports
205/// on it.
206//
207// Eq is intentionally NOT derived: `toml::Value` only implements
208// `PartialEq` (its `Float` variant has NaN-style ambiguity), and
209// `Eq` propagates through `BTreeMap<String, toml::Value>`.
210// `PartialEq` is enough for tests that compare configs.
211#[derive(Debug, Clone, Default, PartialEq)]
212pub struct RouterConfig {
213    /// All `[[source]]` blocks in declaration order.
214    pub sources: Vec<SourceDefinition>,
215    /// `[default]`, if present. Optional because a config may
216    /// declare only routes / per-secret overrides and leave the
217    /// default unset (in which case unmatched paths fail with a
218    /// "no route" error at resolution time).
219    pub default: Option<DefaultRoute>,
220    /// All `[[route]]` blocks in declaration order. The router
221    /// picks the longest matching prefix at resolution time.
222    pub routes: Vec<RouteRule>,
223    /// All `[secret."<path>"]` blocks. Ordered by path for stable
224    /// iteration in `doctor` output and tests.
225    pub secret_overrides: BTreeMap<SecretPath, SecretOverride>,
226}
227
228/// Access mode for one `[[source]]` — a capability mask
229/// layered over whatever the source plugin declares.
230///
231/// A source plugin advertises a static [`Capabilities`] set
232/// (`local-vault` is `READ | LIST | VALIDATE | WRITE | ROTATE
233/// | …`). The `access` key in `[[source]]` lets an operator
234/// *narrow* that per config without touching the plugin:
235/// mount the team's shared vault `read` everywhere, leave
236/// `readwrite` only on the box that owns rotation.
237#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
238#[serde(rename_all = "lowercase")]
239pub enum SourceAccess {
240    /// Read-only — the effective capability set is the
241    /// plugin's declared set AND-ed with `READ | LIST |
242    /// VALIDATE`. `WRITE` / `ROTATE` are masked off even when
243    /// the plugin supports them.
244    Read,
245    /// Full access — the plugin's declared capability set is
246    /// used unchanged. This is the default when `access` is
247    /// omitted, so existing configs keep their behaviour.
248    #[default]
249    ReadWrite,
250}
251
252impl SourceAccess {
253    /// Apply this access mode to a source plugin's `declared`
254    /// capability set, returning the *effective* set the
255    /// router / UI should honour.
256    pub fn mask(self, declared: Capabilities) -> Capabilities {
257        match self {
258            SourceAccess::ReadWrite => declared,
259            SourceAccess::Read => {
260                declared & (Capabilities::READ | Capabilities::LIST | Capabilities::VALIDATE)
261            }
262        }
263    }
264}
265
266/// One `[[source]]` block.
267#[derive(Debug, Clone, PartialEq)]
268pub struct SourceDefinition {
269    /// Stable name; used everywhere else in the config.
270    pub name: String,
271    /// `type` field. Renamed because `type` is a reserved word in
272    /// Rust.
273    pub source_type: String,
274    /// Access mask — `read` or `readwrite` (default). Narrows
275    /// the source plugin's declared capabilities; see
276    /// [`SourceAccess`].
277    pub access: SourceAccess,
278    /// Any additional fields the source plugin understands
279    /// (`account`, `addr`, `mount`, `file`, …). Stored verbatim;
280    /// the router does not inspect them.
281    pub settings: BTreeMap<String, toml::Value>,
282}
283
284impl SourceDefinition {
285    /// The capability set the router / UI should honour for
286    /// this source — the plugin's `declared` set narrowed by
287    /// the configured [`SourceAccess`].
288    pub fn effective_capabilities(&self, declared: Capabilities) -> Capabilities {
289        self.access.mask(declared)
290    }
291}
292
293/// `[default]` block.
294#[derive(Debug, Clone, PartialEq, Eq)]
295pub struct DefaultRoute {
296    /// Source name to dispatch to when no `[[route]]` matches.
297    pub source: String,
298    /// Source name to fall back to when `default.source` reports
299    /// `is_available() == NotInstalled`. Per ADR-021 §2 step 3.
300    pub fallback: Option<String>,
301}
302
303/// One `[[route]]` block.
304#[derive(Debug, Clone, PartialEq)]
305pub struct RouteRule {
306    /// Path prefix. The router matches by longest prefix; ties go
307    /// to declaration order (resolved in P5.3).
308    pub prefix: String,
309    /// Source name to dispatch to.
310    pub source: String,
311    /// Source-specific extras (e.g. `mount`, `vault`). Stored
312    /// verbatim.
313    pub settings: BTreeMap<String, toml::Value>,
314}
315
316/// `[secret."<path>"]` block — explicit override for one path.
317#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
318pub struct SecretOverride {
319    /// Source name to dispatch to.
320    pub source: String,
321    /// Backend-specific reference string handed to the source's
322    /// `get` (e.g. `op://Work/Acme Jira/credential`).
323    pub reference: String,
324}
325
326// =============================================================================
327// Loading
328// =============================================================================
329
330impl RouterConfig {
331    /// Resolve the canonical on-disk path
332    /// (`<config_dir>/devboy-tools/secrets/sources.toml`).
333    pub fn default_path() -> Result<PathBuf, RouterConfigError> {
334        let dir = dirs::config_dir().ok_or(RouterConfigError::NoConfigDir)?;
335        Ok(dir
336            .join("devboy-tools")
337            .join(SECRETS_SUBDIR)
338            .join(SOURCES_FILENAME))
339    }
340
341    /// Load the config from the canonical default path. Returns an
342    /// empty (no-sources) config if the file does not exist — the
343    /// router config is opt-in.
344    pub fn load() -> Result<Self, RouterConfigError> {
345        let path = Self::default_path()?;
346        Self::load_from(&path)
347    }
348
349    /// Load from a specific path. Returns an empty config if the
350    /// file does not exist; otherwise reads, parses, and validates.
351    pub fn load_from(path: &Path) -> Result<Self, RouterConfigError> {
352        if !path.exists() {
353            debug!(path = ?path, "router config not present, using empty");
354            return Ok(Self::default());
355        }
356        let body = fs::read_to_string(path).map_err(|e| RouterConfigError::Read {
357            path: path.to_path_buf(),
358            source: e,
359        })?;
360        Self::parse(&body).map_err(|e| match e {
361            RouterConfigError::Parse { source, .. } => RouterConfigError::Parse {
362                path: path.to_path_buf(),
363                source,
364            },
365            other => other,
366        })
367    }
368
369    /// Parse + validate from an in-memory TOML string. Path-aware
370    /// errors that expose a [`PathBuf`] (`Read`, `Parse`) carry a
371    /// placeholder `<inline>` from this entry point — use
372    /// [`Self::load_from`] when you want them to point at the real
373    /// file.
374    pub fn parse(toml_body: &str) -> Result<Self, RouterConfigError> {
375        let raw: RawConfig = toml::from_str(toml_body).map_err(|e| RouterConfigError::Parse {
376            path: PathBuf::from("<inline>"),
377            source: e,
378        })?;
379        raw.into_validated()
380    }
381}
382
383// =============================================================================
384// Raw (serde) layer
385// =============================================================================
386
387#[derive(Debug, Deserialize, Default)]
388struct RawConfig {
389    #[serde(default, rename = "source")]
390    sources: Vec<RawSource>,
391    #[serde(default)]
392    default: Option<RawDefault>,
393    #[serde(default, rename = "route")]
394    routes: Vec<RawRoute>,
395    #[serde(default)]
396    secret: HashMap<String, RawSecret>,
397}
398
399#[derive(Debug, Deserialize)]
400struct RawSource {
401    name: String,
402    #[serde(rename = "type")]
403    source_type: String,
404    /// `read` / `readwrite`; defaults to `readwrite` when
405    /// omitted. Declared as an explicit field so the
406    /// `#[serde(flatten)]` below does NOT sweep it into
407    /// `settings`.
408    #[serde(default)]
409    access: SourceAccess,
410    #[serde(flatten)]
411    settings: BTreeMap<String, toml::Value>,
412}
413
414#[derive(Debug, Deserialize)]
415struct RawDefault {
416    source: String,
417    fallback: Option<String>,
418}
419
420#[derive(Debug, Deserialize)]
421struct RawRoute {
422    prefix: String,
423    source: String,
424    #[serde(flatten)]
425    settings: BTreeMap<String, toml::Value>,
426}
427
428#[derive(Debug, Deserialize)]
429struct RawSecret {
430    source: String,
431    reference: String,
432}
433
434impl RawConfig {
435    fn into_validated(self) -> Result<RouterConfig, RouterConfigError> {
436        // 1) Sources: validate names, reject duplicates.
437        let mut seen_names = HashSet::new();
438        let mut sources = Vec::with_capacity(self.sources.len());
439        for raw in self.sources {
440            validate_source_name(&raw.name)?;
441            if !seen_names.insert(raw.name.clone()) {
442                return Err(RouterConfigError::DuplicateSource { name: raw.name });
443            }
444            sources.push(SourceDefinition {
445                name: raw.name,
446                source_type: raw.source_type,
447                access: raw.access,
448                settings: raw.settings,
449            });
450        }
451
452        // 2) Default.
453        let default = self
454            .default
455            .map(|d| {
456                if !seen_names.contains(&d.source) {
457                    return Err(RouterConfigError::UndefinedDefaultSource {
458                        name: d.source.clone(),
459                    });
460                }
461                if let Some(f) = &d.fallback
462                    && !seen_names.contains(f)
463                {
464                    return Err(RouterConfigError::UndefinedFallbackSource { name: f.clone() });
465                }
466                Ok(DefaultRoute {
467                    source: d.source,
468                    fallback: d.fallback,
469                })
470            })
471            .transpose()?;
472
473        // 3) Routes: prefixes must end with '/', no duplicates,
474        //    sources must be defined.
475        let mut seen_prefixes = HashSet::new();
476        let mut routes = Vec::with_capacity(self.routes.len());
477        for raw in self.routes {
478            if !raw.prefix.ends_with('/') {
479                return Err(RouterConfigError::BadRoutePrefix { prefix: raw.prefix });
480            }
481            if !seen_prefixes.insert(raw.prefix.clone()) {
482                return Err(RouterConfigError::DuplicateRoutePrefix { prefix: raw.prefix });
483            }
484            if !seen_names.contains(&raw.source) {
485                return Err(RouterConfigError::UndefinedRouteSource {
486                    prefix: raw.prefix,
487                    name: raw.source,
488                });
489            }
490            routes.push(RouteRule {
491                prefix: raw.prefix,
492                source: raw.source,
493                settings: raw.settings,
494            });
495        }
496
497        // 4) Per-secret overrides: paths must parse, sources must
498        //    be defined. `parse_internal` is used (not `parse`) so
499        //    users can explicitly route source-credentials living
500        //    under `__sources/` (per ADR-021 §5) — the recursion
501        //    check (P5.5) ultimately enforces those land on a
502        //    credential-free source.
503        let mut secret_overrides = BTreeMap::new();
504        for (path_str, raw) in self.secret {
505            let parsed = SecretPath::parse_internal(&path_str).map_err(|source| {
506                RouterConfigError::BadSecretPath {
507                    path: path_str.clone(),
508                    source,
509                }
510            })?;
511            if !seen_names.contains(&raw.source) {
512                return Err(RouterConfigError::UndefinedSecretSource {
513                    path: path_str,
514                    name: raw.source,
515                });
516            }
517            secret_overrides.insert(
518                parsed,
519                SecretOverride {
520                    source: raw.source,
521                    reference: raw.reference,
522                },
523            );
524        }
525
526        Ok(RouterConfig {
527            sources,
528            default,
529            routes,
530            secret_overrides,
531        })
532    }
533}
534
535// =============================================================================
536// Validators
537// =============================================================================
538
539fn validate_source_name(name: &str) -> Result<(), RouterConfigError> {
540    if name.is_empty() {
541        return Err(RouterConfigError::BadSourceName {
542            name: name.to_owned(),
543            reason: "must be non-empty".into(),
544        });
545    }
546    let first = name.as_bytes()[0];
547    if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
548        return Err(RouterConfigError::BadSourceName {
549            name: name.to_owned(),
550            reason: "must start with a lowercase letter or a digit".into(),
551        });
552    }
553    for c in name.chars() {
554        let ok = c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_';
555        if !ok {
556            return Err(RouterConfigError::BadSourceName {
557                name: name.to_owned(),
558                reason: format!(
559                    "invalid character '{c}' (allowed: lowercase letters, digits, '-', '_')"
560                ),
561            });
562        }
563    }
564    Ok(())
565}
566
567// =============================================================================
568// Tests
569// =============================================================================
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574    use tempfile::TempDir;
575
576    // -- Parsing happy path ---------------------------------------------
577
578    #[test]
579    fn parse_minimal_config_with_only_sources() {
580        let cfg = RouterConfig::parse(
581            r#"
582            [[source]]
583            name = "keychain"
584            type = "keychain"
585            "#,
586        )
587        .unwrap();
588
589        assert_eq!(cfg.sources.len(), 1);
590        assert_eq!(cfg.sources[0].name, "keychain");
591        assert_eq!(cfg.sources[0].source_type, "keychain");
592        assert!(cfg.sources[0].settings.is_empty());
593        assert!(cfg.default.is_none());
594        assert!(cfg.routes.is_empty());
595        assert!(cfg.secret_overrides.is_empty());
596    }
597
598    // -- SourceAccess (W1) -------------------------------------
599
600    #[test]
601    fn source_access_defaults_to_readwrite_when_omitted() {
602        let cfg = RouterConfig::parse(
603            r#"
604            [[source]]
605            name = "keychain"
606            type = "keychain"
607            "#,
608        )
609        .unwrap();
610        assert_eq!(cfg.sources[0].access, SourceAccess::ReadWrite);
611    }
612
613    #[test]
614    fn source_access_read_parses_and_does_not_leak_into_settings() {
615        let cfg = RouterConfig::parse(
616            r#"
617            [[source]]
618            name = "team-vault"
619            type = "vault"
620            access = "read"
621            addr = "https://vault.example.invalid"
622            "#,
623        )
624        .unwrap();
625        assert_eq!(cfg.sources[0].access, SourceAccess::Read);
626        // `access` is an explicit field — it must NOT be swept
627        // into the flattened settings map.
628        assert!(!cfg.sources[0].settings.contains_key("access"));
629        // …but other unknown keys still flow through to settings.
630        assert!(cfg.sources[0].settings.contains_key("addr"));
631    }
632
633    #[test]
634    fn source_access_readwrite_parses_explicitly() {
635        let cfg = RouterConfig::parse(
636            r#"
637            [[source]]
638            name = "rw"
639            type = "local-vault"
640            access = "readwrite"
641            "#,
642        )
643        .unwrap();
644        assert_eq!(cfg.sources[0].access, SourceAccess::ReadWrite);
645    }
646
647    #[test]
648    fn read_access_masks_off_write_and_rotate() {
649        // local-vault declares the full set; `access = "read"`
650        // must narrow it to READ | LIST | VALIDATE.
651        let declared = Capabilities::READ
652            | Capabilities::LIST
653            | Capabilities::VALIDATE
654            | Capabilities::WRITE
655            | Capabilities::ROTATE;
656        let masked = SourceAccess::Read.mask(declared);
657        assert!(masked.contains(Capabilities::READ));
658        assert!(masked.contains(Capabilities::LIST));
659        assert!(masked.contains(Capabilities::VALIDATE));
660        assert!(!masked.contains(Capabilities::WRITE));
661        assert!(!masked.contains(Capabilities::ROTATE));
662    }
663
664    #[test]
665    fn readwrite_access_passes_the_declared_set_through_unchanged() {
666        let declared = Capabilities::READ | Capabilities::WRITE | Capabilities::ROTATE;
667        assert_eq!(SourceAccess::ReadWrite.mask(declared), declared);
668    }
669
670    #[test]
671    fn read_access_cannot_grant_a_capability_the_plugin_lacks() {
672        // env-store only declares READ — masking with `read`
673        // must not magically add LIST / VALIDATE.
674        let declared = Capabilities::READ;
675        let masked = SourceAccess::Read.mask(declared);
676        assert_eq!(masked, Capabilities::READ);
677        assert!(!masked.contains(Capabilities::LIST));
678    }
679
680    #[test]
681    fn effective_capabilities_routes_through_the_access_mode() {
682        let cfg = RouterConfig::parse(
683            r#"
684            [[source]]
685            name = "ro"
686            type = "vault"
687            access = "read"
688            "#,
689        )
690        .unwrap();
691        let declared = Capabilities::READ | Capabilities::WRITE | Capabilities::ROTATE;
692        let effective = cfg.sources[0].effective_capabilities(declared);
693        assert!(effective.contains(Capabilities::READ));
694        assert!(!effective.contains(Capabilities::WRITE));
695    }
696
697    #[test]
698    fn bad_access_value_is_a_parse_error() {
699        let err = RouterConfig::parse(
700            r#"
701            [[source]]
702            name = "x"
703            type = "vault"
704            access = "write-only"
705            "#,
706        )
707        .unwrap_err();
708        assert!(matches!(err, RouterConfigError::Parse { .. }));
709    }
710
711    #[test]
712    fn parse_full_adr_021_example() {
713        // Mirrors the example block in ADR-021 §2.
714        let cfg = RouterConfig::parse(
715            r#"
716            [[source]]
717            name = "keychain"
718            type = "keychain"
719
720            [[source]]
721            name = "local-vault"
722            type = "local-vault"
723
724            [[source]]
725            name = "1p-personal"
726            type = "1password"
727            account = "personal.example.1password.com"
728
729            [[source]]
730            name = "vault-team"
731            type = "vault"
732            addr  = "https://vault.example.internal/"
733            mount = "secret"
734
735            [default]
736            source   = "keychain"
737            fallback = "local-vault"
738
739            [[route]]
740            prefix = "team/"
741            source = "vault-team"
742            mount  = "secret/data/team"
743
744            [[route]]
745            prefix = "personal/"
746            source = "1p-personal"
747            vault  = "Personal"
748
749            [secret."client-acme/jira/api-key"]
750            source    = "1p-personal"
751            reference = "op://Work/Acme Jira/credential"
752            "#,
753        )
754        .unwrap();
755
756        assert_eq!(cfg.sources.len(), 4);
757        let vault_team = cfg.sources.iter().find(|s| s.name == "vault-team").unwrap();
758        assert_eq!(vault_team.source_type, "vault");
759        assert_eq!(
760            vault_team.settings.get("addr").unwrap().as_str().unwrap(),
761            "https://vault.example.internal/"
762        );
763
764        let default = cfg.default.unwrap();
765        assert_eq!(default.source, "keychain");
766        assert_eq!(default.fallback.as_deref(), Some("local-vault"));
767
768        assert_eq!(cfg.routes.len(), 2);
769        assert_eq!(cfg.routes[0].prefix, "team/");
770        assert_eq!(
771            cfg.routes[0]
772                .settings
773                .get("mount")
774                .unwrap()
775                .as_str()
776                .unwrap(),
777            "secret/data/team"
778        );
779
780        let path = SecretPath::parse("client-acme/jira/api-key").unwrap();
781        let ovr = cfg.secret_overrides.get(&path).unwrap();
782        assert_eq!(ovr.source, "1p-personal");
783        assert_eq!(ovr.reference, "op://Work/Acme Jira/credential");
784    }
785
786    // -- Source-name validation -----------------------------------------
787
788    #[test]
789    fn empty_source_name_rejected() {
790        let err = RouterConfig::parse(
791            r#"
792            [[source]]
793            name = ""
794            type = "keychain"
795            "#,
796        )
797        .unwrap_err();
798        match err {
799            RouterConfigError::BadSourceName { name, reason } => {
800                assert_eq!(name, "");
801                assert!(reason.contains("non-empty"));
802            }
803            other => panic!("expected BadSourceName, got {other:?}"),
804        }
805    }
806
807    #[test]
808    fn uppercase_source_name_rejected() {
809        let err = RouterConfig::parse(
810            r#"
811            [[source]]
812            name = "Keychain"
813            type = "keychain"
814            "#,
815        )
816        .unwrap_err();
817        assert!(matches!(err, RouterConfigError::BadSourceName { .. }));
818    }
819
820    #[test]
821    fn source_name_starting_with_dash_rejected() {
822        let err = RouterConfig::parse(
823            r#"
824            [[source]]
825            name = "-bad"
826            type = "x"
827            "#,
828        )
829        .unwrap_err();
830        assert!(matches!(err, RouterConfigError::BadSourceName { .. }));
831    }
832
833    #[test]
834    fn source_name_with_digit_first_accepted() {
835        // The ADR uses `1p-personal` — make sure our validator
836        // matches the spec.
837        let cfg = RouterConfig::parse(
838            r#"
839            [[source]]
840            name = "1p-personal"
841            type = "1password"
842            "#,
843        )
844        .unwrap();
845        assert_eq!(cfg.sources[0].name, "1p-personal");
846    }
847
848    #[test]
849    fn duplicate_source_name_rejected() {
850        let err = RouterConfig::parse(
851            r#"
852            [[source]]
853            name = "vault-x"
854            type = "vault"
855
856            [[source]]
857            name = "vault-x"
858            type = "vault"
859            "#,
860        )
861        .unwrap_err();
862        match err {
863            RouterConfigError::DuplicateSource { name } => assert_eq!(name, "vault-x"),
864            other => panic!("expected DuplicateSource, got {other:?}"),
865        }
866    }
867
868    // -- Default reference checks --------------------------------------
869
870    #[test]
871    fn default_referencing_undefined_source_rejected() {
872        let err = RouterConfig::parse(
873            r#"
874            [[source]]
875            name = "keychain"
876            type = "keychain"
877
878            [default]
879            source = "nope"
880            "#,
881        )
882        .unwrap_err();
883        match err {
884            RouterConfigError::UndefinedDefaultSource { name } => assert_eq!(name, "nope"),
885            other => panic!("expected UndefinedDefaultSource, got {other:?}"),
886        }
887    }
888
889    #[test]
890    fn default_fallback_referencing_undefined_source_rejected() {
891        let err = RouterConfig::parse(
892            r#"
893            [[source]]
894            name = "keychain"
895            type = "keychain"
896
897            [default]
898            source = "keychain"
899            fallback = "nope"
900            "#,
901        )
902        .unwrap_err();
903        assert!(matches!(
904            err,
905            RouterConfigError::UndefinedFallbackSource { .. }
906        ));
907    }
908
909    // -- Route checks ----------------------------------------------------
910
911    #[test]
912    fn route_prefix_without_trailing_slash_rejected() {
913        let err = RouterConfig::parse(
914            r#"
915            [[source]]
916            name = "vault-team"
917            type = "vault"
918
919            [[route]]
920            prefix = "team"
921            source = "vault-team"
922            "#,
923        )
924        .unwrap_err();
925        match err {
926            RouterConfigError::BadRoutePrefix { prefix } => assert_eq!(prefix, "team"),
927            other => panic!("expected BadRoutePrefix, got {other:?}"),
928        }
929    }
930
931    #[test]
932    fn duplicate_route_prefix_rejected() {
933        let err = RouterConfig::parse(
934            r#"
935            [[source]]
936            name = "vault-a"
937            type = "vault"
938            [[source]]
939            name = "vault-b"
940            type = "vault"
941
942            [[route]]
943            prefix = "team/"
944            source = "vault-a"
945
946            [[route]]
947            prefix = "team/"
948            source = "vault-b"
949            "#,
950        )
951        .unwrap_err();
952        assert!(matches!(
953            err,
954            RouterConfigError::DuplicateRoutePrefix { .. }
955        ));
956    }
957
958    #[test]
959    fn route_with_undefined_source_rejected() {
960        let err = RouterConfig::parse(
961            r#"
962            [[source]]
963            name = "keychain"
964            type = "keychain"
965
966            [[route]]
967            prefix = "team/"
968            source = "vault-team"
969            "#,
970        )
971        .unwrap_err();
972        match err {
973            RouterConfigError::UndefinedRouteSource { prefix, name } => {
974                assert_eq!(prefix, "team/");
975                assert_eq!(name, "vault-team");
976            }
977            other => panic!("expected UndefinedRouteSource, got {other:?}"),
978        }
979    }
980
981    // -- Per-secret override checks --------------------------------------
982
983    #[test]
984    fn secret_override_with_invalid_path_rejected() {
985        let err = RouterConfig::parse(
986            r#"
987            [[source]]
988            name = "keychain"
989            type = "keychain"
990
991            [secret."BAD"]
992            source = "keychain"
993            reference = "BAD"
994            "#,
995        )
996        .unwrap_err();
997        match err {
998            RouterConfigError::BadSecretPath { path, .. } => assert_eq!(path, "BAD"),
999            other => panic!("expected BadSecretPath, got {other:?}"),
1000        }
1001    }
1002
1003    #[test]
1004    fn secret_override_with_undefined_source_rejected() {
1005        let err = RouterConfig::parse(
1006            r#"
1007            [[source]]
1008            name = "keychain"
1009            type = "keychain"
1010
1011            [secret."team/gitlab/token-deploy"]
1012            source = "nope"
1013            reference = "x"
1014            "#,
1015        )
1016        .unwrap_err();
1017        assert!(matches!(
1018            err,
1019            RouterConfigError::UndefinedSecretSource { .. }
1020        ));
1021    }
1022
1023    // -- Loading ---------------------------------------------------------
1024
1025    #[test]
1026    fn load_from_missing_file_returns_default_empty_config() {
1027        let dir = TempDir::new().unwrap();
1028        let path = dir.path().join("nope.toml");
1029        let cfg = RouterConfig::load_from(&path).unwrap();
1030        assert_eq!(cfg, RouterConfig::default());
1031    }
1032
1033    #[test]
1034    fn load_from_invalid_toml_surfaces_path() {
1035        let dir = TempDir::new().unwrap();
1036        let path = dir.path().join("sources.toml");
1037        std::fs::write(&path, "[[ this is not toml").unwrap();
1038        let err = RouterConfig::load_from(&path).unwrap_err();
1039        match err {
1040            RouterConfigError::Parse { path: errpath, .. } => assert_eq!(errpath, path),
1041            other => panic!("expected Parse, got {other:?}"),
1042        }
1043    }
1044
1045    #[test]
1046    fn load_from_valid_file_round_trips_through_parse() {
1047        let dir = TempDir::new().unwrap();
1048        let path = dir.path().join("sources.toml");
1049        std::fs::write(
1050            &path,
1051            r#"
1052            [[source]]
1053            name = "keychain"
1054            type = "keychain"
1055
1056            [default]
1057            source = "keychain"
1058            "#,
1059        )
1060        .unwrap();
1061        let cfg = RouterConfig::load_from(&path).unwrap();
1062        assert_eq!(cfg.sources.len(), 1);
1063        assert_eq!(cfg.default.as_ref().unwrap().source, "keychain");
1064    }
1065
1066    // -- default_path ----------------------------------------------------
1067
1068    #[test]
1069    fn default_path_lives_under_devboy_tools_secrets_sources_toml() {
1070        let p = RouterConfig::default_path().unwrap();
1071        let s = p.to_string_lossy();
1072        assert!(s.contains("devboy-tools"));
1073        assert!(
1074            s.ends_with(&format!("{SECRETS_SUBDIR}/{SOURCES_FILENAME}"))
1075                || s.ends_with(&format!("{SECRETS_SUBDIR}\\{SOURCES_FILENAME}"))
1076        );
1077    }
1078}