use secrecy::SecretString;
use thiserror::Error;
pub const ALIAS_PREFIX: &str = "@secret:";
pub fn is_alias(s: &str) -> bool {
parse_alias(s).is_some()
}
pub fn parse_alias(s: &str) -> Option<&str> {
s.strip_prefix(ALIAS_PREFIX).filter(|p| !p.is_empty())
}
#[derive(Debug, Error)]
pub enum AliasResolverError {
#[error("no value for alias path '{path}'")]
NotFound {
path: String,
},
#[error("alias path '{path}' is malformed: {reason}")]
BadPath {
path: String,
reason: String,
},
#[error("secret backend error resolving '{path}': {message}")]
Backend {
path: String,
message: String,
},
}
pub trait SecretResolver: Send + Sync {
fn resolve(&self, path: &str) -> Result<SecretString, AliasResolverError>;
}
#[cfg(test)]
mod tests {
use super::*;
use secrecy::ExposeSecret;
use std::collections::HashMap;
use std::sync::Mutex;
#[test]
fn parse_alias_extracts_path() {
assert_eq!(
parse_alias("@secret:team/gitlab/token-deploy"),
Some("team/gitlab/token-deploy")
);
}
#[test]
fn parse_alias_rejects_bare_prefix() {
assert_eq!(parse_alias("@secret:"), None);
}
#[test]
fn parse_alias_rejects_strings_without_prefix() {
assert_eq!(parse_alias("team/gitlab/token-deploy"), None);
assert_eq!(parse_alias(""), None);
assert_eq!(parse_alias("not-an-alias"), None);
}
#[test]
fn parse_alias_does_not_match_partial_occurrence() {
assert_eq!(parse_alias("Bearer @secret:foo/bar/baz"), None);
assert_eq!(parse_alias("foo @secret:bar/baz"), None);
}
#[test]
fn is_alias_mirrors_parse_alias() {
assert!(is_alias("@secret:foo/bar/baz"));
assert!(!is_alias("not-an-alias"));
assert!(!is_alias("@secret:"));
}
#[test]
fn alias_prefix_constant_matches_adr_020() {
assert_eq!(ALIAS_PREFIX, "@secret:");
}
struct MapResolver {
entries: Mutex<HashMap<String, String>>,
}
impl MapResolver {
fn new(entries: impl IntoIterator<Item = (&'static str, &'static str)>) -> Self {
Self {
entries: Mutex::new(
entries
.into_iter()
.map(|(k, v)| (k.to_owned(), v.to_owned()))
.collect(),
),
}
}
}
impl SecretResolver for MapResolver {
fn resolve(&self, path: &str) -> Result<SecretString, AliasResolverError> {
let map = self.entries.lock().expect("MapResolver mutex poisoned");
match map.get(path) {
Some(v) => Ok(SecretString::from(v.clone())),
None => Err(AliasResolverError::NotFound {
path: path.to_owned(),
}),
}
}
}
#[test]
fn resolver_trait_works_through_dyn_box() {
let resolver: Box<dyn SecretResolver> = Box::new(MapResolver::new([(
"team/gitlab/token-deploy",
"ghp_fixture",
)]));
let value = resolver.resolve("team/gitlab/token-deploy").unwrap();
assert_eq!(value.expose_secret(), "ghp_fixture");
}
#[test]
fn resolver_returns_not_found_for_missing_path() {
let resolver = MapResolver::new([("a/b/c", "v")]);
let err = resolver.resolve("does/not/exist").unwrap_err();
match err {
AliasResolverError::NotFound { path } => assert_eq!(path, "does/not/exist"),
other => panic!("expected NotFound, got {other:?}"),
}
}
#[test]
fn roundtrip_preserves_alias_in_string_field() {
#[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)]
struct Cfg {
alias_text: String,
}
let original = Cfg {
alias_text: "@secret:team/gitlab/token-deploy".to_owned(),
};
let toml_text = toml::to_string(&original).unwrap();
assert!(toml_text.contains("@secret:team/gitlab/token-deploy"));
let back: Cfg = toml::from_str(&toml_text).unwrap();
assert_eq!(back, original);
assert_eq!(back.alias_text, "@secret:team/gitlab/token-deploy");
}
#[test]
fn roundtrip_preserves_alias_in_optional_field() {
#[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)]
struct Cfg {
alias_text: Option<String>,
}
let original = Cfg {
alias_text: Some("@secret:personal/github/pat".to_owned()),
};
let toml_text = toml::to_string(&original).unwrap();
let back: Cfg = toml::from_str(&toml_text).unwrap();
assert_eq!(
back.alias_text.as_deref(),
Some("@secret:personal/github/pat")
);
}
#[test]
fn error_display_includes_path_for_not_found() {
let e = AliasResolverError::NotFound {
path: "team/x/y".into(),
};
let s = format!("{e}");
assert!(s.contains("team/x/y"));
}
#[test]
fn error_display_includes_reason_for_bad_path() {
let e = AliasResolverError::BadPath {
path: "BAD".into(),
reason: "uppercase letter".into(),
};
let s = format!("{e}");
assert!(s.contains("BAD"));
assert!(s.contains("uppercase letter"));
}
#[test]
fn error_display_includes_backend_message() {
let e = AliasResolverError::Backend {
path: "team/x/y".into(),
message: "vault unsealed but token expired".into(),
};
let s = format!("{e}");
assert!(s.contains("vault unsealed but token expired"));
}
}