use std::collections::HashMap;
use std::iter::Peekable;
use std::str::Chars;
use zeroize::Zeroizing;
pub struct SecretValue(Zeroizing<Vec<u8>>);
impl SecretValue {
pub fn new(bytes: Vec<u8>) -> Self {
SecretValue(Zeroizing::new(bytes))
}
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
pub fn into_bytes(self) -> Zeroizing<Vec<u8>> {
self.0
}
pub fn as_str(&self) -> Result<&str, SecretError> {
std::str::from_utf8(&self.0)
.map_err(|_| SecretError::DecodeFailed("not valid UTF-8".into()))
}
pub fn extract_field(&self, field: &str) -> Result<SecretValue, SecretError> {
json_extract_string_field(self.as_bytes(), field)
}
pub fn extract_path(&self, path: &[&str]) -> Result<SecretValue, SecretError> {
let raw = json_navigate(self.as_bytes(), path)?;
Ok(SecretValue::new(raw.to_vec()))
}
pub fn extract_path_field(
&self,
path: &[&str],
field: &str,
) -> Result<SecretValue, SecretError> {
let raw = json_navigate(self.as_bytes(), path)?;
json_extract_string_field(raw, field)
}
}
fn json_extract_string_field(bytes: &[u8], field: &str) -> Result<SecretValue, SecretError> {
let s = std::str::from_utf8(bytes)
.map_err(|_| SecretError::DecodeFailed("not valid UTF-8".into()))?;
let mut chars = s.chars().peekable();
json_skip_ws(&mut chars);
json_expect(&mut chars, '{')?;
json_skip_ws(&mut chars);
if chars.peek() == Some(&'}') {
return Err(SecretError::DecodeFailed(format!(
"field `{field}` not found"
)));
}
loop {
json_expect(&mut chars, '"')?;
let key = json_parse_string(&mut chars)?;
json_skip_ws(&mut chars);
json_expect(&mut chars, ':')?;
json_skip_ws(&mut chars);
if key == field {
if chars.peek() != Some(&'"') {
return Err(SecretError::DecodeFailed(format!(
"field `{field}` is not a string"
)));
}
chars.next(); let value = json_parse_string(&mut chars)?;
json_skip_ws(&mut chars);
match chars.peek() {
Some(&',') | Some(&'}') => {}
Some(&c) => {
return Err(SecretError::DecodeFailed(format!(
"expected ',' or '}}' after value of field `{field}`, got '{c}'"
)));
}
None => {
return Err(SecretError::DecodeFailed(
"unexpected end of input after field value".into(),
));
}
}
return Ok(SecretValue::new(value.into_bytes()));
}
json_skip_value(&mut chars)?;
json_skip_ws(&mut chars);
match chars.next() {
Some(',') => {
json_skip_ws(&mut chars);
if chars.peek() == Some(&'}') {
return Err(SecretError::DecodeFailed(
"trailing comma in JSON object".into(),
));
}
}
Some('}') => {
return Err(SecretError::DecodeFailed(format!(
"field `{field}` not found"
)));
}
Some(c) => {
return Err(SecretError::DecodeFailed(format!(
"expected ',' or '}}' in JSON object, got '{c}'"
)));
}
None => {
return Err(SecretError::DecodeFailed(
"unexpected end of JSON object".into(),
));
}
}
}
}
fn json_skip_ws(chars: &mut Peekable<Chars<'_>>) {
while matches!(
chars.peek(),
Some(' ') | Some('\t') | Some('\n') | Some('\r')
) {
chars.next();
}
}
fn json_expect(chars: &mut Peekable<Chars<'_>>, expected: char) -> Result<(), SecretError> {
match chars.next() {
Some(c) if c == expected => Ok(()),
Some(c) => Err(SecretError::DecodeFailed(format!(
"expected '{expected}', got '{c}'"
))),
None => Err(SecretError::DecodeFailed(format!(
"expected '{expected}', got end of input"
))),
}
}
fn json_parse_string(chars: &mut Peekable<Chars<'_>>) -> Result<String, SecretError> {
let mut result = String::new();
loop {
match chars.next() {
None => return Err(SecretError::DecodeFailed("unterminated JSON string".into())),
Some('"') => return Ok(result),
Some('\\') => match chars.next() {
None => {
return Err(SecretError::DecodeFailed(
"truncated escape in JSON string".into(),
))
}
Some('"') => result.push('"'),
Some('\\') => result.push('\\'),
Some('/') => result.push('/'),
Some('b') => result.push('\x08'),
Some('f') => result.push('\x0C'),
Some('n') => result.push('\n'),
Some('r') => result.push('\r'),
Some('t') => result.push('\t'),
Some('u') => {
let ch = json_consume_unicode_escape(chars)?;
result.push(ch);
}
Some(c) => {
return Err(SecretError::DecodeFailed(format!(
"unknown JSON escape '\\{c}'"
)))
}
},
Some(c) if (c as u32) < 0x20 => {
return Err(SecretError::DecodeFailed(format!(
"unescaped control character U+{:04X} in JSON string",
c as u32
)));
}
Some(c) => result.push(c),
}
}
}
fn json_consume_unicode_escape(chars: &mut Peekable<Chars<'_>>) -> Result<char, SecretError> {
let hex: String = chars.by_ref().take(4).collect();
if hex.len() != 4 {
return Err(SecretError::DecodeFailed(
"truncated \\uXXXX escape in JSON string".into(),
));
}
let code = u32::from_str_radix(&hex, 16)
.map_err(|_| SecretError::DecodeFailed("invalid hex digits in \\uXXXX escape".into()))?;
if (0xD800..=0xDBFF).contains(&code) {
if chars.next() != Some('\\') || chars.next() != Some('u') {
return Err(SecretError::DecodeFailed(format!(
"\\u{code:04X} is a high surrogate not followed by \\uXXXX"
)));
}
let low_hex: String = chars.by_ref().take(4).collect();
if low_hex.len() != 4 {
return Err(SecretError::DecodeFailed(
"truncated \\uXXXX low-surrogate escape".into(),
));
}
let low = u32::from_str_radix(&low_hex, 16).map_err(|_| {
SecretError::DecodeFailed("invalid hex digits in \\uXXXX low-surrogate escape".into())
})?;
if !(0xDC00..=0xDFFF).contains(&low) {
return Err(SecretError::DecodeFailed(format!(
"\\u{code:04X} is a high surrogate but \\u{low:04X} is not a low surrogate"
)));
}
let codepoint = 0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00);
char::from_u32(codepoint).ok_or_else(|| {
SecretError::DecodeFailed(
"surrogate pair decoded to invalid Unicode scalar value".into(),
)
})
} else if (0xDC00..=0xDFFF).contains(&code) {
Err(SecretError::DecodeFailed(format!(
"\\u{code:04X} is a lone low surrogate"
)))
} else {
char::from_u32(code).ok_or_else(|| {
SecretError::DecodeFailed("\\uXXXX escape is not a valid Unicode scalar value".into())
})
}
}
fn json_skip_value(chars: &mut Peekable<Chars<'_>>) -> Result<(), SecretError> {
match chars.peek().copied() {
Some('"') => {
chars.next(); json_skip_string(chars)
}
Some('t') => json_skip_literal(chars, "true"),
Some('f') => json_skip_literal(chars, "false"),
Some('n') => json_skip_literal(chars, "null"),
Some(c) if c == '-' || c.is_ascii_digit() => json_skip_number(chars),
Some('[') => json_skip_container(chars, '[', ']'),
Some('{') => json_skip_container(chars, '{', '}'),
Some(c) => Err(SecretError::DecodeFailed(format!(
"unexpected character '{c}' at start of JSON value"
))),
None => Err(SecretError::DecodeFailed(
"unexpected end of input in JSON value".into(),
)),
}
}
fn json_skip_string(chars: &mut Peekable<Chars<'_>>) -> Result<(), SecretError> {
loop {
match chars.next() {
None => return Err(SecretError::DecodeFailed("unterminated JSON string".into())),
Some('"') => return Ok(()),
Some('\\') => match chars.next() {
None => {
return Err(SecretError::DecodeFailed(
"truncated escape in JSON string".into(),
))
}
Some('u') => {
json_consume_unicode_escape(chars)?;
}
Some('"' | '\\' | '/' | 'b' | 'f' | 'n' | 'r' | 't') => {}
Some(c) => {
return Err(SecretError::DecodeFailed(format!(
"unknown JSON escape '\\{c}'"
)));
}
},
Some(c) if (c as u32) < 0x20 => {
return Err(SecretError::DecodeFailed(format!(
"unescaped control character U+{:04X} in JSON string",
c as u32
)));
}
Some(_) => {}
}
}
}
fn json_skip_literal(chars: &mut Peekable<Chars<'_>>, literal: &str) -> Result<(), SecretError> {
for expected in literal.chars() {
match chars.next() {
Some(c) if c == expected => {}
Some(c) => {
return Err(SecretError::DecodeFailed(format!(
"invalid JSON literal: expected '{expected}', got '{c}'"
)))
}
None => {
return Err(SecretError::DecodeFailed(
"unexpected end of input in JSON literal".into(),
))
}
}
}
Ok(())
}
fn json_skip_number(chars: &mut Peekable<Chars<'_>>) -> Result<(), SecretError> {
if chars.peek() == Some(&'-') {
chars.next();
}
if !chars.peek().map(|c| c.is_ascii_digit()).unwrap_or(false) {
return Err(SecretError::DecodeFailed(
"invalid JSON number: expected digit after '-'".into(),
));
}
let first = chars.next().expect("peeked above");
if first == '0' && chars.peek().map(|c| c.is_ascii_digit()).unwrap_or(false) {
return Err(SecretError::DecodeFailed(
"invalid JSON number: leading zeros are not allowed".into(),
));
}
while chars.peek().map(|c| c.is_ascii_digit()).unwrap_or(false) {
chars.next();
}
if chars.peek() == Some(&'.') {
chars.next();
if !chars.peek().map(|c| c.is_ascii_digit()).unwrap_or(false) {
return Err(SecretError::DecodeFailed(
"invalid JSON number: expected digit after decimal point".into(),
));
}
while chars.peek().map(|c| c.is_ascii_digit()).unwrap_or(false) {
chars.next();
}
}
if matches!(chars.peek(), Some('e') | Some('E')) {
chars.next();
if matches!(chars.peek(), Some('+') | Some('-')) {
chars.next();
}
if !chars.peek().map(|c| c.is_ascii_digit()).unwrap_or(false) {
return Err(SecretError::DecodeFailed(
"invalid JSON number: exponent has no digits".into(),
));
}
while chars.peek().map(|c| c.is_ascii_digit()).unwrap_or(false) {
chars.next();
}
}
Ok(())
}
fn json_skip_container(
chars: &mut Peekable<Chars<'_>>,
open: char,
close: char,
) -> Result<(), SecretError> {
chars.next();
let mut depth = 1usize;
loop {
match chars.next() {
None => {
return Err(SecretError::DecodeFailed(
"unterminated JSON container".into(),
))
}
Some('"') => json_skip_string(chars)?,
Some(c) if c == open => depth += 1,
Some(c) if c == close => {
depth -= 1;
if depth == 0 {
return Ok(());
}
}
Some(_) => {}
}
}
}
fn json_navigate<'a>(bytes: &'a [u8], path: &[&str]) -> Result<&'a [u8], SecretError> {
let mut current = bytes;
for key in path {
current = json_find_value_b(current, key)?;
}
Ok(current)
}
fn json_find_value_b<'a>(bytes: &'a [u8], key: &str) -> Result<&'a [u8], SecretError> {
if std::str::from_utf8(bytes).is_err() {
return Err(SecretError::DecodeFailed("not valid UTF-8".into()));
}
let mut pos = skip_ws_b(bytes, 0);
if bytes.get(pos) != Some(&b'{') {
return Err(SecretError::DecodeFailed("expected JSON object '{'".into()));
}
pos += 1;
pos = skip_ws_b(bytes, pos);
if bytes.get(pos) == Some(&b'}') {
return Err(SecretError::DecodeFailed(format!("key `{key}` not found")));
}
loop {
pos = skip_ws_b(bytes, pos);
if bytes.get(pos) != Some(&b'"') {
return Err(SecretError::DecodeFailed("expected '\"' for key".into()));
}
let (k, new_pos) = scan_string_key_b(bytes, pos + 1)?;
pos = new_pos;
pos = skip_ws_b(bytes, pos);
if bytes.get(pos) != Some(&b':') {
return Err(SecretError::DecodeFailed("expected ':' after key".into()));
}
pos += 1;
pos = skip_ws_b(bytes, pos);
let value_start = pos;
let value_end = skip_value_b(bytes, pos)?;
if k == key {
return Ok(&bytes[value_start..value_end]);
}
pos = skip_ws_b(bytes, value_end);
match bytes.get(pos) {
Some(&b',') => {
pos += 1;
pos = skip_ws_b(bytes, pos);
if bytes.get(pos) == Some(&b'}') {
return Err(SecretError::DecodeFailed(
"trailing comma in JSON object".into(),
));
}
}
Some(&b'}') => {
return Err(SecretError::DecodeFailed(format!("key `{key}` not found")));
}
Some(&c) => {
return Err(SecretError::DecodeFailed(format!(
"expected ',' or '}}' in JSON object, got byte {c:#04x}"
)));
}
None => {
return Err(SecretError::DecodeFailed(
"unexpected end of JSON object".into(),
));
}
}
}
}
fn skip_ws_b(bytes: &[u8], mut pos: usize) -> usize {
while matches!(
bytes.get(pos),
Some(b' ') | Some(b'\t') | Some(b'\n') | Some(b'\r')
) {
pos += 1;
}
pos
}
fn scan_string_key_b(bytes: &[u8], mut pos: usize) -> Result<(String, usize), SecretError> {
let mut key = String::new();
while pos < bytes.len() {
let b = bytes[pos];
pos += 1;
match b {
b'"' => return Ok((key, pos)),
b'\\' => {
if pos >= bytes.len() {
return Err(SecretError::DecodeFailed(
"truncated escape in JSON key".into(),
));
}
let e = bytes[pos];
pos += 1;
match e {
b'"' => key.push('"'),
b'\\' => key.push('\\'),
b'/' => key.push('/'),
b'b' => key.push('\x08'),
b'f' => key.push('\x0C'),
b'n' => key.push('\n'),
b'r' => key.push('\r'),
b't' => key.push('\t'),
b'u' => {
if pos + 4 > bytes.len() {
return Err(SecretError::DecodeFailed(
"truncated \\uXXXX in JSON key".into(),
));
}
let hex = std::str::from_utf8(&bytes[pos..pos + 4]).map_err(|_| {
SecretError::DecodeFailed("non-ASCII bytes in \\uXXXX escape".into())
})?;
let code = u32::from_str_radix(hex, 16).map_err(|_| {
SecretError::DecodeFailed("invalid hex digits in \\uXXXX".into())
})?;
pos += 4;
if (0xD800..=0xDBFF).contains(&code) {
if bytes.get(pos..pos + 2) != Some(b"\\u") {
return Err(SecretError::DecodeFailed(format!(
"\\u{code:04X} is a high surrogate not followed by \\uXXXX"
)));
}
if pos + 6 > bytes.len() {
return Err(SecretError::DecodeFailed(
"truncated low-surrogate \\uXXXX".into(),
));
}
let low_hex =
std::str::from_utf8(&bytes[pos + 2..pos + 6]).map_err(|_| {
SecretError::DecodeFailed(
"non-ASCII bytes in low-surrogate \\uXXXX".into(),
)
})?;
let low = u32::from_str_radix(low_hex, 16).map_err(|_| {
SecretError::DecodeFailed(
"invalid hex in low-surrogate \\uXXXX".into(),
)
})?;
if !(0xDC00..=0xDFFF).contains(&low) {
return Err(SecretError::DecodeFailed(format!(
"\\u{code:04X} high surrogate not followed by low surrogate (got \\u{low:04X})"
)));
}
let cp = 0x10000u32 + ((code - 0xD800) << 10) + (low - 0xDC00);
key.push(char::from_u32(cp).ok_or_else(|| {
SecretError::DecodeFailed(
"surrogate pair decoded to invalid scalar".into(),
)
})?);
pos += 6;
} else if (0xDC00..=0xDFFF).contains(&code) {
return Err(SecretError::DecodeFailed(format!(
"\\u{code:04X} is a lone low surrogate"
)));
} else {
key.push(char::from_u32(code).ok_or_else(|| {
SecretError::DecodeFailed(
"\\uXXXX decoded to invalid Unicode scalar".into(),
)
})?);
}
}
_ => {
return Err(SecretError::DecodeFailed(format!(
"unknown JSON escape '\\{}'",
e as char
)))
}
}
}
b if b < 0x20 => {
return Err(SecretError::DecodeFailed(format!(
"unescaped control character {b:#04x} in JSON key"
)));
}
b if b < 0x80 => {
key.push(b as char);
}
_ => {
let rest = std::str::from_utf8(&bytes[pos - 1..])
.expect("UTF-8 validity confirmed at json_find_value_b entry");
let ch = rest
.chars()
.next()
.expect("non-empty slice has at least one char");
key.push(ch);
pos += ch.len_utf8() - 1; }
}
}
Err(SecretError::DecodeFailed("unterminated JSON string".into()))
}
fn skip_value_b(bytes: &[u8], pos: usize) -> Result<usize, SecretError> {
match bytes.get(pos) {
Some(b'"') => skip_string_b(bytes, pos + 1),
Some(b'{') => skip_container_b(bytes, pos + 1, b'}'),
Some(b'[') => skip_container_b(bytes, pos + 1, b']'),
Some(b't') => expect_literal_b(bytes, pos, b"true"),
Some(b'f') => expect_literal_b(bytes, pos, b"false"),
Some(b'n') => expect_literal_b(bytes, pos, b"null"),
Some(&c) if c == b'-' || c.is_ascii_digit() => skip_number_b(bytes, pos),
Some(&c) => Err(SecretError::DecodeFailed(format!(
"unexpected byte {c:#04x} at start of JSON value"
))),
None => Err(SecretError::DecodeFailed(
"unexpected end of input at JSON value".into(),
)),
}
}
fn skip_string_b(bytes: &[u8], mut pos: usize) -> Result<usize, SecretError> {
while pos < bytes.len() {
match bytes[pos] {
b'"' => return Ok(pos + 1),
b'\\' => {
pos += 1;
if pos >= bytes.len() {
return Err(SecretError::DecodeFailed(
"truncated escape in JSON string".into(),
));
}
pos += if bytes[pos] == b'u' { 5 } else { 1 };
}
b if b < 0x20 => {
return Err(SecretError::DecodeFailed(format!(
"unescaped control character {b:#04x} in JSON string"
)));
}
_ => pos += 1,
}
}
Err(SecretError::DecodeFailed("unterminated JSON string".into()))
}
fn skip_container_b(bytes: &[u8], mut pos: usize, close: u8) -> Result<usize, SecretError> {
let mut depth: u32 = 1;
while pos < bytes.len() {
match bytes[pos] {
b'"' => pos = skip_string_b(bytes, pos + 1)?,
b'{' | b'[' => {
depth += 1;
pos += 1;
}
b'}' | b']' => {
depth -= 1;
if depth == 0 {
if bytes[pos] != close {
return Err(SecretError::DecodeFailed("mismatched JSON brackets".into()));
}
return Ok(pos + 1);
}
pos += 1;
}
_ => pos += 1,
}
}
Err(SecretError::DecodeFailed(
"unterminated JSON container".into(),
))
}
fn expect_literal_b(bytes: &[u8], pos: usize, literal: &[u8]) -> Result<usize, SecretError> {
let end = pos + literal.len();
if bytes.get(pos..end) == Some(literal) {
Ok(end)
} else {
Err(SecretError::DecodeFailed(format!(
"expected JSON literal `{}`",
std::str::from_utf8(literal).unwrap_or("?")
)))
}
}
fn skip_number_b(bytes: &[u8], mut pos: usize) -> Result<usize, SecretError> {
if bytes.get(pos) == Some(&b'-') {
pos += 1;
}
if !bytes.get(pos).is_some_and(u8::is_ascii_digit) {
return Err(SecretError::DecodeFailed(
"invalid JSON number: expected digit".into(),
));
}
let first = bytes[pos];
pos += 1;
if first == b'0' && bytes.get(pos).is_some_and(u8::is_ascii_digit) {
return Err(SecretError::DecodeFailed(
"invalid JSON number: leading zeros are not allowed".into(),
));
}
while bytes.get(pos).is_some_and(u8::is_ascii_digit) {
pos += 1;
}
if bytes.get(pos) == Some(&b'.') {
pos += 1;
if !bytes.get(pos).is_some_and(u8::is_ascii_digit) {
return Err(SecretError::DecodeFailed(
"invalid JSON number: expected digit after '.'".into(),
));
}
while bytes.get(pos).is_some_and(u8::is_ascii_digit) {
pos += 1;
}
}
if matches!(bytes.get(pos), Some(b'e') | Some(b'E')) {
pos += 1;
if matches!(bytes.get(pos), Some(b'+') | Some(b'-')) {
pos += 1;
}
if !bytes.get(pos).is_some_and(u8::is_ascii_digit) {
return Err(SecretError::DecodeFailed(
"invalid JSON number: expected digit in exponent".into(),
));
}
while bytes.get(pos).is_some_and(u8::is_ascii_digit) {
pos += 1;
}
}
Ok(pos)
}
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum SecretError {
#[error("secret not found")]
NotFound,
#[error("backend `{backend}` error: {source}")]
Backend {
backend: &'static str,
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
#[error("invalid URI: {0}")]
InvalidUri(String),
#[error("decode failed: {0}")]
DecodeFailed(String),
#[error("backend `{backend}` unavailable: {source}")]
Unavailable {
backend: &'static str,
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
}
fn percent_decode(s: &str) -> Result<String, SecretError> {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' {
if i + 2 >= bytes.len() {
return Err(SecretError::InvalidUri(format!(
"incomplete percent-encoding at position {i} in `{s}`"
)));
}
let hi = hex_digit(bytes[i + 1]).ok_or_else(|| {
SecretError::InvalidUri(format!(
"invalid percent-encoding `%{}{}` at position {i} in `{s}`",
bytes[i + 1] as char,
bytes[i + 2] as char
))
})?;
let lo = hex_digit(bytes[i + 2]).ok_or_else(|| {
SecretError::InvalidUri(format!(
"invalid percent-encoding `%{}{}` at position {i} in `{s}`",
bytes[i + 1] as char,
bytes[i + 2] as char
))
})?;
out.push((hi << 4) | lo);
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
String::from_utf8(out).map_err(|_| {
SecretError::InvalidUri(format!(
"percent-decoded bytes in `{s}` are not valid UTF-8"
))
})
}
fn hex_digit(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SecretUri {
backend: String,
path: String,
params: HashMap<String, String>,
}
impl SecretUri {
const SCHEME: &'static str = "secretx://";
pub fn parse(uri: &str) -> Result<Self, SecretError> {
let rest = uri.strip_prefix(Self::SCHEME).ok_or_else(|| {
SecretError::InvalidUri(format!("URI must start with `secretx://`, got: {uri}"))
})?;
let (path_part, query_part) = match rest.find('?') {
Some(i) => (&rest[..i], Some(&rest[i + 1..])),
None => (rest, None),
};
let (backend, raw_path) = match path_part.find('/') {
Some(i) => (&path_part[..i], &path_part[i + 1..]),
None => (path_part, ""),
};
if backend.is_empty() {
return Err(SecretError::InvalidUri(format!(
"missing backend name in URI: {uri}"
)));
}
let path = percent_decode(raw_path)?;
let mut params = HashMap::new();
if let Some(q) = query_part {
for pair in q.split('&').filter(|s| !s.is_empty()) {
match pair.find('=') {
Some(i) => {
let key = percent_decode(&pair[..i])?;
let val = percent_decode(&pair[i + 1..])?;
params.insert(key, val);
}
None => {
params.insert(percent_decode(pair)?, String::new());
}
}
}
}
Ok(SecretUri {
backend: backend.to_string(),
path,
params,
})
}
pub fn backend(&self) -> &str {
&self.backend
}
pub fn path(&self) -> &str {
&self.path
}
pub fn param(&self, key: &str) -> Option<&str> {
self.params.get(key).map(String::as_str)
}
}
#[async_trait::async_trait]
pub trait SecretStore: Send + Sync {
async fn get(&self) -> Result<SecretValue, SecretError>;
async fn put(&self, value: SecretValue) -> Result<(), SecretError>;
async fn refresh(&self) -> Result<SecretValue, SecretError>;
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SigningAlgorithm {
Ed25519,
EcdsaP256Sha256,
RsaPss2048Sha256,
}
#[async_trait::async_trait]
pub trait SigningBackend: Send + Sync {
async fn sign(&self, message: &[u8]) -> Result<Vec<u8>, SecretError>;
async fn public_key_der(&self) -> Result<Vec<u8>, SecretError>;
fn algorithm(&self) -> Result<SigningAlgorithm, SecretError>;
}
#[cfg(feature = "blocking")]
pub fn run_on_new_thread<F, Fut, T>(f: F, backend: &'static str) -> Result<T, SecretError>
where
F: FnOnce() -> Fut + Send,
Fut: std::future::Future<Output = Result<T, SecretError>>,
T: Send,
{
let mut result: Option<Result<T, SecretError>> = None;
std::thread::scope(|s| {
let join = s.spawn(|| {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| SecretError::Backend {
backend,
source: e.into(),
})
.and_then(|rt| rt.block_on(f()))
});
result = Some(join.join().unwrap_or_else(|_| {
Err(SecretError::Backend {
backend,
source: "client init thread panicked".into(),
})
}));
});
result.expect("scope always sets result before exiting")
}
#[cfg(feature = "blocking")]
pub fn get_blocking(store: &dyn SecretStore) -> Result<SecretValue, SecretError> {
match tokio::runtime::Handle::try_current() {
Err(_) => tokio::runtime::Builder::new_current_thread()
.build()
.map_err(|e| SecretError::Backend {
backend: "blocking",
source: e.into(),
})?
.block_on(store.get()),
Ok(_) => {
let mut result: Option<Result<SecretValue, SecretError>> = None;
std::thread::scope(|s| {
let join = s.spawn(|| {
tokio::runtime::Builder::new_current_thread()
.build()
.map_err(|e| SecretError::Backend {
backend: "blocking",
source: e.into(),
})?
.block_on(store.get())
});
result = Some(join.join().unwrap_or_else(|_| {
Err(SecretError::Backend {
backend: "blocking",
source: "get_blocking thread panicked".into(),
})
}));
});
result.expect("scope always sets result before exiting")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn secret_value_as_bytes() {
let v = SecretValue::new(b"hello".to_vec());
assert_eq!(v.as_bytes(), b"hello");
}
#[test]
fn secret_value_as_str() {
let v = SecretValue::new(b"hello".to_vec());
assert_eq!(v.as_str().unwrap(), "hello");
}
#[test]
fn secret_value_as_str_invalid_utf8() {
let v = SecretValue::new(vec![0xff, 0xfe]);
assert!(matches!(v.as_str(), Err(SecretError::DecodeFailed(_))));
}
#[test]
fn extract_field_ok() {
let v = SecretValue::new(br#"{"password":"hunter2","user":"alice"}"#.to_vec());
let pw = v.extract_field("password").unwrap();
assert_eq!(pw.as_bytes(), b"hunter2");
}
#[test]
fn extract_field_missing() {
let v = SecretValue::new(br#"{"user":"alice"}"#.to_vec());
assert!(matches!(
v.extract_field("password"),
Err(SecretError::DecodeFailed(_))
));
}
#[test]
fn extract_field_not_string() {
let v = SecretValue::new(br#"{"count":42}"#.to_vec());
assert!(matches!(
v.extract_field("count"),
Err(SecretError::DecodeFailed(_))
));
}
#[test]
fn extract_field_invalid_json() {
let v = SecretValue::new(b"not json".to_vec());
assert!(matches!(
v.extract_field("x"),
Err(SecretError::DecodeFailed(_))
));
}
#[test]
fn extract_field_surrogate_pair() {
let v = SecretValue::new(br#"{"pw":"\uD83D\uDE00"}"#.to_vec());
let pw = v.extract_field("pw").unwrap();
assert_eq!(pw.as_bytes(), "😀".as_bytes());
}
#[test]
fn extract_field_lone_high_surrogate() {
let v = SecretValue::new(br#"{"pw":"\uD800"}"#.to_vec());
assert!(matches!(
v.extract_field("pw"),
Err(SecretError::DecodeFailed(_))
));
}
#[test]
fn extract_field_lone_low_surrogate() {
let v = SecretValue::new(br#"{"pw":"\uDC00"}"#.to_vec());
assert!(matches!(
v.extract_field("pw"),
Err(SecretError::DecodeFailed(_))
));
}
#[test]
fn extract_field_high_surrogate_wrong_follow() {
let v = SecretValue::new(br#"{"pw":"\uD800\u0041"}"#.to_vec());
assert!(matches!(
v.extract_field("pw"),
Err(SecretError::DecodeFailed(_))
));
}
#[test]
fn extract_field_high_surrogate_no_follow() {
let v = SecretValue::new(br#"{"pw":"\uD800abc"}"#.to_vec());
assert!(matches!(
v.extract_field("pw"),
Err(SecretError::DecodeFailed(_))
));
}
#[test]
fn skip_field_lone_high_surrogate_rejected() {
let v = SecretValue::new(br#"{"other":"\uD800","password":"hunter2"}"#.to_vec());
assert!(matches!(
v.extract_field("password"),
Err(SecretError::DecodeFailed(_))
));
}
#[test]
fn skip_field_lone_low_surrogate_rejected() {
let v = SecretValue::new(br#"{"other":"\uDC00","password":"hunter2"}"#.to_vec());
assert!(matches!(
v.extract_field("password"),
Err(SecretError::DecodeFailed(_))
));
}
#[test]
fn skip_field_surrogate_pair_valid() {
let v = SecretValue::new(br#"{"emoji":"\uD83D\uDE00","password":"hunter2"}"#.to_vec());
let pw = v.extract_field("password").unwrap();
assert_eq!(pw.as_bytes(), b"hunter2");
}
#[test]
fn skip_number_bare_minus_rejected() {
let v = SecretValue::new(br#"{"count":-,"password":"hunter2"}"#.to_vec());
assert!(matches!(
v.extract_field("password"),
Err(SecretError::DecodeFailed(_))
));
}
#[test]
fn skip_number_bare_exponent_rejected() {
let v = SecretValue::new(br#"{"count":1e,"password":"hunter2"}"#.to_vec());
assert!(matches!(
v.extract_field("password"),
Err(SecretError::DecodeFailed(_))
));
}
#[test]
fn skip_number_signed_exponent_no_digits_rejected() {
let v = SecretValue::new(br#"{"count":1e+,"password":"hunter2"}"#.to_vec());
assert!(matches!(
v.extract_field("password"),
Err(SecretError::DecodeFailed(_))
));
}
#[test]
fn skip_number_valid_exponent_accepted() {
let v = SecretValue::new(br#"{"count":1e3,"password":"hunter2"}"#.to_vec());
let pw = v.extract_field("password").unwrap();
assert_eq!(pw.as_bytes(), b"hunter2");
}
#[test]
fn skip_number_leading_zero_two_digits_rejected() {
let v = SecretValue::new(br#"{"count":01,"password":"hunter2"}"#.to_vec());
assert!(matches!(
v.extract_field("password"),
Err(SecretError::DecodeFailed(_))
));
}
#[test]
fn skip_number_leading_zero_multi_digit_rejected() {
let v = SecretValue::new(br#"{"count":007,"password":"hunter2"}"#.to_vec());
assert!(matches!(
v.extract_field("password"),
Err(SecretError::DecodeFailed(_))
));
}
#[test]
fn skip_number_bare_zero_accepted() {
let v = SecretValue::new(br#"{"count":0,"password":"hunter2"}"#.to_vec());
let pw = v.extract_field("password").unwrap();
assert_eq!(pw.as_bytes(), b"hunter2");
}
#[test]
fn skip_number_negative_leading_zero_rejected() {
let v = SecretValue::new(br#"{"count":-01,"password":"hunter2"}"#.to_vec());
assert!(matches!(
v.extract_field("password"),
Err(SecretError::DecodeFailed(_))
));
}
#[test]
fn skip_number_negative_zero_accepted() {
let v = SecretValue::new(br#"{"count":-0,"password":"hunter2"}"#.to_vec());
let pw = v.extract_field("password").unwrap();
assert_eq!(pw.as_bytes(), b"hunter2");
}
#[test]
fn skip_number_no_fractional_digits_rejected() {
let v = SecretValue::new(br#"{"count":1.,"password":"hunter2"}"#.to_vec());
assert!(matches!(
v.extract_field("password"),
Err(SecretError::DecodeFailed(_))
));
}
#[test]
fn skip_number_negative_no_fractional_digits_rejected() {
let v = SecretValue::new(br#"{"count":-1.,"password":"hunter2"}"#.to_vec());
assert!(matches!(
v.extract_field("password"),
Err(SecretError::DecodeFailed(_))
));
}
#[test]
fn skip_number_zero_no_fractional_digits_rejected() {
let v = SecretValue::new(br#"{"count":0.,"password":"hunter2"}"#.to_vec());
assert!(matches!(
v.extract_field("password"),
Err(SecretError::DecodeFailed(_))
));
}
#[test]
fn skip_number_fractional_digits_accepted() {
let v = SecretValue::new(br#"{"count":1.5,"password":"hunter2"}"#.to_vec());
let pw = v.extract_field("password").unwrap();
assert_eq!(pw.as_bytes(), b"hunter2");
}
#[test]
fn skip_field_unknown_escape_rejected() {
let v = SecretValue::new(br#"{"other":"\z","password":"hunter2"}"#.to_vec());
assert!(matches!(
v.extract_field("password"),
Err(SecretError::DecodeFailed(_))
));
}
#[test]
fn skip_field_all_valid_single_char_escapes_accepted() {
let v = SecretValue::new(br#"{"other":"\"\\\/\b\f\n\r\t","password":"hunter2"}"#.to_vec());
let pw = v.extract_field("password").unwrap();
assert_eq!(pw.as_bytes(), b"hunter2");
}
#[test]
fn extract_field_null_byte_in_value_rejected() {
let v = SecretValue::new(b"{\"key\":\"val\x00ue\"}".to_vec());
assert!(matches!(
v.extract_field("key"),
Err(SecretError::DecodeFailed(_))
));
}
#[test]
fn extract_field_control_char_soh_rejected() {
let v = SecretValue::new(b"{\"key\":\"\x01\"}".to_vec());
assert!(matches!(
v.extract_field("key"),
Err(SecretError::DecodeFailed(_))
));
}
#[test]
fn extract_field_control_char_us_rejected() {
let v = SecretValue::new(b"{\"key\":\"\x1f\"}".to_vec());
assert!(matches!(
v.extract_field("key"),
Err(SecretError::DecodeFailed(_))
));
}
#[test]
fn extract_field_space_accepted() {
let v = SecretValue::new(b"{\"key\":\"val ue\"}".to_vec());
assert_eq!(v.extract_field("key").unwrap().as_bytes(), b"val ue");
}
#[test]
fn extract_field_trailing_garbage_first_field_rejected() {
let v = SecretValue::new(br#"{"password":"hunter2" GARBAGE}"#.to_vec());
assert!(
matches!(
v.extract_field("password"),
Err(SecretError::DecodeFailed(_))
),
"trailing garbage after first field must be rejected"
);
}
#[test]
fn extract_field_trailing_garbage_last_field_rejected() {
let v = SecretValue::new(br#"{"other":"x","password":"hunter2" GARBAGE}"#.to_vec());
assert!(
matches!(
v.extract_field("password"),
Err(SecretError::DecodeFailed(_))
),
"trailing garbage after last field must be rejected"
);
}
#[test]
fn skip_field_control_char_in_other_field_rejected() {
let v = SecretValue::new(b"{\"other\":\"\x01bad\",\"password\":\"hunter2\"}".to_vec());
assert!(matches!(
v.extract_field("password"),
Err(SecretError::DecodeFailed(_))
));
}
#[test]
fn uri_env() {
let u = SecretUri::parse("secretx://env/MY_SECRET").unwrap();
assert_eq!(u.backend, "env");
assert_eq!(u.path, "MY_SECRET");
assert!(u.params.is_empty());
}
#[test]
fn uri_file_relative() {
let u = SecretUri::parse("secretx://file/relative/path/key").unwrap();
assert_eq!(u.backend, "file");
assert_eq!(u.path, "relative/path/key");
}
#[test]
fn uri_file_absolute() {
let u = SecretUri::parse("secretx://file//etc/secrets/key").unwrap();
assert_eq!(u.backend, "file");
assert_eq!(u.path, "/etc/secrets/key");
}
#[test]
fn uri_aws_sm_with_params() {
let u =
SecretUri::parse("secretx://aws-sm/prod/signing-key?field=password&version=AWSCURRENT")
.unwrap();
assert_eq!(u.backend, "aws-sm");
assert_eq!(u.path, "prod/signing-key");
assert_eq!(u.param("field"), Some("password"));
assert_eq!(u.param("version"), Some("AWSCURRENT"));
}
#[test]
fn uri_pkcs11_with_lib() {
let u = SecretUri::parse("secretx://pkcs11/0/my-key?lib=/usr/lib/libsofthsm2.so").unwrap();
assert_eq!(u.backend, "pkcs11");
assert_eq!(u.path, "0/my-key");
assert_eq!(u.param("lib"), Some("/usr/lib/libsofthsm2.so"));
}
#[test]
fn uri_no_path() {
let u = SecretUri::parse("secretx://wolfhsm/my-key").unwrap();
assert_eq!(u.backend, "wolfhsm");
assert_eq!(u.path, "my-key");
}
#[test]
fn uri_wrong_scheme() {
assert!(matches!(
SecretUri::parse("https://example.com/secret"),
Err(SecretError::InvalidUri(_))
));
}
#[test]
fn uri_empty_backend() {
assert!(matches!(
SecretUri::parse("secretx:///path"),
Err(SecretError::InvalidUri(_))
));
}
#[test]
fn uri_missing_param() {
let u = SecretUri::parse("secretx://aws-sm/my-secret").unwrap();
assert_eq!(u.param("field"), None);
}
#[test]
fn uri_percent_decoded_path() {
let u = SecretUri::parse("secretx://env/MY%20SECRET").unwrap();
assert_eq!(u.path, "MY SECRET");
}
#[test]
fn uri_percent_decoded_param_value() {
let u = SecretUri::parse("secretx://aws-sm/my-secret?field=my%20field").unwrap();
assert_eq!(u.param("field"), Some("my field"));
}
#[test]
fn uri_percent_decoded_param_key() {
let u = SecretUri::parse("secretx://aws-sm/my-secret?my%20key=val").unwrap();
assert_eq!(u.param("my key"), Some("val"));
}
#[test]
fn uri_invalid_percent_encoding() {
assert!(matches!(
SecretUri::parse("secretx://env/MY%ZZsecret"),
Err(SecretError::InvalidUri(_))
));
}
#[test]
fn uri_incomplete_percent_encoding() {
assert!(matches!(
SecretUri::parse("secretx://env/MY%2"),
Err(SecretError::InvalidUri(_))
));
}
#[cfg(feature = "blocking")]
#[test]
fn get_blocking_outside_runtime() {
use std::sync::Arc;
struct FakeStore;
#[async_trait::async_trait]
impl SecretStore for FakeStore {
async fn get(&self) -> Result<SecretValue, SecretError> {
Ok(SecretValue::new(b"test-value".to_vec()))
}
async fn put(&self, _: SecretValue) -> Result<(), SecretError> {
Ok(())
}
async fn refresh(&self) -> Result<SecretValue, SecretError> {
self.get().await
}
}
let store = Arc::new(FakeStore);
let v = get_blocking(store.as_ref()).unwrap();
assert_eq!(v.as_bytes(), b"test-value");
}
#[cfg(feature = "blocking")]
#[tokio::test]
async fn get_blocking_inside_runtime() {
use std::sync::Arc;
struct FakeStore;
#[async_trait::async_trait]
impl SecretStore for FakeStore {
async fn get(&self) -> Result<SecretValue, SecretError> {
Ok(SecretValue::new(b"inside-runtime".to_vec()))
}
async fn put(&self, _: SecretValue) -> Result<(), SecretError> {
Ok(())
}
async fn refresh(&self) -> Result<SecretValue, SecretError> {
self.get().await
}
}
let store = Arc::new(FakeStore);
let v = get_blocking(store.as_ref()).unwrap();
assert_eq!(v.as_bytes(), b"inside-runtime");
}
#[test]
fn navigate_empty_path_returns_input() {
let input = br#"{"k":"v"}"#;
let result = json_navigate(input, &[]).unwrap();
assert_eq!(result, input);
}
#[test]
fn navigate_single_key() {
let input = br#"{"data":{"key":"val"}}"#;
let result = json_navigate(input, &["data"]).unwrap();
assert_eq!(result, br#"{"key":"val"}"#);
}
#[test]
fn navigate_two_levels_vault_pattern() {
let input = br#"{"data":{"data":{"password":"s3cr3t"},"metadata":{"version":3}}}"#;
let result = json_navigate(input, &["data", "data"]).unwrap();
assert_eq!(result, br#"{"password":"s3cr3t"}"#);
}
#[test]
fn navigate_key_not_found() {
let input = br#"{"a":"b"}"#;
assert!(matches!(
json_navigate(input, &["missing"]),
Err(SecretError::DecodeFailed(_))
));
}
#[test]
fn navigate_intermediate_not_object() {
let input = br#"{"data":"flat-string"}"#;
assert!(matches!(
json_navigate(input, &["data", "key"]),
Err(SecretError::DecodeFailed(_))
));
}
#[test]
fn navigate_key_with_escape_in_path() {
let input = b"{\"my\\nkey\":\"found\"}";
let result = json_navigate(input, &["my\nkey"]).unwrap();
assert_eq!(result, b"\"found\"");
}
#[test]
fn navigate_whitespace_around_value() {
let input = br#"{"k": 42 }"#;
let result = json_navigate(input, &["k"]).unwrap();
assert_eq!(result, b"42");
}
#[test]
fn navigate_empty_object_returns_not_found() {
let input = br#"{}"#;
assert!(matches!(
json_navigate(input, &["k"]),
Err(SecretError::DecodeFailed(_))
));
}
#[test]
fn extract_path_vault_nested_object() {
let json = br#"{"data":{"data":{"token":"abc123"},"metadata":{}}}"#.to_vec();
let sv = SecretValue::new(json);
let inner = sv.extract_path(&["data", "data"]).unwrap();
assert_eq!(inner.as_bytes(), br#"{"token":"abc123"}"#);
}
#[test]
fn extract_path_field_vault_pattern() {
let json =
br#"{"data":{"data":{"token":"s3cr3t","ttl":300},"metadata":{"version":1}}}"#.to_vec();
let sv = SecretValue::new(json);
let token = sv.extract_path_field(&["data", "data"], "token").unwrap();
assert_eq!(token.as_bytes(), b"s3cr3t");
}
#[test]
fn extract_path_missing_key_returns_decode_failed() {
let json = br#"{"data":{"other":"val"}}"#.to_vec();
let sv = SecretValue::new(json);
assert!(matches!(
sv.extract_path(&["data", "data"]),
Err(SecretError::DecodeFailed(_))
));
}
#[test]
fn extract_path_field_missing_field_returns_decode_failed() {
let json = br#"{"data":{"data":{"a":"b"}}}"#.to_vec();
let sv = SecretValue::new(json);
assert!(matches!(
sv.extract_path_field(&["data", "data"], "missing"),
Err(SecretError::DecodeFailed(_))
));
}
#[test]
fn skip_number_b_bare_decimal_rejected_via_navigate() {
let json = br#"{"n":1.,"k":"v"}"#.to_vec();
let sv = SecretValue::new(json);
assert!(
matches!(sv.extract_path(&["k"]), Err(SecretError::DecodeFailed(_))),
"bare decimal point must be rejected by skip_number_b"
);
}
#[test]
fn skip_number_b_bare_exponent_rejected_via_navigate() {
let json = br#"{"n":1e,"k":"v"}"#.to_vec();
let sv = SecretValue::new(json);
assert!(
matches!(sv.extract_path(&["k"]), Err(SecretError::DecodeFailed(_))),
"bare exponent must be rejected by skip_number_b"
);
}
#[test]
fn skip_number_b_signed_exponent_no_digits_rejected_via_navigate() {
let json = br#"{"n":1e+,"k":"v"}"#.to_vec();
let sv = SecretValue::new(json);
assert!(
matches!(sv.extract_path(&["k"]), Err(SecretError::DecodeFailed(_))),
"signed exponent with no digits must be rejected by skip_number_b"
);
}
#[test]
fn skip_number_b_valid_number_allows_navigation() {
let json = br#"{"n":3.14e2,"k":"found"}"#.to_vec();
let sv = SecretValue::new(json);
let result = sv.extract_path(&["k"]).unwrap();
assert_eq!(result.as_bytes(), b"\"found\"");
}
#[test]
fn skip_number_b_leading_zero_rejected_via_navigate() {
let json = br#"{"n":01,"k":"v"}"#.to_vec();
let sv = SecretValue::new(json);
assert!(
matches!(sv.extract_path(&["k"]), Err(SecretError::DecodeFailed(_))),
"leading zero must be rejected by skip_number_b"
);
}
#[test]
fn skip_number_b_negative_leading_zero_rejected_via_navigate() {
let json = br#"{"n":-01,"k":"v"}"#.to_vec();
let sv = SecretValue::new(json);
assert!(
matches!(sv.extract_path(&["k"]), Err(SecretError::DecodeFailed(_))),
"-01 must be rejected by skip_number_b"
);
}
#[test]
fn skip_number_b_zero_alone_accepted_via_navigate() {
let json = br#"{"n":0,"k":"v"}"#.to_vec();
let sv = SecretValue::new(json);
let result = sv.extract_path(&["k"]).unwrap();
assert_eq!(result.as_bytes(), b"\"v\"");
}
#[test]
fn skip_string_b_control_char_in_skipped_value_rejected_via_navigate() {
let json = b"{\"other\":\"\x01bad\",\"k\":\"v\"}".to_vec();
let sv = SecretValue::new(json);
assert!(
matches!(sv.extract_path(&["k"]), Err(SecretError::DecodeFailed(_))),
"control char in skipped string value must be rejected by skip_string_b"
);
}
#[test]
fn skip_string_b_null_byte_in_skipped_value_rejected_via_navigate() {
let json = b"{\"other\":\"val\x00ue\",\"k\":\"v\"}".to_vec();
let sv = SecretValue::new(json);
assert!(
matches!(sv.extract_path(&["k"]), Err(SecretError::DecodeFailed(_))),
"NUL byte in skipped string value must be rejected by skip_string_b"
);
}
#[test]
fn skip_string_b_space_in_skipped_value_accepted_via_navigate() {
let json = br#"{"other":"val ue","k":"v"}"#.to_vec();
let sv = SecretValue::new(json);
let result = sv.extract_path(&["k"]).unwrap();
assert_eq!(result.as_bytes(), b"\"v\"");
}
}