Skip to main content

basil_bin/
client_cli.rs

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