Skip to main content

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}