use hasp_core::{
Backend, BackendFailureKind, Entry, Error, ExposeSecret, ProxyConfig, SecretString,
};
use std::time::Duration;
use url::Url;
#[derive(Debug)]
pub struct VaultUrl {
pub mount: String,
pub path: String,
pub field: Option<String>,
}
impl TryFrom<&Url> for VaultUrl {
type Error = Error;
fn try_from(url: &Url) -> Result<Self, Self::Error> {
if url.scheme() != "vault" {
return Err(Error::InvalidUrl("expected vault:// scheme".into()));
}
let mount = url
.host_str()
.ok_or_else(|| Error::InvalidUrl("vault:// requires a mount point (host)".into()))?
.to_owned();
if mount.is_empty() {
return Err(Error::InvalidUrl("vault:// mount must not be empty".into()));
}
let path = url.path().to_owned();
let mut field = None;
for (k, v) in url.query_pairs() {
if k == "field" {
field = Some(v.into_owned());
} else {
return Err(Error::InvalidUrl(format!(
"vault:// unknown query parameter: {k}"
)));
}
}
Ok(VaultUrl { mount, path, field })
}
}
#[derive(Debug)]
pub struct VaultBackend {
proxy: Option<ProxyConfig>,
}
impl VaultBackend {
pub fn new() -> Self {
Self::with_proxy(None)
}
pub fn with_proxy(proxy: Option<ProxyConfig>) -> Self {
Self { proxy }
}
}
impl Default for VaultBackend {
fn default() -> Self {
Self::new()
}
}
impl Backend for VaultBackend {
fn scheme(&self) -> &'static str {
"vault"
}
fn validate(&self, url: &Url) -> Result<(), Error> {
VaultUrl::try_from(url).map(|_| ())
}
fn get(&self, url: &Url) -> Result<SecretString, Error> {
check_ambient_credentials()?;
let vault_url = VaultUrl::try_from(url)?;
let (token, addr) = ambient_credentials()?;
let request_url = build_request_url(&addr, &vault_url.mount, &vault_url.path);
let client = build_client(self.proxy.as_ref())?;
let response = client
.get(&request_url)
.header("X-Vault-Token", token)
.send()
.map_err(map_reqwest_error)?;
let status = response.status();
if status != reqwest::StatusCode::OK {
return Err(map_vault_status(status, url));
}
let body: serde_json::Value = response.json().map_err(|e| Error::Backend {
scheme: "vault",
kind: BackendFailureKind::Permanent,
message: format!("invalid JSON from Vault: {e}"),
})?;
extract_secret(&body, vault_url.field.as_deref())
}
fn put(&self, url: &Url, value: &SecretString) -> Result<(), Error> {
check_ambient_credentials()?;
let vault_url = VaultUrl::try_from(url)?;
let (token, addr) = ambient_credentials()?;
let request_url = build_request_url(&addr, &vault_url.mount, &vault_url.path);
let client = build_client(self.proxy.as_ref())?;
let data = if let Some(ref field) = vault_url.field {
let get_resp = client
.get(&request_url)
.header("X-Vault-Token", &token)
.send()
.map_err(map_reqwest_error)?;
let mut obj = match get_resp.status() {
reqwest::StatusCode::OK => {
let body: serde_json::Value = get_resp.json().map_err(|e| Error::Backend {
scheme: "vault",
kind: BackendFailureKind::Permanent,
message: format!("invalid JSON from Vault: {e}"),
})?;
body.get("data")
.and_then(|d| d.get("data"))
.cloned()
.unwrap_or_else(|| serde_json::json!({}))
}
reqwest::StatusCode::FORBIDDEN | reqwest::StatusCode::NOT_FOUND => {
serde_json::json!({})
}
status => return Err(map_vault_status(status, url)),
};
let json_value = serde_json::from_str(value.expose_secret())
.unwrap_or_else(|_| serde_json::Value::String(value.expose_secret().to_owned()));
if let Some(map) = obj.as_object_mut() {
map.insert(field.clone(), json_value);
} else {
return Err(Error::Backend {
scheme: "vault",
kind: BackendFailureKind::Permanent,
message: "Vault secret data is not a JSON object; cannot update field".into(),
});
}
obj
} else {
serde_json::from_str(value.expose_secret()).map_err(|e| {
Error::InvalidUrl(format!("vault:// put value must be valid JSON: {e}"))
})?
};
let body = serde_json::json!({ "data": data });
let post_resp = client
.post(&request_url)
.header("X-Vault-Token", &token)
.json(&body)
.send()
.map_err(map_reqwest_error)?;
match post_resp.status() {
reqwest::StatusCode::OK | reqwest::StatusCode::NO_CONTENT => Ok(()),
status => Err(map_vault_status(status, url)),
}
}
fn list(&self, url: &Url) -> Result<Vec<Entry>, Error> {
check_ambient_credentials()?;
let vault_url = VaultUrl::try_from(url)?;
let (token, addr) = ambient_credentials()?;
let path_str = vault_url.path.trim_start_matches('/');
let prefix = if let Some(after_data) = path_str.strip_prefix("data/") {
after_data
.rfind('/')
.map(|i| &after_data[..i])
.unwrap_or("")
} else {
path_str.rfind('/').map(|i| &path_str[..i]).unwrap_or("")
};
let metadata_path = if prefix.is_empty() {
"/metadata".into()
} else {
format!("/metadata/{prefix}")
};
let request_url = build_request_url(&addr, &vault_url.mount, &metadata_path);
let client = build_client(self.proxy.as_ref())?;
let response = client
.request(
reqwest::Method::from_bytes(b"LIST").expect("LIST is a valid HTTP method"),
&request_url,
)
.header("X-Vault-Token", &token)
.send()
.map_err(map_reqwest_error)?;
let status = response.status();
if status != reqwest::StatusCode::OK {
return Err(map_vault_status(status, url));
}
let body: serde_json::Value = response.json().map_err(|e| Error::Backend {
scheme: "vault",
kind: BackendFailureKind::Permanent,
message: format!("invalid JSON from Vault: {e}"),
})?;
let keys = body
.get("data")
.and_then(|d| d.get("keys"))
.and_then(|k| k.as_array())
.ok_or_else(|| Error::Backend {
scheme: "vault",
kind: BackendFailureKind::Permanent,
message: "Vault LIST response missing data.keys field".into(),
})?;
let mut entries = Vec::new();
for key in keys {
let name = key.as_str().unwrap_or("").trim_end_matches('/').to_owned();
if name.is_empty() {
continue;
}
let entry_url = if vault_url.path.starts_with("/data/") {
let base_path = vault_url.path.trim_start_matches("/data/");
let parent = base_path.rfind('/').map(|i| &base_path[..i]).unwrap_or("");
if parent.is_empty() {
format!("vault://{}/data/{name}", vault_url.mount)
} else {
format!("vault://{}/data/{}/{name}", vault_url.mount, parent)
}
} else {
format!("vault://{}/{name}", vault_url.mount)
};
let parsed = Url::parse(&entry_url).map_err(|e| Error::Backend {
scheme: "vault",
kind: BackendFailureKind::Permanent,
message: format!("failed to parse list entry URL: {e}"),
})?;
entries.push(Entry { name, url: parsed });
}
Ok(entries)
}
fn delete(&self, url: &Url) -> Result<(), Error> {
check_ambient_credentials()?;
let vault_url = VaultUrl::try_from(url)?;
let (token, addr) = ambient_credentials()?;
let request_url = build_request_url(&addr, &vault_url.mount, &vault_url.path);
let client = build_client(self.proxy.as_ref())?;
let response = client
.delete(&request_url)
.header("X-Vault-Token", &token)
.send()
.map_err(map_reqwest_error)?;
match response.status() {
reqwest::StatusCode::NO_CONTENT => Ok(()),
status => Err(map_vault_status(status, url)),
}
}
fn exists(&self, url: &Url) -> Result<bool, Error> {
check_ambient_credentials()?;
let vault_url = VaultUrl::try_from(url)?;
let (token, addr) = ambient_credentials()?;
let request_url = build_request_url(&addr, &vault_url.mount, &vault_url.path);
let client = build_client(self.proxy.as_ref())?;
let response = client
.get(&request_url)
.header("X-Vault-Token", token)
.send()
.map_err(map_reqwest_error)?;
match response.status() {
reqwest::StatusCode::OK => Ok(true),
reqwest::StatusCode::FORBIDDEN | reqwest::StatusCode::NOT_FOUND => Ok(false),
status => Err(map_vault_status(status, url)),
}
}
}
fn build_client(proxy: Option<&ProxyConfig>) -> Result<reqwest::blocking::Client, Error> {
let mut builder = reqwest::blocking::Client::builder().timeout(Duration::from_secs(10));
if let Some(p) = proxy {
let reqwest_proxy =
reqwest::Proxy::all(p.url_without_credentials()).map_err(|e| Error::Backend {
scheme: "vault",
kind: BackendFailureKind::Permanent,
message: format!("invalid proxy URL: {e}"),
})?;
builder = builder.proxy(reqwest_proxy);
}
builder.build().map_err(|e| Error::Backend {
scheme: "vault",
kind: BackendFailureKind::Permanent,
message: format!("failed to build HTTP client: {e}"),
})
}
fn ambient_credentials() -> Result<(String, String), Error> {
let token = std::env::var("VAULT_TOKEN").map_err(|_| {
Error::AuthenticationFailed("no ambient Vault credentials; set VAULT_TOKEN".into())
})?;
let addr = std::env::var("VAULT_ADDR").map_err(|_| {
Error::AuthenticationFailed("no ambient Vault address; set VAULT_ADDR".into())
})?;
Ok((token, addr))
}
fn check_ambient_credentials() -> Result<(), Error> {
ambient_credentials().map(|_| ())
}
fn build_request_url(addr: &str, mount: &str, path: &str) -> String {
format!("{}/v1/{}{path}", addr.trim_end_matches('/'), mount)
}
fn map_reqwest_error(err: reqwest::Error) -> Error {
if err.is_timeout() || err.is_connect() {
Error::Backend {
scheme: "vault",
kind: BackendFailureKind::Transient,
message: format!("Vault request failed: {err}"),
}
} else {
Error::Backend {
scheme: "vault",
kind: BackendFailureKind::Permanent,
message: format!("Vault request failed: {err}"),
}
}
}
fn map_vault_status(status: reqwest::StatusCode, url: &Url) -> Error {
match status {
reqwest::StatusCode::FORBIDDEN | reqwest::StatusCode::NOT_FOUND => {
Error::NotFound(url.to_string())
}
reqwest::StatusCode::TOO_MANY_REQUESTS => Error::Backend {
scheme: "vault",
kind: BackendFailureKind::Throttled,
message: format!("Vault returned HTTP {status}"),
},
status if status.is_server_error() => Error::Backend {
scheme: "vault",
kind: BackendFailureKind::Transient,
message: format!("Vault returned HTTP {status}"),
},
status => Error::Backend {
scheme: "vault",
kind: BackendFailureKind::Permanent,
message: format!("Vault returned HTTP {status}"),
},
}
}
fn extract_secret(body: &serde_json::Value, field: Option<&str>) -> Result<SecretString, Error> {
let data = body
.get("data")
.and_then(|d| d.get("data"))
.ok_or_else(|| Error::Backend {
scheme: "vault",
kind: BackendFailureKind::Permanent,
message: "Vault response missing data.data field".into(),
})?;
let value = match field {
Some(f) => hasp_core::extract_field(data, f)?,
None => data.to_string(),
};
Ok(SecretString::new(value.into()))
}
#[cfg(test)]
mod tests {
use super::*;
use hasp_core::test_utils::{EnvGuard, ENV_LOCK};
use hasp_core::ExposeSecret;
#[test]
fn parse_valid_url_with_field() {
let url = Url::parse("vault://secret/data/myapp/config?field=password").unwrap();
let v = VaultUrl::try_from(&url).unwrap();
assert_eq!(v.mount, "secret");
assert_eq!(v.path, "/data/myapp/config");
assert_eq!(v.field, Some("password".into()));
}
#[test]
fn parse_valid_url_without_field() {
let url = Url::parse("vault://kv/data/prod/db").unwrap();
let v = VaultUrl::try_from(&url).unwrap();
assert_eq!(v.mount, "kv");
assert_eq!(v.path, "/data/prod/db");
assert_eq!(v.field, None);
}
#[test]
fn parse_valid_url_root_path() {
let url = Url::parse("vault://secret/").unwrap();
let v = VaultUrl::try_from(&url).unwrap();
assert_eq!(v.mount, "secret");
assert_eq!(v.path, "/");
assert_eq!(v.field, None);
}
#[test]
fn parse_missing_host_fails() {
let url = Url::parse("vault:///data/myapp/config").unwrap();
assert!(VaultUrl::try_from(&url).is_err());
}
#[test]
fn parse_empty_mount_fails() {
let url = Url::parse("vault:///").unwrap();
assert!(VaultUrl::try_from(&url).is_err());
}
#[test]
fn parse_unknown_query_fails() {
let url = Url::parse("vault://secret/data/app?raw=true").unwrap();
assert!(VaultUrl::try_from(&url).is_err());
}
#[test]
fn error_map_403_to_not_found() {
let url = Url::parse("vault://secret/data/myapp/config").unwrap();
let err = map_vault_status(reqwest::StatusCode::FORBIDDEN, &url);
assert!(matches!(err, Error::NotFound(ref s) if s == "vault://secret/data/myapp/config"));
}
#[test]
fn error_map_404_to_not_found() {
let url = Url::parse("vault://secret/data/myapp/config").unwrap();
let err = map_vault_status(reqwest::StatusCode::NOT_FOUND, &url);
assert!(matches!(err, Error::NotFound(_)));
}
#[test]
fn error_map_429_to_throttled() {
let url = Url::parse("vault://secret/data/myapp/config").unwrap();
let err = map_vault_status(reqwest::StatusCode::TOO_MANY_REQUESTS, &url);
assert!(matches!(
err,
Error::Backend {
kind: BackendFailureKind::Throttled,
..
}
));
}
#[test]
fn error_map_500_to_transient() {
let url = Url::parse("vault://secret/data/myapp/config").unwrap();
let err = map_vault_status(reqwest::StatusCode::INTERNAL_SERVER_ERROR, &url);
assert!(matches!(
err,
Error::Backend {
kind: BackendFailureKind::Transient,
..
}
));
}
#[test]
fn error_map_418_to_permanent() {
let url = Url::parse("vault://secret/data/myapp/config").unwrap();
let err = map_vault_status(reqwest::StatusCode::IM_A_TEAPOT, &url);
assert!(matches!(
err,
Error::Backend {
kind: BackendFailureKind::Permanent,
..
}
));
}
#[test]
fn extract_field_found() {
let body = serde_json::json!({
"data": {
"data": {
"password": "secret123"
}
}
});
let secret = extract_secret(&body, Some("password")).unwrap();
assert_eq!(secret.expose_secret(), "secret123");
}
#[test]
fn extract_field_as_number_returns_stringified() {
let body = serde_json::json!({
"data": {
"data": {
"count": 42
}
}
});
let secret = extract_secret(&body, Some("count")).unwrap();
assert_eq!(secret.expose_secret(), "42");
}
#[test]
fn extract_field_missing() {
let body = serde_json::json!({
"data": {
"data": {
"password": "secret123"
}
}
});
let err = extract_secret(&body, Some("missing")).unwrap_err();
assert!(matches!(err, Error::NotFound(_)));
}
#[test]
fn extract_field_dotted_path_into_nested_object() {
let body = serde_json::json!({
"data": {
"data": {
"credentials": { "api_key": "ak-xyz" }
}
}
});
let secret = extract_secret(&body, Some(".credentials.api_key")).unwrap();
assert_eq!(secret.expose_secret(), "ak-xyz");
}
#[test]
fn extract_no_field_returns_json() {
let body = serde_json::json!({
"data": {
"data": {
"password": "secret123"
}
}
});
let secret = extract_secret(&body, None).unwrap();
assert_eq!(secret.expose_secret(), r#"{"password":"secret123"}"#);
}
#[test]
fn extract_missing_data_data() {
let body = serde_json::json!({ "data": {} });
let err = extract_secret(&body, Some("password")).unwrap_err();
assert!(matches!(err, Error::Backend { .. }));
}
#[test]
fn preflight_auth_no_token_fails_fast() {
let _lock = ENV_LOCK.lock().unwrap();
let old_token = std::env::var("VAULT_TOKEN").ok();
let old_addr = std::env::var("VAULT_ADDR").ok();
std::env::remove_var("VAULT_TOKEN");
std::env::remove_var("VAULT_ADDR");
let result = check_ambient_credentials();
match old_token {
Some(v) => std::env::set_var("VAULT_TOKEN", v),
None => std::env::remove_var("VAULT_TOKEN"),
}
match old_addr {
Some(v) => std::env::set_var("VAULT_ADDR", v),
None => std::env::remove_var("VAULT_ADDR"),
}
assert!(
matches!(result, Err(Error::AuthenticationFailed(_))),
"expected AuthenticationFailed when no ambient credentials are present"
);
}
#[test]
fn preflight_auth_token_no_addr_fails_fast() {
let _lock = ENV_LOCK.lock().unwrap();
let old_token = std::env::var("VAULT_TOKEN").ok();
let old_addr = std::env::var("VAULT_ADDR").ok();
std::env::remove_var("VAULT_TOKEN");
std::env::remove_var("VAULT_ADDR");
let _guard = EnvGuard::set("VAULT_TOKEN", "test-token");
let result = check_ambient_credentials();
match old_token {
Some(v) => std::env::set_var("VAULT_TOKEN", v),
None => std::env::remove_var("VAULT_TOKEN"),
}
match old_addr {
Some(v) => std::env::set_var("VAULT_ADDR", v),
None => std::env::remove_var("VAULT_ADDR"),
}
assert!(
matches!(result, Err(Error::AuthenticationFailed(_))),
"expected AuthenticationFailed when VAULT_ADDR is missing"
);
}
#[test]
fn preflight_auth_both_present_ok() {
let _lock = ENV_LOCK.lock().unwrap();
let _token_guard = EnvGuard::set("VAULT_TOKEN", "test-token");
let _addr_guard = EnvGuard::set("VAULT_ADDR", "http://localhost:8200");
assert!(check_ambient_credentials().is_ok());
}
#[test]
fn list_parsing_from_json() {
let body = serde_json::json!({
"data": {
"keys": [
"app/",
"db/",
"shared"
]
}
});
let keys = body
.get("data")
.and_then(|d| d.get("keys"))
.and_then(|k| k.as_array())
.expect("keys array");
assert_eq!(keys.len(), 3);
let names: Vec<String> = keys
.iter()
.map(|k| {
let s = k.as_str().unwrap_or("").trim_end_matches('/');
s.to_owned()
})
.collect();
assert_eq!(names, vec!["app", "db", "shared"]);
}
#[test]
fn list_url_strips_field_query() {
let url = Url::parse("vault://secret/data/myapp/config?field=password").unwrap();
let v = VaultUrl::try_from(&url).unwrap();
assert_eq!(v.mount, "secret");
assert_eq!(v.path, "/data/myapp/config");
}
#[test]
fn put_invalid_json_without_field() {
let _lock = ENV_LOCK.lock().unwrap();
let _token_guard = EnvGuard::set("VAULT_TOKEN", "test-token");
let _addr_guard = EnvGuard::set("VAULT_ADDR", "http://localhost:8200");
let backend = VaultBackend::new();
let url = Url::parse("vault://secret/data/test").unwrap();
let dummy = SecretString::new("not-valid-json".into());
let err = backend.put(&url, &dummy).unwrap_err();
assert!(
matches!(err, Error::InvalidUrl(ref s) if s.contains("must be valid JSON")),
"expected InvalidUrl for non-JSON value without field, got: {err:?}"
);
}
#[test]
fn put_with_field_requires_auth() {
let _lock = ENV_LOCK.lock().unwrap();
let _token_guard = EnvGuard::set("VAULT_TOKEN", "test-token");
let _addr_guard = EnvGuard::set("VAULT_ADDR", "http://localhost:8200");
let backend = VaultBackend::new();
let url = Url::parse("vault://secret/data/test?field=password").unwrap();
let dummy = SecretString::new("secret123".into());
let err = backend.put(&url, &dummy).unwrap_err();
assert!(
matches!(
err,
Error::Backend { .. } | Error::NotFound(_) | Error::AuthenticationFailed(_)
),
"expected network-layer error for put with field, got: {err:?}"
);
}
#[test]
fn supported_operations() {
let backend = VaultBackend::new();
let url = Url::parse("vault://secret/data/test?field=password").unwrap();
assert!(
matches!(
backend.delete(&url),
Err(Error::AuthenticationFailed(_))
| Err(Error::Backend { .. })
| Err(Error::NotFound(_))
),
"delete supported (fails at network layer)"
);
assert!(
matches!(
backend.list(&url),
Err(Error::AuthenticationFailed(_))
| Err(Error::Backend { .. })
| Err(Error::NotFound(_))
),
"list supported (fails at network layer)"
);
let dummy = SecretString::new(r#"{"password":"x}"#.into());
assert!(
matches!(
backend.put(&url, &dummy),
Err(Error::AuthenticationFailed(_))
| Err(Error::Backend { .. })
| Err(Error::NotFound(_))
),
"put now supported (fails at network layer)"
);
}
}