1use 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
88pub const SOURCES_FILENAME: &str = "sources.toml";
90
91#[derive(Debug, Error)]
97pub enum RouterConfigError {
98 #[error("could not resolve the user's config directory")]
102 NoConfigDir,
103
104 #[error("failed to read {}: {source}", path.display())]
106 Read {
107 path: PathBuf,
109 #[source]
111 source: io::Error,
112 },
113
114 #[error("failed to parse {}: {source}", path.display())]
116 Parse {
117 path: PathBuf,
119 #[source]
121 source: toml::de::Error,
122 },
123
124 #[error("invalid source name '{name}': {reason}")]
126 BadSourceName {
127 name: String,
129 reason: String,
131 },
132
133 #[error("source '{name}' is defined more than once")]
135 DuplicateSource {
136 name: String,
138 },
139
140 #[error("[default].source = '{name}' references an undefined source")]
142 UndefinedDefaultSource {
143 name: String,
145 },
146
147 #[error("[default].fallback = '{name}' references an undefined source")]
149 UndefinedFallbackSource {
150 name: String,
152 },
153
154 #[error("[[route]] for prefix '{prefix}' references undefined source '{name}'")]
156 UndefinedRouteSource {
157 prefix: String,
159 name: String,
161 },
162
163 #[error("[secret.\"{path}\"] references undefined source '{name}'")]
165 UndefinedSecretSource {
166 path: String,
168 name: String,
170 },
171
172 #[error("[[route]].prefix '{prefix}' must end with '/'")]
174 BadRoutePrefix {
175 prefix: String,
177 },
178
179 #[error("[[route]].prefix '{prefix}' is declared more than once")]
181 DuplicateRoutePrefix {
182 prefix: String,
184 },
185
186 #[error("[secret.\"{path}\"] is not a valid secret path: {source}")]
188 BadSecretPath {
189 path: String,
191 #[source]
193 source: PathError,
194 },
195}
196
197#[derive(Debug, Clone, Default, PartialEq)]
212pub struct RouterConfig {
213 pub sources: Vec<SourceDefinition>,
215 pub default: Option<DefaultRoute>,
220 pub routes: Vec<RouteRule>,
223 pub secret_overrides: BTreeMap<SecretPath, SecretOverride>,
226}
227
228#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
238#[serde(rename_all = "lowercase")]
239pub enum SourceAccess {
240 Read,
245 #[default]
249 ReadWrite,
250}
251
252impl SourceAccess {
253 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#[derive(Debug, Clone, PartialEq)]
268pub struct SourceDefinition {
269 pub name: String,
271 pub source_type: String,
274 pub access: SourceAccess,
278 pub settings: BTreeMap<String, toml::Value>,
282}
283
284impl SourceDefinition {
285 pub fn effective_capabilities(&self, declared: Capabilities) -> Capabilities {
289 self.access.mask(declared)
290 }
291}
292
293#[derive(Debug, Clone, PartialEq, Eq)]
295pub struct DefaultRoute {
296 pub source: String,
298 pub fallback: Option<String>,
301}
302
303#[derive(Debug, Clone, PartialEq)]
305pub struct RouteRule {
306 pub prefix: String,
309 pub source: String,
311 pub settings: BTreeMap<String, toml::Value>,
314}
315
316#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
318pub struct SecretOverride {
319 pub source: String,
321 pub reference: String,
324}
325
326impl RouterConfig {
331 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 pub fn load() -> Result<Self, RouterConfigError> {
345 let path = Self::default_path()?;
346 Self::load_from(&path)
347 }
348
349 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 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#[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 #[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 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 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 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 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
535fn 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#[cfg(test)]
572mod tests {
573 use super::*;
574 use tempfile::TempDir;
575
576 #[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 #[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 assert!(!cfg.sources[0].settings.contains_key("access"));
629 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 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 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 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 #[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 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 #[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 #[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 #[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 #[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 #[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}