Skip to main content

basil_core/
init.rs

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