cindy 0.2.1

Managing infrastructure at breakneck speed.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
//! Vaulted secrets. A `Secret<T>` is a wrapper that:
//!
//!   * Round-trips through serde — JSON, postcard, anything — as a
//!     compact tagged blob (`{ vault, ciphertext }`).
//!   * Reveals the inner `T` only on an explicit `.reveal()` call,
//!     using a per-vault data-encryption key looked up at runtime.
//!   * Refuses to leak the plaintext via `Debug`/`Display`. Drops with
//!     `zeroize` so plaintext doesn't linger in freed memory.
//!
//! There are two states the type lives in over its lifecycle. **Plain**
//! is only meant to exist transiently in source code that hasn't been
//! sealed yet (and at runtime in dev/tests); the `cindy secret seal`
//! workflow rewrites these into the **Sealed** form. The sealed form
//! is what the CLI actually serialises, deserialises, ships through
//! `CINDY_HOST_CONTEXT`, and so on.

use std::fmt;

use base64::Engine as _;
use serde::{Deserialize, Serialize};
use serde::{Deserializer, Serializer};

pub mod crypto;
pub mod keychain;

use crate::Context as _;

/// Vaulted wrapper over `T`.
///
/// Two states:
///   * `Plain(T)` — pre-seal. The CLI's `cindy secret seal` step rewrites
///     this in source to `Sealed { .. }`. At runtime, `.reveal()` just
///     hands the inner value back.
///   * `Sealed { vault, ciphertext }` — committed form. `.reveal()`
///     looks up the named vault's DEK via the keychain and decrypts.
pub enum Secret<T> {
    Plain(T),
    Sealed { vault: String, ciphertext: Vec<u8> },
}

/// What actually gets encrypted by `cindy secret seal`. The wrapper
/// carries both the postcard-encoded `T` (so `Secret::<T>::reveal`
/// can hand a typed value back to user code) *and* the literal
/// source-tokens of the original `secret!` value expression (so
/// `cindy secret unseal` can splice the same syntax back into the file
/// and recover the editable shape).
///
/// Cost: roughly 2× the ciphertext size compared with a postcard-only
/// payload. Worth it for the round-trip UX — and `cargo fmt` cleans up
/// the whitespace `stringify!()` injects.
#[doc(hidden)]
#[derive(serde::Serialize, serde::Deserialize)]
pub struct SealedPayload {
    /// `postcard::to_allocvec(&value)` — the bytes `Secret<T>::reveal`
    /// hands to `postcard::from_bytes::<T>(...)`.
    pub value: Vec<u8>,
    /// `stringify!($value)` captured at macro time. Used only by
    /// `cindy secret unseal` to put the original `secret!(...)` call
    /// back. Encrypted alongside `value` so leaking nothing about
    /// shape/field-names.
    pub source: String,
}

impl<T> Secret<T> {
    /// Construct a not-yet-sealed secret. Used in source code before
    /// `cindy secret seal` rewrites it. Never produced at runtime on the
    /// orchestrator.
    pub fn new(value: T) -> Self {
        Self::Plain(value)
    }

    /// Construct an already-sealed secret. Used by the seal step (and
    /// by serde deserialisation).
    pub fn sealed(vault: impl Into<String>, ciphertext: Vec<u8>) -> Self {
        Self::Sealed {
            vault: vault.into(),
            ciphertext,
        }
    }

    /// Construct a sealed secret from a base64-encoded ciphertext.
    /// This is the constructor the `cindy secret seal` source-rewriter
    /// emits in your code, because pasting raw bytes into a `.rs` file
    /// is awful and base64 is unambiguous + greppable.
    ///
    /// Panics at runtime if `ciphertext_b64` isn't valid base64; we'd
    /// rather fail loudly than silently produce a `Sealed` with bogus
    /// bytes that only blows up much later in `.reveal()`.
    pub fn sealed_b64(vault: impl Into<String>, ciphertext_b64: &str) -> Self {
        let bytes = base64::engine::general_purpose::STANDARD
            .decode(ciphertext_b64.as_bytes())
            .expect("`Secret::sealed_b64` got non-base64 ciphertext");
        Self::Sealed {
            vault: vault.into(),
            ciphertext: bytes,
        }
    }

    /// Name of the vault this secret is encrypted under. `Plain`
    /// secrets have no vault yet; this returns `None` for them.
    pub fn vault(&self) -> Option<&str> {
        match self {
            Self::Plain(_) => None,
            Self::Sealed { vault, .. } => Some(vault.as_str()),
        }
    }
}

/// Inventory-crate slot for a `secret!(...)` macro invocation waiting
/// to be sealed. The `#[cindy::main]`-generated entry walks all
/// `PendingSecret`s when launched with `CINDY_SEAL_SECRETS=1`, invokes
/// each `serialize` thunk to get the plaintext bytes, encrypts them
/// under the named vault's DEK, and emits one JSON object per pending
/// secret on stdout so the CLI can patch the source files.
///
/// The same registry doubles as the *vault-preflight* source: iterating
/// it yields every vault literal compiled into the binary (see
/// [`registered_vaults`]).
#[doc(hidden)]
pub struct PendingSecret {
    /// Source-file path captured by `file!()` at the macro call site.
    pub file: &'static str,
    /// 1-based line number from `line!()`.
    pub line: u32,
    /// 1-based column number from `column!()`.
    pub column: u32,
    /// Vault name (the macro's first argument).
    pub vault: &'static str,
    /// Thunk that re-evaluates the macro's value expression and
    /// returns its `postcard`-serialised bytes. Required to be a
    /// `fn` pointer (no captures) so it's registerable at link time;
    /// this is what forces the "literals-only" constraint on
    /// `secret!`.
    pub serialize: fn() -> Vec<u8>,
}

inventory::collect!(PendingSecret);

/// Collect the distinct vaults referenced by `secret!` invocations
/// compiled into this binary.
///
/// This is the "secrets in code" half of the vault preflight. It's an
/// over-approximation of what a given run will actually touch — it
/// includes `secret!`s in code paths a host might not hit, and can't
/// tell whether a given secret reveals on the orchestrator or a worker
/// (that isn't recoverable cross-crate). Both are the conservative bias
/// a key preflight wants: every participating machine is simply
/// required to hold every vault any `secret!` references, so a run can
/// never fail mid-play on a missing in-code secret key.
///
/// The *precise*, per-host half — secrets living in inventory `vars` —
/// is handled separately by walking the host's data (see the CLI and
/// `inventory`), because there the exact vault set per host *is*
/// knowable.
#[doc(hidden)]
pub fn registered_vaults() -> Vec<String> {
    let mut vaults: Vec<String> = crate::__reexports::inventory::iter::<PendingSecret>()
        .map(|p| p.vault.to_owned())
        .collect();
    vaults.sort();
    vaults.dedup();
    vaults
}

/// Verify that every vault in `vaults` has a reachable DEK, returning
/// the sorted list of those that don't.
///
/// "Reachable" means the keychain can load it — from the in-memory
/// cache (on a worker, pre-installed from `CINDY_VAULT_KEYS`) or the
/// on-disk `keys/<vault>.dek` (on the orchestrator). This is the actual
/// preflight probe: it touches no ciphertext, just confirms the key is
/// present, so it can run before any real work.
#[doc(hidden)]
pub fn missing_vaults<I, S>(vaults: I) -> Vec<String>
where
    I: IntoIterator<Item = S>,
    S: AsRef<str>,
{
    let mut missing: Vec<String> = vaults
        .into_iter()
        .filter(|v| keychain::get_dek(v.as_ref()).is_err())
        .map(|v| v.as_ref().to_owned())
        .collect();
    missing.sort();
    missing.dedup();
    missing
}

/// Run the vault preflight for a participant (orchestrator or worker):
/// the union of every `secret!`-referenced vault compiled into this
/// binary plus any vaults found in `host_context_json` (a serialised
/// `Host<V>`, whose sealed `vars` secrets are walked precisely). If any
/// key is missing, print an actionable message naming all of them and
/// return `Err` so the caller can fail before doing any work.
///
/// `host_context_json` is optional: pass `None` for the zero-arg `main`
/// shape (no host context), in which case only the in-code `secret!`
/// vaults are checked.
#[doc(hidden)]
pub fn preflight(role: &str, host_context_json: Option<&str>) -> crate::Result<()> {
    let mut needed: std::collections::BTreeSet<String> = registered_vaults().into_iter().collect();

    if let Some(json) = host_context_json
        && let Ok(value) =
            crate::__reexports::serde_json::from_str::<crate::__reexports::serde_json::Value>(json)
    {
        crate::inventory::collect_sealed_vaults(&value, &mut needed);
    }

    let missing = missing_vaults(&needed);
    if missing.is_empty() {
        return Ok(());
    }
    crate::bail!(
        "missing decryption keys before {role} could start: \
         no DEK available for vault(s) {missing:?}. \
         Provision the key file(s) (`cindy secret vault create <name>`, \
         or copy `keys/<name>.dek` from a teammate) and re-run. \
         Failing now rather than partway through the play."
    )
}

#[allow(non_snake_case, unused)]
#[deprecated(
    since = "0.0.0",
    note = "\n\n⚠️ Unsealed secret. Run `cindy secret seal`.\n\n"
)]
#[doc(hidden)]
pub fn UNSEALED_SECRET() {}

/// Wrap a literal value in a [`Secret<T>`] and register it for the
/// `cindy secret seal` step to encrypt in-place.
///
/// At runtime this expands to a [`Secret<T>::Plain`]. Your code will
/// be analyzed and will compile, but you might not be able to run
/// it, depending on where the [`Secret`] is present, and if it needs
/// to be transferred over the wire
/// (in other words; if [`serde::Serialize`] needs to be used).
///
/// To seal the secret, use `cindy secret seal [--all]`.
/// The [`secret!`] calls will get rewritten in the source code to a [`Secret::sealed_b64`]
/// call containing the encrypted value + encrypted raw tokens for `cindy secret unseal [--all]`.
///
/// The values used as secrets must be either constants or literals
/// (or [`include_str!`] / [`include_bytes!`]; anything resolvable at compile time).
/// You cannot capture variables from local scope. Doing so will surface as E0435 or E0425.
///
/// ```rust,ignore
/// let pw = cindy::secret!("prod", Credentials {
///     user: "admin".into(),
///     pw:   "hunter2".into(),
/// });
/// // After `cindy secret seal`:
/// // let pw = ::cindy::Secret::sealed_b64("prod", "AAAA...");
/// ```
#[macro_export]
macro_rules! secret {
    ($vault:literal, $value:expr $(,)?) => {{
        #[allow(non_upper_case_globals)]
        const __CINDY_SECRET_VALUES_MUST_BE_SELF_CONTAINED: fn() -> ::std::vec::Vec<u8> = || {
            let value_bytes = $crate::__reexports::postcard::to_allocvec(&($value))
                .expect("`cindy::secret!`: postcard couldn't serialise the value");
            let payload = $crate::secret::SealedPayload {
                value: value_bytes,
                source: ::core::stringify!($value).to_owned(),
            };
            $crate::__reexports::postcard::to_allocvec(&payload)
                .expect("`cindy::secret!`: postcard couldn't serialise the SealedPayload")
        };
        $crate::__reexports::inventory::submit! {
            $crate::secret::PendingSecret {
                file:      ::core::file!(),
                line:      ::core::line!(),
                column:    ::core::column!(),
                vault:     $vault,
                serialize: __CINDY_SECRET_VALUES_MUST_BE_SELF_CONTAINED,
            }
        }

        $crate::secret::UNSEALED_SECRET();

        $crate::Secret::new($value)
    }};
}

impl<T: serde::de::DeserializeOwned> Secret<T> {
    /// Reveal the inner value. For `Plain`, returns it as-is. For
    /// `Sealed`, fetches the vault DEK from the keychain, decrypts
    /// the ciphertext into a [`SealedPayload`], and
    /// `postcard`-deserialises the inner `T` out of its `value`
    /// field. The `source` field of the payload is ignored here —
    /// it's there for `cindy secret unseal`.
    pub fn reveal(self) -> crate::Result<T> {
        match self {
            Self::Plain(v) => Ok(v),
            Self::Sealed { vault, ciphertext } => {
                let dek = keychain::get_dek(&vault)?;
                let plaintext = crypto::unseal(&dek, &ciphertext)?;
                let payload: SealedPayload =
                    postcard::from_bytes(&plaintext).context(format!(
                            "couldn't deserialise SealedPayload for vault `{vault}`. \
                             If this secret was sealed by an older cindy version, \
                             rewrite it as `cindy::secret!(\"{vault}\", ..)` and re-run `cindy secret seal`."
                        ))?;
                let value: T = postcard::from_bytes(&payload.value)
                    .context(format!("couldn't deserialise inner T for vault `{vault}`"))?;
                Ok(value)
            }
        }
    }
}

thread_local! {
    /// When set, the `Debug` and `Serialize` impls of `Secret<T>`
    /// decrypt and render the real inner value instead of redacting it.
    /// Scoped strictly to the closure passed to [`with_revealed_secrets`]
    /// so it can never leak into ordinary logging or persisted output.
    static REVEAL_SECRETS: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}

/// Whether reveal mode is currently active on this thread.
pub(crate) fn revealing() -> bool {
    REVEAL_SECRETS.with(|c| c.get())
}

/// Run `f` with secret revelation enabled on the current thread, then
/// restore the previous setting. Inside the closure, `Debug` and
/// `Serialize` of any `Secret<T>` render the *decrypted value* rather
/// than `<secret vault=…>` / the sealed `{vault, ciphertext}` blob.
///
/// This is *only* wired up by `cindy inventory --reveal`, which runs in
/// the orchestrator process where the vault DEKs are reachable. It is
/// deliberately not exposed to normal operation — a misplaced reveal is
/// exactly the leak `Secret` exists to prevent.
pub fn with_revealed_secrets<R>(f: impl FnOnce() -> R) -> R {
    REVEAL_SECRETS.with(|c| {
        let prev = c.replace(true);
        let out = f();
        c.set(prev);
        out
    })
}

impl<T: serde::de::DeserializeOwned> Secret<T> {
    /// Decrypt into the inner `T` by reference (for reveal rendering).
    /// Unlike [`Secret::reveal`], doesn't consume `self`. `Plain` is
    /// not decryptable by reference (we can't move the value out), so
    /// it errors — but `Plain` never reaches the reveal path in
    /// practice, since the orchestrator only renders sealed inventories.
    fn reveal_ref(&self) -> crate::Result<T> {
        match self {
            Self::Plain(_) => crate::bail!("cannot reveal a `Plain` secret by reference"),
            Self::Sealed { vault, ciphertext } => {
                let dek = keychain::get_dek(vault)?;
                let plaintext = crypto::unseal(&dek, ciphertext)?;
                let payload: SealedPayload = postcard::from_bytes(&plaintext).context(format!(
                    "couldn't deserialise SealedPayload for vault `{vault}`"
                ))?;
                let value: T = postcard::from_bytes(&payload.value)
                    .context(format!("couldn't deserialise inner T for vault `{vault}`"))?;
                Ok(value)
            }
        }
    }
}

impl<T: fmt::Debug + serde::de::DeserializeOwned> fmt::Debug for Secret<T> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        if revealing() {
            match self.reveal_ref() {
                Ok(value) => return value.fmt(f),
                Err(e) => return write!(f, "<secret reveal failed: {e}>"),
            }
        }
        match self {
            Self::Plain(_) => write!(f, "<secret plain>"),
            Self::Sealed { vault, .. } => write!(f, "<secret vault={vault}>"),
        }
    }
}

impl<T: Clone> Clone for Secret<T> {
    fn clone(&self) -> Self {
        match self {
            Self::Plain(v) => Self::Plain(v.clone()),
            Self::Sealed { vault, ciphertext } => Self::Sealed {
                vault: vault.clone(),
                ciphertext: ciphertext.clone(),
            },
        }
    }
}

// Note: deliberately no `Drop` impl. We'd want one for zeroizing the
// ciphertext buffer, but auto-`Drop` makes `reveal(self)` impossible
// (you can't move out of a type that implements `Drop`). Ciphertext is
// non-secret material once it's left our process anyway; the
// load-bearing zeroize is on the plaintext that lives inside the
// `Zeroizing<Vec<u8>>` returned by [`crypto::unseal`] during a
// `.reveal()` call. For `Plain(T)` containing sensitive data, callers
// should make sure `T` itself uses zeroize-aware types.

// ---- serde ---------------------------------------------------------
//
// The on-the-wire shape is **always** the sealed form. `Plain` values
// serialise by encrypting on the fly (so JSON dumps from a dev `main`
// don't leak plaintext to disk). The `vault` lookup pulls a DEK from
// the keychain at serialisation time — note this means *Plain* secrets
// can't be serialised by code that doesn't have the vault key loaded;
// that's a deliberate guardrail, not a bug.
//
// Deserialisation always produces `Sealed`; revealing requires an
// explicit `.reveal()` call.

#[derive(Serialize, Deserialize)]
struct WireForm<'a> {
    vault: std::borrow::Cow<'a, str>,
    /// Base64 of the AEAD blob (nonce || ciphertext || tag).
    ciphertext: std::borrow::Cow<'a, str>,
}

impl<T: Serialize + serde::de::DeserializeOwned> Serialize for Secret<T> {
    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
        use serde::ser::Error as _;
        // Reveal mode (orchestrator, `inventory --reveal --json`):
        // decrypt and serialise the *plaintext value* in place of the
        // sealed blob. Strictly scoped to `with_revealed_secrets`.
        if revealing() {
            let value = self.reveal_ref().map_err(S::Error::custom)?;
            return value.serialize(serializer);
        }
        match self {
            Self::Sealed { vault, ciphertext } => {
                let b64 = base64::engine::general_purpose::STANDARD.encode(ciphertext);
                WireForm {
                    vault: std::borrow::Cow::Borrowed(vault),
                    ciphertext: std::borrow::Cow::Owned(b64),
                }
                .serialize(serializer)
            }
            Self::Plain(_) => Err(S::Error::custom(
                "refusing to serialise a `Secret::Plain(_)`. \
                 Run `cindy secret seal` to turn it into `Secret::Sealed { .. }` \
                 before persisting it (or any inventory containing it).",
            )),
        }
    }
}

impl<'de, T> Deserialize<'de> for Secret<T> {
    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
        use serde::de::Error as _;
        let wire = WireForm::deserialize(deserializer)?;
        let ciphertext = base64::engine::general_purpose::STANDARD
            .decode(wire.ciphertext.as_bytes())
            .map_err(D::Error::custom)?;
        Ok(Self::Sealed {
            vault: wire.vault.into_owned(),
            ciphertext,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde::{Deserialize, Serialize};

    #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
    struct Creds {
        user: String,
        pw: String,
    }

    #[test]
    fn debug_never_leaks_plain() {
        let s = Secret::new(Creds {
            user: "alice".into(),
            pw: "hunter2".into(),
        });
        let out = format!("{s:?}");
        assert!(!out.contains("alice"));
        assert!(!out.contains("hunter2"));
        assert_eq!(out, "<secret plain>");
    }

    #[test]
    fn debug_includes_vault_for_sealed() {
        let s: Secret<Creds> = Secret::sealed("prod", b"ignored".to_vec());
        let out = format!("{s:?}");
        assert!(out.contains("prod"));
    }

    #[test]
    fn refuses_to_serialise_plain() {
        let s = Secret::new(Creds {
            user: "alice".into(),
            pw: "hunter2".into(),
        });
        assert!(serde_json::to_string(&s).is_err());
    }

    #[test]
    fn sealed_round_trips_through_json() {
        let s: Secret<()> = Secret::sealed("prod", vec![1, 2, 3, 4]);
        let j = serde_json::to_string(&s).unwrap();
        let back: Secret<()> = serde_json::from_str(&j).unwrap();
        assert!(matches!(back, Secret::Sealed { ref vault, ref ciphertext }
                              if vault == "prod" && ciphertext == &[1, 2, 3, 4]));
    }

    #[test]
    fn reveal_plain_is_identity() {
        let s = Secret::new(Creds {
            user: "u".into(),
            pw: "p".into(),
        });
        let revealed = s.reveal().unwrap();
        assert_eq!(revealed.user, "u");
        assert_eq!(revealed.pw, "p");
    }

    #[test]
    fn reveal_sealed_roundtrip_with_installed_dek() {
        let dek = crypto::generate_dek();
        let creds = Creds {
            user: "alice".into(),
            pw: "hunter2".into(),
        };
        let payload = SealedPayload {
            value: postcard::to_allocvec(&creds).unwrap(),
            source: "Creds { user: \"alice\".into(), pw: \"hunter2\".into() }".to_owned(),
        };
        let payload_bytes = postcard::to_allocvec(&payload).unwrap();
        let ciphertext = crypto::seal(&dek, &payload_bytes).unwrap();

        let mut chain = std::collections::HashMap::new();
        chain.insert("test-reveal".to_owned(), dek);
        keychain::install_keys(chain);

        let s: Secret<Creds> = Secret::sealed("test-reveal", ciphertext);
        let revealed = s.reveal().unwrap();
        assert_eq!(revealed, creds);
    }

    fn seal_creds(vault: &str, creds: &Creds) -> Vec<u8> {
        let dek = crypto::generate_dek();
        let payload = SealedPayload {
            value: postcard::to_allocvec(creds).unwrap(),
            source: "ignored".to_owned(),
        };
        let ciphertext = crypto::seal(&dek, &postcard::to_allocvec(&payload).unwrap()).unwrap();
        let mut chain = std::collections::HashMap::new();
        chain.insert(vault.to_owned(), dek);
        keychain::install_keys(chain);
        ciphertext
    }

    #[test]
    fn debug_reveal_prints_real_value_only_inside_scope() {
        let creds = Creds {
            user: "bob".into(),
            pw: "s3cr3t".into(),
        };
        let s: Secret<Creds> = Secret::sealed("dbg-reveal", seal_creds("dbg-reveal", &creds));

        // Default: redacted.
        assert_eq!(format!("{s:?}"), "<secret vault=dbg-reveal>");

        // Inside the scope: the *decrypted value's* own Debug, not the
        // source tokens.
        let revealed = with_revealed_secrets(|| format!("{s:?}"));
        assert_eq!(revealed, format!("{creds:?}"));
        assert!(revealed.contains("s3cr3t"));

        // Flag is restored afterwards.
        assert_eq!(format!("{s:?}"), "<secret vault=dbg-reveal>");
    }

    #[test]
    fn serialize_reveal_emits_plaintext_value_inside_scope() {
        let creds = Creds {
            user: "carol".into(),
            pw: "pw123".into(),
        };
        let s: Secret<Creds> = Secret::sealed("ser-reveal", seal_creds("ser-reveal", &creds));

        // Default: sealed blob with vault + ciphertext, no plaintext.
        let sealed_json = serde_json::to_value(&s).unwrap();
        assert!(sealed_json.get("ciphertext").is_some());
        assert!(!sealed_json.to_string().contains("pw123"));

        // Revealed: the plaintext value serialised in place.
        let revealed = with_revealed_secrets(|| serde_json::to_value(&s).unwrap());
        assert_eq!(revealed, serde_json::to_value(&creds).unwrap());
        assert_eq!(revealed["pw"], "pw123");
    }

    #[test]
    fn collect_sealed_vaults_finds_nested_secrets() {
        let value = crate::inventory::Host {
            name: "h1".into(),
            tags: crate::tags![],
            vars: serde_json::json!({
                "api_token": { "vault": "prod", "ciphertext": "AAAA" },
                "nested": {
                    "deep": { "vault": "staging", "ciphertext": "BBBB" }
                },
                "list": [
                    { "vault": "prod", "ciphertext": "CCCC" },
                    { "vault": "ci", "ciphertext": "DDDD" }
                ],
                "decoy_vault_only": { "vault": "not-a-secret" },
                "decoy_ct_only": { "ciphertext": "no-vault" }
            }),
        };

        let mut out = std::collections::BTreeSet::new();
        crate::inventory::collect_sealed_vaults(&serde_json::to_value(value).unwrap(), &mut out);
        let got: Vec<&str> = out.iter().map(String::as_str).collect();
        assert_eq!(got, vec!["ci", "prod", "staging"]);
    }

    #[test]
    fn missing_vaults_reports_unavailable_only() {
        // Install one vault; the other should be reported missing.
        let dek = crypto::generate_dek();
        let mut chain = std::collections::HashMap::new();
        chain.insert("present-vault".to_owned(), dek);
        keychain::install_keys(chain);

        let missing = missing_vaults(["present-vault", "absent-vault-xyz"]);
        assert_eq!(missing, vec!["absent-vault-xyz".to_owned()]);
    }

    #[test]
    fn debug_reveal_failure_does_not_leak_and_does_not_panic() {
        // No DEK installed for this vault → reveal fails gracefully.
        let s: Secret<Creds> = Secret::sealed("no-such-vault-for-debug", vec![9, 9, 9]);
        let out = with_revealed_secrets(|| format!("{s:?}"));
        assert!(out.starts_with("<secret reveal failed:"));
    }
}