Skip to main content

basil_core/
init.rs

1// SPDX-FileCopyrightText: 2026 OpenBasil Contributors
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! `basil init`: first-run scaffolding (basil-p50).
6//!
7//! Generates a minimal, valid, **least-privilege** starter set into a target
8//! directory so a new operator can stand up a local broker without hand-authoring
9//! JSON/TOML from scratch:
10//!
11//! - `catalog.json`, one working example key for the chosen backend;
12//! - `policy.json` grants only the running uid a narrow `signer` role over that
13//!   one key, default-deny everywhere else;
14//! - `basil-agent.toml` points at the catalog/policy/bundle/socket it writes;
15//! - printed **next steps**: the exact `basil bundle create ...` command for the
16//!   chosen unlock method, then `check` / `run` / a `basil sign` round-trip.
17//!
18//! `init` writes **configuration/scaffolding only**, never secret material. It
19//! does NOT create the sealed bundle (that needs interactive unlock material); it
20//! PRINTS the bundle-bootstrap command instead. The catalog/policy JSON are
21//! produced by serializing the **real** schema/wire types (`Catalog`,
22//! [`RawPolicy`](crate::catalog::RawPolicy)), so the output is valid by
23//! construction and cannot drift from what [`load`](crate::catalog::load) parses.
24//!
25//! No-clobber: an existing target file is refused unless `--force`.
26
27use std::collections::{BTreeMap, BTreeSet};
28use std::fmt::Write as _;
29use std::path::{Path, PathBuf};
30
31use crate::catalog::{
32    BackendKind, BackendRef, Catalog, Class, Config, Engine, KeyAlgorithm, KeyEntry, Labels,
33    MissingPolicy, NameTable, Op, PrincipalSpec, RawPolicy, RawRule, RawSubjectDefinition,
34};
35use anyhow::{Context, Result, bail};
36use clap::{Args, ValueEnum};
37
38/// The catalog key name of the scaffolded example signing key. Matches the
39/// `basil` CLI's `sign --key-id` default so the printed round-trip Just Works.
40const EXAMPLE_KEY: &str = "example.signing_key";
41/// The catalog backend name the scaffolded key routes to.
42const BACKEND_NAME: &str = "primary";
43/// The least-privilege role granted to the running uid (sign + verify + the
44/// public-key read needed to verify).
45const SIGNER_ROLE: &str = "example-signer";
46
47/// `init` subcommand arguments.
48#[derive(Debug, Args)]
49pub struct InitArgs {
50    /// The backend the scaffolded broker will route its example key to.
51    #[arg(long, value_enum, default_value_t = InitBackend::Openbao)]
52    backend: InitBackend,
53
54    /// The unlock method whose `bundle create` command the next-steps output prints.
55    /// `init` never seals a bundle; this only selects which command to show.
56    #[arg(long, value_enum, default_value_t = InitUnlock::Bip39)]
57    unlock: InitUnlock,
58
59    /// Directory to write `catalog.json`, `policy.json`, and `basil-agent.toml`
60    /// into (created if absent). The sealed bundle + unix socket paths in the
61    /// generated config also live under here.
62    #[arg(long, value_name = "DIR", default_value = "./basil")]
63    dir: PathBuf,
64
65    /// Backend address for the `vault`/`openbao` backends (a Vault-compatible
66    /// HTTP URL). Ignored for the `keystore` backend, whose `addr` is a local DB
67    /// file path under the target dir.
68    #[arg(long, default_value = "http://127.0.0.1:8200")]
69    addr: String,
70
71    /// Transit secrets-engine mount the example key lives under (vault/openbao
72    /// only). The default matches a stock `transit` mount.
73    #[arg(long, default_value = "transit")]
74    transit_mount: String,
75
76    /// Existing 0600 passphrase file to bake into the generated
77    /// `unlock-passphrase-file` config and printed `bundle create --slot`
78    /// command. Only valid with `--unlock passphrase`.
79    #[arg(long, value_name = "PATH")]
80    passphrase_file: Option<PathBuf>,
81
82    /// Overwrite any target file that already exists. Without it, `init` refuses
83    /// and reports which files are in the way (no clobber).
84    #[arg(long)]
85    force: bool,
86}
87
88/// The backend kind to scaffold for. `openbao` and `vault` share one wire API
89/// (one [`BackendKind::Vault`]) and differ only in the bundle-bootstrap CLI; the
90/// distinction is kept so the printed commands name the right binary.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
92enum InitBackend {
93    /// `OpenBao` (the `bao` CLI) over the Vault-compatible transit engine.
94    Openbao,
95    /// `HashiCorp` Vault (the `vault` CLI) over its transit engine.
96    Vault,
97    /// The local materialize-to-use db-keystore backend (no external server).
98    Keystore,
99}
100
101/// The unlock method whose `bundle create` invocation the next-steps prints.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
103enum InitUnlock {
104    /// A 24-word `BIP39` break-glass phrase (shown once at `bundle create`).
105    Bip39,
106    /// A production passphrase file.
107    Passphrase,
108    /// A TPM2 sealed slot bound to host PCR state (broker built with the
109    /// `unlock-tpm` feature; sealed to the host `TPM` at `bundle create` time).
110    Tpm,
111    /// An age + age-plugin-yubikey hardware slot (enrolled out of band).
112    AgeYubikey,
113}
114
115impl InitBackend {
116    /// The catalog [`BackendKind`] this scaffolds.
117    const fn kind(self) -> BackendKind {
118        match self {
119            Self::Openbao | Self::Vault => BackendKind::Vault,
120            Self::Keystore => BackendKind::Keystore,
121        }
122    }
123
124    /// The server CLI binary that bootstraps the engine + writes the cred token,
125    /// for the vault-family backends. `None` for keystore (no server).
126    const fn server_cli(self) -> Option<&'static str> {
127        match self {
128            Self::Openbao => Some("bao"),
129            Self::Vault => Some("vault"),
130            Self::Keystore => None,
131        }
132    }
133
134    /// Human label for the next-steps banner.
135    const fn label(self) -> &'static str {
136        match self {
137            Self::Openbao => "OpenBao",
138            Self::Vault => "HashiCorp Vault",
139            Self::Keystore => "db-keystore",
140        }
141    }
142}
143
144/// Paths of everything `init` writes, all under the target directory.
145struct Layout {
146    dir: PathBuf,
147    catalog: PathBuf,
148    policy: PathBuf,
149    config: PathBuf,
150    /// Where the operator is told to write the sealed bundle (init does NOT
151    /// create it).
152    bundle: PathBuf,
153    /// The unix socket the generated config binds.
154    socket: PathBuf,
155    /// (keystore only) the local DB file the keystore backend `addr` points at.
156    keystore_db: PathBuf,
157}
158
159impl Layout {
160    /// Build the target layout. `socket` is the caller-resolved socket path (see
161    /// [`resolve_socket`]); when `None` the socket falls back to
162    /// `<dir>/basil.sock`.
163    fn new(dir: &Path, socket: Option<&str>) -> Self {
164        Self {
165            dir: dir.to_path_buf(),
166            catalog: dir.join("catalog.json"),
167            policy: dir.join("policy.json"),
168            config: dir.join("basil-agent.toml"),
169            bundle: dir.join("bundle.sealed"),
170            socket: socket.map_or_else(|| dir.join("basil.sock"), PathBuf::from),
171            keystore_db: dir.join("keystore.db"),
172        }
173    }
174
175    /// The three files `init` writes (in clobber-check order).
176    fn written(&self) -> [&Path; 3] {
177        [&self.catalog, &self.policy, &self.config]
178    }
179}
180
181/// Run `basil init`: build the scaffolding, refuse to clobber unless `--force`,
182/// write the files, and print the next-steps summary.
183///
184/// `socket` is the resolved global `--socket <path>` flag. The socket written
185/// into `basil-agent.toml` follows this precedence (highest first): explicit
186/// `--socket <path>` > `BASIL_SOCKET` env var > `<dir>/basil.sock`. The clap
187/// global flag already folds `BASIL_SOCKET` into `socket`; the direct env read
188/// here keeps the precedence correct for non-clap callers too.
189pub fn run(args: &InitArgs, socket: Option<&str>) -> Result<()> {
190    validate_args(args)?;
191
192    let env_socket = std::env::var("BASIL_SOCKET").ok();
193    let socket = resolve_socket(socket, env_socket.as_deref());
194    let layout = Layout::new(&args.dir, socket.as_deref());
195
196    std::fs::create_dir_all(&layout.dir)
197        .with_context(|| format!("creating target dir {}", layout.dir.display()))?;
198
199    refuse_clobber(&layout, args.force)?;
200
201    let uid = current_uid();
202
203    let catalog = build_catalog(args, &layout);
204    let policy = build_policy(uid);
205
206    // Serialize the REAL schema/wire types (pretty), then validate the pair
207    // through the SAME loader `check`/`run` use: fail closed if the scaffold is
208    // somehow invalid rather than writing a broken starter set.
209    let catalog_json = serde_json::to_string_pretty(&catalog).context("serializing catalog")?;
210    let policy_json = serde_json::to_string_pretty(&policy).context("serializing policy")?;
211    crate::load(&catalog_json, &policy_json)
212        .context("the generated catalog/policy did not pass loader validation (internal bug)")?;
213
214    let config_toml = build_config_toml(args, &layout);
215
216    write_file(&layout.catalog, &format!("{catalog_json}\n"))?;
217    write_file(&layout.policy, &format!("{policy_json}\n"))?;
218    write_file(&layout.config, &config_toml)?;
219
220    print_next_steps(args, &layout, uid);
221    Ok(())
222}
223
224/// Refuse to overwrite any existing target file unless `--force`, listing every
225/// offending path so the operator sees them all at once.
226fn refuse_clobber(layout: &Layout, force: bool) -> Result<()> {
227    if force {
228        return Ok(());
229    }
230    let existing: Vec<String> = layout
231        .written()
232        .into_iter()
233        .filter(|p| p.exists())
234        .map(|p| p.display().to_string())
235        .collect();
236    if existing.is_empty() {
237        return Ok(());
238    }
239    bail!(
240        "refusing to overwrite existing file(s): {}\n(pass --force to overwrite)",
241        existing.join(", ")
242    );
243}
244
245/// Build the one-key example [`Catalog`] for the chosen backend.
246///
247/// - vault/openbao: a **transit** Ed25519 signing key with `missing: generate`,
248///   so startup reconcile creates it in place on the first run.
249/// - keystore: a `kind: keystore` backend with an Ed25519 signing key over its
250///   in-keystore `transit` engine, also `missing: generate`.
251fn build_catalog(args: &InitArgs, layout: &Layout) -> Catalog {
252    let (addr, engines) = match args.backend {
253        InitBackend::Openbao | InitBackend::Vault => (args.addr.clone(), vec![Engine::Transit]),
254        InitBackend::Keystore => (
255            layout.keystore_db.display().to_string(),
256            vec![Engine::Transit, Engine::Kv2],
257        ),
258    };
259
260    let mut backends = BTreeMap::new();
261    backends.insert(
262        BACKEND_NAME.to_string(),
263        BackendRef {
264            kind: args.backend.kind(),
265            addr,
266            engines,
267            capabilities: Vec::new(),
268            mint_key_types: vec![KeyAlgorithm::Ed25519],
269            requires: Vec::new(),
270        },
271    );
272
273    // The transit key path: a BARE key name for vault/openbao (the transit
274    // backend composes the verb sub-path `transit/<verb>/<name>` itself, and the
275    // configured `transit-mount` is prepended, and a `<mount>/keys/<name>` catalog
276    // path would double the mount and 404 with "unsupported path", `vault-w3n`);
277    // a slugged name for the keystore. The catalog `path` is the backend-native
278    // locator, opaque to policy.
279    let path = match args.backend {
280        InitBackend::Openbao | InitBackend::Vault => "example-signing-key".to_string(),
281        InitBackend::Keystore => "example/signing-key".to_string(),
282    };
283
284    let mut keys = BTreeMap::new();
285    keys.insert(
286        EXAMPLE_KEY.to_string(),
287        KeyEntry {
288            class: Class::Asymmetric,
289            key_type: Some(KeyAlgorithm::Ed25519),
290            backend: BACKEND_NAME.to_string(),
291            engine: Some(Engine::Transit),
292            path,
293            public_path: None,
294            writable: true,
295            // Created in place by startup reconcile on first run.
296            missing: MissingPolicy::Generate,
297            generate: None,
298            sealing_pin: None,
299            labels: Labels::default(),
300            description: "Example Ed25519 signing key scaffolded by `basil init`.".to_string(),
301        },
302    );
303
304    Catalog {
305        schema_version: 1,
306        backends,
307        keys,
308    }
309}
310
311/// Build the least-privilege [`RawPolicy`]: one `signer` role (sign + verify +
312/// the public-key read verify needs), granted to **only** the running uid over
313/// **only** the one example key. Everything else is default-deny.
314fn build_policy(uid: u32) -> RawPolicy {
315    let mut roles = BTreeMap::new();
316    roles.insert(
317        SIGNER_ROLE.to_string(),
318        BTreeSet::from([Op::Sign, Op::Verify, Op::GetPublicKey]),
319    );
320
321    let rule = RawRule {
322        id: "running-user-may-sign-example-key".to_string(),
323        subjects: vec!["init.user".to_string()],
324        action: vec![format!("role:{SIGNER_ROLE}")],
325        target: vec![EXAMPLE_KEY.to_string()],
326        comment: Some(
327            "Least-privilege: only the uid that ran `basil init` may sign/verify \
328             the one example key. Everything else is default-deny."
329                .to_string(),
330        ),
331    };
332
333    let mut names = NameTable::default();
334    names.users.insert(uid, "init-user".to_string());
335    let mut memberships = BTreeMap::new();
336    memberships.insert(uid, BTreeSet::new());
337    let mut subjects = BTreeMap::new();
338    subjects.insert(
339        "init.user".to_string(),
340        RawSubjectDefinition {
341            break_glass: false,
342            all_of: Some(vec![PrincipalSpec::Unix {
343                uid: Some(uid),
344                gid: None,
345            }]),
346            any_of: None,
347        },
348    );
349
350    RawPolicy {
351        schema_version: 2,
352        subjects,
353        unauthenticated_subject: None,
354        roles,
355        rules: vec![rule],
356        config: Config { names, memberships },
357    }
358}
359
360/// Build the commented TOML agent config pointing at everything `init` writes.
361/// Comments are allowed in TOML (the catalog/policy JSON round-trip through the
362/// real types); the keystore arm adds the `db-keystore-cipher` line.
363fn build_config_toml(args: &InitArgs, layout: &Layout) -> String {
364    let mut out = String::new();
365    out.push_str("# basil-agent config scaffolded by `basil init`.\n");
366    out.push_str("# Edit the placeholders, create the sealed bundle (see the printed\n");
367    out.push_str(
368        "# next-steps), then `basil doctor --keys -c this-file` and `run -c this-file`.\n\n",
369    );
370    let _ = writeln!(out, "catalog = {}", toml_str(&layout.catalog));
371    let _ = writeln!(out, "policy = {}", toml_str(&layout.policy));
372    out.push_str("# The sealed bundle is NOT created by init. Create it with `bundle create`.\n");
373    let _ = writeln!(out, "bundle = {}", toml_str(&layout.bundle));
374    let _ = writeln!(out, "socket = {}", toml_str(&layout.socket));
375    out.push_str("# Socket mode defaults to 0600 (owner-only); widen deliberately if a peer\n");
376    out.push_str("# group must connect, e.g. socket-mode = \"0660\" + socket-group = \"basil\".\n");
377    out.push_str("socket-mode = \"0600\"\n");
378
379    if args.backend == InitBackend::Keystore {
380        out.push_str("\n# db-keystore backend: the local AEAD cipher for the at-rest DB.\n");
381        out.push_str("db-keystore-cipher = \"aegis256\"\n");
382    } else {
383        let _ = writeln!(out, "vault-addr = {}", toml_str_s(&args.addr));
384        let _ = writeln!(
385            out,
386            "transit-mount = {}",
387            toml_str_s(trim_mount(&args.transit_mount))
388        );
389    }
390
391    out.push('\n');
392    out.push_str("[unlock]\n");
393    match args.unlock {
394        InitUnlock::Bip39 => {
395            out.push_str(
396                "# Unlock with the `BIP39` break-glass phrase from `bundle create --slot bip39`.\n",
397            );
398            out.push_str(
399                "# TODO: point bip39-phrase-file at a 0600 file holding the 24-word phrase.\n",
400            );
401            out.push_str("bip39-phrase-file = \"REPLACE_WITH_PATH_TO_BIP39_PHRASE_FILE\"\n");
402        }
403        InitUnlock::Passphrase => {
404            out.push_str("# Unlock with a passphrase read from a 0600 file.\n");
405            out.push_str("# TODO: point unlock-passphrase-file at the runtime credential file.\n");
406            let passphrase_file = args.passphrase_file.as_deref().map_or_else(
407                || toml_str_s("REPLACE_WITH_PATH_TO_PASSPHRASE_FILE"),
408                toml_str,
409            );
410            let _ = writeln!(out, "unlock-passphrase-file = {passphrase_file}");
411        }
412        InitUnlock::Tpm => {
413            out.push_str("# Unlock with a TPM2 sealed slot bound to host PCR state.\n");
414            out.push_str("# Requires the broker built with --features unlock-tpm and a host TPM\n");
415            out.push_str("# (/dev/tpmrm0); availability is the runtime device probe, no secret.\n");
416            out.push_str("unlock-tpm = true\n");
417        }
418        InitUnlock::AgeYubikey => {
419            out.push_str("# Unlock with an enrolled age + age-plugin-yubikey hardware slot.\n");
420            out.push_str("age-yubikey = true\n");
421        }
422    }
423    out.push('\n');
424    out.push_str("[broker-identity]\n");
425    out.push_str("# Required when [invocation] enable = true.\n");
426    out.push_str("# id = \"basil://prod/us-east-1/agent-a\"\n");
427    out.push_str("# response-signing-key-id = \"broker.response_signing.2026q3\"\n");
428    out.push('\n');
429    out.push_str("[invocation]\n");
430    out.push_str("# Sealed bridged invocation is compiled in but disabled by default.\n");
431    out.push_str("enable = false\n");
432    out.push_str("# audience = [\"basil://prod/us-east-1/agent-a\"]\n");
433    out.push_str("# request-encryption-key-id = \"broker.request_encryption.2026q3\"\n");
434    out.push_str("# max-ttl-secs = 60\n");
435    out.push_str("# clock-skew-secs = 30\n");
436    out.push_str("# replay-cache-capacity = 4096\n");
437    out
438}
439
440/// Print the concrete next-steps: the exact `bundle create` for the chosen unlock
441/// method + backend cred, then `check`, `run`, and a `basil sign` round-trip.
442fn print_next_steps(args: &InitArgs, layout: &Layout, uid: u32) {
443    let cfg = layout.config.display();
444    println!(
445        "Scaffolded a {} starter set in {}:",
446        args.backend.label(),
447        layout.dir.display()
448    );
449    println!("  catalog: {}", layout.catalog.display());
450    println!(
451        "  policy:  {} (grants only uid {uid} sign/verify over `{EXAMPLE_KEY}`)",
452        layout.policy.display()
453    );
454    println!("  config:  {cfg}");
455    println!();
456    println!("init writes config/scaffolding ONLY: no secret material, and NOT the sealed bundle.");
457    println!();
458
459    println!("Next steps:");
460    println!();
461    println!("1. Create the sealed credential bundle (init cannot: it needs unlock material):");
462    print_bundle_init(args, layout);
463    println!();
464
465    if let Some(cli) = args.backend.server_cli() {
466        println!(
467            "   The bundle's backend credential must be a token for a running {}",
468            args.backend.label()
469        );
470        println!(
471            "   with the `{}` transit mount enabled. For a dev server:",
472            trim_mount(&args.transit_mount)
473        );
474        println!("       {cli} secrets enable transit");
475        println!(
476            "   (reconcile will create the `{EXAMPLE_KEY}` key on first run, missing=generate.)"
477        );
478    } else {
479        println!("   Build the agent with the keystore backend: --features db-keystore");
480        println!("   and seed a 32-byte DEK file for the bundle's DbKeystoreDek credential.");
481    }
482    println!();
483
484    println!("2. Validate the config (offline + authenticated key probe):");
485    println!("       basil doctor --keys -c {cfg}");
486    println!();
487    println!("3. Run the broker:");
488    println!("       basil agent -c {cfg}");
489    println!();
490    println!("4. Exercise the example key over the socket:");
491    println!(
492        "       basil --socket {} sign --key-id {EXAMPLE_KEY} 'hello basil'",
493        layout.socket.display()
494    );
495}
496
497/// Print the exact `basil bundle create ...` command for the chosen unlock
498/// method + backend, using only real flags.
499fn print_bundle_init(args: &InitArgs, layout: &Layout) {
500    let out = layout.bundle.display();
501    let slot = bundle_init_slot_flag(args);
502    let cred = match args.backend {
503        InitBackend::Openbao | InitBackend::Vault => {
504            format!(
505                "--backend id={BACKEND_NAME},type=openbao,addr=REPLACE_WITH_BACKEND_ADDR,token-file=REPLACE_WITH_BACKEND_TOKEN_FILE"
506            )
507        }
508        InitBackend::Keystore => {
509            format!(
510                "--backend id={BACKEND_NAME},type=db-keystore,path=REPLACE_WITH_DB_PATH,dek-file=REPLACE_WITH_PATH_TO_32BYTE_DEK_FILE"
511            )
512        }
513    };
514    println!("       basil bundle create {out} \\");
515    println!("           --slot {slot} \\");
516    println!("           {cred}");
517    if args.unlock == InitUnlock::Tpm {
518        println!(
519            "   (the TPM slot seals to THIS host's TPM at `bundle create` time; run it on \
520             the target host with /dev/tpmrm0 and a broker built with --features unlock-tpm.)"
521        );
522    }
523    if args.unlock == InitUnlock::AgeYubikey {
524        println!(
525            "   (age-yubikey needs a recipient in `--slot age-yubikey:recipient=...`; \
526             a bip39 break-glass slot is shown above so the bundle is creatable.)"
527        );
528    }
529}
530
531/// The `bundle create --slot` value the next-steps prints for the chosen unlock
532/// method.
533fn bundle_init_slot_flag(args: &InitArgs) -> String {
534    match args.unlock {
535        InitUnlock::Passphrase => {
536            let path = args.passphrase_file.as_deref().map_or_else(
537                || "REPLACE_WITH_PATH_TO_PASSPHRASE_FILE".to_string(),
538                |path| path.display().to_string(),
539            );
540            format!("passphrase:file={path}")
541        }
542        InitUnlock::Tpm => "tpm".to_string(),
543        InitUnlock::Bip39 | InitUnlock::AgeYubikey => "bip39".to_string(),
544    }
545}
546
547/// Resolve the unix-socket path written into the generated `basil-agent.toml`.
548///
549/// Precedence, highest first: `explicit` (the global `--socket <path>` flag),
550/// then `env` (the `BASIL_SOCKET` variable), then `None` so [`Layout::new`]
551/// falls back to `<dir>/basil.sock`. Kept as a pure two-argument function so the
552/// precedence is unit-testable without touching the process environment.
553fn resolve_socket(explicit: Option<&str>, env: Option<&str>) -> Option<String> {
554    explicit.or(env).map(str::to_owned)
555}
556
557/// Validate argument combinations before writing any scaffold files.
558fn validate_args(args: &InitArgs) -> Result<()> {
559    if args.passphrase_file.is_some() && args.unlock != InitUnlock::Passphrase {
560        bail!("--passphrase-file can only be used with --unlock passphrase");
561    }
562    Ok(())
563}
564
565/// Write a scaffold file (config/catalog/policy are non-secret; default perms).
566fn write_file(path: &Path, contents: &str) -> Result<()> {
567    std::fs::write(path, contents).with_context(|| format!("writing {}", path.display()))
568}
569
570/// Resolve the real uid of the running process, the authorization anchor the
571/// policy grant binds to, the same identity the broker proves at runtime via
572/// `SO_PEERCRED`. Uses `rustix`'s safe `getuid()` so it works on Linux and
573/// macOS alike (no `/proc` dependency) and never panics.
574fn current_uid() -> u32 {
575    rustix::process::getuid().as_raw()
576}
577
578/// Strip a single trailing `/` from a mount path so `path = "<mount>/keys/<k>"`
579/// never doubles the separator.
580fn trim_mount(mount: &str) -> &str {
581    mount.strip_suffix('/').unwrap_or(mount)
582}
583
584/// TOML-quote a path value.
585fn toml_str(path: &Path) -> String {
586    toml_str_s(&path.display().to_string())
587}
588
589/// TOML-quote a string value (basic-string escaping of `\` and `"`).
590fn toml_str_s(s: &str) -> String {
591    let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
592    format!("\"{escaped}\"")
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598
599    fn args_for(backend: InitBackend, unlock: InitUnlock, dir: &Path) -> InitArgs {
600        InitArgs {
601            backend,
602            unlock,
603            dir: dir.to_path_buf(),
604            addr: "http://127.0.0.1:8200".to_string(),
605            transit_mount: "transit".to_string(),
606            passphrase_file: None,
607            force: false,
608        }
609    }
610
611    fn temp_dir() -> PathBuf {
612        let p = std::env::temp_dir().join(format!(
613            "basil-init-test-{}-{}",
614            std::process::id(),
615            uuid::Uuid::new_v4()
616        ));
617        std::fs::create_dir_all(&p).expect("mk temp dir");
618        p
619    }
620
621    /// The load-bearing test: the generated catalog + policy for EVERY backend
622    /// kind pass the REAL loader/validation (the same `load` path `check` uses).
623    #[test]
624    fn generated_pair_passes_real_loader_for_every_backend() {
625        for backend in [
626            InitBackend::Openbao,
627            InitBackend::Vault,
628            InitBackend::Keystore,
629        ] {
630            let dir = temp_dir();
631            let layout = Layout::new(&dir, None);
632            let args = args_for(backend, InitUnlock::Bip39, &dir);
633            let catalog = build_catalog(&args, &layout);
634            let policy = build_policy(4242);
635
636            let catalog_json = serde_json::to_string_pretty(&catalog).expect("ser catalog");
637            let policy_json = serde_json::to_string_pretty(&policy).expect("ser policy");
638            let loaded = crate::load(&catalog_json, &policy_json)
639                .unwrap_or_else(|e| panic!("{backend:?} pair must load clean: {e}"));
640            let warnings = loaded.3;
641            assert!(
642                warnings.is_empty(),
643                "{backend:?} pair should load without warnings, got {warnings:?}"
644            );
645            std::fs::remove_dir_all(&dir).ok();
646        }
647    }
648
649    /// The policy grants ONLY the generated Unix subject for the running uid and
650    /// only over the one example key.
651    #[test]
652    fn policy_grants_only_the_running_uid() {
653        let uid = 9931;
654        let policy = build_policy(uid);
655        assert_eq!(policy.rules.len(), 1);
656        let rule = policy.rules.first().expect("one rule");
657        assert_eq!(rule.subjects, vec!["init.user".to_string()]);
658        assert_eq!(rule.target, vec![EXAMPLE_KEY.to_string()]);
659        assert_eq!(policy.subjects.len(), 1);
660        // The signer role is sign/verify/get_public_key only. No write ops.
661        let signer = policy.roles.get(SIGNER_ROLE).expect("signer role present");
662        assert!(
663            !signer.iter().any(|op| op.is_write()),
664            "signer role must hold no write op"
665        );
666    }
667
668    /// `run` refuses to clobber an existing target file without `--force`, and
669    /// overwrites with it.
670    #[test]
671    fn refuses_to_clobber_without_force() {
672        let dir = temp_dir();
673        let args = args_for(InitBackend::Openbao, InitUnlock::Bip39, &dir);
674
675        run(&args, None).expect("first init writes clean");
676        let layout = Layout::new(&dir, None);
677        assert!(layout.catalog.exists() && layout.policy.exists() && layout.config.exists());
678
679        // Second run without --force must refuse (and name the offenders).
680        let err = run(&args, None).expect_err("second init must refuse");
681        let msg = err.to_string();
682        assert!(msg.contains("refusing to overwrite"), "got: {msg}");
683
684        // With --force it overwrites.
685        let forced = InitArgs {
686            force: true,
687            ..args_for(InitBackend::Openbao, InitUnlock::Bip39, &dir)
688        };
689        run(&forced, None).expect("forced init overwrites");
690
691        std::fs::remove_dir_all(&dir).ok();
692    }
693
694    /// The written catalog/policy files on disk reload through the real loader
695    /// (end-to-end through `run`, not just the in-memory structs).
696    #[test]
697    fn written_files_reload_through_the_loader() {
698        let dir = temp_dir();
699        let args = args_for(InitBackend::Keystore, InitUnlock::Passphrase, &dir);
700        run(&args, None).expect("init writes");
701        let layout = Layout::new(&dir, None);
702
703        let catalog_json = std::fs::read_to_string(&layout.catalog).expect("read catalog");
704        let policy_json = std::fs::read_to_string(&layout.policy).expect("read policy");
705        crate::load(&catalog_json, &policy_json).expect("written pair must reload");
706
707        // The TOML config parses and points at the files init wrote.
708        let config = std::fs::read_to_string(&layout.config).expect("read config");
709        let parsed: toml::Value = toml::from_str(&config).expect("config is valid TOML");
710        assert_eq!(
711            parsed.get("catalog").and_then(toml::Value::as_str),
712            Some(layout.catalog.display().to_string().as_str())
713        );
714        // Socket mode defaults to 0600 (owner-only) in the generated config.
715        assert_eq!(
716            parsed.get("socket-mode").and_then(toml::Value::as_str),
717            Some("0600")
718        );
719
720        std::fs::remove_dir_all(&dir).ok();
721    }
722
723    /// The generated TOML config parses through the REAL `AgentConfigFile`
724    /// loader the daemon uses (`crate::load_config_file`), and resolves to the
725    /// catalog/policy/bundle/socket paths init wrote with the 0600 socket mode.
726    /// The vault/openbao arm needs no feature; the keystore arm emits a
727    /// feature-gated `db-keystore-cipher` so it is gated to match.
728    #[test]
729    fn generated_config_loads_through_agent_config_file() {
730        let dir = temp_dir();
731        let args = args_for(InitBackend::Openbao, InitUnlock::Bip39, &dir);
732        run(&args, None).expect("init writes");
733        let layout = Layout::new(&dir, None);
734
735        let overrides = crate::agent_cli::ConfigOverrides {
736            config: Some(layout.config.clone()),
737            catalog: None,
738            policy: None,
739            bundle: None,
740            socket: None,
741            vault_addr: None,
742        };
743        let file =
744            crate::agent_cli::load_config_file(&overrides).expect("agent parses generated config");
745        assert_eq!(file.catalog.as_deref(), Some(layout.catalog.as_path()));
746        assert_eq!(file.policy.as_deref(), Some(layout.policy.as_path()));
747        assert_eq!(file.bundle.as_deref(), Some(layout.bundle.as_path()));
748        assert_eq!(
749            file.socket.as_deref(),
750            Some(layout.socket.display().to_string().as_str())
751        );
752        // Socket mode default is 0600 (owner-only).
753        let mode = file.socket_mode.expect("socket-mode set");
754        assert_eq!(mode.0, 0o600);
755
756        std::fs::remove_dir_all(&dir).ok();
757    }
758
759    #[cfg(feature = "keystore-backend")]
760    #[test]
761    fn generated_keystore_config_loads_through_agent_config_file() {
762        let dir = temp_dir();
763        let args = args_for(InitBackend::Keystore, InitUnlock::AgeYubikey, &dir);
764        run(&args, None).expect("init writes");
765        let layout = Layout::new(&dir, None);
766
767        let overrides = crate::agent_cli::ConfigOverrides {
768            config: Some(layout.config),
769            catalog: None,
770            policy: None,
771            bundle: None,
772            socket: None,
773            vault_addr: None,
774        };
775        crate::agent_cli::load_config_file(&overrides)
776            .expect("agent parses generated keystore config (db-keystore-cipher key)");
777
778        std::fs::remove_dir_all(&dir).ok();
779    }
780
781    /// `--unlock tpm` generates `unlock-tpm = true` in the `[unlock]` section and
782    /// the next-steps prints a `bundle create ... --slot tpm` command.
783    #[test]
784    fn tpm_unlock_generates_config_and_bundle_command() {
785        let dir = Path::new("/unused-init-dir");
786        let layout = Layout::new(dir, None);
787        let args = args_for(InitBackend::Openbao, InitUnlock::Tpm, dir);
788
789        let toml = build_config_toml(&args, &layout);
790        assert!(
791            toml.contains("unlock-tpm = true"),
792            "tpm config must set unlock-tpm = true, got:\n{toml}"
793        );
794        assert!(!toml.contains("unlock-passphrase-file"), "got:\n{toml}");
795
796        // The printed `bundle create` command uses the real `tpm` slot value.
797        assert_eq!(bundle_init_slot_flag(&args), "tpm");
798    }
799
800    #[test]
801    fn passphrase_file_is_baked_into_config_and_bundle_command() {
802        let dir = Path::new("/unused-init-dir");
803        let layout = Layout::new(dir, None);
804        let passphrase = dir.join("passphrase.txt");
805        let args = InitArgs {
806            passphrase_file: Some(passphrase.clone()),
807            ..args_for(InitBackend::Openbao, InitUnlock::Passphrase, dir)
808        };
809
810        let toml = build_config_toml(&args, &layout);
811        assert!(
812            toml.contains(&format!(
813                "unlock-passphrase-file = \"{}\"",
814                passphrase.display()
815            )),
816            "passphrase config must use the provided file, got:\n{toml}"
817        );
818        assert_eq!(
819            bundle_init_slot_flag(&args),
820            format!("passphrase:file={}", passphrase.display())
821        );
822    }
823
824    #[test]
825    fn passphrase_file_requires_passphrase_unlock() {
826        let dir = temp_dir();
827        let args = InitArgs {
828            passphrase_file: Some(dir.join("passphrase.txt")),
829            ..args_for(InitBackend::Openbao, InitUnlock::Bip39, &dir)
830        };
831
832        let err = run(&args, None).expect_err("invalid unlock combination must fail");
833        assert!(
834            err.to_string().contains("--unlock passphrase"),
835            "got: {err}"
836        );
837    }
838
839    #[test]
840    fn current_uid_resolves_to_a_number() {
841        // Smoke: the real-uid resolver returns *some* uid and never panics.
842        // Mostly just asserting it ran; any u32 is acceptable.
843        let _uid = current_uid();
844    }
845
846    /// Socket precedence in the generated `basil-agent.toml` (basil-u00):
847    /// explicit `--socket <path>` > `BASIL_SOCKET` env var > `<dir>/basil.sock`.
848    /// Driven through the pure `resolve_socket` + `build_config_toml` so no
849    /// process env is touched (env-var tests are otherwise order-sensitive).
850    #[test]
851    fn socket_precedence_in_generated_config() {
852        let dir = Path::new("/unused-init-dir");
853        // (explicit --socket flag, BASIL_SOCKET env, expected socket path)
854        let cases = [
855            (
856                Some("/run/explicit.sock"),
857                Some("/run/env.sock"),
858                "/run/explicit.sock",
859            ),
860            (None, Some("/run/env.sock"), "/run/env.sock"),
861            (None, None, "/unused-init-dir/basil.sock"),
862        ];
863        for (flag, env, expected) in cases {
864            let resolved = resolve_socket(flag, env);
865            let layout = Layout::new(dir, resolved.as_deref());
866            assert_eq!(
867                layout.socket,
868                PathBuf::from(expected),
869                "resolve for flag={flag:?} env={env:?}"
870            );
871            let args = args_for(InitBackend::Openbao, InitUnlock::Bip39, dir);
872            let toml = build_config_toml(&args, &layout);
873            assert!(
874                toml.contains(&format!("socket = \"{expected}\"")),
875                "generated TOML must write socket = \"{expected}\" \
876                 for flag={flag:?} env={env:?}, got:\n{toml}"
877            );
878        }
879    }
880}