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`."
301                .to_string(),
302        },
303    );
304
305    Catalog {
306        schema_version: 1,
307        backends,
308        keys,
309    }
310}
311
312/// Build the least-privilege [`RawPolicy`]: one `signer` role (sign + verify +
313/// the public-key read verify needs), granted to **only** the running uid over
314/// **only** the one example key. Everything else is default-deny.
315fn build_policy(uid: u32) -> RawPolicy {
316    let mut roles = BTreeMap::new();
317    roles.insert(
318        SIGNER_ROLE.to_string(),
319        BTreeSet::from([Op::Sign, Op::Verify, Op::GetPublicKey]),
320    );
321
322    let rule = RawRule {
323        id: "running-user-may-sign-example-key".to_string(),
324        subjects: vec!["init.user".to_string()],
325        action: vec![format!("role:{SIGNER_ROLE}")],
326        target: vec![EXAMPLE_KEY.to_string()],
327        comment: Some(
328            "Least-privilege: only the uid that ran `basil init` may sign/verify \
329             the one example key. Everything else is default-deny."
330                .to_string(),
331        ),
332    };
333
334    let mut names = NameTable::default();
335    names.users.insert(uid, "init-user".to_string());
336    let mut memberships = BTreeMap::new();
337    memberships.insert(uid, BTreeSet::new());
338    let mut subjects = BTreeMap::new();
339    subjects.insert(
340        "init.user".to_string(),
341        RawSubjectDefinition {
342            break_glass: false,
343            all_of: Some(vec![PrincipalSpec::Unix {
344                uid: Some(uid),
345                gid: None,
346            }]),
347            any_of: None,
348        },
349    );
350
351    RawPolicy {
352        schema_version: 2,
353        subjects,
354        unauthenticated_subject: None,
355        roles,
356        rules: vec![rule],
357        config: Config { names, memberships },
358    }
359}
360
361/// Build the commented TOML agent config pointing at everything `init` writes.
362/// Comments are allowed in TOML (the catalog/policy JSON round-trip through the
363/// real types); the keystore arm adds the `db-keystore-cipher` line.
364fn build_config_toml(args: &InitArgs, layout: &Layout) -> String {
365    let mut out = String::new();
366    out.push_str("# basil-agent config scaffolded by `basil init`.\n");
367    out.push_str("# Edit the placeholders, create the sealed bundle (see the printed\n");
368    out.push_str(
369        "# next-steps), then `basil doctor --keys -c this-file` and `run -c this-file`.\n\n",
370    );
371    let _ = writeln!(out, "catalog = {}", toml_str(&layout.catalog));
372    let _ = writeln!(out, "policy = {}", toml_str(&layout.policy));
373    out.push_str("# The sealed bundle is NOT created by init. Create it with `bundle create`.\n");
374    let _ = writeln!(out, "bundle = {}", toml_str(&layout.bundle));
375    let _ = writeln!(out, "socket = {}", toml_str(&layout.socket));
376    out.push_str("# Socket mode defaults to 0600 (owner-only); widen deliberately if a peer\n");
377    out.push_str("# group must connect, e.g. socket-mode = \"0660\" + socket-group = \"basil\".\n");
378    out.push_str("socket-mode = \"0600\"\n");
379
380    if args.backend == InitBackend::Keystore {
381        out.push_str("\n# db-keystore backend: the local AEAD cipher for the at-rest DB.\n");
382        out.push_str("db-keystore-cipher = \"aegis256\"\n");
383    } else {
384        let _ = writeln!(out, "vault-addr = {}", toml_str_s(&args.addr));
385        let _ = writeln!(
386            out,
387            "transit-mount = {}",
388            toml_str_s(trim_mount(&args.transit_mount))
389        );
390    }
391
392    out.push('\n');
393    out.push_str("[unlock]\n");
394    match args.unlock {
395        InitUnlock::Bip39 => {
396            out.push_str(
397                "# Unlock with the `BIP39` break-glass phrase from `bundle create --slot bip39`.\n",
398            );
399            out.push_str(
400                "# TODO: point bip39-phrase-file at a 0600 file holding the 24-word phrase.\n",
401            );
402            out.push_str("bip39-phrase-file = \"REPLACE_WITH_PATH_TO_BIP39_PHRASE_FILE\"\n");
403        }
404        InitUnlock::Passphrase => {
405            out.push_str("# Unlock with a passphrase read from a 0600 file.\n");
406            out.push_str("# TODO: point unlock-passphrase-file at the runtime credential file.\n");
407            let passphrase_file = args.passphrase_file.as_deref().map_or_else(
408                || toml_str_s("REPLACE_WITH_PATH_TO_PASSPHRASE_FILE"),
409                toml_str,
410            );
411            let _ = writeln!(out, "unlock-passphrase-file = {passphrase_file}");
412        }
413        InitUnlock::Tpm => {
414            out.push_str("# Unlock with a TPM2 sealed slot bound to host PCR state.\n");
415            out.push_str("# Requires the broker built with --features unlock-tpm and a host TPM\n");
416            out.push_str("# (/dev/tpmrm0); availability is the runtime device probe, no secret.\n");
417            out.push_str("unlock-tpm = true\n");
418        }
419        InitUnlock::AgeYubikey => {
420            out.push_str("# Unlock with an enrolled age + age-plugin-yubikey hardware slot.\n");
421            out.push_str("age-yubikey = true\n");
422        }
423    }
424    out.push('\n');
425    out.push_str("[broker-identity]\n");
426    out.push_str("# Required when [invocation] enable = true.\n");
427    out.push_str("# id = \"basil://prod/us-east-1/agent-a\"\n");
428    out.push_str("# response-signing-key-id = \"broker.response_signing.2026q3\"\n");
429    out.push('\n');
430    out.push_str("[invocation]\n");
431    out.push_str("# Sealed bridged invocation is compiled in but disabled by default.\n");
432    out.push_str("enable = false\n");
433    out.push_str("# audience = [\"basil://prod/us-east-1/agent-a\"]\n");
434    out.push_str("# request-encryption-key-id = \"broker.request_encryption.2026q3\"\n");
435    out.push_str("# max-ttl-secs = 60\n");
436    out.push_str("# clock-skew-secs = 30\n");
437    out.push_str("# replay-cache-capacity = 4096\n");
438    out
439}
440
441/// Print the concrete next-steps: the exact `bundle create` for the chosen unlock
442/// method + backend cred, then `check`, `run`, and a `basil sign` round-trip.
443fn print_next_steps(args: &InitArgs, layout: &Layout, uid: u32) {
444    let cfg = layout.config.display();
445    println!(
446        "Scaffolded a {} starter set in {}:",
447        args.backend.label(),
448        layout.dir.display()
449    );
450    println!("  catalog: {}", layout.catalog.display());
451    println!(
452        "  policy:  {} (grants only uid {uid} sign/verify over `{EXAMPLE_KEY}`)",
453        layout.policy.display()
454    );
455    println!("  config:  {cfg}");
456    println!();
457    println!("init writes config/scaffolding ONLY: no secret material, and NOT the sealed bundle.");
458    println!();
459
460    println!("Next steps:");
461    println!();
462    println!("1. Create the sealed credential bundle (init cannot: it needs unlock material):");
463    print_bundle_init(args, layout);
464    println!();
465
466    if let Some(cli) = args.backend.server_cli() {
467        println!(
468            "   The bundle's backend credential must be a token for a running {}",
469            args.backend.label()
470        );
471        println!(
472            "   with the `{}` transit mount enabled. For a dev server:",
473            trim_mount(&args.transit_mount)
474        );
475        println!("       {cli} secrets enable transit");
476        println!(
477            "   (reconcile will create the `{EXAMPLE_KEY}` key on first run, missing=generate.)"
478        );
479    } else {
480        println!("   Build the agent with the keystore backend: --features db-keystore");
481        println!("   and seed a 32-byte DEK file for the bundle's DbKeystoreDek credential.");
482    }
483    println!();
484
485    println!("2. Validate the config (offline + authenticated key probe):");
486    println!("       basil doctor --keys -c {cfg}");
487    println!();
488    println!("3. Run the broker:");
489    println!("       basil agent -c {cfg}");
490    println!();
491    println!("4. Exercise the example key over the socket:");
492    println!(
493        "       basil --socket {} sign --key-id {EXAMPLE_KEY} 'hello basil'",
494        layout.socket.display()
495    );
496}
497
498/// Print the exact `basil bundle create ...` command for the chosen unlock
499/// method + backend, using only real flags.
500fn print_bundle_init(args: &InitArgs, layout: &Layout) {
501    let out = layout.bundle.display();
502    let slot = bundle_init_slot_flag(args);
503    let cred = match args.backend {
504        InitBackend::Openbao | InitBackend::Vault => {
505            format!(
506                "--backend id={BACKEND_NAME},type=openbao,addr=REPLACE_WITH_BACKEND_ADDR,token-file=REPLACE_WITH_BACKEND_TOKEN_FILE"
507            )
508        }
509        InitBackend::Keystore => {
510            format!(
511                "--backend id={BACKEND_NAME},type=db-keystore,path=REPLACE_WITH_DB_PATH,dek-file=REPLACE_WITH_PATH_TO_32BYTE_DEK_FILE"
512            )
513        }
514    };
515    println!("       basil bundle create {out} \\");
516    println!("           --slot {slot} \\");
517    println!("           {cred}");
518    if args.unlock == InitUnlock::Tpm {
519        println!(
520            "   (the TPM slot seals to THIS host's TPM at `bundle create` time; run it on \
521             the target host with /dev/tpmrm0 and a broker built with --features unlock-tpm.)"
522        );
523    }
524    if args.unlock == InitUnlock::AgeYubikey {
525        println!(
526            "   (age-yubikey needs a recipient in `--slot age-yubikey:recipient=...`; \
527             a bip39 break-glass slot is shown above so the bundle is creatable.)"
528        );
529    }
530}
531
532/// The `bundle create --slot` value the next-steps prints for the chosen unlock
533/// method.
534fn bundle_init_slot_flag(args: &InitArgs) -> String {
535    match args.unlock {
536        InitUnlock::Passphrase => {
537            let path = args.passphrase_file.as_deref().map_or_else(
538                || "REPLACE_WITH_PATH_TO_PASSPHRASE_FILE".to_string(),
539                |path| path.display().to_string(),
540            );
541            format!("passphrase:file={path}")
542        }
543        InitUnlock::Tpm => "tpm".to_string(),
544        InitUnlock::Bip39 | InitUnlock::AgeYubikey => "bip39".to_string(),
545    }
546}
547
548/// Resolve the unix-socket path written into the generated `basil-agent.toml`.
549///
550/// Precedence, highest first: `explicit` (the global `--socket <path>` flag),
551/// then `env` (the `BASIL_SOCKET` variable), then `None` so [`Layout::new`]
552/// falls back to `<dir>/basil.sock`. Kept as a pure two-argument function so the
553/// precedence is unit-testable without touching the process environment.
554fn resolve_socket(explicit: Option<&str>, env: Option<&str>) -> Option<String> {
555    explicit.or(env).map(str::to_owned)
556}
557
558/// Validate argument combinations before writing any scaffold files.
559fn validate_args(args: &InitArgs) -> Result<()> {
560    if args.passphrase_file.is_some() && args.unlock != InitUnlock::Passphrase {
561        bail!("--passphrase-file can only be used with --unlock passphrase");
562    }
563    Ok(())
564}
565
566/// Write a scaffold file (config/catalog/policy are non-secret; default perms).
567fn write_file(path: &Path, contents: &str) -> Result<()> {
568    std::fs::write(path, contents).with_context(|| format!("writing {}", path.display()))
569}
570
571/// Resolve the real uid of the running process, the authorization anchor the
572/// policy grant binds to, the same identity the broker proves at runtime via
573/// `SO_PEERCRED`. Uses `rustix`'s safe `getuid()` so it works on Linux and
574/// macOS alike (no `/proc` dependency) and never panics.
575fn current_uid() -> u32 {
576    rustix::process::getuid().as_raw()
577}
578
579/// Strip a single trailing `/` from a mount path so `path = "<mount>/keys/<k>"`
580/// never doubles the separator.
581fn trim_mount(mount: &str) -> &str {
582    mount.strip_suffix('/').unwrap_or(mount)
583}
584
585/// TOML-quote a path value.
586fn toml_str(path: &Path) -> String {
587    toml_str_s(&path.display().to_string())
588}
589
590/// TOML-quote a string value (basic-string escaping of `\` and `"`).
591fn toml_str_s(s: &str) -> String {
592    let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
593    format!("\"{escaped}\"")
594}
595
596#[cfg(test)]
597mod tests {
598    use super::*;
599
600    fn args_for(backend: InitBackend, unlock: InitUnlock, dir: &Path) -> InitArgs {
601        InitArgs {
602            backend,
603            unlock,
604            dir: dir.to_path_buf(),
605            addr: "http://127.0.0.1:8200".to_string(),
606            transit_mount: "transit".to_string(),
607            passphrase_file: None,
608            force: false,
609        }
610    }
611
612    fn temp_dir() -> PathBuf {
613        let p = std::env::temp_dir().join(format!(
614            "basil-init-test-{}-{}",
615            std::process::id(),
616            uuid::Uuid::new_v4()
617        ));
618        std::fs::create_dir_all(&p).expect("mk temp dir");
619        p
620    }
621
622    /// The load-bearing test: the generated catalog + policy for EVERY backend
623    /// kind pass the REAL loader/validation (the same `load` path `check` uses).
624    #[test]
625    fn generated_pair_passes_real_loader_for_every_backend() {
626        for backend in [
627            InitBackend::Openbao,
628            InitBackend::Vault,
629            InitBackend::Keystore,
630        ] {
631            let dir = temp_dir();
632            let layout = Layout::new(&dir, None);
633            let args = args_for(backend, InitUnlock::Bip39, &dir);
634            let catalog = build_catalog(&args, &layout);
635            let policy = build_policy(4242);
636
637            let catalog_json = serde_json::to_string_pretty(&catalog).expect("ser catalog");
638            let policy_json = serde_json::to_string_pretty(&policy).expect("ser policy");
639            let loaded = crate::load(&catalog_json, &policy_json)
640                .unwrap_or_else(|e| panic!("{backend:?} pair must load clean: {e}"));
641            let warnings = loaded.3;
642            assert!(
643                warnings.is_empty(),
644                "{backend:?} pair should load without warnings, got {warnings:?}"
645            );
646            std::fs::remove_dir_all(&dir).ok();
647        }
648    }
649
650    /// The policy grants ONLY the generated Unix subject for the running uid and
651    /// only over the one example key.
652    #[test]
653    fn policy_grants_only_the_running_uid() {
654        let uid = 9931;
655        let policy = build_policy(uid);
656        assert_eq!(policy.rules.len(), 1);
657        let rule = policy.rules.first().expect("one rule");
658        assert_eq!(rule.subjects, vec!["init.user".to_string()]);
659        assert_eq!(rule.target, vec![EXAMPLE_KEY.to_string()]);
660        assert_eq!(policy.subjects.len(), 1);
661        // The signer role is sign/verify/get_public_key only. No write ops.
662        let signer = policy.roles.get(SIGNER_ROLE).expect("signer role present");
663        assert!(
664            !signer.iter().any(|op| op.is_write()),
665            "signer role must hold no write op"
666        );
667    }
668
669    /// `run` refuses to clobber an existing target file without `--force`, and
670    /// overwrites with it.
671    #[test]
672    fn refuses_to_clobber_without_force() {
673        let dir = temp_dir();
674        let args = args_for(InitBackend::Openbao, InitUnlock::Bip39, &dir);
675
676        run(&args, None).expect("first init writes clean");
677        let layout = Layout::new(&dir, None);
678        assert!(layout.catalog.exists() && layout.policy.exists() && layout.config.exists());
679
680        // Second run without --force must refuse (and name the offenders).
681        let err = run(&args, None).expect_err("second init must refuse");
682        let msg = err.to_string();
683        assert!(msg.contains("refusing to overwrite"), "got: {msg}");
684
685        // With --force it overwrites.
686        let forced = InitArgs {
687            force: true,
688            ..args_for(InitBackend::Openbao, InitUnlock::Bip39, &dir)
689        };
690        run(&forced, None).expect("forced init overwrites");
691
692        std::fs::remove_dir_all(&dir).ok();
693    }
694
695    /// The written catalog/policy files on disk reload through the real loader
696    /// (end-to-end through `run`, not just the in-memory structs).
697    #[test]
698    fn written_files_reload_through_the_loader() {
699        let dir = temp_dir();
700        let args = args_for(InitBackend::Keystore, InitUnlock::Passphrase, &dir);
701        run(&args, None).expect("init writes");
702        let layout = Layout::new(&dir, None);
703
704        let catalog_json = std::fs::read_to_string(&layout.catalog).expect("read catalog");
705        let policy_json = std::fs::read_to_string(&layout.policy).expect("read policy");
706        crate::load(&catalog_json, &policy_json).expect("written pair must reload");
707
708        // The TOML config parses and points at the files init wrote.
709        let config = std::fs::read_to_string(&layout.config).expect("read config");
710        let parsed: toml::Value = toml::from_str(&config).expect("config is valid TOML");
711        assert_eq!(
712            parsed.get("catalog").and_then(toml::Value::as_str),
713            Some(layout.catalog.display().to_string().as_str())
714        );
715        // Socket mode defaults to 0600 (owner-only) in the generated config.
716        assert_eq!(
717            parsed.get("socket-mode").and_then(toml::Value::as_str),
718            Some("0600")
719        );
720
721        std::fs::remove_dir_all(&dir).ok();
722    }
723
724    /// The generated TOML config parses through the REAL `AgentConfigFile`
725    /// loader the daemon uses (`crate::load_config_file`), and resolves to the
726    /// catalog/policy/bundle/socket paths init wrote with the 0600 socket mode.
727    /// The vault/openbao arm needs no feature; the keystore arm emits a
728    /// feature-gated `db-keystore-cipher` so it is gated to match.
729    #[test]
730    fn generated_config_loads_through_agent_config_file() {
731        let dir = temp_dir();
732        let args = args_for(InitBackend::Openbao, InitUnlock::Bip39, &dir);
733        run(&args, None).expect("init writes");
734        let layout = Layout::new(&dir, None);
735
736        let overrides = crate::agent_cli::ConfigOverrides {
737            config: Some(layout.config.clone()),
738            catalog: None,
739            policy: None,
740            bundle: None,
741            socket: None,
742            vault_addr: None,
743        };
744        let file =
745            crate::agent_cli::load_config_file(&overrides).expect("agent parses generated config");
746        assert_eq!(file.catalog.as_deref(), Some(layout.catalog.as_path()));
747        assert_eq!(file.policy.as_deref(), Some(layout.policy.as_path()));
748        assert_eq!(file.bundle.as_deref(), Some(layout.bundle.as_path()));
749        assert_eq!(
750            file.socket.as_deref(),
751            Some(layout.socket.display().to_string().as_str())
752        );
753        // Socket mode default is 0600 (owner-only).
754        let mode = file.socket_mode.expect("socket-mode set");
755        assert_eq!(mode.0, 0o600);
756
757        std::fs::remove_dir_all(&dir).ok();
758    }
759
760    #[cfg(feature = "keystore-backend")]
761    #[test]
762    fn generated_keystore_config_loads_through_agent_config_file() {
763        let dir = temp_dir();
764        let args = args_for(InitBackend::Keystore, InitUnlock::AgeYubikey, &dir);
765        run(&args, None).expect("init writes");
766        let layout = Layout::new(&dir, None);
767
768        let overrides = crate::agent_cli::ConfigOverrides {
769            config: Some(layout.config),
770            catalog: None,
771            policy: None,
772            bundle: None,
773            socket: None,
774            vault_addr: None,
775        };
776        crate::agent_cli::load_config_file(&overrides)
777            .expect("agent parses generated keystore config (db-keystore-cipher key)");
778
779        std::fs::remove_dir_all(&dir).ok();
780    }
781
782    /// `--unlock tpm` generates `unlock-tpm = true` in the `[unlock]` section and
783    /// the next-steps prints a `bundle create ... --slot tpm` command.
784    #[test]
785    fn tpm_unlock_generates_config_and_bundle_command() {
786        let dir = Path::new("/unused-init-dir");
787        let layout = Layout::new(dir, None);
788        let args = args_for(InitBackend::Openbao, InitUnlock::Tpm, dir);
789
790        let toml = build_config_toml(&args, &layout);
791        assert!(
792            toml.contains("unlock-tpm = true"),
793            "tpm config must set unlock-tpm = true, got:\n{toml}"
794        );
795        assert!(!toml.contains("unlock-passphrase-file"), "got:\n{toml}");
796
797        // The printed `bundle create` command uses the real `tpm` slot value.
798        assert_eq!(bundle_init_slot_flag(&args), "tpm");
799    }
800
801    #[test]
802    fn passphrase_file_is_baked_into_config_and_bundle_command() {
803        let dir = Path::new("/unused-init-dir");
804        let layout = Layout::new(dir, None);
805        let passphrase = dir.join("passphrase.txt");
806        let args = InitArgs {
807            passphrase_file: Some(passphrase.clone()),
808            ..args_for(InitBackend::Openbao, InitUnlock::Passphrase, dir)
809        };
810
811        let toml = build_config_toml(&args, &layout);
812        assert!(
813            toml.contains(&format!(
814                "unlock-passphrase-file = \"{}\"",
815                passphrase.display()
816            )),
817            "passphrase config must use the provided file, got:\n{toml}"
818        );
819        assert_eq!(
820            bundle_init_slot_flag(&args),
821            format!("passphrase:file={}", passphrase.display())
822        );
823    }
824
825    #[test]
826    fn passphrase_file_requires_passphrase_unlock() {
827        let dir = temp_dir();
828        let args = InitArgs {
829            passphrase_file: Some(dir.join("passphrase.txt")),
830            ..args_for(InitBackend::Openbao, InitUnlock::Bip39, &dir)
831        };
832
833        let err = run(&args, None).expect_err("invalid unlock combination must fail");
834        assert!(
835            err.to_string().contains("--unlock passphrase"),
836            "got: {err}"
837        );
838    }
839
840    #[test]
841    fn current_uid_resolves_to_a_number() {
842        // Smoke: the real-uid resolver returns *some* uid and never panics.
843        // Mostly just asserting it ran; any u32 is acceptable.
844        let _uid = current_uid();
845    }
846
847    /// Socket precedence in the generated `basil-agent.toml` (basil-u00):
848    /// explicit `--socket <path>` > `BASIL_SOCKET` env var > `<dir>/basil.sock`.
849    /// Driven through the pure `resolve_socket` + `build_config_toml` so no
850    /// process env is touched (env-var tests are otherwise order-sensitive).
851    #[test]
852    fn socket_precedence_in_generated_config() {
853        let dir = Path::new("/unused-init-dir");
854        // (explicit --socket flag, BASIL_SOCKET env, expected socket path)
855        let cases = [
856            (
857                Some("/run/explicit.sock"),
858                Some("/run/env.sock"),
859                "/run/explicit.sock",
860            ),
861            (None, Some("/run/env.sock"), "/run/env.sock"),
862            (None, None, "/unused-init-dir/basil.sock"),
863        ];
864        for (flag, env, expected) in cases {
865            let resolved = resolve_socket(flag, env);
866            let layout = Layout::new(dir, resolved.as_deref());
867            assert_eq!(
868                layout.socket,
869                PathBuf::from(expected),
870                "resolve for flag={flag:?} env={env:?}"
871            );
872            let args = args_for(InitBackend::Openbao, InitUnlock::Bip39, dir);
873            let toml = build_config_toml(&args, &layout);
874            assert!(
875                toml.contains(&format!("socket = \"{expected}\"")),
876                "generated TOML must write socket = \"{expected}\" \
877                 for flag={flag:?} env={env:?}, got:\n{toml}"
878            );
879        }
880    }
881}