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 or KEM 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",
1675            "explain",
1676            "--subject",
1677            "s",
1678            "--op",
1679            "get",
1680            "--key",
1681            "k",
1682            "--live",
1683        ]);
1684        match cli.command {
1685            crate::Command::Explain(args) => {
1686                assert!(args.is_live(), "--live selects the running-broker path");
1687            }
1688            other => panic!("unexpected command: {other:?}"),
1689        }
1690    }
1691
1692    #[test]
1693    fn explain_effective_needs_no_op_or_key_offline() {
1694        let cli = Cli::parse_from(["basil", "explain", "--subject", "s", "--effective"]);
1695        match cli.command {
1696            crate::Command::Explain(args) => {
1697                assert!(!args.is_live());
1698                assert_eq!(args.op_token(), None, "--effective makes --op optional");
1699                assert_eq!(args.key(), None, "--effective makes --key optional");
1700            }
1701            other => panic!("unexpected command: {other:?}"),
1702        }
1703    }
1704
1705    #[test]
1706    fn explain_live_conflicts_with_effective() {
1707        let err = Cli::try_parse_from([
1708            "basil",
1709            "explain",
1710            "--subject",
1711            "s",
1712            "--live",
1713            "--effective",
1714        ])
1715        .expect_err("--live has no effective mode; clap must reject the combination");
1716        assert!(
1717            err.to_string().contains("cannot be used with"),
1718            "expected a clap conflict error, got: {err}"
1719        );
1720    }
1721
1722    #[test]
1723    fn config_namespace_is_gone() {
1724        // The whole `basil config` tree was removed; `config explain` folded into
1725        // the top-level `basil explain`.
1726        let err = Cli::try_parse_from(["basil", "config", "explain", "--subject", "s"])
1727            .expect_err("config namespace must be gone");
1728        assert!(
1729            err.to_string().contains("unrecognized subcommand")
1730                || err.to_string().contains("invalid subcommand"),
1731            "{err}"
1732        );
1733        assert!(
1734            Cli::try_parse_from(["basil", "config"]).is_err(),
1735            "bare `basil config` must not parse either"
1736        );
1737    }
1738
1739    #[test]
1740    fn explain_json_allow_carries_matched_rule_provenance() {
1741        let explanation = AgentExplanation {
1742            subject: "svc.orders".into(),
1743            op: "sign".into(),
1744            key: "app.signing".into(),
1745            decision: "allow".into(),
1746            via: "user".into(),
1747            reason: String::new(),
1748            matched_rule: Some(MatchedRule {
1749                rule: "rule-7".into(),
1750                via: "user".into(),
1751                action: "sign".into(),
1752                target: "app.*".into(),
1753                subject: "svc.orders".into(),
1754            }),
1755        };
1756        let json = explain_json(&explanation);
1757        assert_eq!(json["decision"], "allow");
1758        assert_eq!(json["subject"], "svc.orders");
1759        assert_eq!(json["op"], "sign");
1760        assert_eq!(json["key"], "app.signing");
1761        assert_eq!(json["via"], "user");
1762        assert_eq!(json["matched_rule"]["rule"], "rule-7");
1763        assert_eq!(json["matched_rule"]["via"], "user");
1764        assert_eq!(json["matched_rule"]["action"], "sign");
1765        assert_eq!(json["matched_rule"]["target"], "app.*");
1766        // An allow object never carries a deny `reason` field.
1767        assert!(json.get("reason").is_none(), "allow omits reason");
1768    }
1769
1770    #[test]
1771    fn explain_json_deny_carries_reason_not_rule() {
1772        let explanation = AgentExplanation {
1773            subject: "svc.orders".into(),
1774            op: "sign".into(),
1775            key: "app.signing".into(),
1776            decision: "deny".into(),
1777            via: String::new(),
1778            reason: "no_matching_rule".into(),
1779            matched_rule: None,
1780        };
1781        let json = explain_json(&explanation);
1782        assert_eq!(json["decision"], "deny");
1783        assert_eq!(json["subject"], "svc.orders");
1784        assert_eq!(json["reason"], "no_matching_rule");
1785        // A deny object carries neither the allow `via` scope nor a matched rule.
1786        assert!(json.get("via").is_none(), "deny omits via");
1787        assert!(
1788            json.get("matched_rule").is_none(),
1789            "deny omits matched_rule"
1790        );
1791    }
1792
1793    #[test]
1794    fn new_key_parses_default_and_explicit_key_type() {
1795        let cli = Cli::parse_from(["basil", "new-key", "--key-id", "asym.default"]);
1796        match cli.command {
1797            crate::Command::Client(ClientCommand::NewKey { key_id, key_type }) => {
1798                assert_eq!(key_id, "asym.default");
1799                assert_eq!(key_type, super::KeyTypeArg::Ed25519);
1800            }
1801            other => panic!("unexpected command: {other:?}"),
1802        }
1803
1804        let cli = Cli::parse_from([
1805            "basil",
1806            "new-key",
1807            "--key-id",
1808            "asym.nkey",
1809            "--key-type",
1810            "ed25519-nkey",
1811        ]);
1812        match cli.command {
1813            crate::Command::Client(ClientCommand::NewKey { key_id, key_type }) => {
1814                assert_eq!(key_id, "asym.nkey");
1815                assert_eq!(key_type, super::KeyTypeArg::Ed25519Nkey);
1816            }
1817            other => panic!("unexpected command: {other:?}"),
1818        }
1819    }
1820
1821    #[test]
1822    fn import_check_flags_parse() {
1823        let cli = Cli::parse_from([
1824            "basil",
1825            "import",
1826            "--key-id",
1827            "byok.signer",
1828            "--seed-hex",
1829            &"00".repeat(32),
1830            "--check",
1831        ]);
1832        match cli.command {
1833            crate::Command::Client(ClientCommand::Import { check, .. }) => {
1834                assert!(check, "--check flag parsed for import");
1835            }
1836            other => panic!("unexpected command: {other:?}"),
1837        }
1838
1839        let cli = Cli::parse_from([
1840            "basil",
1841            "import-set",
1842            "--file",
1843            "/tmp/manifest.json",
1844            "--check",
1845        ]);
1846        match cli.command {
1847            crate::Command::Client(ClientCommand::ImportSet { check, .. }) => {
1848                assert!(check, "--check flag parsed for import-set");
1849            }
1850            other => panic!("unexpected command: {other:?}"),
1851        }
1852    }
1853
1854    #[test]
1855    fn get_format_parses_as_materialization_override() {
1856        let cli = Cli::parse_from([
1857            "basil",
1858            "get",
1859            "--key-id",
1860            "netbird.datastore",
1861            "--format",
1862            "base64",
1863            "--out-file",
1864            "/run/secrets/netbird-datastore-key",
1865        ]);
1866        match cli.command {
1867            crate::Command::Client(ClientCommand::Get {
1868                key_id,
1869                format,
1870                out_file,
1871                ..
1872            }) => {
1873                assert_eq!(key_id, "netbird.datastore");
1874                assert_eq!(format, Some(super::GetOutputFormatArg::Base64));
1875                assert_eq!(
1876                    out_file.as_deref(),
1877                    Some(Path::new("/run/secrets/netbird-datastore-key"))
1878                );
1879            }
1880            other => panic!("unexpected command: {other:?}"),
1881        }
1882    }
1883
1884    #[test]
1885    fn issue_nats_creds_parses_local_file_inputs() {
1886        let cli = Cli::parse_from([
1887            "basil",
1888            "issue-nats-creds",
1889            "--jwt-file",
1890            "/run/secrets/user.jwt",
1891            "--seed-file",
1892            "/run/secrets/user.seed",
1893            "--out-file",
1894            "/run/secrets/user.creds",
1895            "--mode",
1896            "0660",
1897        ]);
1898        match cli.command {
1899            crate::Command::Client(ClientCommand::IssueNatsCreds {
1900                jwt_file,
1901                seed_file,
1902                out_file,
1903                mode,
1904                ..
1905            }) => {
1906                assert_eq!(
1907                    jwt_file.as_deref(),
1908                    Some(Path::new("/run/secrets/user.jwt"))
1909                );
1910                assert_eq!(
1911                    seed_file.as_deref(),
1912                    Some(Path::new("/run/secrets/user.seed"))
1913                );
1914                assert_eq!(out_file, Path::new("/run/secrets/user.creds"));
1915                assert_eq!(mode.mode(), 0o660);
1916            }
1917            other => panic!("unexpected command: {other:?}"),
1918        }
1919    }
1920
1921    #[test]
1922    fn secret_encoding_formats_match_external_consumers() {
1923        let value = [251, 255, 0, 1];
1924        assert_eq!(
1925            super::encode_secret(&value, super::GetOutputFormatArg::Raw),
1926            value
1927        );
1928        assert_eq!(
1929            super::encode_secret(&value, super::GetOutputFormatArg::Hex),
1930            b"fbff0001"
1931        );
1932        assert_eq!(
1933            super::encode_secret(&value, super::GetOutputFormatArg::Base64),
1934            b"+/8AAQ=="
1935        );
1936        assert_eq!(
1937            super::encode_secret(&value, super::GetOutputFormatArg::Base64UrlNoPad),
1938            b"-_8AAQ"
1939        );
1940    }
1941
1942    #[test]
1943    fn pem_block_frames_der_at_64_columns() {
1944        // 48 bytes of DER -> 64 base64 chars on a single body line.
1945        let der = vec![0xABu8; 48];
1946        let pem = super::pem_block("CERTIFICATE", &der);
1947        assert!(pem.starts_with("-----BEGIN CERTIFICATE-----\n"));
1948        assert!(pem.ends_with("-----END CERTIFICATE-----\n"));
1949        let body: Vec<&str> = pem.lines().filter(|l| !l.starts_with("-----")).collect();
1950        assert_eq!(body.len(), 1);
1951        assert_eq!(body.first().map(|l| l.len()), Some(64));
1952    }
1953
1954    #[test]
1955    fn pem_blocks_emits_one_block_per_der() {
1956        let ders = vec![vec![1u8; 10], vec![2u8; 10]];
1957        let pem = super::pem_blocks("CERTIFICATE", &ders);
1958        assert_eq!(pem.matches("-----BEGIN CERTIFICATE-----").count(), 2);
1959        assert_eq!(pem.matches("-----END CERTIFICATE-----").count(), 2);
1960    }
1961
1962    #[test]
1963    fn seed_hex_decodes_to_a_32_byte_ed25519_seed() {
1964        let seed = "01".repeat(32);
1965        let material = key_material(Some(&seed), None).expect("32-byte seed must decode");
1966        assert_eq!(material, KeyMaterial::Ed25519Seed(vec![1u8; 32]));
1967    }
1968
1969    #[test]
1970    fn seed_hex_of_wrong_length_is_rejected() {
1971        // 31 bytes: one short of an Ed25519 seed.
1972        let short = "01".repeat(31);
1973        assert!(key_material(Some(&short), None).is_err());
1974    }
1975
1976    #[test]
1977    fn material_requires_exactly_one_source() {
1978        // Neither source.
1979        assert!(key_material(None, None).is_err());
1980        // Both sources at once.
1981        let seed = "01".repeat(32);
1982        assert!(key_material(Some(&seed), Some(Path::new("/tmp/key.der"))).is_err());
1983    }
1984
1985    #[test]
1986    fn manifest_entry_parses_seed_hex_and_defaults_key_type() {
1987        let json = r#"{"key_id": "byok.a", "seed_hex": "00ff00ff"}"#;
1988        let entry: ManifestEntry = serde_json::from_str(json).expect("manifest entry must parse");
1989        assert_eq!(entry.key_id, "byok.a");
1990        assert_eq!(entry.key_type, super::KeyTypeArg::Ed25519);
1991        assert_eq!(entry.seed_hex.as_deref(), Some("00ff00ff"));
1992        assert!(entry.pkcs8_file.is_none());
1993    }
1994
1995    #[test]
1996    fn manifest_entry_parses_explicit_key_type() {
1997        let json = r#"{"key_id": "byok.b", "key_type": "rsa-2048", "pkcs8_file": "/tmp/b.der"}"#;
1998        let entry: ManifestEntry = serde_json::from_str(json).expect("manifest entry must parse");
1999        assert_eq!(entry.key_type, super::KeyTypeArg::Rsa2048);
2000        assert_eq!(entry.pkcs8_file.as_deref(), Some(Path::new("/tmp/b.der")));
2001
2002        let json =
2003            r#"{"key_id": "byok.ecdsa", "key_type": "ecdsa-p256", "pkcs8_file": "/tmp/e.der"}"#;
2004        let entry: ManifestEntry = serde_json::from_str(json).expect("manifest entry must parse");
2005        assert_eq!(entry.key_type, super::KeyTypeArg::EcdsaP256);
2006        assert_eq!(entry.pkcs8_file.as_deref(), Some(Path::new("/tmp/e.der")));
2007    }
2008
2009    #[test]
2010    fn import_set_check_validates_manifest_and_local_material() {
2011        let base =
2012            std::env::temp_dir().join(format!("basil-import-set-check-{}", std::process::id()));
2013        std::fs::create_dir_all(&base).expect("test temp directory must be created");
2014        let der = base.join("key.der");
2015        std::fs::write(&der, [0x30, 0x03, 0x02, 0x01, 0x00]).expect("DER fixture must be written");
2016        let manifest = base.join("manifest.json");
2017        std::fs::write(
2018            &manifest,
2019            format!(
2020                r#"[
2021                    {{"key_id":"byok.seed","seed_hex":"{}"}},
2022                    {{"key_id":"byok.pkcs8","key_type":"rsa-2048","pkcs8_file":"{}"}}
2023                ]"#,
2024                "11".repeat(32),
2025                der.display()
2026            ),
2027        )
2028        .expect("manifest fixture must be written");
2029
2030        check_import_set(&manifest).expect("valid manifest must pass check mode validation");
2031    }
2032
2033    #[test]
2034    fn import_set_check_rejects_empty_pkcs8_file() {
2035        let base = std::env::temp_dir().join(format!(
2036            "basil-import-set-check-empty-{}",
2037            std::process::id()
2038        ));
2039        std::fs::create_dir_all(&base).expect("test temp directory must be created");
2040        let der = base.join("empty.der");
2041        std::fs::write(&der, []).expect("empty DER fixture must be written");
2042        let manifest = base.join("manifest.json");
2043        std::fs::write(
2044            &manifest,
2045            format!(
2046                r#"[{{"key_id":"byok.pkcs8","key_type":"rsa-2048","pkcs8_file":"{}"}}]"#,
2047                der.display()
2048            ),
2049        )
2050        .expect("manifest fixture must be written");
2051
2052        assert!(
2053            check_import_set(&manifest).is_err(),
2054            "empty PKCS#8 files are rejected in check mode"
2055        );
2056    }
2057
2058    #[tokio::test]
2059    async fn import_checks_do_not_connect_to_agent() {
2060        super::run(
2061            Some("/tmp/basil-check-mode-no-agent.sock".to_string()),
2062            ClientCommand::Import {
2063                key_id: "byok.signer".to_string(),
2064                key_type: super::KeyTypeArg::Ed25519,
2065                seed_hex: Some("22".repeat(32)),
2066                pkcs8_file: None,
2067                check: true,
2068            },
2069        )
2070        .await
2071        .expect("import --check must not require a live agent");
2072
2073        let base = std::env::temp_dir().join(format!(
2074            "basil-import-set-no-connect-{}",
2075            std::process::id()
2076        ));
2077        std::fs::create_dir_all(&base).expect("test temp directory must be created");
2078        let manifest = base.join("manifest.json");
2079        std::fs::write(
2080            &manifest,
2081            format!(
2082                r#"[{{"key_id":"byok.seed","seed_hex":"{}"}}]"#,
2083                "33".repeat(32)
2084            ),
2085        )
2086        .expect("manifest fixture must be written");
2087
2088        super::run(
2089            Some("/tmp/basil-check-mode-no-agent.sock".to_string()),
2090            ClientCommand::ImportSet {
2091                file: manifest,
2092                check: true,
2093            },
2094        )
2095        .await
2096        .expect("import-set --check must not require a live agent");
2097    }
2098
2099    // -----------------------------------------------------------------------
2100    // CLI shape coverage: the scriptable `--json` field set + the exit-code
2101    // mapping for `health` / `ready` / `reload` (`basil-mil0.6`, `basil-a84j`).
2102    // These pin the contract orchestrators gate on WITHOUT a live server: the
2103    // live not-ready→nonzero / reject→nonzero legs are the e2e's job.
2104    // -----------------------------------------------------------------------
2105
2106    #[test]
2107    fn health_json_shape_is_alive_and_version() {
2108        let h = AgentHealth {
2109            alive: true,
2110            version: "0.1.0".to_string(),
2111        };
2112        let v = health_json(&h);
2113        // Exactly two keys, both scriptable: a probe parses `alive`.
2114        assert_eq!(v["alive"], serde_json::json!(true));
2115        assert_eq!(v["version"], serde_json::json!("0.1.0"));
2116        let obj = v.as_object().expect("health --json is a JSON object");
2117        assert_eq!(
2118            obj.len(),
2119            2,
2120            "health --json carries exactly alive + version"
2121        );
2122    }
2123
2124    /// Build an [`AgentReadiness`] with a coarse reason + counts for the shape tests.
2125    fn readiness(ready: bool, reason: ReadinessReason, present: u32, total: u32) -> AgentReadiness {
2126        AgentReadiness {
2127            ready,
2128            reason,
2129            generation: 7,
2130            keys_total: total,
2131            keys_present: present,
2132            keys_required_missing: total - present,
2133            keys_optional_missing: 0,
2134        }
2135    }
2136
2137    #[test]
2138    fn ready_json_shape_and_reason_tokens() {
2139        // READY: present == total, the active generation is surfaced.
2140        let r = readiness(true, ReadinessReason::Ready, 4, 4);
2141        let v = ready_json(&r);
2142        assert_eq!(v["ready"], serde_json::json!(true));
2143        assert_eq!(v["reason"], serde_json::json!("ready"));
2144        assert_eq!(v["generation"], serde_json::json!(7));
2145        assert_eq!(v["keys_total"], serde_json::json!(4));
2146        assert_eq!(v["keys_present"], serde_json::json!(4));
2147        assert_eq!(v["keys_required_missing"], serde_json::json!(0));
2148        assert_eq!(v["keys_optional_missing"], serde_json::json!(0));
2149        let obj = v.as_object().expect("ready --json is a JSON object");
2150        assert_eq!(
2151            obj.len(),
2152            7,
2153            "ready --json carries the full 7-field summary"
2154        );
2155
2156        // The coarse reason tokens automation matches on are stable.
2157        assert_eq!(
2158            ready_json(&readiness(false, ReadinessReason::BackendUnreachable, 0, 4))["reason"],
2159            serde_json::json!("backend_unreachable")
2160        );
2161        assert_eq!(
2162            ready_json(&readiness(false, ReadinessReason::RequiredKeyMissing, 3, 4))["reason"],
2163            serde_json::json!("required_key_missing")
2164        );
2165    }
2166
2167    #[test]
2168    fn ready_exit_code_maps_ready_to_zero_and_not_ready_to_one() {
2169        assert_eq!(
2170            ready_exit_code(&readiness(true, ReadinessReason::Ready, 4, 4)),
2171            0,
2172            "a ready broker exits 0"
2173        );
2174        assert_eq!(
2175            ready_exit_code(&readiness(false, ReadinessReason::BackendUnreachable, 0, 4)),
2176            1,
2177            "a backend-unreachable broker exits 1 (not ready)"
2178        );
2179        assert_eq!(
2180            ready_exit_code(&readiness(false, ReadinessReason::RequiredKeyMissing, 3, 4)),
2181            1,
2182            "a required-key-missing broker exits 1 (not ready)"
2183        );
2184    }
2185
2186    /// Build an applied [`AgentReload`] (a real swap) for the shape tests.
2187    fn reload_applied() -> AgentReload {
2188        AgentReload {
2189            applied: true,
2190            checked: false,
2191            previous_generation: 4,
2192            new_generation: 5,
2193            key_count: 12,
2194            grant_count: 9,
2195            rejection: None,
2196        }
2197    }
2198
2199    /// Build a rejected [`AgentReload`] (the previous generation keeps serving).
2200    fn reload_rejected() -> AgentReload {
2201        AgentReload {
2202            applied: false,
2203            checked: false,
2204            previous_generation: 4,
2205            new_generation: 4,
2206            key_count: 12,
2207            grant_count: 9,
2208            rejection: Some(ReloadRejection {
2209                reason: "routing_shape_changed".to_string(),
2210                message: "a key's backend locator changed (restart-only)".to_string(),
2211            }),
2212        }
2213    }
2214
2215    #[test]
2216    fn reload_json_shape_applied_has_null_rejection() {
2217        let v = reload_json(&reload_applied());
2218        assert_eq!(v["applied"], serde_json::json!(true));
2219        assert_eq!(v["checked"], serde_json::json!(false));
2220        assert_eq!(v["previous_generation"], serde_json::json!(4));
2221        assert_eq!(v["new_generation"], serde_json::json!(5));
2222        assert_eq!(v["key_count"], serde_json::json!(12));
2223        assert_eq!(v["grant_count"], serde_json::json!(9));
2224        // An applied reload carries an explicit `null` rejection (the key is
2225        // always present so automation can test for it unconditionally).
2226        assert_eq!(v["rejection"], serde_json::Value::Null);
2227        let obj = v.as_object().expect("reload --json is a JSON object");
2228        assert_eq!(obj.len(), 7, "reload --json carries 7 keys incl. rejection");
2229    }
2230
2231    #[test]
2232    fn reload_json_shape_rejected_nests_reason_and_message() {
2233        let v = reload_json(&reload_rejected());
2234        assert_eq!(v["applied"], serde_json::json!(false));
2235        // The previous generation keeps serving: prev == new on a rejection.
2236        assert_eq!(v["previous_generation"], v["new_generation"]);
2237        let rej = v["rejection"]
2238            .as_object()
2239            .expect("a rejected reload nests a rejection object");
2240        assert_eq!(rej["reason"], serde_json::json!("routing_shape_changed"));
2241        assert_eq!(
2242            rej["message"],
2243            serde_json::json!("a key's backend locator changed (restart-only)")
2244        );
2245    }
2246
2247    #[test]
2248    fn reload_exit_code_maps_accepted_to_zero_and_rejected_to_one() {
2249        assert_eq!(
2250            reload_exit_code(&reload_applied()),
2251            0,
2252            "an applied reload exits 0"
2253        );
2254        assert_eq!(
2255            reload_exit_code(&reload_rejected()),
2256            1,
2257            "a rejected reload exits 1 (never a silent 0)"
2258        );
2259        // A clean dry-run (`--check`) also exits 0: validated, no swap, no rejection.
2260        let mut dry = reload_applied();
2261        dry.applied = false;
2262        dry.checked = true;
2263        assert_eq!(reload_exit_code(&dry), 0, "a clean --check dry-run exits 0");
2264    }
2265
2266    #[test]
2267    fn revoke_json_shape_is_stable() {
2268        let v = revoke_json(&AgentRevocation {
2269            trust_domain: "example.test".into(),
2270            jti: "token-1".into(),
2271            expires_at_unix: 2_000_000_000,
2272            persisted: true,
2273        });
2274        assert_eq!(v["trust_domain"], serde_json::json!("example.test"));
2275        assert_eq!(v["jti"], serde_json::json!("token-1"));
2276        assert_eq!(v["expires_at_unix"], serde_json::json!(2_000_000_000_u64));
2277        assert_eq!(v["persisted"], serde_json::json!(true));
2278        let obj = v.as_object().expect("revoke --json is a JSON object");
2279        assert_eq!(obj.len(), 4, "revoke --json carries exactly four fields");
2280    }
2281}