use std::collections::{BTreeMap, HashMap, HashSet};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tracing::debug;
use crate::index::SECRETS_SUBDIR;
use crate::secret_path::{PathError, SecretPath};
use crate::source::Capabilities;
pub const SOURCES_FILENAME: &str = "sources.toml";
#[derive(Debug, Error)]
pub enum RouterConfigError {
#[error("could not resolve the user's config directory")]
NoConfigDir,
#[error("failed to read {}: {source}", path.display())]
Read {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("failed to parse {}: {source}", path.display())]
Parse {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("invalid source name '{name}': {reason}")]
BadSourceName {
name: String,
reason: String,
},
#[error("source '{name}' is defined more than once")]
DuplicateSource {
name: String,
},
#[error("[default].source = '{name}' references an undefined source")]
UndefinedDefaultSource {
name: String,
},
#[error("[default].fallback = '{name}' references an undefined source")]
UndefinedFallbackSource {
name: String,
},
#[error("[[route]] for prefix '{prefix}' references undefined source '{name}'")]
UndefinedRouteSource {
prefix: String,
name: String,
},
#[error("[secret.\"{path}\"] references undefined source '{name}'")]
UndefinedSecretSource {
path: String,
name: String,
},
#[error("[[route]].prefix '{prefix}' must end with '/'")]
BadRoutePrefix {
prefix: String,
},
#[error("[[route]].prefix '{prefix}' is declared more than once")]
DuplicateRoutePrefix {
prefix: String,
},
#[error("[secret.\"{path}\"] is not a valid secret path: {source}")]
BadSecretPath {
path: String,
#[source]
source: PathError,
},
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct RouterConfig {
pub sources: Vec<SourceDefinition>,
pub default: Option<DefaultRoute>,
pub routes: Vec<RouteRule>,
pub secret_overrides: BTreeMap<SecretPath, SecretOverride>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SourceAccess {
Read,
#[default]
ReadWrite,
}
impl SourceAccess {
pub fn mask(self, declared: Capabilities) -> Capabilities {
match self {
SourceAccess::ReadWrite => declared,
SourceAccess::Read => {
declared & (Capabilities::READ | Capabilities::LIST | Capabilities::VALIDATE)
}
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct SourceDefinition {
pub name: String,
pub source_type: String,
pub access: SourceAccess,
pub settings: BTreeMap<String, toml::Value>,
}
impl SourceDefinition {
pub fn effective_capabilities(&self, declared: Capabilities) -> Capabilities {
self.access.mask(declared)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DefaultRoute {
pub source: String,
pub fallback: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RouteRule {
pub prefix: String,
pub source: String,
pub settings: BTreeMap<String, toml::Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SecretOverride {
pub source: String,
pub reference: String,
}
impl RouterConfig {
pub fn default_path() -> Result<PathBuf, RouterConfigError> {
let dir = dirs::config_dir().ok_or(RouterConfigError::NoConfigDir)?;
Ok(dir
.join("devboy-tools")
.join(SECRETS_SUBDIR)
.join(SOURCES_FILENAME))
}
pub fn load() -> Result<Self, RouterConfigError> {
let path = Self::default_path()?;
Self::load_from(&path)
}
pub fn load_from(path: &Path) -> Result<Self, RouterConfigError> {
if !path.exists() {
debug!(path = ?path, "router config not present, using empty");
return Ok(Self::default());
}
let body = fs::read_to_string(path).map_err(|e| RouterConfigError::Read {
path: path.to_path_buf(),
source: e,
})?;
Self::parse(&body).map_err(|e| match e {
RouterConfigError::Parse { source, .. } => RouterConfigError::Parse {
path: path.to_path_buf(),
source,
},
other => other,
})
}
pub fn parse(toml_body: &str) -> Result<Self, RouterConfigError> {
let raw: RawConfig = toml::from_str(toml_body).map_err(|e| RouterConfigError::Parse {
path: PathBuf::from("<inline>"),
source: e,
})?;
raw.into_validated()
}
}
#[derive(Debug, Deserialize, Default)]
struct RawConfig {
#[serde(default, rename = "source")]
sources: Vec<RawSource>,
#[serde(default)]
default: Option<RawDefault>,
#[serde(default, rename = "route")]
routes: Vec<RawRoute>,
#[serde(default)]
secret: HashMap<String, RawSecret>,
}
#[derive(Debug, Deserialize)]
struct RawSource {
name: String,
#[serde(rename = "type")]
source_type: String,
#[serde(default)]
access: SourceAccess,
#[serde(flatten)]
settings: BTreeMap<String, toml::Value>,
}
#[derive(Debug, Deserialize)]
struct RawDefault {
source: String,
fallback: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RawRoute {
prefix: String,
source: String,
#[serde(flatten)]
settings: BTreeMap<String, toml::Value>,
}
#[derive(Debug, Deserialize)]
struct RawSecret {
source: String,
reference: String,
}
impl RawConfig {
fn into_validated(self) -> Result<RouterConfig, RouterConfigError> {
let mut seen_names = HashSet::new();
let mut sources = Vec::with_capacity(self.sources.len());
for raw in self.sources {
validate_source_name(&raw.name)?;
if !seen_names.insert(raw.name.clone()) {
return Err(RouterConfigError::DuplicateSource { name: raw.name });
}
sources.push(SourceDefinition {
name: raw.name,
source_type: raw.source_type,
access: raw.access,
settings: raw.settings,
});
}
let default = self
.default
.map(|d| {
if !seen_names.contains(&d.source) {
return Err(RouterConfigError::UndefinedDefaultSource {
name: d.source.clone(),
});
}
if let Some(f) = &d.fallback
&& !seen_names.contains(f)
{
return Err(RouterConfigError::UndefinedFallbackSource { name: f.clone() });
}
Ok(DefaultRoute {
source: d.source,
fallback: d.fallback,
})
})
.transpose()?;
let mut seen_prefixes = HashSet::new();
let mut routes = Vec::with_capacity(self.routes.len());
for raw in self.routes {
if !raw.prefix.ends_with('/') {
return Err(RouterConfigError::BadRoutePrefix { prefix: raw.prefix });
}
if !seen_prefixes.insert(raw.prefix.clone()) {
return Err(RouterConfigError::DuplicateRoutePrefix { prefix: raw.prefix });
}
if !seen_names.contains(&raw.source) {
return Err(RouterConfigError::UndefinedRouteSource {
prefix: raw.prefix,
name: raw.source,
});
}
routes.push(RouteRule {
prefix: raw.prefix,
source: raw.source,
settings: raw.settings,
});
}
let mut secret_overrides = BTreeMap::new();
for (path_str, raw) in self.secret {
let parsed = SecretPath::parse_internal(&path_str).map_err(|source| {
RouterConfigError::BadSecretPath {
path: path_str.clone(),
source,
}
})?;
if !seen_names.contains(&raw.source) {
return Err(RouterConfigError::UndefinedSecretSource {
path: path_str,
name: raw.source,
});
}
secret_overrides.insert(
parsed,
SecretOverride {
source: raw.source,
reference: raw.reference,
},
);
}
Ok(RouterConfig {
sources,
default,
routes,
secret_overrides,
})
}
}
fn validate_source_name(name: &str) -> Result<(), RouterConfigError> {
if name.is_empty() {
return Err(RouterConfigError::BadSourceName {
name: name.to_owned(),
reason: "must be non-empty".into(),
});
}
let first = name.as_bytes()[0];
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
return Err(RouterConfigError::BadSourceName {
name: name.to_owned(),
reason: "must start with a lowercase letter or a digit".into(),
});
}
for c in name.chars() {
let ok = c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_';
if !ok {
return Err(RouterConfigError::BadSourceName {
name: name.to_owned(),
reason: format!(
"invalid character '{c}' (allowed: lowercase letters, digits, '-', '_')"
),
});
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn parse_minimal_config_with_only_sources() {
let cfg = RouterConfig::parse(
r#"
[[source]]
name = "keychain"
type = "keychain"
"#,
)
.unwrap();
assert_eq!(cfg.sources.len(), 1);
assert_eq!(cfg.sources[0].name, "keychain");
assert_eq!(cfg.sources[0].source_type, "keychain");
assert!(cfg.sources[0].settings.is_empty());
assert!(cfg.default.is_none());
assert!(cfg.routes.is_empty());
assert!(cfg.secret_overrides.is_empty());
}
#[test]
fn source_access_defaults_to_readwrite_when_omitted() {
let cfg = RouterConfig::parse(
r#"
[[source]]
name = "keychain"
type = "keychain"
"#,
)
.unwrap();
assert_eq!(cfg.sources[0].access, SourceAccess::ReadWrite);
}
#[test]
fn source_access_read_parses_and_does_not_leak_into_settings() {
let cfg = RouterConfig::parse(
r#"
[[source]]
name = "team-vault"
type = "vault"
access = "read"
addr = "https://vault.example.invalid"
"#,
)
.unwrap();
assert_eq!(cfg.sources[0].access, SourceAccess::Read);
assert!(!cfg.sources[0].settings.contains_key("access"));
assert!(cfg.sources[0].settings.contains_key("addr"));
}
#[test]
fn source_access_readwrite_parses_explicitly() {
let cfg = RouterConfig::parse(
r#"
[[source]]
name = "rw"
type = "local-vault"
access = "readwrite"
"#,
)
.unwrap();
assert_eq!(cfg.sources[0].access, SourceAccess::ReadWrite);
}
#[test]
fn read_access_masks_off_write_and_rotate() {
let declared = Capabilities::READ
| Capabilities::LIST
| Capabilities::VALIDATE
| Capabilities::WRITE
| Capabilities::ROTATE;
let masked = SourceAccess::Read.mask(declared);
assert!(masked.contains(Capabilities::READ));
assert!(masked.contains(Capabilities::LIST));
assert!(masked.contains(Capabilities::VALIDATE));
assert!(!masked.contains(Capabilities::WRITE));
assert!(!masked.contains(Capabilities::ROTATE));
}
#[test]
fn readwrite_access_passes_the_declared_set_through_unchanged() {
let declared = Capabilities::READ | Capabilities::WRITE | Capabilities::ROTATE;
assert_eq!(SourceAccess::ReadWrite.mask(declared), declared);
}
#[test]
fn read_access_cannot_grant_a_capability_the_plugin_lacks() {
let declared = Capabilities::READ;
let masked = SourceAccess::Read.mask(declared);
assert_eq!(masked, Capabilities::READ);
assert!(!masked.contains(Capabilities::LIST));
}
#[test]
fn effective_capabilities_routes_through_the_access_mode() {
let cfg = RouterConfig::parse(
r#"
[[source]]
name = "ro"
type = "vault"
access = "read"
"#,
)
.unwrap();
let declared = Capabilities::READ | Capabilities::WRITE | Capabilities::ROTATE;
let effective = cfg.sources[0].effective_capabilities(declared);
assert!(effective.contains(Capabilities::READ));
assert!(!effective.contains(Capabilities::WRITE));
}
#[test]
fn bad_access_value_is_a_parse_error() {
let err = RouterConfig::parse(
r#"
[[source]]
name = "x"
type = "vault"
access = "write-only"
"#,
)
.unwrap_err();
assert!(matches!(err, RouterConfigError::Parse { .. }));
}
#[test]
fn parse_full_adr_021_example() {
let cfg = RouterConfig::parse(
r#"
[[source]]
name = "keychain"
type = "keychain"
[[source]]
name = "local-vault"
type = "local-vault"
[[source]]
name = "1p-personal"
type = "1password"
account = "personal.example.1password.com"
[[source]]
name = "vault-team"
type = "vault"
addr = "https://vault.example.internal/"
mount = "secret"
[default]
source = "keychain"
fallback = "local-vault"
[[route]]
prefix = "team/"
source = "vault-team"
mount = "secret/data/team"
[[route]]
prefix = "personal/"
source = "1p-personal"
vault = "Personal"
[secret."client-acme/jira/api-key"]
source = "1p-personal"
reference = "op://Work/Acme Jira/credential"
"#,
)
.unwrap();
assert_eq!(cfg.sources.len(), 4);
let vault_team = cfg.sources.iter().find(|s| s.name == "vault-team").unwrap();
assert_eq!(vault_team.source_type, "vault");
assert_eq!(
vault_team.settings.get("addr").unwrap().as_str().unwrap(),
"https://vault.example.internal/"
);
let default = cfg.default.unwrap();
assert_eq!(default.source, "keychain");
assert_eq!(default.fallback.as_deref(), Some("local-vault"));
assert_eq!(cfg.routes.len(), 2);
assert_eq!(cfg.routes[0].prefix, "team/");
assert_eq!(
cfg.routes[0]
.settings
.get("mount")
.unwrap()
.as_str()
.unwrap(),
"secret/data/team"
);
let path = SecretPath::parse("client-acme/jira/api-key").unwrap();
let ovr = cfg.secret_overrides.get(&path).unwrap();
assert_eq!(ovr.source, "1p-personal");
assert_eq!(ovr.reference, "op://Work/Acme Jira/credential");
}
#[test]
fn empty_source_name_rejected() {
let err = RouterConfig::parse(
r#"
[[source]]
name = ""
type = "keychain"
"#,
)
.unwrap_err();
match err {
RouterConfigError::BadSourceName { name, reason } => {
assert_eq!(name, "");
assert!(reason.contains("non-empty"));
}
other => panic!("expected BadSourceName, got {other:?}"),
}
}
#[test]
fn uppercase_source_name_rejected() {
let err = RouterConfig::parse(
r#"
[[source]]
name = "Keychain"
type = "keychain"
"#,
)
.unwrap_err();
assert!(matches!(err, RouterConfigError::BadSourceName { .. }));
}
#[test]
fn source_name_starting_with_dash_rejected() {
let err = RouterConfig::parse(
r#"
[[source]]
name = "-bad"
type = "x"
"#,
)
.unwrap_err();
assert!(matches!(err, RouterConfigError::BadSourceName { .. }));
}
#[test]
fn source_name_with_digit_first_accepted() {
let cfg = RouterConfig::parse(
r#"
[[source]]
name = "1p-personal"
type = "1password"
"#,
)
.unwrap();
assert_eq!(cfg.sources[0].name, "1p-personal");
}
#[test]
fn duplicate_source_name_rejected() {
let err = RouterConfig::parse(
r#"
[[source]]
name = "vault-x"
type = "vault"
[[source]]
name = "vault-x"
type = "vault"
"#,
)
.unwrap_err();
match err {
RouterConfigError::DuplicateSource { name } => assert_eq!(name, "vault-x"),
other => panic!("expected DuplicateSource, got {other:?}"),
}
}
#[test]
fn default_referencing_undefined_source_rejected() {
let err = RouterConfig::parse(
r#"
[[source]]
name = "keychain"
type = "keychain"
[default]
source = "nope"
"#,
)
.unwrap_err();
match err {
RouterConfigError::UndefinedDefaultSource { name } => assert_eq!(name, "nope"),
other => panic!("expected UndefinedDefaultSource, got {other:?}"),
}
}
#[test]
fn default_fallback_referencing_undefined_source_rejected() {
let err = RouterConfig::parse(
r#"
[[source]]
name = "keychain"
type = "keychain"
[default]
source = "keychain"
fallback = "nope"
"#,
)
.unwrap_err();
assert!(matches!(
err,
RouterConfigError::UndefinedFallbackSource { .. }
));
}
#[test]
fn route_prefix_without_trailing_slash_rejected() {
let err = RouterConfig::parse(
r#"
[[source]]
name = "vault-team"
type = "vault"
[[route]]
prefix = "team"
source = "vault-team"
"#,
)
.unwrap_err();
match err {
RouterConfigError::BadRoutePrefix { prefix } => assert_eq!(prefix, "team"),
other => panic!("expected BadRoutePrefix, got {other:?}"),
}
}
#[test]
fn duplicate_route_prefix_rejected() {
let err = RouterConfig::parse(
r#"
[[source]]
name = "vault-a"
type = "vault"
[[source]]
name = "vault-b"
type = "vault"
[[route]]
prefix = "team/"
source = "vault-a"
[[route]]
prefix = "team/"
source = "vault-b"
"#,
)
.unwrap_err();
assert!(matches!(
err,
RouterConfigError::DuplicateRoutePrefix { .. }
));
}
#[test]
fn route_with_undefined_source_rejected() {
let err = RouterConfig::parse(
r#"
[[source]]
name = "keychain"
type = "keychain"
[[route]]
prefix = "team/"
source = "vault-team"
"#,
)
.unwrap_err();
match err {
RouterConfigError::UndefinedRouteSource { prefix, name } => {
assert_eq!(prefix, "team/");
assert_eq!(name, "vault-team");
}
other => panic!("expected UndefinedRouteSource, got {other:?}"),
}
}
#[test]
fn secret_override_with_invalid_path_rejected() {
let err = RouterConfig::parse(
r#"
[[source]]
name = "keychain"
type = "keychain"
[secret."BAD"]
source = "keychain"
reference = "BAD"
"#,
)
.unwrap_err();
match err {
RouterConfigError::BadSecretPath { path, .. } => assert_eq!(path, "BAD"),
other => panic!("expected BadSecretPath, got {other:?}"),
}
}
#[test]
fn secret_override_with_undefined_source_rejected() {
let err = RouterConfig::parse(
r#"
[[source]]
name = "keychain"
type = "keychain"
[secret."team/gitlab/token-deploy"]
source = "nope"
reference = "x"
"#,
)
.unwrap_err();
assert!(matches!(
err,
RouterConfigError::UndefinedSecretSource { .. }
));
}
#[test]
fn load_from_missing_file_returns_default_empty_config() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("nope.toml");
let cfg = RouterConfig::load_from(&path).unwrap();
assert_eq!(cfg, RouterConfig::default());
}
#[test]
fn load_from_invalid_toml_surfaces_path() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("sources.toml");
std::fs::write(&path, "[[ this is not toml").unwrap();
let err = RouterConfig::load_from(&path).unwrap_err();
match err {
RouterConfigError::Parse { path: errpath, .. } => assert_eq!(errpath, path),
other => panic!("expected Parse, got {other:?}"),
}
}
#[test]
fn load_from_valid_file_round_trips_through_parse() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("sources.toml");
std::fs::write(
&path,
r#"
[[source]]
name = "keychain"
type = "keychain"
[default]
source = "keychain"
"#,
)
.unwrap();
let cfg = RouterConfig::load_from(&path).unwrap();
assert_eq!(cfg.sources.len(), 1);
assert_eq!(cfg.default.as_ref().unwrap().source, "keychain");
}
#[test]
fn default_path_lives_under_devboy_tools_secrets_sources_toml() {
let p = RouterConfig::default_path().unwrap();
let s = p.to_string_lossy();
assert!(s.contains("devboy-tools"));
assert!(
s.ends_with(&format!("{SECRETS_SUBDIR}/{SOURCES_FILENAME}"))
|| s.ends_with(&format!("{SECRETS_SUBDIR}\\{SOURCES_FILENAME}"))
);
}
}