use std::path::PathBuf;
use std::str::FromStr;
use serde::{Deserialize, Deserializer};
use crate::error::SecretError;
#[derive(Debug, Clone, PartialEq)]
pub enum SecretUri {
Plain(String),
Keychain { service: String, key: String },
OnePassword {
vault: String,
item: String,
field: String,
},
Env { var_name: String },
File { path: PathBuf },
Base64 { data: String },
}
impl SecretUri {
pub fn is_plain(&self) -> bool {
matches!(self, SecretUri::Plain(_))
}
pub fn backend_name(&self) -> &'static str {
match self {
SecretUri::Plain(_) => "plain",
SecretUri::Keychain { .. } => "keychain",
SecretUri::OnePassword { .. } => "1password",
SecretUri::Env { .. } => "env",
SecretUri::File { .. } => "file",
SecretUri::Base64 { .. } => "base64",
}
}
}
impl FromStr for SecretUri {
type Err = SecretError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.starts_with("keychain://") {
parse_keychain_uri(s)
} else if s.starts_with("op://") {
parse_onepassword_uri(s)
} else if s.starts_with("env://") {
parse_env_uri(s)
} else if s.starts_with("file://") {
parse_file_uri(s)
} else if s.starts_with("base64://") {
parse_base64_uri(s)
} else if looks_like_file_path(s) {
Ok(SecretUri::File {
path: PathBuf::from(s),
})
} else {
Ok(SecretUri::Plain(s.to_string()))
}
}
}
fn parse_keychain_uri(s: &str) -> Result<SecretUri, SecretError> {
let rest = s.strip_prefix("keychain://").unwrap();
let parts: Vec<&str> = rest.splitn(2, '/').collect();
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
return Err(SecretError::invalid_uri(
s,
"keychain URI must be keychain://service/key",
));
}
Ok(SecretUri::Keychain {
service: parts[0].to_string(),
key: parts[1].to_string(),
})
}
fn parse_onepassword_uri(s: &str) -> Result<SecretUri, SecretError> {
let rest = s.strip_prefix("op://").unwrap();
let parts: Vec<&str> = rest.splitn(3, '/').collect();
if parts.len() != 3 || parts.iter().any(|p| p.is_empty()) {
return Err(SecretError::invalid_uri(
s,
"1Password URI must be op://vault/item/field",
));
}
Ok(SecretUri::OnePassword {
vault: parts[0].to_string(),
item: parts[1].to_string(),
field: parts[2].to_string(),
})
}
fn parse_env_uri(s: &str) -> Result<SecretUri, SecretError> {
let var_name = s.strip_prefix("env://").unwrap();
if var_name.is_empty() {
return Err(SecretError::invalid_uri(
s,
"env URI must specify a variable name",
));
}
Ok(SecretUri::Env {
var_name: var_name.to_string(),
})
}
fn parse_file_uri(s: &str) -> Result<SecretUri, SecretError> {
let path = s.strip_prefix("file://").unwrap();
if path.is_empty() {
return Err(SecretError::invalid_uri(s, "file URI must specify a path"));
}
Ok(SecretUri::File {
path: PathBuf::from(path),
})
}
fn parse_base64_uri(s: &str) -> Result<SecretUri, SecretError> {
let data = s.strip_prefix("base64://").unwrap();
if data.is_empty() {
return Err(SecretError::invalid_uri(
s,
"base64 URI must contain encoded data",
));
}
Ok(SecretUri::Base64 {
data: data.to_string(),
})
}
fn looks_like_file_path(s: &str) -> bool {
s.starts_with('/')
|| s.starts_with("./")
|| s.starts_with("../")
|| (s.len() > 2 && s.chars().nth(1) == Some(':')) || s.contains(".pem")
|| s.contains(".crt")
|| s.contains(".key")
}
impl<'de> Deserialize<'de> for SecretUri {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
SecretUri::from_str(&s).map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_keychain_uri() {
let uri: SecretUri = "keychain://myservice/mykey".parse().unwrap();
assert_eq!(
uri,
SecretUri::Keychain {
service: "myservice".to_string(),
key: "mykey".to_string(),
}
);
}
#[test]
fn test_parse_onepassword_uri() {
let uri: SecretUri = "op://Private/Server/api-token".parse().unwrap();
assert_eq!(
uri,
SecretUri::OnePassword {
vault: "Private".to_string(),
item: "Server".to_string(),
field: "api-token".to_string(),
}
);
}
#[test]
fn test_parse_env_uri() {
let uri: SecretUri = "env://MY_SECRET".parse().unwrap();
assert_eq!(
uri,
SecretUri::Env {
var_name: "MY_SECRET".to_string(),
}
);
}
#[test]
fn test_parse_file_uri() {
let uri: SecretUri = "file:///etc/tunnel/secret.key".parse().unwrap();
assert_eq!(
uri,
SecretUri::File {
path: PathBuf::from("/etc/tunnel/secret.key"),
}
);
}
#[test]
fn test_parse_bare_path() {
let uri: SecretUri = "/etc/tunnel/server.crt".parse().unwrap();
assert_eq!(
uri,
SecretUri::File {
path: PathBuf::from("/etc/tunnel/server.crt"),
}
);
}
#[test]
fn test_parse_plain_value() {
let uri: SecretUri = "my-secret-token".parse().unwrap();
assert_eq!(uri, SecretUri::Plain("my-secret-token".to_string()));
}
#[test]
fn test_invalid_keychain_uri() {
let result: Result<SecretUri, _> = "keychain://onlyservice".parse();
assert!(result.is_err());
}
#[test]
fn test_invalid_onepassword_uri() {
let result: Result<SecretUri, _> = "op://vault/item".parse();
assert!(result.is_err());
}
#[test]
fn test_invalid_env_uri() {
let result: Result<SecretUri, _> = "env://".parse();
assert!(result.is_err());
}
#[test]
fn test_parse_base64_uri() {
let uri: SecretUri = "base64://SGVsbG8gV29ybGQ=".parse().unwrap();
assert_eq!(
uri,
SecretUri::Base64 {
data: "SGVsbG8gV29ybGQ=".to_string(),
}
);
}
#[test]
fn test_invalid_base64_uri() {
let result: Result<SecretUri, _> = "base64://".parse();
assert!(result.is_err());
}
}