use reqwest::Method;
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use crate::{
Authenticated, Client, Result,
path::{validate_endpoint_path, validate_mount_path},
response::{Empty, ListEntries, ResponseEnvelope, deserialize_bounded_string_vec},
};
#[derive(Debug)]
pub struct Cubbyhole<'a> {
client: &'a Client<Authenticated>,
mount: Vec<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct CubbyholeList {
#[serde(default, deserialize_with = "deserialize_bounded_string_vec")]
pub keys: Vec<String>,
}
impl ListEntries for CubbyholeList {
fn entries(&self) -> &[String] {
&self.keys
}
}
impl Client<Authenticated> {
pub fn cubbyhole(&self) -> Result<Cubbyhole<'_>> {
self.cubbyhole_at("cubbyhole")
}
pub fn cubbyhole_at(&self, mount: impl Into<String>) -> Result<Cubbyhole<'_>> {
let mount = mount.into();
Ok(Cubbyhole {
client: self,
mount: validate_mount_path(&mount)?,
})
}
}
impl Cubbyhole<'_> {
pub async fn read<T>(&self, path: &str) -> Result<T>
where
T: DeserializeOwned,
{
let envelope: ResponseEnvelope<T> = self
.client
.request_json(Method::GET, &self.path(path)?, Option::<&Empty>::None)
.await?;
Ok(envelope.data)
}
pub async fn read_optional<T>(&self, path: &str) -> Result<Option<T>>
where
T: DeserializeOwned,
{
match self.read(path).await {
Ok(secret) => Ok(Some(secret)),
Err(error) if error.is_not_found() => Ok(None),
Err(error) => Err(error),
}
}
#[cfg(feature = "kv2")]
pub async fn read_service_config(
&self,
path: &str,
) -> Result<crate::secrets::kv2::Kv2ServiceConfig> {
self.read(path).await
}
pub async fn write<T>(&self, path: &str, data: T) -> Result<Empty>
where
T: Serialize,
{
self.client
.request_json(Method::POST, &self.path(path)?, Some(&data))
.await
}
pub async fn delete(&self, path: &str) -> Result<Empty> {
self.client
.request_json(Method::DELETE, &self.path(path)?, Option::<&Empty>::None)
.await
}
pub async fn list(&self, path: &str) -> Result<CubbyholeList> {
let method = Method::from_bytes(b"LIST")
.map_err(|error| crate::Error::InvalidHeader(error.to_string()))?;
let envelope: ResponseEnvelope<CubbyholeList> = self
.client
.request_json(method, &self.path(path)?, Option::<&Empty>::None)
.await?;
Ok(envelope.data)
}
fn path(&self, path: &str) -> Result<String> {
let mut segments = self.mount.clone();
segments.extend(validate_endpoint_path(path)?);
Ok(segments.join("/"))
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::panic)]
use secrecy::SecretString;
use crate::{Client, OpenBaoConfig};
use super::CubbyholeList;
#[test]
fn cubbyhole_paths_are_validated() {
let config = OpenBaoConfig::new("http://127.0.0.1:8200")
.and_then(OpenBaoConfig::allow_localhost_http)
.unwrap_or_else(|error| panic!("{error}"));
let client = Client::from_config(config)
.and_then(|client| client.try_with_token(SecretString::from("token")))
.unwrap_or_else(|error| panic!("{error}"));
let cubbyhole = client.cubbyhole().unwrap_or_else(|error| panic!("{error}"));
assert_eq!(
cubbyhole
.path("handoff/config")
.unwrap_or_else(|error| panic!("{error}")),
"cubbyhole/handoff/config"
);
assert!(cubbyhole.path("../config").is_err());
assert!(client.cubbyhole_at("../cubbyhole").is_err());
}
#[test]
fn cubbyhole_list_keys_are_bounded() {
let mut keys = Vec::new();
for index in 0..=crate::response::MAX_RESPONSE_STRINGS {
keys.push(format!("key-{index}"));
}
let value = serde_json::json!({ "keys": keys });
let error = match serde_json::from_value::<CubbyholeList>(value) {
Ok(_) => panic!("oversized Cubbyhole key list unexpectedly decoded"),
Err(error) => error,
};
assert!(error.to_string().contains("exceeds item limit"));
}
}