Skip to main content

basil_bin/
client_cli.rs

1//! `basil`: command-line client for the basil agent.
2
3// Index into fixed `serde_json::Value` shapes in test assertions is fine (the
4// keys are constants we just rendered); the no-panic `indexing_slicing` gate has
5// no test-allow config option, unlike unwrap/expect. Matches `basil/src/lib.rs`.
6#![cfg_attr(test, allow(clippy::indexing_slicing))]
7
8use std::ffi::OsString;
9use std::path::{Path, PathBuf};
10
11use anyhow::{Context, Result, bail};
12use base64::Engine as _;
13use basil::{
14    AeadAlgorithm, CiphertextEnvelope, Client, ImportEntry, KeyMaterial, KeyType, NatsJwtType,
15    NatsUserPermissions, SignNatsJwtOptions,
16};
17use clap::{Subcommand, ValueEnum};
18
19#[derive(Clone, Copy, Debug, ValueEnum)]
20pub enum AeadAlg {
21    Aes256Gcm,
22    Chacha20Poly1305,
23}
24
25impl From<AeadAlg> for AeadAlgorithm {
26    fn from(value: AeadAlg) -> Self {
27        match value {
28            AeadAlg::Aes256Gcm => Self::Aes256Gcm,
29            AeadAlg::Chacha20Poly1305 => Self::Chacha20Poly1305,
30        }
31    }
32}
33
34/// Asymmetric key type accepted by `new-key` / `import` / `import-set`.
35///
36/// The serde
37/// and value names match the wire `KeyType` so a manifest or flag spells
38/// `key_type` the same way the rest of Basil does. The post-quantum types
39/// (`ml-dsa-*` signing, `ml-kem-*` sealing) are provisionable only via `new-key`
40/// against an operator-declared software-custody catalog entry; `import` of a
41/// post-quantum key is rejected by the broker (custody records are broker-sealed).
42#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum, serde::Deserialize)]
43pub enum KeyTypeArg {
44    #[serde(rename = "ed25519")]
45    Ed25519,
46    #[serde(rename = "ed25519-nkey")]
47    Ed25519Nkey,
48    #[serde(rename = "rsa-2048")]
49    #[value(name = "rsa-2048")]
50    Rsa2048,
51    #[serde(rename = "ecdsa-p256")]
52    #[value(name = "ecdsa-p256")]
53    EcdsaP256,
54    #[serde(rename = "ecdsa-p384")]
55    #[value(name = "ecdsa-p384")]
56    EcdsaP384,
57    #[serde(rename = "ecdsa-p521")]
58    #[value(name = "ecdsa-p521")]
59    EcdsaP521,
60    #[serde(rename = "ml-dsa-44")]
61    #[value(name = "ml-dsa-44")]
62    MlDsa44,
63    #[serde(rename = "ml-dsa-65")]
64    #[value(name = "ml-dsa-65")]
65    MlDsa65,
66    #[serde(rename = "ml-dsa-87")]
67    #[value(name = "ml-dsa-87")]
68    MlDsa87,
69    #[serde(rename = "ml-kem-512")]
70    #[value(name = "ml-kem-512")]
71    MlKem512,
72    #[serde(rename = "ml-kem-768")]
73    #[value(name = "ml-kem-768")]
74    MlKem768,
75    #[serde(rename = "ml-kem-1024")]
76    #[value(name = "ml-kem-1024")]
77    MlKem1024,
78}
79
80impl From<KeyTypeArg> for KeyType {
81    fn from(value: KeyTypeArg) -> Self {
82        match value {
83            KeyTypeArg::Ed25519 => Self::Ed25519,
84            KeyTypeArg::Ed25519Nkey => Self::Ed25519Nkey,
85            KeyTypeArg::Rsa2048 => Self::Rsa2048,
86            KeyTypeArg::EcdsaP256 => Self::EcdsaP256,
87            KeyTypeArg::EcdsaP384 => Self::EcdsaP384,
88            KeyTypeArg::EcdsaP521 => Self::EcdsaP521,
89            KeyTypeArg::MlDsa44 => Self::MlDsa44,
90            KeyTypeArg::MlDsa65 => Self::MlDsa65,
91            KeyTypeArg::MlDsa87 => Self::MlDsa87,
92            KeyTypeArg::MlKem512 => Self::MlKem512,
93            KeyTypeArg::MlKem768 => Self::MlKem768,
94            KeyTypeArg::MlKem1024 => Self::MlKem1024,
95        }
96    }
97}
98
99#[derive(Clone, Copy, Debug, ValueEnum)]
100pub enum NatsJwtTypeArg {
101    User,
102    Account,
103    Operator,
104    Signer,
105    Server,
106    Curve,
107}
108
109impl From<NatsJwtTypeArg> for NatsJwtType {
110    fn from(value: NatsJwtTypeArg) -> Self {
111        match value {
112            NatsJwtTypeArg::User => Self::User,
113            NatsJwtTypeArg::Account => Self::Account,
114            NatsJwtTypeArg::Operator => Self::Operator,
115            NatsJwtTypeArg::Signer => Self::Signer,
116            NatsJwtTypeArg::Server => Self::Server,
117            NatsJwtTypeArg::Curve => Self::Curve,
118        }
119    }
120}
121
122#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
123pub enum GetOutputFormatArg {
124    Raw,
125    Hex,
126    Base64,
127    #[value(name = "base64-url-no-pad")]
128    Base64UrlNoPad,
129}
130
131#[derive(Clone, Copy, Debug, ValueEnum)]
132pub enum SecretFileModeArg {
133    #[value(name = "0600")]
134    OwnerReadWrite,
135    #[value(name = "0660")]
136    OwnerGroupReadWrite,
137}
138
139impl SecretFileModeArg {
140    const fn mode(self) -> u32 {
141        match self {
142            Self::OwnerReadWrite => 0o600,
143            Self::OwnerGroupReadWrite => 0o660,
144        }
145    }
146}
147
148#[derive(Debug, Subcommand)]
149pub enum Command {
150    /// Create a new asymmetric key.
151    NewKey {
152        /// Dotted catalog name for the new key.
153        #[arg(long, default_value = "example.signing_key")]
154        key_id: String,
155        /// Key type to create.
156        #[arg(long, value_enum, default_value_t = KeyTypeArg::Ed25519)]
157        key_type: KeyTypeArg,
158    },
159
160    /// Import caller-provided (BYOK) key material into a catalog key. The broker
161    /// fetches the backend transit `wrapping_key`, wraps the material with
162    /// RSA-OAEP + AES-KWP in place, and POSTs it to `keys/<k>/import`; the raw
163    /// material never lands on the backend unwrapped. Supply exactly one material
164    /// source: `--seed-hex` (64 hex chars = a 32-byte Ed25519 seed; Ed25519-only)
165    /// or `--pkcs8-file` (a PKCS#8 DER private key for Ed25519/RSA/P-256).
166    /// Prints the `key_id` + `public_key` hex.
167    Import {
168        /// Dotted catalog name to import the key under.
169        #[arg(long)]
170        key_id: String,
171        /// Key type of the imported material.
172        #[arg(long, value_enum, default_value_t = KeyTypeArg::Ed25519)]
173        key_type: KeyTypeArg,
174        /// 32-byte raw Ed25519 seed as 64 hex chars.
175        #[arg(long, conflicts_with = "pkcs8_file")]
176        seed_hex: Option<String>,
177        /// Path to a PKCS#8 DER private key file.
178        #[arg(long)]
179        pkcs8_file: Option<PathBuf>,
180        /// Validate local inputs without contacting the agent or importing.
181        #[arg(long)]
182        check: bool,
183    },
184
185    /// Import several keys from a JSON manifest in one all-or-nothing call. The
186    /// broker authorizes `import` on EVERY entry before importing ANY, so an
187    /// unauthorized entry rejects the whole batch and creates no keys. The
188    /// manifest is a JSON array of objects: `{"key_id": "...", "key_type":
189    /// "ed25519", "seed_hex": "<64 hex>"}` or with `"pkcs8_file": "<path>"`
190    /// instead of `"seed_hex"`. Raw seeds are Ed25519-only; RSA/P-256 use
191    /// PKCS#8 DER. `key_type` defaults to `ed25519` when omitted.
192    /// Prints one `key_id`/`public_key` pair per imported key.
193    ImportSet {
194        /// Path to the JSON manifest of keys to import.
195        #[arg(long)]
196        file: PathBuf,
197        /// Validate the manifest without contacting the agent or importing.
198        #[arg(long)]
199        check: bool,
200    },
201
202    /// Sign a payload and print the signature as hex.
203    Sign {
204        #[arg(long)]
205        key_id: String,
206        payload: String,
207    },
208
209    /// Verify a payload signature.
210    Verify {
211        #[arg(long)]
212        key_id: String,
213        #[arg(long)]
214        signature: String,
215        payload: String,
216    },
217
218    /// Encrypt a payload and print envelope fields.
219    Encrypt {
220        #[arg(long)]
221        key_id: String,
222        #[arg(long, value_enum, default_value_t = AeadAlg::Aes256Gcm)]
223        algorithm: AeadAlg,
224        #[arg(long)]
225        aad_hex: Option<String>,
226        plaintext: String,
227    },
228
229    /// Decrypt an envelope and print plaintext as hex.
230    Decrypt {
231        #[arg(long)]
232        key_id: String,
233        #[arg(long, value_enum)]
234        algorithm: AeadAlg,
235        #[arg(long)]
236        version: u32,
237        #[arg(long)]
238        nonce: String,
239        #[arg(long)]
240        ciphertext: String,
241        #[arg(long)]
242        aad_hex: Option<String>,
243    },
244
245    /// Fetch a value key. Prints `version` + hex `value` by default; `--raw` writes
246    /// the bytes to stdout and `--out-file` writes them to a 0600 file. Use
247    /// `--format base64` when materializing secrets for consumers that require
248    /// standard padded Base64.
249    ///
250    /// Basil attests the caller by its kernel `SO_PEERCRED` uid/gid, so to fetch a
251    /// secret as a particular service, run this CLI under that service's identity
252    /// (systemd `User=`/`Group=`, or `runuser -u <svc>`); the CLI cannot impersonate.
253    Get {
254        #[arg(long)]
255        key_id: String,
256        #[arg(long)]
257        version: Option<u32>,
258        /// Write the raw secret bytes to stdout (no hex, no `version:` line).
259        #[arg(long, conflicts_with = "out_file")]
260        raw: bool,
261        /// Encode the value as `raw`, `hex`, standard padded `base64`, or
262        /// URL-safe unpadded Base64. When set, stdout prints only the value and
263        /// `--out-file` writes the encoded value instead of raw bytes.
264        #[arg(long, value_enum)]
265        format: Option<GetOutputFormatArg>,
266        /// Write the raw secret bytes to this file, created/truncated mode 0600.
267        #[arg(long)]
268        out_file: Option<PathBuf>,
269    },
270
271    /// Assemble a local `NATS` user `.creds` file from a signed user JWT and
272    /// user `NKey` seed. This is local file plumbing: mint/sign the JWT first,
273    /// then call this command to render the canonical credentials document.
274    IssueNatsCreds {
275        /// Signed compact `NATS` user JWT. Prefer `--jwt-file` for automation so
276        /// secrets do not appear in argv.
277        #[arg(long, conflicts_with = "jwt_file")]
278        jwt: Option<String>,
279        /// File containing the signed compact `NATS` user JWT.
280        #[arg(long, conflicts_with = "jwt")]
281        jwt_file: Option<PathBuf>,
282        /// User `NKey` seed. Prefer `--seed-file` so the seed does not appear in
283        /// argv.
284        #[arg(long, conflicts_with = "seed_file")]
285        seed: Option<String>,
286        /// File containing the user `NKey` seed.
287        #[arg(long, conflicts_with = "seed")]
288        seed_file: Option<PathBuf>,
289        /// Destination `.creds` path.
290        #[arg(long)]
291        out_file: PathBuf,
292        /// Destination file mode.
293        #[arg(long, value_enum, default_value_t = SecretFileModeArg::OwnerReadWrite)]
294        mode: SecretFileModeArg,
295    },
296
297    /// Store a value key from text or hex.
298    Set {
299        #[arg(long)]
300        key_id: String,
301        #[arg(long)]
302        hex: bool,
303        value: String,
304    },
305
306    /// Rotate a key and print the new version.
307    Rotate {
308        #[arg(long)]
309        key_id: String,
310    },
311
312    /// List visible catalog keys.
313    List {
314        #[arg(long)]
315        prefix: Option<String>,
316    },
317
318    /// Mint a generic JWT.
319    MintJwt {
320        #[arg(long)]
321        key_id: String,
322        #[arg(long)]
323        sub: String,
324        #[arg(long)]
325        ttl_secs: Option<u64>,
326        #[arg(long, default_value = "{}")]
327        claims_json: String,
328    },
329
330    /// Mint a NATS user JWT signed by an account key held by Basil.
331    ///
332    /// When `--key-id` is an account *signing* key (not the account identity
333    /// key), pass `--issuer-account` with the owning account's identity public
334    /// `NKey` (`A…`); otherwise the minted user carries no `issuer_account` and
335    /// nats-server rejects the connection with an authorization violation. Omit
336    /// `--issuer-account` when `--key-id` is the account identity key itself.
337    MintNatsUser {
338        #[arg(long)]
339        key_id: String,
340        #[arg(long)]
341        user_nkey: String,
342        /// Owning account identity public `NKey` (`A…`); required when `--key-id`
343        /// is an account signing key.
344        #[arg(long = "issuer-account")]
345        issuer_account: Option<String>,
346        #[arg(long, default_value = "basil-user")]
347        name: String,
348        #[arg(long)]
349        ttl_secs: Option<u64>,
350        #[arg(long = "pub-allow")]
351        pub_allow: Vec<String>,
352        #[arg(long = "pub-deny")]
353        pub_deny: Vec<String>,
354        #[arg(long = "sub-allow")]
355        sub_allow: Vec<String>,
356        #[arg(long = "sub-deny")]
357        sub_deny: Vec<String>,
358    },
359
360    /// Validate and sign a caller-supplied NATS JWT claim document.
361    SignNatsJwt {
362        #[arg(long)]
363        key_id: String,
364        #[arg(long, conflicts_with = "claims_file")]
365        claims_json: Option<String>,
366        #[arg(long, conflicts_with = "claims_json")]
367        claims_file: Option<PathBuf>,
368        #[arg(long, value_enum)]
369        expect_type: Option<NatsJwtTypeArg>,
370        #[arg(long, conflicts_with = "expires_at_unix")]
371        ttl_secs: Option<u64>,
372        #[arg(long)]
373        expires_at_unix: Option<u64>,
374        #[arg(long)]
375        issued_at_unix: Option<u64>,
376        #[arg(long)]
377        rewrite_jti: bool,
378    },
379
380    /// Issue a DNS/IP-SAN X.509 leaf (TLS cert) from a backend PKI engine, signed
381    /// by the issuer CA the broker holds in place. Prints the leaf+chain and the
382    /// issuing-CA chain as `CERTIFICATE` PEM blocks and the leaf key as a
383    /// `PRIVATE KEY` PEM block, so the output pipes into `openssl x509`/`openssl
384    /// pkey`. The issuing CA key never leaves the backend.
385    IssueCert {
386        /// Catalog key of the issuer (a `pki/issue/<role>` engine key).
387        #[arg(long)]
388        key_id: String,
389        /// Subject common name for the leaf certificate.
390        #[arg(long)]
391        common_name: String,
392        /// DNS SAN to bind (repeatable).
393        #[arg(long = "dns-san")]
394        dns_san: Vec<String>,
395        /// IP SAN to bind (repeatable).
396        #[arg(long = "ip-san")]
397        ip_san: Vec<String>,
398        /// Requested certificate lifetime in seconds.
399        #[arg(long)]
400        ttl_secs: u64,
401    },
402
403    /// Print the agent backend, version, and protocol.
404    Status,
405
406    /// Liveness probe: is the agent process up and serving the socket? Cheap; the
407    /// agent does no backend I/O. Exits 0 when alive, nonzero on a connect/RPC
408    /// failure. With `--json`, prints a stable one-line JSON object for automation
409    /// (systemd `WatchdogSec` companion, container liveness check).
410    Health {
411        /// Emit a machine-readable JSON object instead of human lines.
412        #[arg(long)]
413        json: bool,
414    },
415
416    /// Readiness probe: can the agent actually serve data-plane ops? The agent
417    /// probes every backend and catalog key and returns a non-secret summary.
418    /// Exits 0 when ready, 1 when not ready (and nonzero on a connect/RPC
419    /// failure). With `--json`, prints a stable one-line JSON object for
420    /// automation (systemd `ExecStartPost`, container `HEALTHCHECK`, k8s readiness
421    /// probe). Never prints key names or secret material.
422    Ready {
423        /// Emit a machine-readable JSON object instead of human lines.
424        #[arg(long)]
425        json: bool,
426    },
427
428    /// Hot-reload the agent's catalog/policy generation FROM DISK. The agent
429    /// re-reads the catalog/policy from the paths it was started with (the request
430    /// carries NO config: config can never be supplied over the wire), validates
431    /// the candidate, and atomically swaps in a new generation on success. Prints
432    /// the old->new generation id + key/grant counts. Requires the dedicated
433    /// `reload` permission (not implied by any data-plane grant); a caller lacking
434    /// it is denied. Exits 0 on success, nonzero on rejection or permission-denied.
435    /// With `--check`, validates the candidate WITHOUT swapping (dry-run).
436    Reload {
437        /// Dry-run: validate the on-disk candidate without swapping the serving
438        /// generation. Exits nonzero if the candidate would be rejected.
439        #[arg(long)]
440        check: bool,
441        /// Emit a machine-readable JSON object instead of human lines.
442        #[arg(long)]
443        json: bool,
444    },
445
446    /// Ask the running agent why a subject would be allowed or denied for a
447    /// given operation and key. Requires the dedicated `explain` admin permission.
448    Explain {
449        /// Subject name to evaluate.
450        #[arg(long)]
451        subject: String,
452        /// Policy op token to evaluate (`get`, `sign`, `set`, ...).
453        #[arg(long)]
454        op: String,
455        /// Catalog key/target to evaluate.
456        #[arg(long)]
457        key: String,
458        /// Emit a machine-readable JSON object instead of human lines.
459        #[arg(long)]
460        json: bool,
461    },
462
463    /// Revoke a JWT-SVID by trust-domain and jti. Requires the dedicated
464    /// `revoke` admin permission over `broker.revoke` and a configured
465    /// persistent `revocation_store=jwt-svid` value key.
466    Revoke {
467        /// SPIFFE trust domain for the token issuer, without `spiffe://`.
468        #[arg(long)]
469        trust_domain: String,
470        /// JWT ID (`jti`) to deny.
471        #[arg(long)]
472        jti: String,
473        /// Unix expiry of the credential; the deny-list entry expires then.
474        #[arg(long)]
475        expires_at_unix: u64,
476        /// Emit a machine-readable JSON object instead of human lines.
477        #[arg(long)]
478        json: bool,
479    },
480}
481
482pub async fn run(socket: Option<String>, command: Command) -> Result<()> {
483    if let Command::IssueNatsCreds {
484        jwt,
485        jwt_file,
486        seed,
487        seed_file,
488        out_file,
489        mode,
490    } = command
491    {
492        return issue_nats_creds(
493            jwt,
494            jwt_file.as_deref(),
495            seed,
496            seed_file.as_deref(),
497            &out_file,
498            mode,
499        );
500    }
501
502    match &command {
503        Command::Import {
504            key_type,
505            seed_hex,
506            pkcs8_file,
507            check: true,
508            ..
509        } => return check_import(*key_type, seed_hex.as_deref(), pkcs8_file.as_deref()),
510        Command::ImportSet { file, check: true } => return check_import_set(file),
511        _ => {}
512    }
513
514    let socket = socket.unwrap_or_else(|| basil::constants::DEFAULT_SOCKET_PATH.to_string());
515    let mut client = Client::connect(&socket)
516        .await
517        .with_context(|| format!("connecting to agent at {socket}"))?;
518
519    dispatch(&mut client, command).await?;
520
521    drop(client);
522    Ok(())
523}
524
525#[allow(clippy::too_many_lines)]
526async fn dispatch(client: &mut Client, command: Command) -> Result<()> {
527    match command {
528        Command::NewKey { key_id, key_type } => new_key(client, &key_id, key_type).await,
529        Command::Import {
530            key_id,
531            key_type,
532            seed_hex,
533            pkcs8_file,
534            check,
535        } => {
536            import(
537                client,
538                &key_id,
539                key_type,
540                seed_hex,
541                pkcs8_file.as_deref(),
542                check,
543            )
544            .await
545        }
546        Command::ImportSet { file, check } => import_set(client, &file, check).await,
547        Command::Sign { key_id, payload } => sign(client, &key_id, &payload).await,
548        Command::Verify {
549            key_id,
550            signature,
551            payload,
552        } => verify(client, &key_id, &signature, &payload).await,
553        Command::Encrypt {
554            key_id,
555            algorithm,
556            aad_hex,
557            plaintext,
558        } => encrypt(client, &key_id, algorithm, aad_hex, &plaintext).await,
559        Command::Decrypt {
560            key_id,
561            algorithm,
562            version,
563            nonce,
564            ciphertext,
565            aad_hex,
566        } => {
567            decrypt(
568                client,
569                &key_id,
570                algorithm,
571                version,
572                &nonce,
573                &ciphertext,
574                aad_hex,
575            )
576            .await
577        }
578        Command::Get {
579            key_id,
580            version,
581            raw,
582            format,
583            out_file,
584        } => get(client, &key_id, version, raw, format, out_file.as_deref()).await,
585        Command::IssueNatsCreds {
586            jwt,
587            jwt_file,
588            seed,
589            seed_file,
590            out_file,
591            mode,
592        } => issue_nats_creds(
593            jwt,
594            jwt_file.as_deref(),
595            seed,
596            seed_file.as_deref(),
597            &out_file,
598            mode,
599        ),
600        Command::Set { key_id, hex, value } => set(client, &key_id, hex, value).await,
601        Command::Rotate { key_id } => rotate(client, &key_id).await,
602        Command::List { prefix } => list(client, prefix.as_deref()).await,
603        Command::MintJwt {
604            key_id,
605            sub,
606            ttl_secs,
607            claims_json,
608        } => mint_jwt(client, &key_id, &sub, ttl_secs, &claims_json).await,
609        Command::MintNatsUser {
610            key_id,
611            user_nkey,
612            issuer_account,
613            name,
614            ttl_secs,
615            pub_allow,
616            pub_deny,
617            sub_allow,
618            sub_deny,
619        } => {
620            mint_nats_user(
621                client,
622                &key_id,
623                &user_nkey,
624                issuer_account.as_deref(),
625                &name,
626                ttl_secs,
627                NatsUserPermissions {
628                    pub_allow,
629                    pub_deny,
630                    sub_allow,
631                    sub_deny,
632                },
633            )
634            .await
635        }
636        Command::SignNatsJwt {
637            key_id,
638            claims_json,
639            claims_file,
640            expect_type,
641            ttl_secs,
642            expires_at_unix,
643            issued_at_unix,
644            rewrite_jti,
645        } => {
646            sign_nats_jwt(
647                client,
648                &key_id,
649                claims_json.as_deref(),
650                claims_file.as_deref(),
651                expect_type.map(Into::into),
652                ttl_secs,
653                expires_at_unix,
654                issued_at_unix,
655                rewrite_jti,
656            )
657            .await
658        }
659        Command::IssueCert {
660            key_id,
661            common_name,
662            dns_san,
663            ip_san,
664            ttl_secs,
665        } => issue_cert(client, &key_id, &common_name, &dns_san, &ip_san, ttl_secs).await,
666        Command::Status => status(client).await,
667        Command::Health { json } => health(client, json).await,
668        Command::Ready { json } => ready(client, json).await,
669        Command::Reload { check, json } => reload(client, check, json).await,
670        Command::Explain {
671            subject,
672            op,
673            key,
674            json,
675        } => explain(client, &subject, &op, &key, json).await,
676        Command::Revoke {
677            trust_domain,
678            jti,
679            expires_at_unix,
680            json,
681        } => revoke(client, &trust_domain, &jti, expires_at_unix, json).await,
682    }
683}
684
685async fn new_key(client: &mut Client, key_id: &str, key_type: KeyTypeArg) -> Result<()> {
686    let key = client.new_key(key_id, key_type.into()).await?;
687    println!("key_id: {}", key.key_id);
688    println!("public_key: {}", hex::encode(key.public_key));
689    Ok(())
690}
691
692/// One entry in an `import-set` JSON manifest. Carries exactly one material
693/// source (`seed_hex` xor `pkcs8_file`); `key_type` defaults to `ed25519`.
694#[derive(Debug, serde::Deserialize)]
695struct ManifestEntry {
696    key_id: String,
697    #[serde(default = "default_manifest_key_type")]
698    key_type: KeyTypeArg,
699    #[serde(default)]
700    seed_hex: Option<String>,
701    #[serde(default)]
702    pkcs8_file: Option<PathBuf>,
703}
704
705const fn default_manifest_key_type() -> KeyTypeArg {
706    KeyTypeArg::Ed25519
707}
708
709/// Build a [`KeyMaterial`] from the two mutually-exclusive material sources. The
710/// caller must supply exactly one; zero or both is an error.
711fn key_material(seed_hex: Option<&str>, pkcs8_file: Option<&Path>) -> Result<KeyMaterial> {
712    match (seed_hex, pkcs8_file) {
713        (Some(seed), None) => {
714            let seed = decode_hex(seed, "seed-hex")?;
715            if seed.len() != 32 {
716                bail!("seed-hex must decode to 32 bytes (got {})", seed.len());
717            }
718            Ok(KeyMaterial::Ed25519Seed(seed))
719        }
720        (None, Some(path)) => {
721            let der = std::fs::read(path)
722                .with_context(|| format!("reading PKCS#8 DER from {}", path.display()))?;
723            if der.is_empty() {
724                bail!("pkcs8-file {} is empty", path.display());
725            }
726            Ok(KeyMaterial::Pkcs8Der(der))
727        }
728        (None, None) => bail!("supply key material: --seed-hex or --pkcs8-file"),
729        (Some(_), Some(_)) => bail!("supply only one of --seed-hex or --pkcs8-file"),
730    }
731}
732
733async fn import(
734    client: &mut Client,
735    key_id: &str,
736    key_type: KeyTypeArg,
737    seed_hex: Option<String>,
738    pkcs8_file: Option<&Path>,
739    check: bool,
740) -> Result<()> {
741    let material = key_material(seed_hex.as_deref(), pkcs8_file)?;
742    if check {
743        return Ok(());
744    }
745    let key = client.import(key_id, key_type.into(), material).await?;
746    println!("key_id: {}", key.key_id);
747    println!("public_key: {}", hex::encode(key.public_key));
748    Ok(())
749}
750
751fn check_import(
752    key_type: KeyTypeArg,
753    seed_hex: Option<&str>,
754    pkcs8_file: Option<&Path>,
755) -> Result<()> {
756    let _material = key_material(seed_hex, pkcs8_file)?;
757    let _key_type = KeyType::from(key_type);
758    Ok(())
759}
760
761fn import_set_entries(file: &Path) -> Result<Vec<ImportEntry>> {
762    let raw = std::fs::read_to_string(file)
763        .with_context(|| format!("reading import manifest {}", file.display()))?;
764    let manifest: Vec<ManifestEntry> =
765        serde_json::from_str(&raw).context("import manifest is not a JSON array of key entries")?;
766    if manifest.is_empty() {
767        bail!("import manifest {} has no entries", file.display());
768    }
769    let mut entries = Vec::with_capacity(manifest.len());
770    for entry in manifest {
771        let material = key_material(entry.seed_hex.as_deref(), entry.pkcs8_file.as_deref())
772            .with_context(|| format!("manifest entry {}", entry.key_id))?;
773        entries.push(ImportEntry {
774            key_id: entry.key_id,
775            key_type: entry.key_type.into(),
776            material,
777        });
778    }
779    Ok(entries)
780}
781
782fn check_import_set(file: &Path) -> Result<()> {
783    let _entries = import_set_entries(file)?;
784    Ok(())
785}
786
787async fn import_set(client: &mut Client, file: &Path, check: bool) -> Result<()> {
788    let entries = import_set_entries(file)?;
789    if check {
790        return Ok(());
791    }
792    for key in client.import_set(entries).await? {
793        println!("key_id: {}", key.key_id);
794        println!("public_key: {}", hex::encode(key.public_key));
795    }
796    Ok(())
797}
798
799async fn sign(client: &mut Client, key_id: &str, payload: &str) -> Result<()> {
800    let sig = client.sign(key_id, payload.as_bytes()).await?;
801    println!("{}", hex::encode(sig));
802    Ok(())
803}
804
805async fn verify(client: &mut Client, key_id: &str, signature: &str, payload: &str) -> Result<()> {
806    let sig = decode_hex(signature, "signature")?;
807    let valid = client.verify(key_id, payload.as_bytes(), &sig).await?;
808    println!("{valid}");
809    if !valid {
810        std::process::exit(1);
811    }
812    Ok(())
813}
814
815async fn encrypt(
816    client: &mut Client,
817    key_id: &str,
818    algorithm: AeadAlg,
819    aad_hex: Option<String>,
820    plaintext: &str,
821) -> Result<()> {
822    let aad = optional_hex(aad_hex, "aad")?;
823    let envelope = client
824        .encrypt(
825            key_id,
826            algorithm.into(),
827            plaintext.as_bytes(),
828            aad.as_deref(),
829        )
830        .await?;
831    println!("algorithm: {}", aead_name(envelope.alg));
832    println!("version: {}", envelope.key_version);
833    println!("nonce: {}", hex::encode(envelope.nonce));
834    println!("ciphertext: {}", hex::encode(envelope.ciphertext));
835    Ok(())
836}
837
838async fn decrypt(
839    client: &mut Client,
840    key_id: &str,
841    algorithm: AeadAlg,
842    version: u32,
843    nonce: &str,
844    ciphertext: &str,
845    aad_hex: Option<String>,
846) -> Result<()> {
847    let aad = optional_hex(aad_hex, "aad")?;
848    // Vault/OpenBao transit embeds the nonce inside its ciphertext blob, so a
849    // transit-AEAD envelope carries an EMPTY nonce (documented broker invariant,
850    // see `TransitClient::decrypt`). Permit it; a provider that genuinely needs a
851    // discrete nonce fails the decrypt at the broker, not at this arg guard.
852    let nonce_bytes = if nonce.trim().is_empty() {
853        Vec::new()
854    } else {
855        decode_hex(nonce, "nonce")?
856    };
857    let envelope = CiphertextEnvelope {
858        alg: algorithm.into(),
859        key_version: version,
860        nonce: nonce_bytes,
861        ciphertext: decode_hex(ciphertext, "ciphertext")?,
862    };
863    let plaintext = client
864        .decrypt(key_id, envelope, aad.as_deref())
865        .await
866        .context("decrypt failed")?;
867    println!("{}", hex::encode(plaintext));
868    Ok(())
869}
870
871async fn get(
872    client: &mut Client,
873    key_id: &str,
874    version: Option<u32>,
875    raw: bool,
876    format: Option<GetOutputFormatArg>,
877    out_file: Option<&Path>,
878) -> Result<()> {
879    if raw && format.is_some_and(|format| format != GetOutputFormatArg::Raw) {
880        bail!("--raw cannot be combined with a non-raw --format");
881    }
882    let format = format.or_else(|| raw.then_some(GetOutputFormatArg::Raw));
883    let secret = client.get_secret(key_id, version).await?;
884    let (value, version) = (secret.value, secret.version);
885    if let Some(path) = out_file {
886        let encoded = format.map_or_else(|| value.clone(), |format| encode_secret(&value, format));
887        return write_secret_file(path, &encoded)
888            .with_context(|| format!("writing secret to {}", path.display()));
889    }
890    if let Some(format) = format {
891        use std::io::Write as _;
892
893        let encoded = encode_secret(&value, format);
894        return std::io::stdout()
895            .write_all(&encoded)
896            .and_then(|()| {
897                if format == GetOutputFormatArg::Raw {
898                    Ok(())
899                } else {
900                    std::io::stdout().write_all(b"\n")
901                }
902            })
903            .context("writing formatted secret to stdout");
904    }
905    println!("version: {version}");
906    println!("value: {}", hex::encode(value));
907    Ok(())
908}
909
910fn encode_secret(value: &[u8], format: GetOutputFormatArg) -> Vec<u8> {
911    match format {
912        GetOutputFormatArg::Raw => value.to_vec(),
913        GetOutputFormatArg::Hex => hex::encode(value).into_bytes(),
914        GetOutputFormatArg::Base64 => base64::engine::general_purpose::STANDARD
915            .encode(value)
916            .into_bytes(),
917        GetOutputFormatArg::Base64UrlNoPad => base64::engine::general_purpose::URL_SAFE_NO_PAD
918            .encode(value)
919            .into_bytes(),
920    }
921}
922
923/// Write secret bytes to `path`, creating/truncating it mode 0600. Enforces 0600
924/// even if the file pre-existed with broader permissions.
925fn write_secret_file(path: &Path, value: &[u8]) -> Result<()> {
926    write_secret_file_with_mode(path, value, 0o600)
927}
928
929/// Atomically write secret bytes to `path`, enforcing `mode` even if the file
930/// pre-existed with broader permissions.
931fn write_secret_file_with_mode(path: &Path, value: &[u8], mode: u32) -> Result<()> {
932    use std::io::Write as _;
933    use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
934
935    let parent = path
936        .parent()
937        .filter(|parent| !parent.as_os_str().is_empty())
938        .unwrap_or_else(|| Path::new("."));
939    let file_name = path
940        .file_name()
941        .context("secret output path must name a file")?;
942    let mut temporary_name = OsString::from(".");
943    temporary_name.push(file_name);
944    temporary_name.push(format!(".{}.tmp", std::process::id()));
945    let temporary_path = parent.join(temporary_name);
946
947    let mut file = std::fs::OpenOptions::new()
948        .write(true)
949        .create_new(true)
950        .mode(mode)
951        .open(&temporary_path)?;
952    let result = (|| -> Result<()> {
953        file.set_permissions(std::fs::Permissions::from_mode(mode))?;
954        file.write_all(value)?;
955        file.sync_all()?;
956        drop(file);
957        std::fs::rename(&temporary_path, path)?;
958        std::fs::File::open(parent)?.sync_all()?;
959        Ok(())
960    })();
961    if result.is_err() {
962        let _ = std::fs::remove_file(&temporary_path);
963    }
964    result
965}
966
967fn issue_nats_creds(
968    jwt: Option<String>,
969    jwt_file: Option<&Path>,
970    seed: Option<String>,
971    seed_file: Option<&Path>,
972    out_file: &Path,
973    mode: SecretFileModeArg,
974) -> Result<()> {
975    let jwt = read_exactly_one_secret_arg(jwt, jwt_file, "jwt")?;
976    let seed = read_exactly_one_secret_arg(seed, seed_file, "seed")?;
977    let creds =
978        basil_nats::format_user_creds(&jwt, &seed).context("formatting NATS credentials")?;
979    write_secret_file_with_mode(out_file, creds.as_bytes(), mode.mode())
980        .with_context(|| format!("writing NATS credentials to {}", out_file.display()))?;
981    Ok(())
982}
983
984fn read_exactly_one_secret_arg(
985    inline: Option<String>,
986    file: Option<&Path>,
987    field: &'static str,
988) -> Result<String> {
989    match (inline, file) {
990        (Some(value), None) => Ok(value),
991        (None, Some(path)) => std::fs::read_to_string(path)
992            .with_context(|| format!("reading {field} file {}", path.display())),
993        (None, None) => bail!("supply --{field} or --{field}-file"),
994        (Some(_), Some(_)) => bail!("supply only one of --{field} or --{field}-file"),
995    }
996}
997
998async fn set(client: &mut Client, key_id: &str, hex: bool, value: String) -> Result<()> {
999    let value = if hex {
1000        decode_hex(&value, "value")?
1001    } else {
1002        value.into_bytes()
1003    };
1004    let version = client.set_secret(key_id, &value).await?;
1005    println!("version: {version}");
1006    Ok(())
1007}
1008
1009async fn rotate(client: &mut Client, key_id: &str) -> Result<()> {
1010    let version = client.rotate_secret(key_id).await?;
1011    println!("version: {version}");
1012    Ok(())
1013}
1014
1015async fn list(client: &mut Client, prefix: Option<&str>) -> Result<()> {
1016    for key in client.list_catalog(prefix).await? {
1017        println!(
1018            "{}\t{}\t{}\t{}",
1019            key.name,
1020            key.kind,
1021            key.key_type.unwrap_or_default(),
1022            key.latest_version
1023        );
1024    }
1025    Ok(())
1026}
1027
1028async fn mint_jwt(
1029    client: &mut Client,
1030    key_id: &str,
1031    sub: &str,
1032    ttl_secs: Option<u64>,
1033    claims_json: &str,
1034) -> Result<()> {
1035    let claims: serde_json::Value =
1036        serde_json::from_str(claims_json).context("claims-json is not valid JSON")?;
1037    let jwt = client.mint_jwt(key_id, sub, ttl_secs, claims).await?;
1038    println!("{}", jwt.token);
1039    if let Some(expires_at) = jwt.expires_at {
1040        println!("expires_at: {expires_at}");
1041    }
1042    Ok(())
1043}
1044
1045async fn mint_nats_user(
1046    client: &mut Client,
1047    key_id: &str,
1048    user_nkey: &str,
1049    issuer_account: Option<&str>,
1050    name: &str,
1051    ttl_secs: Option<u64>,
1052    permissions: NatsUserPermissions,
1053) -> Result<()> {
1054    let token = client
1055        .mint_nats_user(
1056            key_id,
1057            user_nkey,
1058            issuer_account,
1059            name,
1060            ttl_secs,
1061            permissions,
1062        )
1063        .await?;
1064    println!("{token}");
1065    Ok(())
1066}
1067
1068#[allow(clippy::too_many_arguments)]
1069async fn sign_nats_jwt(
1070    client: &mut Client,
1071    key_id: &str,
1072    claims_json: Option<&str>,
1073    claims_file: Option<&Path>,
1074    expected_type: Option<NatsJwtType>,
1075    ttl_secs: Option<u64>,
1076    expires_at: Option<u64>,
1077    issued_at: Option<u64>,
1078    rewrite_jti: bool,
1079) -> Result<()> {
1080    let claims_text = match (claims_json, claims_file) {
1081        (Some(value), None) => value.to_string(),
1082        (None, Some(path)) => std::fs::read_to_string(path)
1083            .with_context(|| format!("reading claims file `{}`", path.display()))?,
1084        (None, None) | (Some(_), Some(_)) => {
1085            bail!("supply exactly one of --claims-json or --claims-file")
1086        }
1087    };
1088    let _: serde_json::Value =
1089        serde_json::from_str(&claims_text).context("NATS JWT claims are not valid JSON")?;
1090    let jwt = client
1091        .sign_nats_jwt_json(
1092            key_id,
1093            claims_text.into_bytes(),
1094            SignNatsJwtOptions {
1095                expected_type,
1096                ttl_secs,
1097                expires_at,
1098                issued_at,
1099                rewrite_jti,
1100            },
1101        )
1102        .await?;
1103    println!("{}", jwt.token);
1104    if let Some(expires_at) = jwt.expires_at {
1105        println!("expires_at: {expires_at}");
1106    }
1107    Ok(())
1108}
1109
1110async fn issue_cert(
1111    client: &mut Client,
1112    key_id: &str,
1113    common_name: &str,
1114    dns_sans: &[String],
1115    ip_sans: &[String],
1116    ttl_secs: u64,
1117) -> Result<()> {
1118    let issued = client
1119        .issue_certificate(key_id, common_name, dns_sans, ip_sans, ttl_secs)
1120        .await?;
1121    // Leaf + any intermediate issuers (DER), re-framed as CERTIFICATE PEM. The
1122    // first block is the leaf; the broker hands back its DER already decoded.
1123    print!("{}", pem_blocks("CERTIFICATE", &issued.cert_chain_der));
1124    // The issuing-CA / trust-bundle chain (DER), labelled distinctly so the e2e
1125    // can tell the leaf chain from the CA chain. On a single internal root with
1126    // no intermediates this is the root cert; OpenBao and Vault both populate it
1127    // from the issue response's `issuing_ca` field.
1128    print!("{}", pem_blocks("CERTIFICATE", &issued.ca_chain_der));
1129    // The PKCS#8 leaf private key (DER): released to the caller for a TLS server.
1130    print!("{}", pem_block("PRIVATE KEY", &issued.private_key_der));
1131    Ok(())
1132}
1133
1134/// Frame a sequence of DER blobs as concatenated PEM blocks under `label`.
1135fn pem_blocks(label: &str, ders: &[Vec<u8>]) -> String {
1136    ders.iter().map(|der| pem_block(label, der)).collect()
1137}
1138
1139/// Frame a single DER blob as one PEM block (`-----BEGIN <label>-----` … with
1140/// the base64 body wrapped at 64 columns, per RFC 7468).
1141fn pem_block(label: &str, der: &[u8]) -> String {
1142    use base64::Engine as _;
1143    use std::fmt::Write as _;
1144    let body = base64::engine::general_purpose::STANDARD.encode(der);
1145    let mut out = String::new();
1146    // `write!` to a String is infallible; ignore the formatter Result.
1147    let _ = writeln!(out, "-----BEGIN {label}-----");
1148    for chunk in body.as_bytes().chunks(64) {
1149        // chunk is ASCII base64; from_utf8 cannot fail here.
1150        out.push_str(std::str::from_utf8(chunk).unwrap_or_default());
1151        out.push('\n');
1152    }
1153    let _ = writeln!(out, "-----END {label}-----");
1154    out
1155}
1156
1157async fn status(client: &mut Client) -> Result<()> {
1158    let status = client.status().await?;
1159    println!("backend: {}", status.backend);
1160    println!("version: {}", status.version);
1161    println!("protocol: {}", status.protocol);
1162    Ok(())
1163}
1164
1165/// Liveness probe. A returned health response means the agent is alive; we exit
1166/// 0. A connect/RPC failure propagates as an `Err` (the `?`), which `main`
1167/// reports and turns into a nonzero exit, so a dead agent reads as not-alive to
1168/// the caller's probe.
1169async fn health(client: &mut Client, json: bool) -> Result<()> {
1170    let health = client.health().await?;
1171    if json {
1172        // A stable, single-line object for `systemd`/container probes to parse.
1173        println!("{}", health_json(&health));
1174    } else {
1175        println!("alive: {}", health.alive);
1176        println!("version: {}", health.version);
1177    }
1178    Ok(())
1179}
1180
1181/// The stable one-line `--json` object for `basil health` (the scriptable
1182/// liveness contract: `systemd`/container/k8s probes parse this). Pure: lifted
1183/// out of [`health`] so the field set is unit-testable without a live broker.
1184fn health_json(health: &basil::AgentHealth) -> serde_json::Value {
1185    serde_json::json!({ "alive": health.alive, "version": health.version })
1186}
1187
1188/// Readiness probe. Prints the non-secret summary and maps readiness to the
1189/// process exit code: 0 when ready, 1 when not ready (a connect/RPC failure is a
1190/// separate nonzero exit via `main`). The JSON shape is stable for automation
1191/// (systemd `ExecStartPost`, container `HEALTHCHECK`, k8s `exec` readiness).
1192async fn ready(client: &mut Client, json: bool) -> Result<()> {
1193    let r = client.readiness().await?;
1194    if json {
1195        println!("{}", ready_json(&r));
1196    } else {
1197        println!("ready: {}", r.ready);
1198        println!("reason: {}", r.reason);
1199        println!("generation: {}", r.generation);
1200        println!("keys_total: {}", r.keys_total);
1201        println!("keys_present: {}", r.keys_present);
1202        println!("keys_required_missing: {}", r.keys_required_missing);
1203        println!("keys_optional_missing: {}", r.keys_optional_missing);
1204    }
1205    if ready_exit_code(&r) != 0 {
1206        // Exit 1 = "not ready" so a probe can gate on the code, distinct from a
1207        // connect/RPC failure (which `main` surfaces as its own nonzero exit).
1208        std::process::exit(1);
1209    }
1210    Ok(())
1211}
1212
1213/// The stable one-line `--json` object for `basil ready` (the scriptable
1214/// readiness contract). Pure: lifted out of [`ready`] so the field set + the
1215/// coarse `reason` token are unit-testable without a live broker.
1216fn ready_json(r: &basil::AgentReadiness) -> serde_json::Value {
1217    serde_json::json!({
1218        "ready": r.ready,
1219        "reason": r.reason.to_string(),
1220        "generation": r.generation,
1221        "keys_total": r.keys_total,
1222        "keys_present": r.keys_present,
1223        "keys_required_missing": r.keys_required_missing,
1224        "keys_optional_missing": r.keys_optional_missing,
1225    })
1226}
1227
1228/// Map a readiness outcome to the process exit code orchestrators gate on:
1229/// `0` when ready, `1` when not ready (a connect/RPC failure is a separate
1230/// nonzero exit `main` surfaces). Pure, so the not-ready→nonzero contract is
1231/// unit-testable without a live server.
1232const fn ready_exit_code(r: &basil::AgentReadiness) -> i32 {
1233    if r.ready { 0 } else { 1 }
1234}
1235
1236/// Hot-reload the agent's catalog/policy generation from disk (`basil-atq`).
1237///
1238/// The agent re-reads config from its on-disk paths only; this call sends no
1239/// config (the trust boundary is enforced by construction). Prints the old->new
1240/// generation id + counts. A permission-denied surfaces as an `Err` (the `?`),
1241/// which `main` turns into a nonzero exit. A *rejection* (validation/routing,
1242/// the previous generation keeps serving) exits 1, distinct from a connect/RPC
1243/// failure, so automation can gate on the code. `--check` validates without
1244/// swapping.
1245async fn reload(client: &mut Client, check: bool, json: bool) -> Result<()> {
1246    let r = client.reload(check).await?;
1247    if json {
1248        println!("{}", reload_json(&r));
1249    } else if let Some(rej) = &r.rejection {
1250        println!("reload rejected: {} ({})", rej.reason, rej.message);
1251        println!(
1252            "previous_generation: {} (still serving)",
1253            r.previous_generation
1254        );
1255    } else if check {
1256        println!("checked: ok (no swap)");
1257        println!("previous_generation: {}", r.previous_generation);
1258        println!("would_be_generation: {}", r.new_generation);
1259        println!("key_count: {}", r.key_count);
1260        println!("grant_count: {}", r.grant_count);
1261    } else {
1262        println!("applied: {}", r.applied);
1263        println!("previous_generation: {}", r.previous_generation);
1264        println!("new_generation: {}", r.new_generation);
1265        println!("key_count: {}", r.key_count);
1266        println!("grant_count: {}", r.grant_count);
1267    }
1268    if reload_exit_code(&r) != 0 {
1269        // Exit 1 = "rejected" (the candidate did not validate / changed a
1270        // restart-only dimension). Distinct from a permission-denied or
1271        // connect/RPC failure, which `main` reports as its own nonzero exit.
1272        std::process::exit(1);
1273    }
1274    Ok(())
1275}
1276
1277/// The stable one-line `--json` object for `basil reload [--check]` (the
1278/// scriptable reload contract). Pure: lifted out of [`reload`] so the field set,
1279/// including the nested `rejection` object (present only on a rejected
1280/// candidate), is unit-testable without a live broker.
1281fn reload_json(r: &basil::AgentReload) -> serde_json::Value {
1282    serde_json::json!({
1283        "applied": r.applied,
1284        "checked": r.checked,
1285        "previous_generation": r.previous_generation,
1286        "new_generation": r.new_generation,
1287        "key_count": r.key_count,
1288        "grant_count": r.grant_count,
1289        "rejection": r.rejection.as_ref().map(|rej| serde_json::json!({
1290            "reason": rej.reason,
1291            "message": rej.message,
1292        })),
1293    })
1294}
1295
1296/// Map a reload outcome to the process exit code automation gates on: `0` when
1297/// the candidate was accepted (applied, or a dry-run that validated), `1` on a
1298/// rejection (the previous generation keeps serving). A permission-denied /
1299/// connect failure never reaches here. It surfaces as its own nonzero exit via
1300/// `main`. Pure, so the reject→nonzero mapping is unit-testable without a live
1301/// server (`basil-a84j`).
1302const fn reload_exit_code(r: &basil::AgentReload) -> i32 {
1303    if r.succeeded() { 0 } else { 1 }
1304}
1305
1306/// Live policy explanation against the running broker's serving generation.
1307async fn explain(
1308    client: &mut Client,
1309    subject: &str,
1310    op: &str,
1311    key: &str,
1312    json: bool,
1313) -> Result<()> {
1314    let explanation = client.explain(subject, op, key).await?;
1315    if json {
1316        println!("{}", explain_json(&explanation));
1317        return Ok(());
1318    }
1319
1320    println!(
1321        "{} {} {} for subject {}",
1322        explanation.decision.to_uppercase(),
1323        explanation.op,
1324        explanation.key,
1325        explanation.subject
1326    );
1327    if explanation.decision == "allow" {
1328        println!("via: {}", explanation.via);
1329        if let Some(rule) = &explanation.matched_rule {
1330            println!("matched_rule: {}", rule.rule);
1331            println!("action: {}", rule.action);
1332            println!("target: {}", rule.target);
1333        }
1334    } else {
1335        println!("reason: {}", explanation.reason);
1336    }
1337    Ok(())
1338}
1339
1340/// Stable JSON shape for `basil explain --json`, mirroring
1341/// `basil config explain --json` for the single-tuple path.
1342fn explain_json(explanation: &basil::AgentExplanation) -> serde_json::Value {
1343    let mut obj = serde_json::Map::new();
1344    obj.insert("subject".into(), explanation.subject.clone().into());
1345    obj.insert("op".into(), explanation.op.clone().into());
1346    obj.insert("key".into(), explanation.key.clone().into());
1347    obj.insert("decision".into(), explanation.decision.clone().into());
1348    if explanation.decision == "allow" {
1349        obj.insert("via".into(), explanation.via.clone().into());
1350        let matched = explanation
1351            .matched_rule
1352            .as_ref()
1353            .map_or(serde_json::Value::Null, |m| {
1354                serde_json::json!({
1355                    "rule": m.rule,
1356                    "via": m.via,
1357                    "action": m.action,
1358                    "target": m.target,
1359                })
1360            });
1361        obj.insert("matched_rule".into(), matched);
1362    } else {
1363        obj.insert("reason".into(), explanation.reason.clone().into());
1364    }
1365    serde_json::Value::Object(obj)
1366}
1367
1368/// Live JWT-SVID revocation against the running broker's deny-list.
1369async fn revoke(
1370    client: &mut Client,
1371    trust_domain: &str,
1372    jti: &str,
1373    expires_at_unix: u64,
1374    json: bool,
1375) -> Result<()> {
1376    let revocation = client.revoke(trust_domain, jti, expires_at_unix).await?;
1377    if json {
1378        println!("{}", revoke_json(&revocation));
1379        return Ok(());
1380    }
1381    println!("revoked: {}", revocation.jti);
1382    println!("trust_domain: {}", revocation.trust_domain);
1383    println!("expires_at_unix: {}", revocation.expires_at_unix);
1384    println!("persisted: {}", revocation.persisted);
1385    Ok(())
1386}
1387
1388/// Stable JSON shape for `basil revoke --json`.
1389fn revoke_json(revocation: &basil::AgentRevocation) -> serde_json::Value {
1390    serde_json::json!({
1391        "trust_domain": revocation.trust_domain,
1392        "jti": revocation.jti,
1393        "expires_at_unix": revocation.expires_at_unix,
1394        "persisted": revocation.persisted,
1395    })
1396}
1397
1398fn optional_hex(value: Option<String>, field: &'static str) -> Result<Option<Vec<u8>>> {
1399    value.map(|value| decode_hex(&value, field)).transpose()
1400}
1401
1402fn decode_hex(value: &str, field: &'static str) -> Result<Vec<u8>> {
1403    let trimmed = value.trim();
1404    if trimmed.is_empty() {
1405        bail!("{field} hex must not be empty");
1406    }
1407    hex::decode(trimmed).with_context(|| format!("{field} is not valid hex"))
1408}
1409
1410const fn aead_name(value: AeadAlgorithm) -> &'static str {
1411    match value {
1412        AeadAlgorithm::Aes256Gcm => "aes256-gcm",
1413        AeadAlgorithm::Chacha20Poly1305 => "chacha20-poly1305",
1414    }
1415}
1416
1417#[cfg(test)]
1418mod tests {
1419    use super::{
1420        Command as ClientCommand, KeyMaterial, ManifestEntry, check_import_set, explain_json,
1421        health_json, key_material, ready_exit_code, ready_json, reload_exit_code, reload_json,
1422        revoke_json,
1423    };
1424    use crate::Cli;
1425    use basil::{
1426        AgentExplanation, AgentHealth, AgentReadiness, AgentReload, AgentRevocation, MatchedRule,
1427        ReadinessReason, ReloadRejection,
1428    };
1429    use clap::Parser;
1430    use std::path::Path;
1431
1432    #[test]
1433    #[allow(clippy::too_many_lines)]
1434    fn parses_grpc_command_surface() {
1435        Cli::parse_from(["basil", "status"]);
1436        Cli::parse_from(["basil", "health"]);
1437        Cli::parse_from(["basil", "health", "--json"]);
1438        Cli::parse_from(["basil", "ready"]);
1439        Cli::parse_from(["basil", "ready", "--json"]);
1440        Cli::parse_from(["basil", "reload"]);
1441        Cli::parse_from(["basil", "reload", "--check"]);
1442        Cli::parse_from(["basil", "reload", "--check", "--json"]);
1443        Cli::parse_from([
1444            "basil",
1445            "new-key",
1446            "--key-id",
1447            "asym.rsa",
1448            "--key-type",
1449            "rsa-2048",
1450        ]);
1451        Cli::parse_from([
1452            "basil",
1453            "revoke",
1454            "--trust-domain",
1455            "example.test",
1456            "--jti",
1457            "token-1",
1458            "--expires-at-unix",
1459            "2000000000",
1460            "--json",
1461        ]);
1462        Cli::parse_from([
1463            "basil",
1464            "explain",
1465            "--subject",
1466            "svc.orders",
1467            "--op",
1468            "sign",
1469            "--key",
1470            "app.signing",
1471            "--json",
1472        ]);
1473        Cli::parse_from([
1474            "basil",
1475            "import",
1476            "--key-id",
1477            "byok.signer",
1478            "--seed-hex",
1479            &"00".repeat(32),
1480            "--check",
1481        ]);
1482        Cli::parse_from([
1483            "basil",
1484            "import",
1485            "--key-id",
1486            "byok.signer",
1487            "--key-type",
1488            "ed25519",
1489            "--pkcs8-file",
1490            "/tmp/key.der",
1491        ]);
1492        Cli::parse_from([
1493            "basil",
1494            "import-set",
1495            "--file",
1496            "/tmp/manifest.json",
1497            "--check",
1498        ]);
1499        Cli::parse_from(["basil", "get", "--key-id", "app.secret"]);
1500        Cli::parse_from(["basil", "get", "--key-id", "app.secret", "--raw"]);
1501        Cli::parse_from([
1502            "basil",
1503            "get",
1504            "--key-id",
1505            "app.secret",
1506            "--format",
1507            "base64",
1508        ]);
1509        Cli::parse_from([
1510            "basil",
1511            "get",
1512            "--key-id",
1513            "app.secret",
1514            "--format",
1515            "base64-url-no-pad",
1516        ]);
1517        Cli::parse_from([
1518            "basil",
1519            "get",
1520            "--key-id",
1521            "app.secret",
1522            "--format",
1523            "hex",
1524            "--out-file",
1525            "/run/secrets/app.hex",
1526        ]);
1527        Cli::parse_from([
1528            "basil",
1529            "get",
1530            "--key-id",
1531            "app.secret",
1532            "--out-file",
1533            "/run/secrets/app",
1534        ]);
1535        Cli::parse_from([
1536            "basil",
1537            "issue-nats-creds",
1538            "--jwt-file",
1539            "/run/secrets/user.jwt",
1540            "--seed-file",
1541            "/run/secrets/user.seed",
1542            "--out-file",
1543            "/run/secrets/user.creds",
1544            "--mode",
1545            "0660",
1546        ]);
1547        Cli::parse_from(["basil", "set", "--key-id", "app.secret", "hunter2"]);
1548        Cli::parse_from(["basil", "rotate", "--key-id", "app.secret"]);
1549        Cli::parse_from(["basil", "list", "--prefix", "app."]);
1550        Cli::parse_from([
1551            "basil",
1552            "mint-jwt",
1553            "--key-id",
1554            "jwt.issuer",
1555            "--sub",
1556            "subject",
1557        ]);
1558        Cli::parse_from([
1559            "basil",
1560            "mint-nats-user",
1561            "--key-id",
1562            "nats.account",
1563            "--user-nkey",
1564            "UAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
1565            "--pub-allow",
1566            "orders.>",
1567            "--sub-deny",
1568            "_INBOX.>",
1569        ]);
1570        Cli::parse_from([
1571            "basil",
1572            "sign-nats-jwt",
1573            "--key-id",
1574            "nats.account",
1575            "--claims-json",
1576            r#"{"sub":"UAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","name":"svc","nats":{"type":"user","version":2}}"#,
1577            "--expect-type",
1578            "user",
1579            "--ttl-secs",
1580            "3600",
1581            "--rewrite-jti",
1582        ]);
1583        Cli::parse_from([
1584            "basil",
1585            "issue-cert",
1586            "--key-id",
1587            "spire.x509",
1588            "--common-name",
1589            "svc.example.org",
1590            "--dns-san",
1591            "svc.example.org",
1592            "--ip-san",
1593            "127.0.0.1",
1594            "--ttl-secs",
1595            "3600",
1596        ]);
1597        Cli::parse_from([
1598            "basil",
1599            "encrypt",
1600            "--key-id",
1601            "app.aead",
1602            "--algorithm",
1603            "aes256-gcm",
1604            "secret",
1605        ]);
1606        Cli::parse_from([
1607            "basil",
1608            "decrypt",
1609            "--key-id",
1610            "app.aead",
1611            "--algorithm",
1612            "aes256-gcm",
1613            "--version",
1614            "1",
1615            "--nonce",
1616            "000000000000000000000000",
1617            "--ciphertext",
1618            "00",
1619        ]);
1620    }
1621
1622    #[test]
1623    fn explain_parses_subject_op_key_and_json() {
1624        let cli = Cli::parse_from([
1625            "basil",
1626            "explain",
1627            "--subject",
1628            "svc.orders",
1629            "--op",
1630            "sign",
1631            "--key",
1632            "app.signing",
1633            "--json",
1634        ]);
1635        match cli.command {
1636            crate::Command::Client(ClientCommand::Explain {
1637                subject,
1638                op,
1639                key,
1640                json,
1641            }) => {
1642                assert_eq!(subject, "svc.orders");
1643                assert_eq!(op, "sign");
1644                assert_eq!(key, "app.signing");
1645                assert!(json, "--json flag parsed");
1646            }
1647            other => panic!("unexpected command: {other:?}"),
1648        }
1649
1650        // `--json` defaults off when omitted.
1651        let cli = Cli::parse_from([
1652            "basil",
1653            "explain",
1654            "--subject",
1655            "s",
1656            "--op",
1657            "get",
1658            "--key",
1659            "k",
1660        ]);
1661        match cli.command {
1662            crate::Command::Client(ClientCommand::Explain { json, .. }) => {
1663                assert!(!json, "--json defaults off");
1664            }
1665            other => panic!("unexpected command: {other:?}"),
1666        }
1667    }
1668
1669    #[test]
1670    fn explain_json_allow_carries_matched_rule_provenance() {
1671        let explanation = AgentExplanation {
1672            subject: "svc.orders".into(),
1673            op: "sign".into(),
1674            key: "app.signing".into(),
1675            decision: "allow".into(),
1676            via: "user".into(),
1677            reason: String::new(),
1678            matched_rule: Some(MatchedRule {
1679                rule: "rule-7".into(),
1680                via: "user".into(),
1681                action: "sign".into(),
1682                target: "app.*".into(),
1683                subject: "svc.orders".into(),
1684            }),
1685        };
1686        let json = explain_json(&explanation);
1687        assert_eq!(json["decision"], "allow");
1688        assert_eq!(json["subject"], "svc.orders");
1689        assert_eq!(json["op"], "sign");
1690        assert_eq!(json["key"], "app.signing");
1691        assert_eq!(json["via"], "user");
1692        assert_eq!(json["matched_rule"]["rule"], "rule-7");
1693        assert_eq!(json["matched_rule"]["via"], "user");
1694        assert_eq!(json["matched_rule"]["action"], "sign");
1695        assert_eq!(json["matched_rule"]["target"], "app.*");
1696        // An allow object never carries a deny `reason` field.
1697        assert!(json.get("reason").is_none(), "allow omits reason");
1698    }
1699
1700    #[test]
1701    fn explain_json_deny_carries_reason_not_rule() {
1702        let explanation = AgentExplanation {
1703            subject: "svc.orders".into(),
1704            op: "sign".into(),
1705            key: "app.signing".into(),
1706            decision: "deny".into(),
1707            via: String::new(),
1708            reason: "no_matching_rule".into(),
1709            matched_rule: None,
1710        };
1711        let json = explain_json(&explanation);
1712        assert_eq!(json["decision"], "deny");
1713        assert_eq!(json["subject"], "svc.orders");
1714        assert_eq!(json["reason"], "no_matching_rule");
1715        // A deny object carries neither the allow `via` scope nor a matched rule.
1716        assert!(json.get("via").is_none(), "deny omits via");
1717        assert!(
1718            json.get("matched_rule").is_none(),
1719            "deny omits matched_rule"
1720        );
1721    }
1722
1723    #[test]
1724    fn new_key_parses_default_and_explicit_key_type() {
1725        let cli = Cli::parse_from(["basil", "new-key", "--key-id", "asym.default"]);
1726        match cli.command {
1727            crate::Command::Client(ClientCommand::NewKey { key_id, key_type }) => {
1728                assert_eq!(key_id, "asym.default");
1729                assert_eq!(key_type, super::KeyTypeArg::Ed25519);
1730            }
1731            other => panic!("unexpected command: {other:?}"),
1732        }
1733
1734        let cli = Cli::parse_from([
1735            "basil",
1736            "new-key",
1737            "--key-id",
1738            "asym.nkey",
1739            "--key-type",
1740            "ed25519-nkey",
1741        ]);
1742        match cli.command {
1743            crate::Command::Client(ClientCommand::NewKey { key_id, key_type }) => {
1744                assert_eq!(key_id, "asym.nkey");
1745                assert_eq!(key_type, super::KeyTypeArg::Ed25519Nkey);
1746            }
1747            other => panic!("unexpected command: {other:?}"),
1748        }
1749    }
1750
1751    #[test]
1752    fn import_check_flags_parse() {
1753        let cli = Cli::parse_from([
1754            "basil",
1755            "import",
1756            "--key-id",
1757            "byok.signer",
1758            "--seed-hex",
1759            &"00".repeat(32),
1760            "--check",
1761        ]);
1762        match cli.command {
1763            crate::Command::Client(ClientCommand::Import { check, .. }) => {
1764                assert!(check, "--check flag parsed for import");
1765            }
1766            other => panic!("unexpected command: {other:?}"),
1767        }
1768
1769        let cli = Cli::parse_from([
1770            "basil",
1771            "import-set",
1772            "--file",
1773            "/tmp/manifest.json",
1774            "--check",
1775        ]);
1776        match cli.command {
1777            crate::Command::Client(ClientCommand::ImportSet { check, .. }) => {
1778                assert!(check, "--check flag parsed for import-set");
1779            }
1780            other => panic!("unexpected command: {other:?}"),
1781        }
1782    }
1783
1784    #[test]
1785    fn get_format_parses_as_materialization_override() {
1786        let cli = Cli::parse_from([
1787            "basil",
1788            "get",
1789            "--key-id",
1790            "netbird.datastore",
1791            "--format",
1792            "base64",
1793            "--out-file",
1794            "/run/secrets/netbird-datastore-key",
1795        ]);
1796        match cli.command {
1797            crate::Command::Client(ClientCommand::Get {
1798                key_id,
1799                format,
1800                out_file,
1801                ..
1802            }) => {
1803                assert_eq!(key_id, "netbird.datastore");
1804                assert_eq!(format, Some(super::GetOutputFormatArg::Base64));
1805                assert_eq!(
1806                    out_file.as_deref(),
1807                    Some(Path::new("/run/secrets/netbird-datastore-key"))
1808                );
1809            }
1810            other => panic!("unexpected command: {other:?}"),
1811        }
1812    }
1813
1814    #[test]
1815    fn issue_nats_creds_parses_local_file_inputs() {
1816        let cli = Cli::parse_from([
1817            "basil",
1818            "issue-nats-creds",
1819            "--jwt-file",
1820            "/run/secrets/user.jwt",
1821            "--seed-file",
1822            "/run/secrets/user.seed",
1823            "--out-file",
1824            "/run/secrets/user.creds",
1825            "--mode",
1826            "0660",
1827        ]);
1828        match cli.command {
1829            crate::Command::Client(ClientCommand::IssueNatsCreds {
1830                jwt_file,
1831                seed_file,
1832                out_file,
1833                mode,
1834                ..
1835            }) => {
1836                assert_eq!(
1837                    jwt_file.as_deref(),
1838                    Some(Path::new("/run/secrets/user.jwt"))
1839                );
1840                assert_eq!(
1841                    seed_file.as_deref(),
1842                    Some(Path::new("/run/secrets/user.seed"))
1843                );
1844                assert_eq!(out_file, Path::new("/run/secrets/user.creds"));
1845                assert_eq!(mode.mode(), 0o660);
1846            }
1847            other => panic!("unexpected command: {other:?}"),
1848        }
1849    }
1850
1851    #[test]
1852    fn secret_encoding_formats_match_external_consumers() {
1853        let value = [251, 255, 0, 1];
1854        assert_eq!(
1855            super::encode_secret(&value, super::GetOutputFormatArg::Raw),
1856            value
1857        );
1858        assert_eq!(
1859            super::encode_secret(&value, super::GetOutputFormatArg::Hex),
1860            b"fbff0001"
1861        );
1862        assert_eq!(
1863            super::encode_secret(&value, super::GetOutputFormatArg::Base64),
1864            b"+/8AAQ=="
1865        );
1866        assert_eq!(
1867            super::encode_secret(&value, super::GetOutputFormatArg::Base64UrlNoPad),
1868            b"-_8AAQ"
1869        );
1870    }
1871
1872    #[test]
1873    fn pem_block_frames_der_at_64_columns() {
1874        // 48 bytes of DER -> 64 base64 chars on a single body line.
1875        let der = vec![0xABu8; 48];
1876        let pem = super::pem_block("CERTIFICATE", &der);
1877        assert!(pem.starts_with("-----BEGIN CERTIFICATE-----\n"));
1878        assert!(pem.ends_with("-----END CERTIFICATE-----\n"));
1879        let body: Vec<&str> = pem.lines().filter(|l| !l.starts_with("-----")).collect();
1880        assert_eq!(body.len(), 1);
1881        assert_eq!(body.first().map(|l| l.len()), Some(64));
1882    }
1883
1884    #[test]
1885    fn pem_blocks_emits_one_block_per_der() {
1886        let ders = vec![vec![1u8; 10], vec![2u8; 10]];
1887        let pem = super::pem_blocks("CERTIFICATE", &ders);
1888        assert_eq!(pem.matches("-----BEGIN CERTIFICATE-----").count(), 2);
1889        assert_eq!(pem.matches("-----END CERTIFICATE-----").count(), 2);
1890    }
1891
1892    #[test]
1893    fn seed_hex_decodes_to_a_32_byte_ed25519_seed() {
1894        let seed = "01".repeat(32);
1895        let material = key_material(Some(&seed), None).expect("32-byte seed must decode");
1896        assert_eq!(material, KeyMaterial::Ed25519Seed(vec![1u8; 32]));
1897    }
1898
1899    #[test]
1900    fn seed_hex_of_wrong_length_is_rejected() {
1901        // 31 bytes: one short of an Ed25519 seed.
1902        let short = "01".repeat(31);
1903        assert!(key_material(Some(&short), None).is_err());
1904    }
1905
1906    #[test]
1907    fn material_requires_exactly_one_source() {
1908        // Neither source.
1909        assert!(key_material(None, None).is_err());
1910        // Both sources at once.
1911        let seed = "01".repeat(32);
1912        assert!(key_material(Some(&seed), Some(Path::new("/tmp/key.der"))).is_err());
1913    }
1914
1915    #[test]
1916    fn manifest_entry_parses_seed_hex_and_defaults_key_type() {
1917        let json = r#"{"key_id": "byok.a", "seed_hex": "00ff00ff"}"#;
1918        let entry: ManifestEntry = serde_json::from_str(json).expect("manifest entry must parse");
1919        assert_eq!(entry.key_id, "byok.a");
1920        assert_eq!(entry.key_type, super::KeyTypeArg::Ed25519);
1921        assert_eq!(entry.seed_hex.as_deref(), Some("00ff00ff"));
1922        assert!(entry.pkcs8_file.is_none());
1923    }
1924
1925    #[test]
1926    fn manifest_entry_parses_explicit_key_type() {
1927        let json = r#"{"key_id": "byok.b", "key_type": "rsa-2048", "pkcs8_file": "/tmp/b.der"}"#;
1928        let entry: ManifestEntry = serde_json::from_str(json).expect("manifest entry must parse");
1929        assert_eq!(entry.key_type, super::KeyTypeArg::Rsa2048);
1930        assert_eq!(entry.pkcs8_file.as_deref(), Some(Path::new("/tmp/b.der")));
1931
1932        let json =
1933            r#"{"key_id": "byok.ecdsa", "key_type": "ecdsa-p256", "pkcs8_file": "/tmp/e.der"}"#;
1934        let entry: ManifestEntry = serde_json::from_str(json).expect("manifest entry must parse");
1935        assert_eq!(entry.key_type, super::KeyTypeArg::EcdsaP256);
1936        assert_eq!(entry.pkcs8_file.as_deref(), Some(Path::new("/tmp/e.der")));
1937    }
1938
1939    #[test]
1940    fn import_set_check_validates_manifest_and_local_material() {
1941        let base =
1942            std::env::temp_dir().join(format!("basil-import-set-check-{}", std::process::id()));
1943        std::fs::create_dir_all(&base).expect("test temp directory must be created");
1944        let der = base.join("key.der");
1945        std::fs::write(&der, [0x30, 0x03, 0x02, 0x01, 0x00]).expect("DER fixture must be written");
1946        let manifest = base.join("manifest.json");
1947        std::fs::write(
1948            &manifest,
1949            format!(
1950                r#"[
1951                    {{"key_id":"byok.seed","seed_hex":"{}"}},
1952                    {{"key_id":"byok.pkcs8","key_type":"rsa-2048","pkcs8_file":"{}"}}
1953                ]"#,
1954                "11".repeat(32),
1955                der.display()
1956            ),
1957        )
1958        .expect("manifest fixture must be written");
1959
1960        check_import_set(&manifest).expect("valid manifest must pass check mode validation");
1961    }
1962
1963    #[test]
1964    fn import_set_check_rejects_empty_pkcs8_file() {
1965        let base = std::env::temp_dir().join(format!(
1966            "basil-import-set-check-empty-{}",
1967            std::process::id()
1968        ));
1969        std::fs::create_dir_all(&base).expect("test temp directory must be created");
1970        let der = base.join("empty.der");
1971        std::fs::write(&der, []).expect("empty DER fixture must be written");
1972        let manifest = base.join("manifest.json");
1973        std::fs::write(
1974            &manifest,
1975            format!(
1976                r#"[{{"key_id":"byok.pkcs8","key_type":"rsa-2048","pkcs8_file":"{}"}}]"#,
1977                der.display()
1978            ),
1979        )
1980        .expect("manifest fixture must be written");
1981
1982        assert!(
1983            check_import_set(&manifest).is_err(),
1984            "empty PKCS#8 files are rejected in check mode"
1985        );
1986    }
1987
1988    #[tokio::test]
1989    async fn import_checks_do_not_connect_to_agent() {
1990        super::run(
1991            Some("/tmp/basil-check-mode-no-agent.sock".to_string()),
1992            ClientCommand::Import {
1993                key_id: "byok.signer".to_string(),
1994                key_type: super::KeyTypeArg::Ed25519,
1995                seed_hex: Some("22".repeat(32)),
1996                pkcs8_file: None,
1997                check: true,
1998            },
1999        )
2000        .await
2001        .expect("import --check must not require a live agent");
2002
2003        let base = std::env::temp_dir().join(format!(
2004            "basil-import-set-no-connect-{}",
2005            std::process::id()
2006        ));
2007        std::fs::create_dir_all(&base).expect("test temp directory must be created");
2008        let manifest = base.join("manifest.json");
2009        std::fs::write(
2010            &manifest,
2011            format!(
2012                r#"[{{"key_id":"byok.seed","seed_hex":"{}"}}]"#,
2013                "33".repeat(32)
2014            ),
2015        )
2016        .expect("manifest fixture must be written");
2017
2018        super::run(
2019            Some("/tmp/basil-check-mode-no-agent.sock".to_string()),
2020            ClientCommand::ImportSet {
2021                file: manifest,
2022                check: true,
2023            },
2024        )
2025        .await
2026        .expect("import-set --check must not require a live agent");
2027    }
2028
2029    // -----------------------------------------------------------------------
2030    // CLI shape coverage: the scriptable `--json` field set + the exit-code
2031    // mapping for `health` / `ready` / `reload` (`basil-mil0.6`, `basil-a84j`).
2032    // These pin the contract orchestrators gate on WITHOUT a live server: the
2033    // live not-ready→nonzero / reject→nonzero legs are the e2e's job.
2034    // -----------------------------------------------------------------------
2035
2036    #[test]
2037    fn health_json_shape_is_alive_and_version() {
2038        let h = AgentHealth {
2039            alive: true,
2040            version: "0.1.0".to_string(),
2041        };
2042        let v = health_json(&h);
2043        // Exactly two keys, both scriptable: a probe parses `alive`.
2044        assert_eq!(v["alive"], serde_json::json!(true));
2045        assert_eq!(v["version"], serde_json::json!("0.1.0"));
2046        let obj = v.as_object().expect("health --json is a JSON object");
2047        assert_eq!(
2048            obj.len(),
2049            2,
2050            "health --json carries exactly alive + version"
2051        );
2052    }
2053
2054    /// Build an [`AgentReadiness`] with a coarse reason + counts for the shape tests.
2055    fn readiness(ready: bool, reason: ReadinessReason, present: u32, total: u32) -> AgentReadiness {
2056        AgentReadiness {
2057            ready,
2058            reason,
2059            generation: 7,
2060            keys_total: total,
2061            keys_present: present,
2062            keys_required_missing: total - present,
2063            keys_optional_missing: 0,
2064        }
2065    }
2066
2067    #[test]
2068    fn ready_json_shape_and_reason_tokens() {
2069        // READY: present == total, the active generation is surfaced.
2070        let r = readiness(true, ReadinessReason::Ready, 4, 4);
2071        let v = ready_json(&r);
2072        assert_eq!(v["ready"], serde_json::json!(true));
2073        assert_eq!(v["reason"], serde_json::json!("ready"));
2074        assert_eq!(v["generation"], serde_json::json!(7));
2075        assert_eq!(v["keys_total"], serde_json::json!(4));
2076        assert_eq!(v["keys_present"], serde_json::json!(4));
2077        assert_eq!(v["keys_required_missing"], serde_json::json!(0));
2078        assert_eq!(v["keys_optional_missing"], serde_json::json!(0));
2079        let obj = v.as_object().expect("ready --json is a JSON object");
2080        assert_eq!(
2081            obj.len(),
2082            7,
2083            "ready --json carries the full 7-field summary"
2084        );
2085
2086        // The coarse reason tokens automation matches on are stable.
2087        assert_eq!(
2088            ready_json(&readiness(false, ReadinessReason::BackendUnreachable, 0, 4))["reason"],
2089            serde_json::json!("backend_unreachable")
2090        );
2091        assert_eq!(
2092            ready_json(&readiness(false, ReadinessReason::RequiredKeyMissing, 3, 4))["reason"],
2093            serde_json::json!("required_key_missing")
2094        );
2095    }
2096
2097    #[test]
2098    fn ready_exit_code_maps_ready_to_zero_and_not_ready_to_one() {
2099        assert_eq!(
2100            ready_exit_code(&readiness(true, ReadinessReason::Ready, 4, 4)),
2101            0,
2102            "a ready broker exits 0"
2103        );
2104        assert_eq!(
2105            ready_exit_code(&readiness(false, ReadinessReason::BackendUnreachable, 0, 4)),
2106            1,
2107            "a backend-unreachable broker exits 1 (not ready)"
2108        );
2109        assert_eq!(
2110            ready_exit_code(&readiness(false, ReadinessReason::RequiredKeyMissing, 3, 4)),
2111            1,
2112            "a required-key-missing broker exits 1 (not ready)"
2113        );
2114    }
2115
2116    /// Build an applied [`AgentReload`] (a real swap) for the shape tests.
2117    fn reload_applied() -> AgentReload {
2118        AgentReload {
2119            applied: true,
2120            checked: false,
2121            previous_generation: 4,
2122            new_generation: 5,
2123            key_count: 12,
2124            grant_count: 9,
2125            rejection: None,
2126        }
2127    }
2128
2129    /// Build a rejected [`AgentReload`] (the previous generation keeps serving).
2130    fn reload_rejected() -> AgentReload {
2131        AgentReload {
2132            applied: false,
2133            checked: false,
2134            previous_generation: 4,
2135            new_generation: 4,
2136            key_count: 12,
2137            grant_count: 9,
2138            rejection: Some(ReloadRejection {
2139                reason: "routing_shape_changed".to_string(),
2140                message: "a key's backend locator changed (restart-only)".to_string(),
2141            }),
2142        }
2143    }
2144
2145    #[test]
2146    fn reload_json_shape_applied_has_null_rejection() {
2147        let v = reload_json(&reload_applied());
2148        assert_eq!(v["applied"], serde_json::json!(true));
2149        assert_eq!(v["checked"], serde_json::json!(false));
2150        assert_eq!(v["previous_generation"], serde_json::json!(4));
2151        assert_eq!(v["new_generation"], serde_json::json!(5));
2152        assert_eq!(v["key_count"], serde_json::json!(12));
2153        assert_eq!(v["grant_count"], serde_json::json!(9));
2154        // An applied reload carries an explicit `null` rejection (the key is
2155        // always present so automation can test for it unconditionally).
2156        assert_eq!(v["rejection"], serde_json::Value::Null);
2157        let obj = v.as_object().expect("reload --json is a JSON object");
2158        assert_eq!(obj.len(), 7, "reload --json carries 7 keys incl. rejection");
2159    }
2160
2161    #[test]
2162    fn reload_json_shape_rejected_nests_reason_and_message() {
2163        let v = reload_json(&reload_rejected());
2164        assert_eq!(v["applied"], serde_json::json!(false));
2165        // The previous generation keeps serving: prev == new on a rejection.
2166        assert_eq!(v["previous_generation"], v["new_generation"]);
2167        let rej = v["rejection"]
2168            .as_object()
2169            .expect("a rejected reload nests a rejection object");
2170        assert_eq!(rej["reason"], serde_json::json!("routing_shape_changed"));
2171        assert_eq!(
2172            rej["message"],
2173            serde_json::json!("a key's backend locator changed (restart-only)")
2174        );
2175    }
2176
2177    #[test]
2178    fn reload_exit_code_maps_accepted_to_zero_and_rejected_to_one() {
2179        assert_eq!(
2180            reload_exit_code(&reload_applied()),
2181            0,
2182            "an applied reload exits 0"
2183        );
2184        assert_eq!(
2185            reload_exit_code(&reload_rejected()),
2186            1,
2187            "a rejected reload exits 1 (never a silent 0)"
2188        );
2189        // A clean dry-run (`--check`) also exits 0: validated, no swap, no rejection.
2190        let mut dry = reload_applied();
2191        dry.applied = false;
2192        dry.checked = true;
2193        assert_eq!(reload_exit_code(&dry), 0, "a clean --check dry-run exits 0");
2194    }
2195
2196    #[test]
2197    fn revoke_json_shape_is_stable() {
2198        let v = revoke_json(&AgentRevocation {
2199            trust_domain: "example.test".into(),
2200            jti: "token-1".into(),
2201            expires_at_unix: 2_000_000_000,
2202            persisted: true,
2203        });
2204        assert_eq!(v["trust_domain"], serde_json::json!("example.test"));
2205        assert_eq!(v["jti"], serde_json::json!("token-1"));
2206        assert_eq!(v["expires_at_unix"], serde_json::json!(2_000_000_000_u64));
2207        assert_eq!(v["persisted"], serde_json::json!(true));
2208        let obj = v.as_object().expect("revoke --json is a JSON object");
2209        assert_eq!(obj.len(), 4, "revoke --json carries exactly four fields");
2210    }
2211}