secretx_core/lib.rs
1//! Core traits and types for the secretx secrets retrieval library.
2//!
3//! Backend crates depend on this crate and implement [`SecretStore`] and/or
4//! [`SigningBackend`]. Use [`SecretUri::parse`] to parse `secretx:` URIs
5//! in backend constructors.
6
7use std::collections::HashMap;
8use std::iter::Peekable;
9use std::str::Chars;
10use zeroize::Zeroizing;
11
12// ── SecretValue ──────────────────────────────────────────────────────────────
13
14/// A secret value whose memory is zeroed on drop.
15///
16/// Does not implement `Debug`, `Display`, or `Clone` to prevent accidental
17/// leakage. Use [`as_bytes`](SecretValue::as_bytes) for comparisons in tests.
18///
19/// # Why not the `secrecy` crate?
20///
21/// The [`secrecy`](https://crates.io/crates/secrecy) crate's `Secret<T>` zeroes
22/// the outer allocation on drop, which is the same guarantee `Zeroizing<Vec<u8>>`
23/// provides here. For a plain byte buffer that guarantee would be sufficient,
24/// and using `secrecy` would be reasonable.
25///
26/// The problem is [`extract_field`](SecretValue::extract_field) and
27/// [`extract_path_field`](SecretValue::extract_path_field). Secrets are often
28/// stored as JSON objects (`{"username":"alice","password":"s3cr3t"}`), so
29/// callers frequently need to pull a single string field out of the raw bytes.
30/// The obvious implementation calls `serde_json::from_slice`, but serde_json
31/// allocates every field value as a plain (non-`Zeroizing`) `String`. Even
32/// after the parsed map is dropped, those allocations are not zeroed: the
33/// password lingers in heap memory until the allocator reuses the page.
34/// `secrecy::Secret` does not help here — it only zeroes what it directly owns.
35///
36/// These methods therefore use a hand-rolled JSON scanner that never allocates
37/// non-target fields at all. Only the one requested value is placed into a
38/// `Zeroizing` buffer; everything else is scanned and discarded in place.
39/// Switching to `secrecy` would either require accepting that leak or keeping
40/// the custom scanner anyway, gaining a dependency without simplifying the code.
41///
42/// **Note on built-in backends:** the network-backed backends (aws-sm, azure-kv,
43/// doppler, gcp-sm, vault) use `serde_json` directly rather than these methods,
44/// because in those backends the secret arrives in non-Zeroizing heap memory (a
45/// `reqwest` response buffer or an SDK-owned `String`) before any parsing begins.
46/// The custom scanner cannot retroactively zero memory it does not own, so using
47/// it there would add complexity without improving the security boundary. These
48/// methods are most useful when the `SecretValue` originates from a backend that
49/// does not pre-leak — e.g. `secretx-file` or `secretx-env` — and the caller
50/// needs to extract a JSON field while minimising additional unzeroed copies.
51///
52/// See the `// ── JSON field extractor` section below for the full rationale.
53pub struct SecretValue(Zeroizing<Vec<u8>>);
54
55impl SecretValue {
56 /// Wrap raw bytes in a `SecretValue`.
57 ///
58 /// The bytes are moved into a [`Zeroizing`] container and zeroed on drop.
59 pub fn new(bytes: Vec<u8>) -> Self {
60 SecretValue(Zeroizing::new(bytes))
61 }
62
63 /// Borrow the secret bytes.
64 pub fn as_bytes(&self) -> &[u8] {
65 &self.0
66 }
67
68 /// Consume the `SecretValue` and return the inner [`Zeroizing<Vec<u8>>`].
69 pub fn into_bytes(self) -> Zeroizing<Vec<u8>> {
70 self.0
71 }
72
73 /// Construct from an already-zeroizing buffer without creating a
74 /// non-`Zeroizing` intermediate copy.
75 pub fn from_zeroizing(z: Zeroizing<Vec<u8>>) -> Self {
76 SecretValue(z)
77 }
78
79 /// Decode as UTF-8 without copying. Fails if not valid UTF-8.
80 pub fn as_str(&self) -> Result<&str, SecretError> {
81 std::str::from_utf8(&self.0)
82 .map_err(|_| SecretError::DecodeFailed("not valid UTF-8".into()))
83 }
84
85 /// Parse as a JSON object and extract a single string field.
86 ///
87 /// Common for secrets that bundle multiple values as JSON,
88 /// e.g. `{"username":"foo","password":"bar"}`.
89 ///
90 /// Uses a hand-rolled JSON scanner so that only the requested field's value
91 /// is allocated. A full-tree parser (e.g. serde_json) would allocate copies
92 /// of every field value, leaving other secret strings in unzeroized heap
93 /// memory even after the parse result is dropped.
94 pub fn extract_field(&self, field: &str) -> Result<SecretValue, SecretError> {
95 json_extract_string_field(self.as_bytes(), field)
96 }
97
98 /// Navigate through nested JSON objects and return the raw bytes of the
99 /// value at `path`'s final key as a new `SecretValue`.
100 ///
101 /// Each key in `path` must exist in the current JSON object. All
102 /// intermediate values (`path[..path.len()-1]`) must be JSON objects.
103 /// The final value may be any JSON type.
104 ///
105 /// # Example (Vault KV v2)
106 ///
107 /// Given `{"data": {"data": {"password": "s3cret"}}, ...}`,
108 /// `extract_path(&["data", "data"])` returns a `SecretValue` containing
109 /// the bytes of `{"password": "s3cret"}`. Call [`SecretValue::extract_field`] on
110 /// the result to retrieve a specific secret field.
111 // NOTE: raw.to_vec() creates a non-Zeroizing intermediate copy of the
112 // navigated JSON slice. This is intentional — the caller may need the
113 // intermediate object (e.g. for further field extraction). The common
114 // single-allocation path is extract_path_field() below, which avoids the
115 // copy entirely. The intermediate copy is wrapped in SecretValue::new()
116 // (Zeroizing) immediately and no reference to it escapes.
117 pub fn extract_path(&self, path: &[&str]) -> Result<SecretValue, SecretError> {
118 let raw = json_navigate(self.as_bytes(), path)?;
119 Ok(SecretValue::new(raw.to_vec()))
120 }
121
122 /// Navigate through nested JSON objects and extract a string field.
123 ///
124 /// Equivalent to `self.extract_path(path)?.extract_field(field)` but
125 /// avoids an intermediate allocation: the nested object bytes are sliced
126 /// from the original input with no copy, and only the target field value
127 /// is placed in a `Zeroizing` buffer.
128 pub fn extract_path_field(
129 &self,
130 path: &[&str],
131 field: &str,
132 ) -> Result<SecretValue, SecretError> {
133 let raw = json_navigate(self.as_bytes(), path)?;
134 json_extract_string_field(raw, field)
135 }
136}
137
138impl From<Zeroizing<Vec<u8>>> for SecretValue {
139 fn from(z: Zeroizing<Vec<u8>>) -> Self {
140 SecretValue(z)
141 }
142}
143
144impl AsRef<[u8]> for SecretValue {
145 fn as_ref(&self) -> &[u8] {
146 &self.0
147 }
148}
149
150// ── JSON scanners ─────────────────────────────────────────────────────────────
151//
152// There are two complementary hand-rolled JSON scanners in this file. They are
153// NOT duplicates of each other; they handle different operations:
154//
155// 1. Char-level string extractor (below this comment): used by
156// `extract_field` and `extract_path_field`. Iterates through a flat
157// `{"key": "value", ...}` object, matches a specific key, then decodes
158// the string value — handling all JSON escape sequences (\n, \t, \uXXXX)
159// and surrogate pairs. Returns the decoded bytes in a Zeroizing buffer.
160//
161// 2. Byte-level navigation scanner (below the `// ── Byte-level JSON
162// navigation` comment): used by `extract_path`. Navigates nested objects
163// by returning `&[u8]` sub-slices of the input WITHOUT allocating or
164// decoding string contents. Byte-level is used because
165// `Peekable<Chars<'_>>` does not expose byte offsets.
166//
167// Maintenance contract: the two scanners are independent. Changes to the
168// byte-level scanner do not require changes to the char-level scanner, and
169// vice versa. The char-level scanner owns string decoding (escapes, surrogate
170// pairs); the byte-level scanner owns structural navigation (nested objects,
171// sub-slice extraction).
172//
173// Why not serde_json?
174// serde_json deserializes all field values into plain (non-Zeroizing) String
175// allocations. Even after the parse result is dropped, those allocations are
176// not zeroed, so every other secret string in the object would linger in heap
177// memory until the allocator happens to reuse those pages. These scanners
178// avoid that by never touching non-target fields and writing only the target
179// value into a Zeroizing buffer.
180//
181// DO NOT replace these scanners with serde_json, and do not replace SecretValue
182// with secrecy::Secret. The zeroization guarantee is the reason this code
183// exists — it is not wheel-reinvention. See the `SecretValue` doc comment for
184// the full explanation. If a contributor suggests either change, point them
185// there.
186//
187// Future: this logic is a candidate for extraction into a small standalone
188// crate (e.g. `zeroizing-json-field`) so the wider Rust secrets ecosystem can
189// benefit. No mainstream JSON crate provides this guarantee today.
190
191/// Extract a single string-valued field from a flat JSON object.
192fn json_extract_string_field(bytes: &[u8], field: &str) -> Result<SecretValue, SecretError> {
193 let s = std::str::from_utf8(bytes)
194 .map_err(|_| SecretError::DecodeFailed("not valid UTF-8".into()))?;
195
196 let mut chars = s.chars().peekable();
197
198 json_skip_ws(&mut chars);
199 json_expect(&mut chars, '{')?;
200
201 // Handle empty object.
202 json_skip_ws(&mut chars);
203 if chars.peek() == Some(&'}') {
204 return Err(SecretError::DecodeFailed(format!(
205 "field `{field}` not found"
206 )));
207 }
208
209 loop {
210 // Each iteration: we are positioned at the start of a key string ('"').
211 json_expect(&mut chars, '"')?;
212 let key = json_parse_string(&mut chars)?;
213
214 json_skip_ws(&mut chars);
215 json_expect(&mut chars, ':')?;
216 json_skip_ws(&mut chars);
217
218 if *key == *field {
219 if chars.peek() != Some(&'"') {
220 return Err(SecretError::DecodeFailed(format!(
221 "field `{field}` is not a string"
222 )));
223 }
224 chars.next(); // consume opening '"'
225 let mut value = json_parse_string(&mut chars)?;
226 // Validate post-value structure. Without this check, trailing
227 // garbage immediately after the target field's value is silently
228 // ignored, but the same garbage positioned before the target field
229 // would cause an error — an asymmetry that is hard to debug.
230 json_skip_ws(&mut chars);
231 match chars.peek() {
232 Some(&',') | Some(&'}') => {}
233 Some(&c) => {
234 return Err(SecretError::DecodeFailed(format!(
235 "expected ',' or '}}' after value of field `{field}`, got '{c}'"
236 )));
237 }
238 None => {
239 return Err(SecretError::DecodeFailed(
240 "unexpected end of input after field value".into(),
241 ));
242 }
243 }
244 // Move the String out of Zeroizing via mem::take (replaces
245 // with empty String which is a no-op to zeroize on drop).
246 // String::into_bytes() is zero-copy — same heap allocation.
247 let s = std::mem::take(&mut *value);
248 return Ok(SecretValue::new(s.into_bytes()));
249 }
250
251 // Skip the value for a non-matching key.
252 json_skip_value(&mut chars)?;
253
254 // After each pair: expect ',' (more items) or '}' (end of object).
255 json_skip_ws(&mut chars);
256 match chars.next() {
257 Some(',') => {
258 json_skip_ws(&mut chars);
259 // Guard against trailing comma before '}'.
260 if chars.peek() == Some(&'}') {
261 return Err(SecretError::DecodeFailed(
262 "trailing comma in JSON object".into(),
263 ));
264 }
265 }
266 Some('}') => {
267 return Err(SecretError::DecodeFailed(format!(
268 "field `{field}` not found"
269 )));
270 }
271 Some(c) => {
272 return Err(SecretError::DecodeFailed(format!(
273 "expected ',' or '}}' in JSON object, got '{c}'"
274 )));
275 }
276 None => {
277 return Err(SecretError::DecodeFailed(
278 "unexpected end of JSON object".into(),
279 ));
280 }
281 }
282 }
283}
284
285fn json_skip_ws(chars: &mut Peekable<Chars<'_>>) {
286 while matches!(
287 chars.peek(),
288 Some(' ') | Some('\t') | Some('\n') | Some('\r')
289 ) {
290 chars.next();
291 }
292}
293
294fn json_expect(chars: &mut Peekable<Chars<'_>>, expected: char) -> Result<(), SecretError> {
295 match chars.next() {
296 Some(c) if c == expected => Ok(()),
297 Some(c) => Err(SecretError::DecodeFailed(format!(
298 "expected '{expected}', got '{c}'"
299 ))),
300 None => Err(SecretError::DecodeFailed(format!(
301 "expected '{expected}', got end of input"
302 ))),
303 }
304}
305
306/// Parse a JSON string after the opening `"` has been consumed.
307///
308/// Returns [`Zeroizing<String>`] so that intermediate heap buffers are zeroed
309/// on drop — preventing secret fragments from leaking through `String`
310/// reallocations. Pre-sized to `remaining` (the number of chars left in the
311/// iterator's underlying slice) to minimize reallocations.
312fn json_parse_string(chars: &mut Peekable<Chars<'_>>) -> Result<Zeroizing<String>, SecretError> {
313 // size_hint().0 is a lower bound on remaining chars — use it to pre-size
314 // the buffer and minimize reallocation-induced secret copies.
315 let hint = chars.size_hint().0;
316 let mut result = Zeroizing::new(String::with_capacity(hint));
317 loop {
318 match chars.next() {
319 None => return Err(SecretError::DecodeFailed("unterminated JSON string".into())),
320 Some('"') => return Ok(result),
321 Some('\\') => match chars.next() {
322 None => {
323 return Err(SecretError::DecodeFailed(
324 "truncated escape in JSON string".into(),
325 ))
326 }
327 Some('"') => result.push('"'),
328 Some('\\') => result.push('\\'),
329 Some('/') => result.push('/'),
330 Some('b') => result.push('\x08'),
331 Some('f') => result.push('\x0C'),
332 Some('n') => result.push('\n'),
333 Some('r') => result.push('\r'),
334 Some('t') => result.push('\t'),
335 Some('u') => {
336 let ch = json_consume_unicode_escape(chars)?;
337 result.push(ch);
338 }
339 Some(c) => {
340 return Err(SecretError::DecodeFailed(format!(
341 "unknown JSON escape '\\{c}'"
342 )))
343 }
344 },
345 // RFC 8259 §7: U+0000–U+001F must be escaped; a bare control
346 // character in a JSON string is invalid.
347 Some(c) if (c as u32) < 0x20 => {
348 return Err(SecretError::DecodeFailed(format!(
349 "unescaped control character U+{:04X} in JSON string",
350 c as u32
351 )));
352 }
353 Some(c) => result.push(c),
354 }
355 }
356}
357
358/// Consume a `\uXXXX` escape sequence; the `\u` has already been consumed.
359///
360/// Handles surrogate pairs: if the first code unit is a high surrogate
361/// (0xD800–0xDBFF) the immediately following `\uXXXX` low surrogate
362/// (0xDC00–0xDFFF) is also consumed and the two are combined into the
363/// supplementary Unicode scalar value. A lone surrogate (either high without
364/// a following low, or low without a preceding high) is rejected.
365///
366/// Returns the decoded Unicode scalar value so callers that are building a
367/// string can push it directly. Callers that are only skipping (not
368/// extracting) can discard the return value; validation still runs.
369fn json_consume_unicode_escape(chars: &mut Peekable<Chars<'_>>) -> Result<char, SecretError> {
370 let hex: String = chars.by_ref().take(4).collect();
371 if hex.len() != 4 {
372 return Err(SecretError::DecodeFailed(
373 "truncated \\uXXXX escape in JSON string".into(),
374 ));
375 }
376 let code = u32::from_str_radix(&hex, 16)
377 .map_err(|_| SecretError::DecodeFailed("invalid hex digits in \\uXXXX escape".into()))?;
378
379 if (0xD800..=0xDBFF).contains(&code) {
380 // High surrogate: RFC 8259 §7 requires an immediately following
381 // \uXXXX low surrogate. Combine the pair into a supplementary
382 // code point: U+10000 + (H - 0xD800) * 0x400 + (L - 0xDC00).
383 if chars.next() != Some('\\') || chars.next() != Some('u') {
384 return Err(SecretError::DecodeFailed(format!(
385 "\\u{code:04X} is a high surrogate not followed by \\uXXXX"
386 )));
387 }
388 let low_hex: String = chars.by_ref().take(4).collect();
389 if low_hex.len() != 4 {
390 return Err(SecretError::DecodeFailed(
391 "truncated \\uXXXX low-surrogate escape".into(),
392 ));
393 }
394 let low = u32::from_str_radix(&low_hex, 16).map_err(|_| {
395 SecretError::DecodeFailed("invalid hex digits in \\uXXXX low-surrogate escape".into())
396 })?;
397 if !(0xDC00..=0xDFFF).contains(&low) {
398 return Err(SecretError::DecodeFailed(format!(
399 "\\u{code:04X} is a high surrogate but \\u{low:04X} is not a low surrogate"
400 )));
401 }
402 let codepoint = 0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00);
403 // All valid surrogate pairs produce U+10000..=U+10FFFF which are
404 // always valid Unicode scalar values.
405 char::from_u32(codepoint).ok_or_else(|| {
406 SecretError::DecodeFailed(
407 "surrogate pair decoded to invalid Unicode scalar value".into(),
408 )
409 })
410 } else if (0xDC00..=0xDFFF).contains(&code) {
411 // Lone low surrogate with no preceding high surrogate.
412 Err(SecretError::DecodeFailed(format!(
413 "\\u{code:04X} is a lone low surrogate"
414 )))
415 } else {
416 char::from_u32(code).ok_or_else(|| {
417 SecretError::DecodeFailed("\\uXXXX escape is not a valid Unicode scalar value".into())
418 })
419 }
420}
421
422/// Skip over a JSON value (string, number, bool, null, array, or object)
423/// without allocating its content.
424fn json_skip_value(chars: &mut Peekable<Chars<'_>>) -> Result<(), SecretError> {
425 match chars.peek().copied() {
426 Some('"') => {
427 chars.next(); // consume '"'
428 json_skip_string(chars)
429 }
430 Some('t') => json_skip_literal(chars, "true"),
431 Some('f') => json_skip_literal(chars, "false"),
432 Some('n') => json_skip_literal(chars, "null"),
433 Some(c) if c == '-' || c.is_ascii_digit() => json_skip_number(chars),
434 Some('[') => json_skip_container(chars, '[', ']'),
435 Some('{') => json_skip_container(chars, '{', '}'),
436 Some(c) => Err(SecretError::DecodeFailed(format!(
437 "unexpected character '{c}' at start of JSON value"
438 ))),
439 None => Err(SecretError::DecodeFailed(
440 "unexpected end of input in JSON value".into(),
441 )),
442 }
443}
444
445/// Skip a JSON string after the opening `"` has been consumed.
446///
447/// Validates `\uXXXX` escapes including surrogate pairs, consistent with
448/// `json_parse_string`. A high surrogate not followed by a low surrogate, or a
449/// lone low surrogate, is rejected — invalid JSON is rejected regardless of
450/// which field is being extracted.
451fn json_skip_string(chars: &mut Peekable<Chars<'_>>) -> Result<(), SecretError> {
452 loop {
453 match chars.next() {
454 None => return Err(SecretError::DecodeFailed("unterminated JSON string".into())),
455 Some('"') => return Ok(()),
456 Some('\\') => match chars.next() {
457 None => {
458 return Err(SecretError::DecodeFailed(
459 "truncated escape in JSON string".into(),
460 ))
461 }
462 Some('u') => {
463 // Validate the escape (including surrogate pairs) but
464 // discard the decoded char — we are skipping, not
465 // extracting.
466 json_consume_unicode_escape(chars)?;
467 }
468 Some('"' | '\\' | '/' | 'b' | 'f' | 'n' | 'r' | 't') => {}
469 Some(c) => {
470 return Err(SecretError::DecodeFailed(format!(
471 "unknown JSON escape '\\{c}'"
472 )));
473 }
474 },
475 // RFC 8259 §7: U+0000–U+001F must be escaped; reject bare
476 // control characters in skipped strings too.
477 Some(c) if (c as u32) < 0x20 => {
478 return Err(SecretError::DecodeFailed(format!(
479 "unescaped control character U+{:04X} in JSON string",
480 c as u32
481 )));
482 }
483 Some(_) => {}
484 }
485 }
486}
487
488fn json_skip_literal(chars: &mut Peekable<Chars<'_>>, literal: &str) -> Result<(), SecretError> {
489 for expected in literal.chars() {
490 match chars.next() {
491 Some(c) if c == expected => {}
492 Some(c) => {
493 return Err(SecretError::DecodeFailed(format!(
494 "invalid JSON literal: expected '{expected}', got '{c}'"
495 )))
496 }
497 None => {
498 return Err(SecretError::DecodeFailed(
499 "unexpected end of input in JSON literal".into(),
500 ))
501 }
502 }
503 }
504 Ok(())
505}
506
507// json_skip_number (char-based, below) and skip_number_b (byte-based, in the
508// byte-level navigation section) implement the same RFC 8259 §6 grammar but
509// cannot be unified: json_skip_number advances a shared Peekable<Chars>
510// iterator and returns (), while skip_number_b takes a positional byte index
511// and returns the new position. The two calling conventions are incompatible.
512// If you update one, update the other to match.
513fn json_skip_number(chars: &mut Peekable<Chars<'_>>) -> Result<(), SecretError> {
514 if chars.peek() == Some(&'-') {
515 chars.next();
516 }
517 // RFC 8259 §6: an integer part (one or more digits) must follow the
518 // optional minus sign. A bare '-' is not a valid JSON number.
519 if !chars.peek().map(|c| c.is_ascii_digit()).unwrap_or(false) {
520 return Err(SecretError::DecodeFailed(
521 "invalid JSON number: expected digit after '-'".into(),
522 ));
523 }
524 // Consume the first integer digit. RFC 8259 §6: if it is '0', no
525 // further digits may appear in the integer part — leading zeros like
526 // 01 or 007 are not valid JSON numbers.
527 let first = chars.next().expect("peeked above");
528 if first == '0' && chars.peek().map(|c| c.is_ascii_digit()).unwrap_or(false) {
529 return Err(SecretError::DecodeFailed(
530 "invalid JSON number: leading zeros are not allowed".into(),
531 ));
532 }
533 // Consume remaining integer digits. When first == '0' the leading-zero
534 // check above guarantees the next char is not a digit, so this is a no-op.
535 while chars.peek().map(|c| c.is_ascii_digit()).unwrap_or(false) {
536 chars.next();
537 }
538 if chars.peek() == Some(&'.') {
539 chars.next();
540 // RFC 8259 §6: frac = decimal-point 1*DIGIT — at least one digit
541 // must follow the decimal point. `1.` is not a valid JSON number.
542 if !chars.peek().map(|c| c.is_ascii_digit()).unwrap_or(false) {
543 return Err(SecretError::DecodeFailed(
544 "invalid JSON number: expected digit after decimal point".into(),
545 ));
546 }
547 while chars.peek().map(|c| c.is_ascii_digit()).unwrap_or(false) {
548 chars.next();
549 }
550 }
551 if matches!(chars.peek(), Some('e') | Some('E')) {
552 chars.next();
553 if matches!(chars.peek(), Some('+') | Some('-')) {
554 chars.next();
555 }
556 // RFC 8259 §6: at least one digit must follow the exponent indicator.
557 if !chars.peek().map(|c| c.is_ascii_digit()).unwrap_or(false) {
558 return Err(SecretError::DecodeFailed(
559 "invalid JSON number: exponent has no digits".into(),
560 ));
561 }
562 while chars.peek().map(|c| c.is_ascii_digit()).unwrap_or(false) {
563 chars.next();
564 }
565 }
566 Ok(())
567}
568
569/// Skip a JSON array `[...]` or object `{...}`, handling nested structures and
570/// strings (which may contain the closing bracket as escaped characters).
571///
572/// # Limitation: mixed bracket types are not validated
573///
574/// This function only counts occurrences of the *specific* `open`/`close` pair
575/// it was called with. A structurally invalid input like `[{]}` will be
576/// accepted: `[` opens depth 1, `{` is ignored (wrong bracket type), `]`
577/// closes depth 0 and returns `Ok`. The iterator position after the call is
578/// correct (we stop at `]`), but the remaining `}` will be unexpected in the
579/// caller and will produce a parse error there.
580///
581/// This is acceptable because `json_extract_string_field` only calls this
582/// function to skip non-target field values, not to validate the JSON
583/// structure. The outer loop will detect the malformed trailing `}` and
584/// return `DecodeFailed`. Do not rely on this function as a structural
585/// validator for bracket-type matching.
586fn json_skip_container(
587 chars: &mut Peekable<Chars<'_>>,
588 open: char,
589 close: char,
590) -> Result<(), SecretError> {
591 // Consume the opening bracket/brace.
592 chars.next();
593 let mut depth = 1usize;
594 loop {
595 match chars.next() {
596 None => {
597 return Err(SecretError::DecodeFailed(
598 "unterminated JSON container".into(),
599 ))
600 }
601 Some('"') => json_skip_string(chars)?,
602 Some(c) if c == open => depth += 1,
603 Some(c) if c == close => {
604 depth -= 1;
605 if depth == 0 {
606 return Ok(());
607 }
608 }
609 Some(_) => {}
610 }
611 }
612}
613
614// ── Byte-level JSON navigation ────────────────────────────────────────────────
615//
616// See the "── JSON scanners ──" comment above for the maintenance contract.
617//
618// These functions provide zero-allocation navigation through nested JSON
619// objects. They track byte positions so they can return `&[u8]` sub-slices of
620// the input without decoding string values. The char-level scanner above this
621// section owns string decoding (escapes, surrogate pairs).
622//
623// Why byte-level and not char-level?
624// `Peekable<Chars<'_>>` does not expose byte offsets. A byte-level scanner
625// is safe for JSON because all structural characters ('{', '}', '[', ']',
626// ':', ',', '"', '\\') are ASCII (< 0x80) and cannot appear as continuation
627// bytes in multi-byte UTF-8 sequences. Key strings with non-ASCII chars are
628// handled by dropping back to `str` for the char boundary.
629
630/// Navigate through `path` in nested JSON objects and return a raw byte slice
631/// of the value at the final key. An empty `path` returns `bytes` unchanged.
632fn json_navigate<'a>(bytes: &'a [u8], path: &[&str]) -> Result<&'a [u8], SecretError> {
633 let mut current = bytes;
634 for key in path {
635 current = json_find_value_b(current, key)?;
636 }
637 Ok(current)
638}
639
640/// Find `key` in the JSON object `bytes` and return a raw byte sub-slice of
641/// its value. Leading/trailing whitespace of the value is excluded.
642fn json_find_value_b<'a>(bytes: &'a [u8], key: &str) -> Result<&'a [u8], SecretError> {
643 // Validate UTF-8 so that scan_string_key_b can safely use str operations.
644 if std::str::from_utf8(bytes).is_err() {
645 return Err(SecretError::DecodeFailed("not valid UTF-8".into()));
646 }
647
648 let mut pos = skip_ws_b(bytes, 0);
649 if bytes.get(pos) != Some(&b'{') {
650 return Err(SecretError::DecodeFailed("expected JSON object '{'".into()));
651 }
652 pos += 1;
653 pos = skip_ws_b(bytes, pos);
654
655 // Handle empty object.
656 if bytes.get(pos) == Some(&b'}') {
657 return Err(SecretError::DecodeFailed(format!("key `{key}` not found")));
658 }
659
660 loop {
661 pos = skip_ws_b(bytes, pos);
662 if bytes.get(pos) != Some(&b'"') {
663 return Err(SecretError::DecodeFailed("expected '\"' for key".into()));
664 }
665 let (k, new_pos) = scan_string_key_b(bytes, pos + 1)?;
666 pos = new_pos;
667
668 pos = skip_ws_b(bytes, pos);
669 if bytes.get(pos) != Some(&b':') {
670 return Err(SecretError::DecodeFailed("expected ':' after key".into()));
671 }
672 pos += 1;
673 pos = skip_ws_b(bytes, pos);
674
675 let value_start = pos;
676 let value_end = skip_value_b(bytes, pos)?;
677
678 if k == key {
679 // skip_value_b stops exactly after the last byte of the value
680 // token; no trailing whitespace is included in [value_start..value_end].
681 return Ok(&bytes[value_start..value_end]);
682 }
683
684 pos = skip_ws_b(bytes, value_end);
685 match bytes.get(pos) {
686 Some(&b',') => {
687 pos += 1;
688 pos = skip_ws_b(bytes, pos);
689 // Guard against trailing comma.
690 if bytes.get(pos) == Some(&b'}') {
691 return Err(SecretError::DecodeFailed(
692 "trailing comma in JSON object".into(),
693 ));
694 }
695 }
696 Some(&b'}') => {
697 return Err(SecretError::DecodeFailed(format!("key `{key}` not found")));
698 }
699 Some(&c) => {
700 return Err(SecretError::DecodeFailed(format!(
701 "expected ',' or '}}' in JSON object, got byte {c:#04x}"
702 )));
703 }
704 None => {
705 return Err(SecretError::DecodeFailed(
706 "unexpected end of JSON object".into(),
707 ));
708 }
709 }
710 }
711}
712
713/// Advance past ASCII whitespace; return the new position.
714fn skip_ws_b(bytes: &[u8], mut pos: usize) -> usize {
715 while matches!(
716 bytes.get(pos),
717 Some(b' ') | Some(b'\t') | Some(b'\n') | Some(b'\r')
718 ) {
719 pos += 1;
720 }
721 pos
722}
723
724/// Parse a JSON key string, starting just AFTER the opening `"`.
725/// Returns `(decoded_key, byte_position_after_closing_quote)`.
726///
727/// Handles all JSON string escapes including `\uXXXX` and surrogate pairs.
728/// Multi-byte UTF-8 characters in keys are passed through correctly.
729fn scan_string_key_b(bytes: &[u8], mut pos: usize) -> Result<(String, usize), SecretError> {
730 let mut key = String::new();
731 while pos < bytes.len() {
732 let b = bytes[pos];
733 pos += 1;
734 match b {
735 b'"' => return Ok((key, pos)),
736 b'\\' => {
737 if pos >= bytes.len() {
738 return Err(SecretError::DecodeFailed(
739 "truncated escape in JSON key".into(),
740 ));
741 }
742 let e = bytes[pos];
743 pos += 1;
744 match e {
745 b'"' => key.push('"'),
746 b'\\' => key.push('\\'),
747 b'/' => key.push('/'),
748 b'b' => key.push('\x08'),
749 b'f' => key.push('\x0C'),
750 b'n' => key.push('\n'),
751 b'r' => key.push('\r'),
752 b't' => key.push('\t'),
753 b'u' => {
754 if pos + 4 > bytes.len() {
755 return Err(SecretError::DecodeFailed(
756 "truncated \\uXXXX in JSON key".into(),
757 ));
758 }
759 let hex = std::str::from_utf8(&bytes[pos..pos + 4]).map_err(|_| {
760 SecretError::DecodeFailed("non-ASCII bytes in \\uXXXX escape".into())
761 })?;
762 let code = u32::from_str_radix(hex, 16).map_err(|_| {
763 SecretError::DecodeFailed("invalid hex digits in \\uXXXX".into())
764 })?;
765 pos += 4;
766 if (0xD800..=0xDBFF).contains(&code) {
767 // High surrogate — must be followed by \uXXXX low surrogate.
768 if bytes.get(pos..pos + 2) != Some(b"\\u") {
769 return Err(SecretError::DecodeFailed(format!(
770 "\\u{code:04X} is a high surrogate not followed by \\uXXXX"
771 )));
772 }
773 if pos + 6 > bytes.len() {
774 return Err(SecretError::DecodeFailed(
775 "truncated low-surrogate \\uXXXX".into(),
776 ));
777 }
778 let low_hex =
779 std::str::from_utf8(&bytes[pos + 2..pos + 6]).map_err(|_| {
780 SecretError::DecodeFailed(
781 "non-ASCII bytes in low-surrogate \\uXXXX".into(),
782 )
783 })?;
784 let low = u32::from_str_radix(low_hex, 16).map_err(|_| {
785 SecretError::DecodeFailed(
786 "invalid hex in low-surrogate \\uXXXX".into(),
787 )
788 })?;
789 if !(0xDC00..=0xDFFF).contains(&low) {
790 return Err(SecretError::DecodeFailed(format!(
791 "\\u{code:04X} high surrogate not followed by low surrogate (got \\u{low:04X})"
792 )));
793 }
794 let cp = 0x10000u32 + ((code - 0xD800) << 10) + (low - 0xDC00);
795 key.push(char::from_u32(cp).ok_or_else(|| {
796 SecretError::DecodeFailed(
797 "surrogate pair decoded to invalid scalar".into(),
798 )
799 })?);
800 pos += 6;
801 } else if (0xDC00..=0xDFFF).contains(&code) {
802 return Err(SecretError::DecodeFailed(format!(
803 "\\u{code:04X} is a lone low surrogate"
804 )));
805 } else {
806 key.push(char::from_u32(code).ok_or_else(|| {
807 SecretError::DecodeFailed(
808 "\\uXXXX decoded to invalid Unicode scalar".into(),
809 )
810 })?);
811 }
812 }
813 _ => {
814 return Err(SecretError::DecodeFailed(format!(
815 "unknown JSON escape '\\{}'",
816 e as char
817 )))
818 }
819 }
820 }
821 b if b < 0x20 => {
822 return Err(SecretError::DecodeFailed(format!(
823 "unescaped control character {b:#04x} in JSON key"
824 )));
825 }
826 b if b < 0x80 => {
827 // Plain ASCII.
828 key.push(b as char);
829 }
830 _ => {
831 // Multi-byte UTF-8 sequence. UTF-8 validity was confirmed by
832 // json_find_value_b; `bytes[pos-1..]` starts at the lead byte.
833 let rest = std::str::from_utf8(&bytes[pos - 1..])
834 .expect("UTF-8 validity confirmed at json_find_value_b entry");
835 let ch = rest
836 .chars()
837 .next()
838 .expect("non-empty slice has at least one char");
839 key.push(ch);
840 pos += ch.len_utf8() - 1; // already consumed lead byte above
841 }
842 }
843 }
844 Err(SecretError::DecodeFailed("unterminated JSON string".into()))
845}
846
847/// Skip a JSON value at `pos` and return the byte position past its last byte.
848fn skip_value_b(bytes: &[u8], pos: usize) -> Result<usize, SecretError> {
849 match bytes.get(pos) {
850 Some(b'"') => skip_string_b(bytes, pos + 1),
851 Some(b'{') => skip_container_b(bytes, pos + 1, b'}'),
852 Some(b'[') => skip_container_b(bytes, pos + 1, b']'),
853 Some(b't') => expect_literal_b(bytes, pos, b"true"),
854 Some(b'f') => expect_literal_b(bytes, pos, b"false"),
855 Some(b'n') => expect_literal_b(bytes, pos, b"null"),
856 Some(&c) if c == b'-' || c.is_ascii_digit() => skip_number_b(bytes, pos),
857 Some(&c) => Err(SecretError::DecodeFailed(format!(
858 "unexpected byte {c:#04x} at start of JSON value"
859 ))),
860 None => Err(SecretError::DecodeFailed(
861 "unexpected end of input at JSON value".into(),
862 )),
863 }
864}
865
866/// Skip a JSON string body (call after consuming the opening `"`).
867/// Returns the byte position past the closing `"`.
868fn skip_string_b(bytes: &[u8], mut pos: usize) -> Result<usize, SecretError> {
869 while pos < bytes.len() {
870 match bytes[pos] {
871 b'"' => return Ok(pos + 1),
872 b'\\' => {
873 pos += 1;
874 if pos >= bytes.len() {
875 return Err(SecretError::DecodeFailed(
876 "truncated escape in JSON string".into(),
877 ));
878 }
879 // For \uXXXX skip 'u' + 4 hex digits.
880 //
881 // Surrogate pairs (\uHHHH\uLLLL) are NOT validated here: this
882 // path skips values without decoding them, and validating
883 // surrogates would require hex parsing and lookahead beyond what
884 // a byte-level skip warrants. The char-level skip
885 // (json_skip_string, used by extract_field) does validate
886 // surrogates. If surrogate-level validation is needed on the
887 // byte-level path, use extract_field instead of extract_path.
888 if bytes[pos] == b'u' {
889 if pos + 5 > bytes.len() {
890 return Err(SecretError::DecodeFailed(
891 "truncated \\uXXXX escape in JSON string".into(),
892 ));
893 }
894 pos += 5;
895 } else {
896 pos += 1;
897 }
898 }
899 // RFC 8259 §7: U+0000–U+001F must be escaped; reject bare control
900 // characters in skipped strings, consistent with json_skip_string.
901 b if b < 0x20 => {
902 return Err(SecretError::DecodeFailed(format!(
903 "unescaped control character {b:#04x} in JSON string"
904 )));
905 }
906 // All other bytes — including multi-byte UTF-8 continuation bytes
907 // (≥ 0x80) — are skipped one byte at a time. They cannot be '"'
908 // or '\' (both ASCII), so this is safe.
909 _ => pos += 1,
910 }
911 }
912 Err(SecretError::DecodeFailed("unterminated JSON string".into()))
913}
914
915/// Skip a JSON `{...}` or `[...]` body (call after consuming the opening
916/// bracket). `close` is the expected closing byte (`b'}'` or `b']'`).
917fn skip_container_b(bytes: &[u8], mut pos: usize, close: u8) -> Result<usize, SecretError> {
918 let mut depth: u32 = 1;
919 while pos < bytes.len() {
920 match bytes[pos] {
921 b'"' => pos = skip_string_b(bytes, pos + 1)?,
922 b'{' | b'[' => {
923 depth += 1;
924 pos += 1;
925 }
926 b'}' | b']' => {
927 depth -= 1;
928 if depth == 0 {
929 if bytes[pos] != close {
930 return Err(SecretError::DecodeFailed("mismatched JSON brackets".into()));
931 }
932 return Ok(pos + 1);
933 }
934 pos += 1;
935 }
936 _ => pos += 1,
937 }
938 }
939 Err(SecretError::DecodeFailed(
940 "unterminated JSON container".into(),
941 ))
942}
943
944/// Verify `bytes[pos..]` starts with `literal` and return `pos + literal.len()`.
945fn expect_literal_b(bytes: &[u8], pos: usize, literal: &[u8]) -> Result<usize, SecretError> {
946 let end = pos + literal.len();
947 if bytes.get(pos..end) == Some(literal) {
948 Ok(end)
949 } else {
950 Err(SecretError::DecodeFailed(format!(
951 "expected JSON literal `{}`",
952 std::str::from_utf8(literal).unwrap_or("?")
953 )))
954 }
955}
956
957/// Skip a JSON number starting at `pos` and return the position past its end.
958///
959/// Implements the same RFC 8259 §6 grammar as `json_skip_number` (char-based)
960/// but operates on a positional byte index rather than a `Peekable<Chars>`
961/// iterator. The two cannot be unified — see the comment above
962/// `json_skip_number` for the reason. If you update one, update the other.
963fn skip_number_b(bytes: &[u8], mut pos: usize) -> Result<usize, SecretError> {
964 if bytes.get(pos) == Some(&b'-') {
965 pos += 1;
966 }
967 if !bytes.get(pos).is_some_and(u8::is_ascii_digit) {
968 return Err(SecretError::DecodeFailed(
969 "invalid JSON number: expected digit".into(),
970 ));
971 }
972 // Consume the first integer digit. RFC 8259 §6: if it is '0', no
973 // further digits may appear in the integer part — leading zeros like
974 // 01 or 007 are not valid JSON numbers.
975 let first = bytes[pos];
976 pos += 1;
977 if first == b'0' && bytes.get(pos).is_some_and(u8::is_ascii_digit) {
978 return Err(SecretError::DecodeFailed(
979 "invalid JSON number: leading zeros are not allowed".into(),
980 ));
981 }
982 while bytes.get(pos).is_some_and(u8::is_ascii_digit) {
983 pos += 1;
984 }
985 if bytes.get(pos) == Some(&b'.') {
986 pos += 1;
987 // RFC 8259: at least one digit must follow the decimal point.
988 if !bytes.get(pos).is_some_and(u8::is_ascii_digit) {
989 return Err(SecretError::DecodeFailed(
990 "invalid JSON number: expected digit after '.'".into(),
991 ));
992 }
993 while bytes.get(pos).is_some_and(u8::is_ascii_digit) {
994 pos += 1;
995 }
996 }
997 if matches!(bytes.get(pos), Some(b'e') | Some(b'E')) {
998 pos += 1;
999 if matches!(bytes.get(pos), Some(b'+') | Some(b'-')) {
1000 pos += 1;
1001 }
1002 // RFC 8259: at least one digit must follow the exponent marker.
1003 if !bytes.get(pos).is_some_and(u8::is_ascii_digit) {
1004 return Err(SecretError::DecodeFailed(
1005 "invalid JSON number: expected digit in exponent".into(),
1006 ));
1007 }
1008 while bytes.get(pos).is_some_and(u8::is_ascii_digit) {
1009 pos += 1;
1010 }
1011 }
1012 Ok(pos)
1013}
1014
1015// ── SecretError ───────────────────────────────────────────────────────────────
1016
1017/// Errors returned by secret store operations.
1018#[non_exhaustive]
1019#[derive(Debug, thiserror::Error)]
1020pub enum SecretError {
1021 /// Backend returned no secret for this name/path.
1022 #[error("secret not found")]
1023 NotFound,
1024
1025 /// Backend encountered a permanent error — retrying **will not** help.
1026 ///
1027 /// Examples: authentication failure, permission denied, malformed
1028 /// request, the named secret was deleted. Callers should surface this
1029 /// to the operator or abort rather than retrying automatically.
1030 ///
1031 /// For transient failures where a retry may succeed (network timeouts,
1032 /// 5xx responses, temporary outages), use [`Unavailable`](SecretError::Unavailable).
1033 #[error("backend `{backend}` error: {source}")]
1034 Backend {
1035 backend: &'static str,
1036 #[source]
1037 source: Box<dyn std::error::Error + Send + Sync>,
1038 },
1039
1040 /// URI was syntactically invalid or named an unknown/disabled backend.
1041 #[error("invalid URI: {0}")]
1042 InvalidUri(String),
1043
1044 /// Secret was present but could not be decoded as expected.
1045 #[error("decode failed: {0}")]
1046 DecodeFailed(String),
1047
1048 /// Backend is temporarily unavailable — retrying **may** succeed.
1049 ///
1050 /// Examples: network timeout, DNS failure, connection refused, HTTP 5xx
1051 /// from a remote secrets service. Callers should implement retry with
1052 /// back-off rather than failing immediately.
1053 ///
1054 /// For permanent errors that will not resolve on retry, use
1055 /// [`Backend`](SecretError::Backend).
1056 #[error("backend `{backend}` unavailable: {source}")]
1057 Unavailable {
1058 backend: &'static str,
1059 #[source]
1060 source: Box<dyn std::error::Error + Send + Sync>,
1061 },
1062
1063 /// Data was irrecoverably lost during a write operation.
1064 ///
1065 /// The backend deleted existing data but failed to write the
1066 /// replacement. The original data is permanently gone. Callers
1067 /// should log an alert and may need to recreate the lost entry.
1068 ///
1069 /// `message` describes what was lost (e.g. an object ID or key name).
1070 #[error("backend `{backend}` data lost: {message}")]
1071 DataLost {
1072 backend: &'static str,
1073 message: String,
1074 },
1075
1076 /// The backend's key algorithm does not match what the caller expected.
1077 ///
1078 /// Returned by adapter layers (e.g. `secretx-signature`) when a
1079 /// `SigningBackend` is wrapped in a typed signer for a different algorithm.
1080 #[error("algorithm mismatch: expected {expected}, got {actual}")]
1081 AlgorithmMismatch {
1082 expected: &'static str,
1083 actual: String,
1084 },
1085}
1086
1087// ── SecretUri helpers ─────────────────────────────────────────────────────────
1088
1089/// Percent-decode a URI component string (path segment or query value).
1090///
1091/// Decodes `%XX` escape sequences where `XX` is a pair of hex digits.
1092/// Returns `Err(SecretError::InvalidUri)` if a `%` is not followed by two
1093/// valid hex digits.
1094fn percent_decode(s: &str) -> Result<String, SecretError> {
1095 let bytes = s.as_bytes();
1096 let mut out = Vec::with_capacity(bytes.len());
1097 let mut iter = bytes.iter().copied().enumerate();
1098 while let Some((pos, b)) = iter.next() {
1099 if b != b'%' {
1100 out.push(b);
1101 continue;
1102 }
1103 let (_, h) = iter.next().ok_or_else(|| {
1104 SecretError::InvalidUri(format!(
1105 "incomplete percent-encoding at position {pos} in `{s}`"
1106 ))
1107 })?;
1108 let (_, l) = iter.next().ok_or_else(|| {
1109 SecretError::InvalidUri(format!(
1110 "incomplete percent-encoding at position {pos} in `{s}`"
1111 ))
1112 })?;
1113 let hi = hex_digit(h).ok_or_else(|| {
1114 SecretError::InvalidUri(format!(
1115 "invalid percent-encoding `%{}{}` at position {pos} in `{s}`",
1116 h as char, l as char
1117 ))
1118 })?;
1119 let lo = hex_digit(l).ok_or_else(|| {
1120 SecretError::InvalidUri(format!(
1121 "invalid percent-encoding `%{}{}` at position {pos} in `{s}`",
1122 h as char, l as char
1123 ))
1124 })?;
1125 out.push((hi << 4) | lo);
1126 }
1127 String::from_utf8(out).map_err(|_| {
1128 SecretError::InvalidUri(format!(
1129 "percent-decoded bytes in `{s}` are not valid UTF-8"
1130 ))
1131 })
1132}
1133
1134fn hex_digit(b: u8) -> Option<u8> {
1135 match b {
1136 b'0'..=b'9' => Some(b - b'0'),
1137 b'a'..=b'f' => Some(b - b'a' + 10),
1138 b'A'..=b'F' => Some(b - b'A' + 10),
1139 _ => None,
1140 }
1141}
1142
1143// ── SecretUri ─────────────────────────────────────────────────────────────────
1144
1145/// A parsed `secretx:` URI.
1146///
1147/// All backend `from_uri` constructors should parse with this type rather than
1148/// rolling their own string splitting.
1149///
1150/// # URI structure
1151///
1152/// ```text
1153/// secretx:<backend>:<path>[?key=val&key2=val2]
1154/// ```
1155///
1156/// Absolute file paths use a leading `/` in the path component:
1157///
1158/// ```text
1159/// secretx:file:/etc/secrets/key → backend="file", path="/etc/secrets/key"
1160/// secretx:file:relative/path → backend="file", path="relative/path"
1161/// ```
1162///
1163/// # Field access
1164///
1165/// All fields are private. Use the accessor methods [`SecretUri::backend`],
1166/// [`SecretUri::path`], and [`SecretUri::param`] to read URI components.
1167/// This preserves the ability to change the internal representation (e.g.
1168/// multi-value params or a different map type) without a breaking API change.
1169#[derive(Debug, Clone, PartialEq, Eq)]
1170pub struct SecretUri {
1171 backend: String,
1172 path: String,
1173 params: HashMap<String, String>,
1174}
1175
1176impl SecretUri {
1177 const SCHEME: &'static str = "secretx:";
1178
1179 /// Parse a `secretx:` URI.
1180 ///
1181 /// The format is `secretx:<backend>:<path>[?<params>]`. Examples:
1182 ///
1183 /// ```text
1184 /// secretx:env:MY_VAR
1185 /// secretx:file:/etc/secrets/key (absolute path)
1186 /// secretx:file:relative/key (relative path)
1187 /// secretx:aws-sm:prod/db-password
1188 /// secretx:vault:secret/myapp?field=password
1189 /// ```
1190 ///
1191 /// Returns [`SecretError::InvalidUri`] if the URI does not start with
1192 /// `secretx:`, has an empty backend component, or contains invalid
1193 /// percent-encoding in the path or query parameters.
1194 pub fn parse(uri: &str) -> Result<Self, SecretError> {
1195 // Provide a helpful error for the legacy secretx://backend/path format.
1196 if uri.starts_with("secretx://") {
1197 return Err(SecretError::InvalidUri(format!(
1198 "URI uses the old `secretx://backend/path` format; \
1199 use `secretx:backend:path` instead (see MIGRATION.md): {uri}"
1200 )));
1201 }
1202
1203 let rest = uri.strip_prefix(Self::SCHEME).ok_or_else(|| {
1204 SecretError::InvalidUri(format!("URI must start with `secretx:`, got: {uri}"))
1205 })?;
1206
1207 // Strip the query string before splitting on ':'.
1208 let (backend_and_path, query_part) = match rest.find('?') {
1209 Some(i) => (&rest[..i], Some(&rest[i + 1..])),
1210 None => (rest, None),
1211 };
1212
1213 // Split backend name from path on the first ':'.
1214 // secretx:env:MY_VAR → backend="env", path="MY_VAR"
1215 // secretx:file:/etc/key → backend="file", path="/etc/key"
1216 // secretx:aws-sm:prod/key → backend="aws-sm", path="prod/key"
1217 // The path may itself contain ':' (e.g. AWS ARNs); only the first ':'
1218 // is the backend/path separator.
1219 let (backend, raw_path) = match backend_and_path.find(':') {
1220 Some(i) => (&backend_and_path[..i], &backend_and_path[i + 1..]),
1221 None => (backend_and_path, ""),
1222 };
1223
1224 if backend.is_empty() {
1225 return Err(SecretError::InvalidUri(format!(
1226 "missing backend name in URI: {uri}"
1227 )));
1228 }
1229
1230 let path = percent_decode(raw_path)?;
1231
1232 // Parse query parameters, percent-decoding both keys and values.
1233 // Duplicate keys are silently resolved with last-wins semantics
1234 // (HashMap::insert overwrites). No secretx URI uses duplicate keys
1235 // intentionally; if a URI is malformed with duplicates, the last value
1236 // wins rather than returning an error.
1237 let mut params = HashMap::new();
1238 if let Some(q) = query_part {
1239 for pair in q.split('&').filter(|s| !s.is_empty()) {
1240 match pair.find('=') {
1241 Some(i) => {
1242 let key = percent_decode(&pair[..i])?;
1243 let val = percent_decode(&pair[i + 1..])?;
1244 params.insert(key, val);
1245 }
1246 None => {
1247 params.insert(percent_decode(pair)?, String::new());
1248 }
1249 }
1250 }
1251 }
1252
1253 Ok(SecretUri {
1254 backend: backend.to_string(),
1255 path,
1256 params,
1257 })
1258 }
1259
1260 /// Return the backend name, e.g. `"aws-sm"`, `"file"`, `"env"`.
1261 pub fn backend(&self) -> &str {
1262 &self.backend
1263 }
1264
1265 /// Return the backend-specific path component of the URI.
1266 pub fn path(&self) -> &str {
1267 &self.path
1268 }
1269
1270 /// Return a query parameter value by key, or `None` if absent.
1271 pub fn param(&self, key: &str) -> Option<&str> {
1272 self.params.get(key).map(String::as_str)
1273 }
1274
1275 /// Return an iterator over all query parameter keys.
1276 pub fn param_keys(&self) -> impl Iterator<Item = &str> {
1277 self.params.keys().map(String::as_str)
1278 }
1279}
1280
1281// ── SecretStore ───────────────────────────────────────────────────────────────
1282
1283/// A backend that retrieves and stores secrets.
1284///
1285/// Implement this trait in a backend crate. Provide a `from_uri` constructor
1286/// as a plain method (not part of this trait) that calls [`SecretUri::parse`]
1287/// and validates the backend component. URI dispatch is handled by
1288/// `secretx::from_uri` in the umbrella crate.
1289///
1290/// Each `SecretStore` instance is bound to exactly one secret, identified by
1291/// the URI passed to `from_uri`. There is no key parameter on `get`; which
1292/// secret is returned is determined entirely by the URI, not by the call site.
1293///
1294/// # Threading
1295///
1296/// The trait requires `Send + Sync` because daemons hold `Arc<dyn SecretStore>`
1297/// in application state shared across many concurrent async tasks. `Send` lets
1298/// futures that call `get` or `refresh` be moved across threads by a
1299/// work-stealing runtime (Tokio). `Sync` lets the same `Arc` be accessed from
1300/// multiple tasks simultaneously without additional locking.
1301///
1302/// Network backends (Vault, AWS, GCP) hold an HTTP client that is itself
1303/// `Send + Sync`, so these bounds don't constrain implementors in practice.
1304/// Backends that need mutable internal state should use interior mutability
1305/// (`Mutex`, `RwLock`, or atomics) rather than `&mut self`.
1306///
1307/// # Why `async_trait` and not native async-fn-in-trait?
1308///
1309/// Native async-fn-in-trait (stable since Rust 1.75) produces unnameable
1310/// associated future types, which makes `Box<dyn SecretStore>` and
1311/// `Arc<dyn SecretStore>` impossible without additional machinery
1312/// (e.g. the `dynosaur` crate or manual `DynSecretStore` wrappers).
1313/// Since callers store backends as `Arc<dyn SecretStore>`, `async_trait`
1314/// is the correct choice for this public API. The boxing overhead is
1315/// incurred once per `get`/`refresh` call, which is acceptable for
1316/// network-bound backends.
1317#[async_trait::async_trait]
1318pub trait SecretStore: Send + Sync {
1319 /// Retrieve the secret.
1320 async fn get(&self) -> Result<SecretValue, SecretError>;
1321
1322 /// Force a fresh fetch from the source, bypassing any cache layer, and
1323 /// return the new value.
1324 async fn refresh(&self) -> Result<SecretValue, SecretError>;
1325}
1326
1327/// A [`SecretStore`] that also supports writing.
1328///
1329/// Implement this trait in addition to [`SecretStore`] for backends that can
1330/// persist new secret values. Read-only backends (`env`, `bitwarden`, etc.)
1331/// implement only `SecretStore`.
1332///
1333/// # Why a separate trait?
1334///
1335/// Not all backends support writes. Putting `put` in the base `SecretStore`
1336/// trait would force every read-only backend to implement a stub that returns
1337/// an error — deferring a type-level contract to a runtime failure. The
1338/// subtrait makes the write capability explicit at compile time: callers that
1339/// need to write hold `Arc<dyn WritableSecretStore>`; callers that only read
1340/// hold `Arc<dyn SecretStore>`.
1341///
1342/// # Implementation contract
1343///
1344/// Implementors must uphold these invariants:
1345///
1346/// - **Durability**: when `put` returns `Ok(())`, the value is durably stored.
1347/// A subsequent call to `get` on the same or a different instance pointing
1348/// at the same URI must return the written value (modulo cache TTL).
1349/// - **Atomicity**: a reader must never observe a partially-written secret.
1350/// Use an atomic rename (temp-file-then-rename) for file backends, or the
1351/// cloud API's native put-then-publish semantics for remote backends.
1352/// - **Failure isolation**: if `put` returns `Err`, the previously stored
1353/// value must remain intact and readable. A failed write must not leave the
1354/// secret in a corrupted or empty state.
1355/// - **No silent truncation**: never coerce or truncate the value. Return
1356/// `Err` if the backend cannot store the full value as provided.
1357#[async_trait::async_trait]
1358pub trait WritableSecretStore: SecretStore {
1359 /// Write or update the secret. The parent directory (for file backends)
1360 /// or the remote namespace (for cloud backends) must already exist.
1361 async fn put(&self, value: SecretValue) -> Result<(), SecretError>;
1362}
1363
1364// ── SigningBackend ────────────────────────────────────────────────────────────
1365
1366/// Key algorithm used by a [`SigningBackend`].
1367///
1368/// This enum is `#[non_exhaustive]` so that new algorithms (e.g. P-384,
1369/// Ed448) can be added in a minor version without breaking downstream
1370/// code that matches on it.
1371#[non_exhaustive]
1372#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1373pub enum SigningAlgorithm {
1374 Ed25519,
1375 EcdsaP256Sha256,
1376 RsaPss2048Sha256,
1377}
1378
1379/// A signing backend where the private key never leaves the HSM.
1380///
1381/// Implemented by AWS KMS, Azure Key Vault HSM, PKCS#11, wolfHSM, and local
1382/// key backends. Call sites are identical regardless of backend.
1383///
1384/// # Threading
1385///
1386/// Requires `Send + Sync` for the same reasons as [`SecretStore`]: callers
1387/// hold `Arc<dyn SigningBackend>` and call `sign` from concurrent async tasks.
1388/// HSM backends that use a non-thread-safe C library should protect internal
1389/// state with a `Mutex` rather than opting out of `Send + Sync`.
1390///
1391/// # Why `async_trait`?
1392///
1393/// See [`SecretStore`] — the same rationale applies. `Arc<dyn SigningBackend>`
1394/// requires object-safe async methods, which native AFIT does not yet provide
1395/// without extra crates.
1396#[async_trait::async_trait]
1397pub trait SigningBackend: Send + Sync {
1398 /// Sign `message` using the backend key. Returns raw signature bytes.
1399 ///
1400 /// # Byte format
1401 ///
1402 /// All backends normalize to a fixed format per algorithm:
1403 ///
1404 /// - **Ed25519**: 64 bytes (R ‖ s).
1405 /// - **ECDSA P-256**: 64 bytes (r ‖ s), each scalar big-endian, zero-padded
1406 /// to 32 bytes. DER-encoded backends (e.g. AWS KMS) convert to this
1407 /// fixed-size format before returning.
1408 /// - **RSA-PSS 2048**: 256 bytes (big-endian signature value).
1409 async fn sign(&self, message: &[u8]) -> Result<Vec<u8>, SecretError>;
1410
1411 /// Return the public key as DER-encoded SubjectPublicKeyInfo.
1412 async fn public_key_der(&self) -> Result<Vec<u8>, SecretError>;
1413
1414 /// Key algorithm identifier.
1415 ///
1416 /// For backends where the algorithm is fixed at construction time (AWS KMS,
1417 /// local-signing) this always returns `Ok`.
1418 ///
1419 /// HSM-backed implementations (e.g. PKCS#11) may require a prior call to
1420 /// [`sign`](SigningBackend::sign) or
1421 /// [`public_key_der`](SigningBackend::public_key_der) to detect and cache
1422 /// the key type. If the cache is cold, `algorithm()` returns an error
1423 /// rather than blocking on HSM I/O.
1424 fn algorithm(&self) -> Result<SigningAlgorithm, SecretError>;
1425}
1426
1427// ── Blocking adapter ─────────────────────────────────────────────────────────
1428
1429/// Run an async block on a dedicated scoped thread with its own single-threaded
1430/// tokio runtime.
1431///
1432/// This is the correct pattern for backends that need to execute async code
1433/// synchronously at construction time (e.g. AWS client initialization via
1434/// `aws_config::load_from_env`). Unlike `block_in_place` or `Handle::block_on`,
1435/// this never panics when called from within an existing `current_thread` runtime
1436/// because the async work runs on a *new* OS thread with its *own* runtime.
1437///
1438/// # Errors
1439///
1440/// Returns `Err(SecretError::Backend)` if the tokio runtime cannot be built or
1441/// Extract a human-readable message from a panic payload.
1442#[cfg(feature = "blocking")]
1443fn panic_message(payload: &Box<dyn std::any::Any + Send>) -> String {
1444 if let Some(s) = payload.downcast_ref::<&str>() {
1445 (*s).to_string()
1446 } else if let Some(s) = payload.downcast_ref::<String>() {
1447 s.clone()
1448 } else {
1449 "thread panicked (no message)".to_string()
1450 }
1451}
1452
1453/// if the spawned thread panics. The `backend` argument is included in the
1454/// error for diagnostics.
1455#[cfg(feature = "blocking")]
1456pub fn run_on_new_thread<F, Fut, T>(f: F, backend: &'static str) -> Result<T, SecretError>
1457where
1458 F: FnOnce() -> Fut + Send,
1459 Fut: std::future::Future<Output = Result<T, SecretError>>,
1460 T: Send,
1461{
1462 let mut result: Option<Result<T, SecretError>> = None;
1463 std::thread::scope(|s| {
1464 let join = s.spawn(|| {
1465 tokio::runtime::Builder::new_current_thread()
1466 .enable_all()
1467 .build()
1468 .map_err(|e| SecretError::Backend {
1469 backend,
1470 source: e.into(),
1471 })
1472 .and_then(|rt| rt.block_on(f()))
1473 });
1474 result = Some(join.join().unwrap_or_else(|panic| {
1475 Err(SecretError::Backend {
1476 backend,
1477 source: format!("client init thread panicked: {}", panic_message(&panic))
1478 .into(),
1479 })
1480 }));
1481 });
1482 result.expect("scope always sets result before exiting")
1483}
1484
1485/// Synchronous wrapper for [`SecretStore::get`].
1486///
1487/// Works both inside an existing tokio runtime and outside one (creates a
1488/// single-threaded runtime for the call). When called from within an existing
1489/// runtime the call is offloaded to a scoped OS thread with its own runtime so
1490/// that `block_on` does not panic.
1491///
1492/// Call [`SecretStore::get`] from a synchronous context.
1493///
1494/// When called outside of any tokio runtime a single-threaded runtime is built
1495/// on the calling thread. When called from inside an existing runtime, a scoped
1496/// thread with its own single-threaded runtime is spawned.
1497///
1498/// # Panics
1499/// Does not panic in normal use. Panics only if the spawned helper thread
1500/// itself panics (i.e. if tokio runtime construction fails).
1501///
1502/// # Limitations
1503///
1504/// The scoped runtime is a **fresh, isolated runtime** that lasts only for the
1505/// duration of the `get` call. If the inner store (or a wrapper like
1506/// `CachingStore`) internally calls `tokio::spawn` or
1507/// `tokio::task::spawn_blocking`, those tasks run on the scoped thread's
1508/// runtime and are **silently dropped** when `block_on` returns. Do not use
1509/// `get_blocking` with stores that depend on background tasks surviving across
1510/// calls (e.g. connection-pool health-check tasks, re-auth loops).
1511#[cfg(feature = "blocking")]
1512pub fn get_blocking<S: SecretStore + ?Sized>(store: &S) -> Result<SecretValue, SecretError> {
1513 // When called from outside any tokio runtime, spin up a one-shot
1514 // current-thread runtime directly on this thread.
1515 //
1516 // When called from inside an existing runtime (current_thread or
1517 // multi-thread), block_on would panic if called on the same thread.
1518 // Instead, use std::thread::scope to spawn a scoped thread that borrows
1519 // `store` and `name` safely. The scope guarantees the thread is joined
1520 // before it exits, so no lifetime transmutation is needed.
1521 if tokio::runtime::Handle::try_current().is_err() {
1522 return tokio::runtime::Builder::new_current_thread()
1523 .enable_all()
1524 .build()
1525 .map_err(|e| SecretError::Backend {
1526 backend: "blocking",
1527 source: e.into(),
1528 })?
1529 .block_on(store.get());
1530 }
1531
1532 // Inside an existing runtime — block_on would panic on the same thread.
1533 // Spawn a scoped thread that borrows `store` safely.
1534 let mut result: Option<Result<SecretValue, SecretError>> = None;
1535 std::thread::scope(|s| {
1536 let join = s.spawn(|| {
1537 tokio::runtime::Builder::new_current_thread()
1538 .enable_all()
1539 .build()
1540 .map_err(|e| SecretError::Backend {
1541 backend: "blocking",
1542 source: e.into(),
1543 })?
1544 .block_on(store.get())
1545 });
1546 result = Some(join.join().unwrap_or_else(|panic| {
1547 Err(SecretError::Backend {
1548 backend: "blocking",
1549 source: format!(
1550 "get_blocking thread panicked: {}",
1551 panic_message(&panic)
1552 )
1553 .into(),
1554 })
1555 }));
1556 });
1557 result.expect("scope always sets result before exiting")
1558}
1559
1560// ── Backend registration ──────────────────────────────────────────────────────
1561
1562/// Registration entry for a [`SecretStore`] backend.
1563#[non_exhaustive]
1564pub struct BackendRegistration {
1565 /// Backend scheme name (e.g. `"env"`, `"file"`, `"aws-sm"`).
1566 pub name: &'static str,
1567 /// Factory function: construct a backend from a pre-parsed [`SecretUri`].
1568 pub factory: fn(&SecretUri) -> Result<std::sync::Arc<dyn SecretStore>, SecretError>,
1569}
1570
1571impl BackendRegistration {
1572 /// Create a new registration entry.
1573 pub const fn new(
1574 name: &'static str,
1575 factory: fn(&SecretUri) -> Result<std::sync::Arc<dyn SecretStore>, SecretError>,
1576 ) -> Self {
1577 Self { name, factory }
1578 }
1579}
1580inventory::collect!(BackendRegistration);
1581
1582/// Registration entry for a [`WritableSecretStore`] backend.
1583#[non_exhaustive]
1584pub struct WritableBackendRegistration {
1585 /// Backend scheme name.
1586 pub name: &'static str,
1587 /// Factory function: construct a writable backend from a pre-parsed [`SecretUri`].
1588 pub factory: fn(&SecretUri) -> Result<std::sync::Arc<dyn WritableSecretStore>, SecretError>,
1589}
1590
1591impl WritableBackendRegistration {
1592 /// Create a new registration entry.
1593 pub const fn new(
1594 name: &'static str,
1595 factory: fn(&SecretUri) -> Result<std::sync::Arc<dyn WritableSecretStore>, SecretError>,
1596 ) -> Self {
1597 Self { name, factory }
1598 }
1599}
1600inventory::collect!(WritableBackendRegistration);
1601
1602/// Registration entry for a [`SigningBackend`] backend.
1603#[non_exhaustive]
1604pub struct SigningBackendRegistration {
1605 /// Backend scheme name.
1606 pub name: &'static str,
1607 /// Factory function: construct a signing backend from a pre-parsed [`SecretUri`].
1608 pub factory: fn(&SecretUri) -> Result<std::sync::Arc<dyn SigningBackend>, SecretError>,
1609}
1610
1611impl SigningBackendRegistration {
1612 /// Create a new registration entry.
1613 pub const fn new(
1614 name: &'static str,
1615 factory: fn(&SecretUri) -> Result<std::sync::Arc<dyn SigningBackend>, SecretError>,
1616 ) -> Self {
1617 Self { name, factory }
1618 }
1619}
1620inventory::collect!(SigningBackendRegistration);
1621
1622// ── Tests ─────────────────────────────────────────────────────────────────────
1623
1624#[cfg(test)]
1625mod tests {
1626 use super::*;
1627
1628 // SecretValue tests
1629
1630 #[test]
1631 fn secret_value_as_bytes() {
1632 let v = SecretValue::new(b"hello".to_vec());
1633 assert_eq!(v.as_bytes(), b"hello");
1634 }
1635
1636 #[test]
1637 fn secret_value_as_str() {
1638 let v = SecretValue::new(b"hello".to_vec());
1639 assert_eq!(v.as_str().unwrap(), "hello");
1640 }
1641
1642 #[test]
1643 fn secret_value_as_str_invalid_utf8() {
1644 let v = SecretValue::new(vec![0xff, 0xfe]);
1645 assert!(matches!(v.as_str(), Err(SecretError::DecodeFailed(_))));
1646 }
1647
1648 #[test]
1649 fn extract_field_ok() {
1650 let v = SecretValue::new(br#"{"password":"hunter2","user":"alice"}"#.to_vec());
1651 let pw = v.extract_field("password").unwrap();
1652 assert_eq!(pw.as_bytes(), b"hunter2");
1653 }
1654
1655 #[test]
1656 fn extract_field_missing() {
1657 let v = SecretValue::new(br#"{"user":"alice"}"#.to_vec());
1658 assert!(matches!(
1659 v.extract_field("password"),
1660 Err(SecretError::DecodeFailed(_))
1661 ));
1662 }
1663
1664 #[test]
1665 fn extract_field_not_string() {
1666 let v = SecretValue::new(br#"{"count":42}"#.to_vec());
1667 assert!(matches!(
1668 v.extract_field("count"),
1669 Err(SecretError::DecodeFailed(_))
1670 ));
1671 }
1672
1673 #[test]
1674 fn extract_field_invalid_json() {
1675 let v = SecretValue::new(b"not json".to_vec());
1676 assert!(matches!(
1677 v.extract_field("x"),
1678 Err(SecretError::DecodeFailed(_))
1679 ));
1680 }
1681
1682 // RFC 8259 §7: surrogate pair \uHHHH\uLLLL must decode to the correct
1683 // supplementary code point. Oracle: U+1F600 (GRINNING FACE) is 😀;
1684 // its UTF-8 encoding 0xF0 0x9F 0x98 0x80 is independent of this code.
1685 #[test]
1686 fn extract_field_surrogate_pair() {
1687 // \uD83D\uDE00 is the surrogate pair for U+1F600 (😀)
1688 let v = SecretValue::new(br#"{"pw":"\uD83D\uDE00"}"#.to_vec());
1689 let pw = v.extract_field("pw").unwrap();
1690 assert_eq!(pw.as_bytes(), "😀".as_bytes());
1691 }
1692
1693 // \uD800 alone (no follow-up low surrogate) must be rejected.
1694 #[test]
1695 fn extract_field_lone_high_surrogate() {
1696 let v = SecretValue::new(br#"{"pw":"\uD800"}"#.to_vec());
1697 assert!(matches!(
1698 v.extract_field("pw"),
1699 Err(SecretError::DecodeFailed(_))
1700 ));
1701 }
1702
1703 // \uDC00 alone (no preceding high surrogate) must be rejected.
1704 #[test]
1705 fn extract_field_lone_low_surrogate() {
1706 let v = SecretValue::new(br#"{"pw":"\uDC00"}"#.to_vec());
1707 assert!(matches!(
1708 v.extract_field("pw"),
1709 Err(SecretError::DecodeFailed(_))
1710 ));
1711 }
1712
1713 // High surrogate followed by a non-low-surrogate \uXXXX must be rejected.
1714 #[test]
1715 fn extract_field_high_surrogate_wrong_follow() {
1716 // \uD800\u0041 — A is not a low surrogate
1717 let v = SecretValue::new(br#"{"pw":"\uD800\u0041"}"#.to_vec());
1718 assert!(matches!(
1719 v.extract_field("pw"),
1720 Err(SecretError::DecodeFailed(_))
1721 ));
1722 }
1723
1724 // High surrogate followed by a non-\u sequence must be rejected.
1725 #[test]
1726 fn extract_field_high_surrogate_no_follow() {
1727 // \uD800abc — 'a' is not the start of \uXXXX
1728 let v = SecretValue::new(br#"{"pw":"\uD800abc"}"#.to_vec());
1729 assert!(matches!(
1730 v.extract_field("pw"),
1731 Err(SecretError::DecodeFailed(_))
1732 ));
1733 }
1734
1735 // Surrogate validation in the *skip* path (non-extracted fields).
1736 // These test that json_skip_string validates surrogates consistently with
1737 // json_parse_string — invalid JSON is rejected regardless of which field
1738 // the invalid sequence appears in.
1739
1740 #[test]
1741 fn skip_field_lone_high_surrogate_rejected() {
1742 // "other" field has lone high surrogate; "password" is valid.
1743 // extract_field must fail even though the targeted field is fine.
1744 let v = SecretValue::new(br#"{"other":"\uD800","password":"hunter2"}"#.to_vec());
1745 assert!(matches!(
1746 v.extract_field("password"),
1747 Err(SecretError::DecodeFailed(_))
1748 ));
1749 }
1750
1751 #[test]
1752 fn skip_field_lone_low_surrogate_rejected() {
1753 let v = SecretValue::new(br#"{"other":"\uDC00","password":"hunter2"}"#.to_vec());
1754 assert!(matches!(
1755 v.extract_field("password"),
1756 Err(SecretError::DecodeFailed(_))
1757 ));
1758 }
1759
1760 #[test]
1761 fn skip_field_surrogate_pair_valid() {
1762 // Valid surrogate pair in a non-extracted field must not cause failure.
1763 // \uD83D\uDE00 = U+1F600 (😀)
1764 let v = SecretValue::new(br#"{"emoji":"\uD83D\uDE00","password":"hunter2"}"#.to_vec());
1765 let pw = v.extract_field("password").unwrap();
1766 assert_eq!(pw.as_bytes(), b"hunter2");
1767 }
1768
1769 // Malformed JSON number validation (json_skip_number).
1770 // Oracle: RFC 8259 §6 — exponent must contain at least one digit.
1771
1772 // RFC 8259 §6: an integer part must follow the optional minus.
1773 // A bare '-' with no digits is not a valid JSON number.
1774 #[test]
1775 fn skip_number_bare_minus_rejected() {
1776 let v = SecretValue::new(br#"{"count":-,"password":"hunter2"}"#.to_vec());
1777 assert!(matches!(
1778 v.extract_field("password"),
1779 Err(SecretError::DecodeFailed(_))
1780 ));
1781 }
1782
1783 #[test]
1784 fn skip_number_bare_exponent_rejected() {
1785 // "count" has an exponent with no digits — invalid per RFC 8259.
1786 let v = SecretValue::new(br#"{"count":1e,"password":"hunter2"}"#.to_vec());
1787 assert!(matches!(
1788 v.extract_field("password"),
1789 Err(SecretError::DecodeFailed(_))
1790 ));
1791 }
1792
1793 #[test]
1794 fn skip_number_signed_exponent_no_digits_rejected() {
1795 let v = SecretValue::new(br#"{"count":1e+,"password":"hunter2"}"#.to_vec());
1796 assert!(matches!(
1797 v.extract_field("password"),
1798 Err(SecretError::DecodeFailed(_))
1799 ));
1800 }
1801
1802 #[test]
1803 fn skip_number_valid_exponent_accepted() {
1804 // RFC 8259-valid exponent must not cause failure.
1805 let v = SecretValue::new(br#"{"count":1e3,"password":"hunter2"}"#.to_vec());
1806 let pw = v.extract_field("password").unwrap();
1807 assert_eq!(pw.as_bytes(), b"hunter2");
1808 }
1809
1810 // RFC 8259 §6: a non-zero integer part must not have leading zeros.
1811 // Oracle: RFC 8259 §6 grammar — int = zero / (digit1-9 *DIGIT).
1812 // Any real JSON parser (jq, Python json.loads) rejects 01 and 007.
1813
1814 #[test]
1815 fn skip_number_leading_zero_two_digits_rejected() {
1816 // 01 — leading zero in front of non-zero digit, invalid per RFC 8259.
1817 let v = SecretValue::new(br#"{"count":01,"password":"hunter2"}"#.to_vec());
1818 assert!(matches!(
1819 v.extract_field("password"),
1820 Err(SecretError::DecodeFailed(_))
1821 ));
1822 }
1823
1824 #[test]
1825 fn skip_number_leading_zero_multi_digit_rejected() {
1826 // 007 — leading zeros, invalid per RFC 8259.
1827 let v = SecretValue::new(br#"{"count":007,"password":"hunter2"}"#.to_vec());
1828 assert!(matches!(
1829 v.extract_field("password"),
1830 Err(SecretError::DecodeFailed(_))
1831 ));
1832 }
1833
1834 #[test]
1835 fn skip_number_bare_zero_accepted() {
1836 // 0 alone is valid per RFC 8259 (zero = %x30).
1837 let v = SecretValue::new(br#"{"count":0,"password":"hunter2"}"#.to_vec());
1838 let pw = v.extract_field("password").unwrap();
1839 assert_eq!(pw.as_bytes(), b"hunter2");
1840 }
1841
1842 #[test]
1843 fn skip_number_negative_leading_zero_rejected() {
1844 // -01 — leading zero after minus, invalid per RFC 8259.
1845 let v = SecretValue::new(br#"{"count":-01,"password":"hunter2"}"#.to_vec());
1846 assert!(matches!(
1847 v.extract_field("password"),
1848 Err(SecretError::DecodeFailed(_))
1849 ));
1850 }
1851
1852 #[test]
1853 fn skip_number_negative_zero_accepted() {
1854 // -0 is a valid JSON number (negative zero).
1855 let v = SecretValue::new(br#"{"count":-0,"password":"hunter2"}"#.to_vec());
1856 let pw = v.extract_field("password").unwrap();
1857 assert_eq!(pw.as_bytes(), b"hunter2");
1858 }
1859
1860 // RFC 8259 §6: frac = decimal-point 1*DIGIT — digit(s) required after '.'.
1861 // Oracle: Python json.loads('{"x":1.}') raises ValueError; jq raises error.
1862
1863 #[test]
1864 fn skip_number_no_fractional_digits_rejected() {
1865 // 1. — decimal point with no fractional digits, invalid per RFC 8259.
1866 let v = SecretValue::new(br#"{"count":1.,"password":"hunter2"}"#.to_vec());
1867 assert!(matches!(
1868 v.extract_field("password"),
1869 Err(SecretError::DecodeFailed(_))
1870 ));
1871 }
1872
1873 #[test]
1874 fn skip_number_negative_no_fractional_digits_rejected() {
1875 // -1. — same violation after a minus sign.
1876 let v = SecretValue::new(br#"{"count":-1.,"password":"hunter2"}"#.to_vec());
1877 assert!(matches!(
1878 v.extract_field("password"),
1879 Err(SecretError::DecodeFailed(_))
1880 ));
1881 }
1882
1883 #[test]
1884 fn skip_number_zero_no_fractional_digits_rejected() {
1885 // 0. — leading zero with decimal point but no fractional digit.
1886 let v = SecretValue::new(br#"{"count":0.,"password":"hunter2"}"#.to_vec());
1887 assert!(matches!(
1888 v.extract_field("password"),
1889 Err(SecretError::DecodeFailed(_))
1890 ));
1891 }
1892
1893 #[test]
1894 fn skip_number_fractional_digits_accepted() {
1895 // 1.5 — valid decimal number must not cause failure.
1896 let v = SecretValue::new(br#"{"count":1.5,"password":"hunter2"}"#.to_vec());
1897 let pw = v.extract_field("password").unwrap();
1898 assert_eq!(pw.as_bytes(), b"hunter2");
1899 }
1900
1901 // RFC 8259 §7: unknown single-char escapes (e.g. \z) are invalid.
1902 // json_skip_string must reject them consistently with json_parse_string.
1903
1904 #[test]
1905 fn skip_field_unknown_escape_rejected() {
1906 // \z is not a valid JSON escape. Extracting "password" from a document
1907 // where "other" contains \z must fail — invalid JSON is invalid regardless
1908 // of which field is the extraction target.
1909 let v = SecretValue::new(br#"{"other":"\z","password":"hunter2"}"#.to_vec());
1910 assert!(matches!(
1911 v.extract_field("password"),
1912 Err(SecretError::DecodeFailed(_))
1913 ));
1914 }
1915
1916 #[test]
1917 fn skip_field_all_valid_single_char_escapes_accepted() {
1918 // All eight valid single-char escapes in a skipped field must not fail.
1919 // Oracle: RFC 8259 §7 — the allowed escapes are \" \\ \/ \b \f \n \r \t.
1920 let v = SecretValue::new(br#"{"other":"\"\\\/\b\f\n\r\t","password":"hunter2"}"#.to_vec());
1921 let pw = v.extract_field("password").unwrap();
1922 assert_eq!(pw.as_bytes(), b"hunter2");
1923 }
1924
1925 // RFC 8259 §7: U+0000–U+001F are control characters that must be escaped.
1926 // Oracle: RFC 8259 §7 grammar — unescaped = %x20-21 / %x23-5B / %x5D-10FFFF.
1927 // Python json.loads('{"k":"\x00"}') raises ValueError.
1928
1929 #[test]
1930 fn extract_field_null_byte_in_value_rejected() {
1931 // U+0000 (NUL) directly in a JSON string value is invalid per RFC 8259 §7.
1932 let v = SecretValue::new(b"{\"key\":\"val\x00ue\"}".to_vec());
1933 assert!(matches!(
1934 v.extract_field("key"),
1935 Err(SecretError::DecodeFailed(_))
1936 ));
1937 }
1938
1939 #[test]
1940 fn extract_field_control_char_soh_rejected() {
1941 // U+0001 (SOH) — lowest non-null control character.
1942 let v = SecretValue::new(b"{\"key\":\"\x01\"}".to_vec());
1943 assert!(matches!(
1944 v.extract_field("key"),
1945 Err(SecretError::DecodeFailed(_))
1946 ));
1947 }
1948
1949 #[test]
1950 fn extract_field_control_char_us_rejected() {
1951 // U+001F (US) — highest control character in the prohibited range.
1952 let v = SecretValue::new(b"{\"key\":\"\x1f\"}".to_vec());
1953 assert!(matches!(
1954 v.extract_field("key"),
1955 Err(SecretError::DecodeFailed(_))
1956 ));
1957 }
1958
1959 #[test]
1960 fn extract_field_space_accepted() {
1961 // U+0020 (SPACE) is the first non-control character; must be accepted.
1962 let v = SecretValue::new(b"{\"key\":\"abc def\"}".to_vec());
1963 assert_eq!(v.extract_field("key").unwrap().as_bytes(), b"abc def");
1964 }
1965
1966 // Trailing garbage after target field value.
1967 // Oracle: the same input with a different target field order must fail
1968 // consistently regardless of whether the garbage comes before or after
1969 // the target field.
1970
1971 #[test]
1972 fn extract_field_trailing_garbage_first_field_rejected() {
1973 // Target field is first; garbage appears before the closing '}'.
1974 let v = SecretValue::new(br#"{"password":"hunter2" GARBAGE}"#.to_vec());
1975 assert!(
1976 matches!(
1977 v.extract_field("password"),
1978 Err(SecretError::DecodeFailed(_))
1979 ),
1980 "trailing garbage after first field must be rejected"
1981 );
1982 }
1983
1984 #[test]
1985 fn extract_field_trailing_garbage_last_field_rejected() {
1986 // Target field is last; garbage appears after its value.
1987 let v = SecretValue::new(br#"{"other":"x","password":"hunter2" GARBAGE}"#.to_vec());
1988 assert!(
1989 matches!(
1990 v.extract_field("password"),
1991 Err(SecretError::DecodeFailed(_))
1992 ),
1993 "trailing garbage after last field must be rejected"
1994 );
1995 }
1996
1997 #[test]
1998 fn skip_field_control_char_in_other_field_rejected() {
1999 // Control char in a skipped field must be caught even when extracting
2000 // a different field — invalid JSON is invalid regardless of target.
2001 let v = SecretValue::new(b"{\"other\":\"\x01bad\",\"password\":\"hunter2\"}".to_vec());
2002 assert!(matches!(
2003 v.extract_field("password"),
2004 Err(SecretError::DecodeFailed(_))
2005 ));
2006 }
2007
2008 // SecretUri tests
2009
2010 #[test]
2011 fn uri_env() {
2012 let u = SecretUri::parse("secretx:env:MY_SECRET").unwrap();
2013 assert_eq!(u.backend, "env");
2014 assert_eq!(u.path, "MY_SECRET");
2015 assert!(u.params.is_empty());
2016 }
2017
2018 #[test]
2019 fn uri_file_relative() {
2020 let u = SecretUri::parse("secretx:file:relative/path/key").unwrap();
2021 assert_eq!(u.backend, "file");
2022 assert_eq!(u.path, "relative/path/key");
2023 }
2024
2025 #[test]
2026 fn uri_file_absolute() {
2027 let u = SecretUri::parse("secretx:file:/etc/secrets/key").unwrap();
2028 assert_eq!(u.backend, "file");
2029 assert_eq!(u.path, "/etc/secrets/key");
2030 }
2031
2032 #[test]
2033 fn uri_aws_sm_with_params() {
2034 let u =
2035 SecretUri::parse("secretx:aws-sm:prod/signing-key?field=password&version=AWSCURRENT")
2036 .unwrap();
2037 assert_eq!(u.backend, "aws-sm");
2038 assert_eq!(u.path, "prod/signing-key");
2039 assert_eq!(u.param("field"), Some("password"));
2040 assert_eq!(u.param("version"), Some("AWSCURRENT"));
2041 }
2042
2043 #[test]
2044 fn uri_pkcs11_with_lib() {
2045 let u = SecretUri::parse("secretx:pkcs11:0/my-key?lib=/usr/lib/libsofthsm2.so").unwrap();
2046 assert_eq!(u.backend, "pkcs11");
2047 assert_eq!(u.path, "0/my-key");
2048 assert_eq!(u.param("lib"), Some("/usr/lib/libsofthsm2.so"));
2049 }
2050
2051 #[test]
2052 fn uri_single_segment_path() {
2053 let u = SecretUri::parse("secretx:wolfhsm:my-key").unwrap();
2054 assert_eq!(u.backend, "wolfhsm");
2055 assert_eq!(u.path, "my-key");
2056 }
2057
2058 #[test]
2059 fn uri_empty_path() {
2060 let u = SecretUri::parse("secretx:wolfhsm:").unwrap();
2061 assert_eq!(u.backend, "wolfhsm");
2062 assert_eq!(u.path, "");
2063 }
2064
2065 #[test]
2066 fn uri_wrong_scheme() {
2067 assert!(matches!(
2068 SecretUri::parse("https://example.com/secret"),
2069 Err(SecretError::InvalidUri(_))
2070 ));
2071 }
2072
2073 #[test]
2074 fn uri_legacy_authority_format_gives_helpful_error() {
2075 let result = SecretUri::parse("secretx://env/MY_VAR");
2076 match result {
2077 Err(SecretError::InvalidUri(msg)) => {
2078 assert!(
2079 msg.contains("secretx://backend/path"),
2080 "error must name the old format, got: {msg}"
2081 );
2082 assert!(
2083 msg.contains("MIGRATION.md"),
2084 "error must reference MIGRATION.md, got: {msg}"
2085 );
2086 }
2087 Err(e) => panic!("expected InvalidUri, got: {e}"),
2088 Ok(_) => panic!("expected Err, got Ok"),
2089 }
2090 }
2091
2092 #[test]
2093 fn uri_empty_backend() {
2094 assert!(matches!(
2095 SecretUri::parse("secretx::path"),
2096 Err(SecretError::InvalidUri(_))
2097 ));
2098 }
2099
2100 #[test]
2101 fn uri_missing_param() {
2102 let u = SecretUri::parse("secretx:aws-sm:my-secret").unwrap();
2103 assert_eq!(u.param("field"), None);
2104 }
2105
2106 #[test]
2107 fn uri_percent_decoded_path() {
2108 let u = SecretUri::parse("secretx:env:MY%20SECRET").unwrap();
2109 assert_eq!(u.path, "MY SECRET");
2110 }
2111
2112 #[test]
2113 fn uri_percent_decoded_param_value() {
2114 let u = SecretUri::parse("secretx:aws-sm:my-secret?field=my%20field").unwrap();
2115 assert_eq!(u.param("field"), Some("my field"));
2116 }
2117
2118 #[test]
2119 fn uri_percent_decoded_param_key() {
2120 let u = SecretUri::parse("secretx:aws-sm:my-secret?my%20key=val").unwrap();
2121 assert_eq!(u.param("my key"), Some("val"));
2122 }
2123
2124 #[test]
2125 fn uri_invalid_percent_encoding() {
2126 assert!(matches!(
2127 SecretUri::parse("secretx:env:MY%ZZsecret"),
2128 Err(SecretError::InvalidUri(_))
2129 ));
2130 }
2131
2132 #[test]
2133 fn uri_incomplete_percent_encoding() {
2134 assert!(matches!(
2135 SecretUri::parse("secretx:env:MY%2"),
2136 Err(SecretError::InvalidUri(_))
2137 ));
2138 }
2139
2140 #[cfg(feature = "blocking")]
2141 #[test]
2142 fn get_blocking_outside_runtime() {
2143 use std::sync::Arc;
2144
2145 struct FakeStore;
2146
2147 #[async_trait::async_trait]
2148 impl SecretStore for FakeStore {
2149 async fn get(&self) -> Result<SecretValue, SecretError> {
2150 Ok(SecretValue::new(b"test-value".to_vec()))
2151 }
2152 async fn refresh(&self) -> Result<SecretValue, SecretError> {
2153 self.get().await
2154 }
2155 }
2156
2157 let store = Arc::new(FakeStore);
2158 let v = get_blocking(store.as_ref()).unwrap();
2159 assert_eq!(v.as_bytes(), b"test-value");
2160 }
2161
2162 // Test the inside-runtime code path: get_blocking called from within an
2163 // existing tokio runtime must spawn a scoped thread rather than calling
2164 // block_on on the current executor thread (which would panic).
2165 // Oracle: the value returned must equal what FakeStore::get produces.
2166 #[cfg(feature = "blocking")]
2167 #[tokio::test]
2168 async fn get_blocking_inside_runtime() {
2169 use std::sync::Arc;
2170
2171 struct FakeStore;
2172
2173 #[async_trait::async_trait]
2174 impl SecretStore for FakeStore {
2175 async fn get(&self) -> Result<SecretValue, SecretError> {
2176 Ok(SecretValue::new(b"inside-runtime".to_vec()))
2177 }
2178 async fn refresh(&self) -> Result<SecretValue, SecretError> {
2179 self.get().await
2180 }
2181 }
2182
2183 let store = Arc::new(FakeStore);
2184 // Calling get_blocking from inside a #[tokio::test] runtime exercises
2185 // the Ok(_) branch of Handle::try_current() — the scoped-thread path.
2186 let v = get_blocking(store.as_ref()).unwrap();
2187 assert_eq!(v.as_bytes(), b"inside-runtime");
2188 }
2189
2190 // ── json_navigate / extract_path / extract_path_field tests ──────────────
2191 //
2192 // Oracle: expected output is derived by manual inspection of the literal
2193 // JSON, not by calling the code under test.
2194
2195 #[test]
2196 fn navigate_empty_path_returns_input() {
2197 // An empty path must return the original bytes unchanged.
2198 let input = br#"{"k":"v"}"#;
2199 let result = json_navigate(input, &[]).unwrap();
2200 assert_eq!(result, input);
2201 }
2202
2203 #[test]
2204 fn navigate_single_key() {
2205 // Oracle: value of "data" is the sub-object {"key":"val"}.
2206 let input = br#"{"data":{"key":"val"}}"#;
2207 let result = json_navigate(input, &["data"]).unwrap();
2208 assert_eq!(result, br#"{"key":"val"}"#);
2209 }
2210
2211 #[test]
2212 fn navigate_two_levels_vault_pattern() {
2213 // Vault KV v2 returns {"data":{"data":{...},"metadata":{...}}}.
2214 // Navigating ["data","data"] should return the inner secret object.
2215 let input = br#"{"data":{"data":{"password":"s3cr3t"},"metadata":{"version":3}}}"#;
2216 let result = json_navigate(input, &["data", "data"]).unwrap();
2217 assert_eq!(result, br#"{"password":"s3cr3t"}"#);
2218 }
2219
2220 #[test]
2221 fn navigate_key_not_found() {
2222 let input = br#"{"a":"b"}"#;
2223 assert!(matches!(
2224 json_navigate(input, &["missing"]),
2225 Err(SecretError::DecodeFailed(_))
2226 ));
2227 }
2228
2229 #[test]
2230 fn navigate_intermediate_not_object() {
2231 // "data" is a string, not an object — navigating into it must fail.
2232 let input = br#"{"data":"flat-string"}"#;
2233 assert!(matches!(
2234 json_navigate(input, &["data", "key"]),
2235 Err(SecretError::DecodeFailed(_))
2236 ));
2237 }
2238
2239 #[test]
2240 fn navigate_key_with_escape_in_path() {
2241 // Key contains a JSON escape sequence; scan_string_key_b must decode it
2242 // to match the raw string supplied to json_navigate.
2243 // Oracle: the key `my\nkey` (backslash-n) decodes to a two-char string
2244 // "my" + newline + "key". We navigate with the decoded form.
2245 let input = b"{\"my\\nkey\":\"found\"}";
2246 let result = json_navigate(input, &["my\nkey"]).unwrap();
2247 assert_eq!(result, b"\"found\"");
2248 }
2249
2250 #[test]
2251 fn navigate_whitespace_around_value() {
2252 // Trailing whitespace on the returned slice must be trimmed.
2253 let input = br#"{"k": 42 }"#;
2254 let result = json_navigate(input, &["k"]).unwrap();
2255 assert_eq!(result, b"42");
2256 }
2257
2258 #[test]
2259 fn navigate_empty_object_returns_not_found() {
2260 let input = br#"{}"#;
2261 assert!(matches!(
2262 json_navigate(input, &["k"]),
2263 Err(SecretError::DecodeFailed(_))
2264 ));
2265 }
2266
2267 #[test]
2268 fn extract_path_vault_nested_object() {
2269 // extract_path(["data","data"]) should capture the inner object as bytes.
2270 let json = br#"{"data":{"data":{"token":"abc123"},"metadata":{}}}"#.to_vec();
2271 let sv = SecretValue::new(json);
2272 let inner = sv.extract_path(&["data", "data"]).unwrap();
2273 assert_eq!(inner.as_bytes(), br#"{"token":"abc123"}"#);
2274 }
2275
2276 #[test]
2277 fn extract_path_field_vault_pattern() {
2278 // extract_path_field(["data","data"], "token") navigates to the inner
2279 // object and then extracts the string field "token".
2280 let json =
2281 br#"{"data":{"data":{"token":"s3cr3t","ttl":300},"metadata":{"version":1}}}"#.to_vec();
2282 let sv = SecretValue::new(json);
2283 let token = sv.extract_path_field(&["data", "data"], "token").unwrap();
2284 assert_eq!(token.as_bytes(), b"s3cr3t");
2285 }
2286
2287 #[test]
2288 fn extract_path_missing_key_returns_decode_failed() {
2289 let json = br#"{"data":{"other":"val"}}"#.to_vec();
2290 let sv = SecretValue::new(json);
2291 assert!(matches!(
2292 sv.extract_path(&["data", "data"]),
2293 Err(SecretError::DecodeFailed(_))
2294 ));
2295 }
2296
2297 #[test]
2298 fn extract_path_field_missing_field_returns_decode_failed() {
2299 let json = br#"{"data":{"data":{"a":"b"}}}"#.to_vec();
2300 let sv = SecretValue::new(json);
2301 assert!(matches!(
2302 sv.extract_path_field(&["data", "data"], "missing"),
2303 Err(SecretError::DecodeFailed(_))
2304 ));
2305 }
2306
2307 // ── skip_number_b direct tests ────────────────────────────────────────────
2308 //
2309 // These tests exercise skip_number_b via the byte-level json_find_value_b
2310 // path (extract_path). The existing skip_number_* tests exercise only the
2311 // char-based path (extract_field). Both paths must be tested independently.
2312 //
2313 // Oracle: RFC 8259 §6 defines the JSON number grammar. Malformed numbers
2314 // are identified by the grammar, not by the implementation under test.
2315
2316 #[test]
2317 fn skip_number_b_bare_decimal_rejected_via_navigate() {
2318 // {"n":1.,"k":"v"} — skip_number_b must reject 1. (no fractional digits)
2319 // when scanning past the non-target field "n" to reach "k".
2320 // RFC 8259: decimal-point must be followed by one or more digits.
2321 let json = br#"{"n":1.,"k":"v"}"#.to_vec();
2322 let sv = SecretValue::new(json);
2323 assert!(
2324 matches!(sv.extract_path(&["k"]), Err(SecretError::DecodeFailed(_))),
2325 "bare decimal point must be rejected by skip_number_b"
2326 );
2327 }
2328
2329 #[test]
2330 fn skip_number_b_bare_exponent_rejected_via_navigate() {
2331 // {"n":1e,"k":"v"} — skip_number_b must reject 1e (no exponent digits).
2332 // RFC 8259: exponent marker must be followed by one or more digits.
2333 let json = br#"{"n":1e,"k":"v"}"#.to_vec();
2334 let sv = SecretValue::new(json);
2335 assert!(
2336 matches!(sv.extract_path(&["k"]), Err(SecretError::DecodeFailed(_))),
2337 "bare exponent must be rejected by skip_number_b"
2338 );
2339 }
2340
2341 #[test]
2342 fn skip_number_b_signed_exponent_no_digits_rejected_via_navigate() {
2343 // {"n":1e+,"k":"v"} — exponent sign must be followed by digits.
2344 let json = br#"{"n":1e+,"k":"v"}"#.to_vec();
2345 let sv = SecretValue::new(json);
2346 assert!(
2347 matches!(sv.extract_path(&["k"]), Err(SecretError::DecodeFailed(_))),
2348 "signed exponent with no digits must be rejected by skip_number_b"
2349 );
2350 }
2351
2352 #[test]
2353 fn skip_number_b_valid_number_allows_navigation() {
2354 // Sanity: a well-formed number in a non-target field must not block navigation.
2355 let json = br#"{"n":3.14e2,"k":"found"}"#.to_vec();
2356 let sv = SecretValue::new(json);
2357 let result = sv.extract_path(&["k"]).unwrap();
2358 assert_eq!(result.as_bytes(), b"\"found\"");
2359 }
2360
2361 // RFC 8259 §6: a non-zero integer part must not have leading zeros.
2362 // Oracle: RFC 8259 §6 grammar — int = zero / (digit1-9 *DIGIT).
2363 // These mirror skip_number_leading_zero_* but exercise the byte-level
2364 // skip_number_b path (via extract_path / json_find_value_b).
2365
2366 #[test]
2367 fn skip_number_b_leading_zero_rejected_via_navigate() {
2368 // 01 — leading zero in non-target field, invalid per RFC 8259.
2369 let json = br#"{"n":01,"k":"v"}"#.to_vec();
2370 let sv = SecretValue::new(json);
2371 assert!(
2372 matches!(sv.extract_path(&["k"]), Err(SecretError::DecodeFailed(_))),
2373 "leading zero must be rejected by skip_number_b"
2374 );
2375 }
2376
2377 #[test]
2378 fn skip_number_b_negative_leading_zero_rejected_via_navigate() {
2379 // -01 — leading zero after minus, invalid per RFC 8259.
2380 let json = br#"{"n":-01,"k":"v"}"#.to_vec();
2381 let sv = SecretValue::new(json);
2382 assert!(
2383 matches!(sv.extract_path(&["k"]), Err(SecretError::DecodeFailed(_))),
2384 "-01 must be rejected by skip_number_b"
2385 );
2386 }
2387
2388 #[test]
2389 fn skip_number_b_zero_alone_accepted_via_navigate() {
2390 // Bare 0 is valid per RFC 8259 (zero = %x30).
2391 let json = br#"{"n":0,"k":"v"}"#.to_vec();
2392 let sv = SecretValue::new(json);
2393 let result = sv.extract_path(&["k"]).unwrap();
2394 assert_eq!(result.as_bytes(), b"\"v\"");
2395 }
2396
2397 // RFC 8259 §7: U+0000–U+001F must be escaped; skip_string_b must reject
2398 // bare control characters in skipped string values, consistent with
2399 // json_skip_string (char path).
2400 // Oracle: RFC 8259 §7 grammar — unescaped = %x20-21 / %x23-5B / %x5D-10FFFF.
2401
2402 #[test]
2403 fn skip_string_b_control_char_in_skipped_value_rejected_via_navigate() {
2404 // U+0001 (SOH) in a non-target string value must cause DecodeFailed
2405 // even though the target field "k" is valid.
2406 let json = b"{\"other\":\"\x01bad\",\"k\":\"v\"}".to_vec();
2407 let sv = SecretValue::new(json);
2408 assert!(
2409 matches!(sv.extract_path(&["k"]), Err(SecretError::DecodeFailed(_))),
2410 "control char in skipped string value must be rejected by skip_string_b"
2411 );
2412 }
2413
2414 #[test]
2415 fn skip_string_b_null_byte_in_skipped_value_rejected_via_navigate() {
2416 // U+0000 (NUL) in a non-target string value must also be rejected.
2417 let json = b"{\"other\":\"val\x00ue\",\"k\":\"v\"}".to_vec();
2418 let sv = SecretValue::new(json);
2419 assert!(
2420 matches!(sv.extract_path(&["k"]), Err(SecretError::DecodeFailed(_))),
2421 "NUL byte in skipped string value must be rejected by skip_string_b"
2422 );
2423 }
2424
2425 #[test]
2426 fn skip_string_b_space_in_skipped_value_accepted_via_navigate() {
2427 // U+0020 (SPACE) is the first non-control char; must pass through.
2428 let json = br#"{"other":"abc def","k":"v"}"#.to_vec();
2429 let sv = SecretValue::new(json);
2430 let result = sv.extract_path(&["k"]).unwrap();
2431 assert_eq!(result.as_bytes(), b"\"v\"");
2432 }
2433
2434 // ── WritableSecretStore tests ────────────────────────────────────────────
2435
2436 // A minimal concrete type that implements both SecretStore and
2437 // WritableSecretStore for use in the tests below.
2438 struct FakeWritable {
2439 value: std::sync::Mutex<Vec<u8>>,
2440 }
2441
2442 impl FakeWritable {
2443 fn new(initial: &[u8]) -> Self {
2444 Self {
2445 value: std::sync::Mutex::new(initial.to_vec()),
2446 }
2447 }
2448 }
2449
2450 #[async_trait::async_trait]
2451 impl SecretStore for FakeWritable {
2452 async fn get(&self) -> Result<SecretValue, SecretError> {
2453 let bytes = self.value.lock().unwrap().clone();
2454 Ok(SecretValue::new(bytes))
2455 }
2456 async fn refresh(&self) -> Result<SecretValue, SecretError> {
2457 self.get().await
2458 }
2459 }
2460
2461 #[async_trait::async_trait]
2462 impl WritableSecretStore for FakeWritable {
2463 async fn put(&self, value: SecretValue) -> Result<(), SecretError> {
2464 *self.value.lock().unwrap() = value.as_bytes().to_vec();
2465 Ok(())
2466 }
2467 }
2468
2469 // A read-only store used to verify SecretStore impls need not have put.
2470 struct ReadOnlyStore;
2471
2472 #[async_trait::async_trait]
2473 impl SecretStore for ReadOnlyStore {
2474 async fn get(&self) -> Result<SecretValue, SecretError> {
2475 Ok(SecretValue::new(b"read-only".to_vec()))
2476 }
2477 async fn refresh(&self) -> Result<SecretValue, SecretError> {
2478 self.get().await
2479 }
2480 }
2481
2482 // Helper used by the compile-time Send+Sync assertion test.
2483 fn assert_send_sync<T: Send + Sync>() {}
2484
2485 #[tokio::test]
2486 async fn writable_store_put_returns_ok() {
2487 // Oracle: put on a concrete FakeWritable must return Ok(()).
2488 let store = FakeWritable::new(b"initial");
2489 let result = store.put(SecretValue::new(b"new-value".to_vec())).await;
2490 assert!(result.is_ok());
2491 }
2492
2493 #[tokio::test]
2494 async fn writable_store_put_then_get_round_trip() {
2495 // Oracle: after put, get must return the same bytes.
2496 let store = FakeWritable::new(b"old");
2497 store
2498 .put(SecretValue::new(b"round-trip-value".to_vec()))
2499 .await
2500 .unwrap();
2501 let got = store.get().await.unwrap();
2502 assert_eq!(got.as_bytes(), b"round-trip-value");
2503 }
2504
2505 #[cfg(feature = "blocking")]
2506 #[test]
2507 fn get_blocking_with_dyn_writable_store() {
2508 use std::sync::Arc;
2509 // Oracle: get_blocking must work when the concrete type is
2510 // Arc<dyn WritableSecretStore>, because WritableSecretStore: SecretStore.
2511 let store: Arc<dyn WritableSecretStore> = Arc::new(FakeWritable::new(b"via-writable-dyn"));
2512 let v = get_blocking(store.as_ref()).unwrap();
2513 assert_eq!(v.as_bytes(), b"via-writable-dyn");
2514 }
2515
2516 #[cfg(feature = "blocking")]
2517 #[tokio::test]
2518 async fn get_blocking_with_dyn_writable_store_inside_runtime() {
2519 use std::sync::Arc;
2520 // Oracle: same as above but exercising the inside-runtime scoped-thread path.
2521 let store: Arc<dyn WritableSecretStore> = Arc::new(FakeWritable::new(b"via-writable-dyn"));
2522 let v = get_blocking(store.as_ref()).unwrap();
2523 assert_eq!(v.as_bytes(), b"via-writable-dyn");
2524 }
2525
2526 #[test]
2527 fn writable_store_is_send_sync() {
2528 // Compile-time check: FakeWritable must satisfy Send + Sync.
2529 assert_send_sync::<FakeWritable>();
2530 }
2531
2532 #[tokio::test]
2533 async fn writable_store_get_and_refresh_accessible_via_secret_store_supertrait() {
2534 // Oracle: calling get() and refresh() through &dyn WritableSecretStore
2535 // must dispatch to FakeWritable's SecretStore impl.
2536 let store: &dyn WritableSecretStore = &FakeWritable::new(b"supertrait-value");
2537 let got = store.get().await.unwrap();
2538 assert_eq!(got.as_bytes(), b"supertrait-value");
2539 let refreshed = store.refresh().await.unwrap();
2540 assert_eq!(refreshed.as_bytes(), b"supertrait-value");
2541 }
2542
2543 #[tokio::test]
2544 async fn secret_store_without_put_still_compiles() {
2545 // Oracle: a type that only implements SecretStore (no WritableSecretStore)
2546 // must still be usable as a SecretStore — put is not required.
2547 let store = ReadOnlyStore;
2548 let got = store.get().await.unwrap();
2549 assert_eq!(got.as_bytes(), b"read-only");
2550 }
2551}