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