use std::collections::BTreeMap;
use thiserror::Error;
use crate::router_config::RouterConfig;
use crate::secret_path::SecretPath;
#[derive(Debug, Clone, PartialEq)]
pub enum RouteDecision<'a> {
Explicit {
source: &'a str,
reference: &'a str,
},
Prefix {
source: &'a str,
prefix: &'a str,
settings: &'a BTreeMap<String, toml::Value>,
},
Default {
source: &'a str,
fallback: Option<&'a str>,
},
}
impl<'a> RouteDecision<'a> {
pub fn source(&self) -> &'a str {
match self {
RouteDecision::Explicit { source, .. }
| RouteDecision::Prefix { source, .. }
| RouteDecision::Default { source, .. } => source,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum ResolveError {
#[error(
"no route for path '{path}' — config has no matching [secret], no matching [[route]], and no [default]"
)]
NoRoute {
path: String,
},
}
pub struct PathResolver<'a> {
config: &'a RouterConfig,
}
impl<'a> PathResolver<'a> {
pub fn new(config: &'a RouterConfig) -> Self {
Self { config }
}
pub fn resolve(&self, path: &SecretPath) -> Result<RouteDecision<'a>, ResolveError> {
if let Some(ovr) = self.config.secret_overrides.get(path) {
return Ok(RouteDecision::Explicit {
source: ovr.source.as_str(),
reference: ovr.reference.as_str(),
});
}
let path_str = path.as_str();
let mut best: Option<&'a crate::router_config::RouteRule> = None;
for r in &self.config.routes {
if !path_str.starts_with(&r.prefix) {
continue;
}
match best {
None => best = Some(r),
Some(prev) if r.prefix.len() > prev.prefix.len() => best = Some(r),
Some(_) => {} }
}
if let Some(r) = best {
return Ok(RouteDecision::Prefix {
source: r.source.as_str(),
prefix: r.prefix.as_str(),
settings: &r.settings,
});
}
if let Some(d) = &self.config.default {
return Ok(RouteDecision::Default {
source: d.source.as_str(),
fallback: d.fallback.as_deref(),
});
}
Err(ResolveError::NoRoute {
path: path_str.to_owned(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::router_config::RouterConfig;
fn cfg(toml: &str) -> RouterConfig {
RouterConfig::parse(toml).expect("fixture config must parse")
}
fn p(s: &str) -> SecretPath {
SecretPath::parse(s).unwrap()
}
#[test]
fn explicit_secret_override_wins_over_prefix_and_default() {
let c = cfg(r#"
[[source]]
name = "keychain"
type = "keychain"
[[source]]
name = "vault-team"
type = "vault"
[[source]]
name = "1p-personal"
type = "1password"
[default]
source = "keychain"
[[route]]
prefix = "team/"
source = "vault-team"
[secret."team/gitlab/token-deploy"]
source = "1p-personal"
reference = "op://Work/Gitlab/credential"
"#);
let r = PathResolver::new(&c);
let d = r.resolve(&p("team/gitlab/token-deploy")).unwrap();
match d {
RouteDecision::Explicit { source, reference } => {
assert_eq!(source, "1p-personal");
assert_eq!(reference, "op://Work/Gitlab/credential");
}
other => panic!("expected Explicit, got {other:?}"),
}
}
#[test]
fn matching_prefix_returns_route_with_settings() {
let c = cfg(r#"
[[source]]
name = "vault-team"
type = "vault"
[[route]]
prefix = "team/"
source = "vault-team"
mount = "secret/data/team"
"#);
let r = PathResolver::new(&c);
let d = r.resolve(&p("team/gitlab/token-deploy")).unwrap();
match d {
RouteDecision::Prefix {
source,
prefix,
settings,
} => {
assert_eq!(source, "vault-team");
assert_eq!(prefix, "team/");
assert_eq!(
settings.get("mount").unwrap().as_str().unwrap(),
"secret/data/team"
);
}
other => panic!("expected Prefix, got {other:?}"),
}
}
#[test]
fn longest_matching_prefix_wins_over_shorter() {
let c = cfg(r#"
[[source]]
name = "vault-team"
type = "vault"
[[source]]
name = "vault-acme"
type = "vault"
[[route]]
prefix = "team/"
source = "vault-team"
[[route]]
prefix = "team/acme/"
source = "vault-acme"
"#);
let r = PathResolver::new(&c);
let d = r.resolve(&p("team/acme/database/url")).unwrap();
assert_eq!(d.source(), "vault-acme");
match d {
RouteDecision::Prefix { prefix, .. } => assert_eq!(prefix, "team/acme/"),
other => panic!("expected Prefix, got {other:?}"),
}
let d = r.resolve(&p("team/foo/x")).unwrap();
assert_eq!(d.source(), "vault-team");
}
#[test]
fn duplicate_prefix_is_rejected_at_config_load_so_resolver_never_sees_it() {
let err = RouterConfig::parse(
r#"
[[source]]
name = "src-a"
type = "x"
[[source]]
name = "src-b"
type = "x"
[[route]]
prefix = "tea/"
source = "src-a"
[[route]]
prefix = "tea/"
source = "src-b"
"#,
)
.unwrap_err();
assert!(matches!(
err,
crate::router_config::RouterConfigError::DuplicateRoutePrefix { .. }
));
}
#[test]
fn earlier_route_wins_when_prefixes_have_different_length_but_one_starts_the_other_short() {
let c = cfg(r#"
[[source]]
name = "long"
type = "x"
[[source]]
name = "short"
type = "x"
[[route]]
prefix = "team/foo/"
source = "long"
[[route]]
prefix = "team/"
source = "short"
"#);
let r = PathResolver::new(&c);
let d = r.resolve(&p("team/foo/secret")).unwrap();
assert_eq!(d.source(), "long");
}
#[test]
fn prefix_must_match_at_segment_boundary() {
let c = cfg(r#"
[[source]]
name = "vault-team"
type = "vault"
[default]
source = "vault-team"
[[route]]
prefix = "team/"
source = "vault-team"
"#);
let r = PathResolver::new(&c);
let d = r.resolve(&p("teamfoo/sub/key")).unwrap();
match d {
RouteDecision::Default { .. } => {}
other => panic!("teamfoo/sub/key must NOT match the team/ prefix; got {other:?}"),
}
}
#[test]
fn unmatched_path_falls_back_to_default() {
let c = cfg(r#"
[[source]]
name = "keychain"
type = "keychain"
[default]
source = "keychain"
"#);
let r = PathResolver::new(&c);
let d = r.resolve(&p("personal/random/key")).unwrap();
match d {
RouteDecision::Default { source, fallback } => {
assert_eq!(source, "keychain");
assert!(fallback.is_none());
}
other => panic!("expected Default, got {other:?}"),
}
}
#[test]
fn default_fallback_is_carried_through() {
let c = cfg(r#"
[[source]]
name = "keychain"
type = "keychain"
[[source]]
name = "local-vault"
type = "local-vault"
[default]
source = "keychain"
fallback = "local-vault"
"#);
let r = PathResolver::new(&c);
let d = r.resolve(&p("anything/goes/here")).unwrap();
match d {
RouteDecision::Default { source, fallback } => {
assert_eq!(source, "keychain");
assert_eq!(fallback, Some("local-vault"));
}
other => panic!("expected Default, got {other:?}"),
}
}
#[test]
fn no_secret_no_prefix_no_default_returns_no_route_error() {
let c = cfg(r#"
[[source]]
name = "vault-team"
type = "vault"
[[route]]
prefix = "team/"
source = "vault-team"
"#);
let r = PathResolver::new(&c);
let err = r.resolve(&p("personal/random/key")).unwrap_err();
match err {
ResolveError::NoRoute { path } => assert_eq!(path, "personal/random/key"),
}
}
#[test]
fn route_decision_source_helper_returns_the_dispatch_target() {
let c = cfg(r#"
[[source]]
name = "keychain"
type = "keychain"
[default]
source = "keychain"
"#);
let r = PathResolver::new(&c);
assert_eq!(r.resolve(&p("a/b/c")).unwrap().source(), "keychain");
}
#[test]
fn fixture_table_exercises_every_branch() {
let c = cfg(r#"
[[source]]
name = "keychain"
type = "keychain"
[[source]]
name = "local-vault"
type = "local-vault"
[[source]]
name = "vault-team"
type = "vault"
[[source]]
name = "1p-personal"
type = "1password"
[default]
source = "keychain"
fallback = "local-vault"
[[route]]
prefix = "team/"
source = "vault-team"
[[route]]
prefix = "team/acme/"
source = "1p-personal"
[secret."client-acme/jira/api-key"]
source = "1p-personal"
reference = "op://Work/Acme Jira/credential"
"#);
let r = PathResolver::new(&c);
let cases: &[(&str, &str, &str)] = &[
("client-acme/jira/api-key", "1p-personal", "Explicit"),
("team/acme/db/url", "1p-personal", "Prefix"),
("team/foo/bar", "vault-team", "Prefix"),
("personal/x/y", "keychain", "Default"),
];
for (input, expected_source, expected_variant) in cases {
let d = r
.resolve(&p(input))
.unwrap_or_else(|e| panic!("resolve('{input}') failed: {e}"));
assert_eq!(
d.source(),
*expected_source,
"fixture for '{input}' picked wrong source"
);
let actual_variant = match &d {
RouteDecision::Explicit { .. } => "Explicit",
RouteDecision::Prefix { .. } => "Prefix",
RouteDecision::Default { .. } => "Default",
};
assert_eq!(
actual_variant, *expected_variant,
"fixture for '{input}' picked wrong variant"
);
}
}
}