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/// The migration role `--from-sops` grants over the imported value stubs.
47const SOPS_ROLE: &str = "sops-migrator";
48
49/// `init` subcommand arguments.
50#[derive(Debug, Args)]
51pub struct InitArgs {
52    /// The backend the scaffolded broker will route its example key to.
53    #[arg(long, value_enum, default_value_t = InitBackend::Openbao)]
54    backend: InitBackend,
55
56    /// The unlock method whose `bundle create` command the next-steps output prints.
57    /// `init` never seals a bundle; this only selects which command to show.
58    #[arg(long, value_enum, default_value_t = InitUnlock::Bip39)]
59    unlock: InitUnlock,
60
61    /// Directory to write `catalog.json`, `policy.json`, and `basil-agent.toml`
62    /// into (created if absent). The sealed bundle + unix socket paths in the
63    /// generated config also live under here.
64    #[arg(long, value_name = "DIR", default_value = "./basil")]
65    dir: PathBuf,
66
67    /// Backend address for the `vault`/`openbao` backends (a Vault-compatible
68    /// HTTP URL). Ignored for the `keystore` backend, whose `addr` is a local DB
69    /// file path under the target dir.
70    #[arg(long, default_value = "http://127.0.0.1:8200")]
71    addr: String,
72
73    /// Transit secrets-engine mount the example key lives under (vault/openbao
74    /// only). The default matches a stock `transit` mount.
75    #[arg(long, default_value = "transit")]
76    transit_mount: String,
77
78    /// Existing 0600 passphrase file to bake into the generated
79    /// `unlock-passphrase-file` config and printed `bundle create --slot`
80    /// command. Only valid with `--unlock passphrase`.
81    #[arg(long, value_name = "PATH")]
82    passphrase_file: Option<PathBuf>,
83
84    /// Path to an existing sops secrets file (YAML or JSON). Adds one `value`
85    /// catalog entry and a read/write grant per secret found, so a sops-nix
86    /// migration starts from generated stubs instead of hand-authored JSON.
87    /// Only the key NAMES are read; the encrypted values are never touched
88    /// (the printed next-steps show the `sops -d`-to-`basil set` hand-off).
89    #[arg(long, value_name = "PATH")]
90    from_sops: Option<PathBuf>,
91
92    /// Overwrite any target file that already exists. Without it, `init` refuses
93    /// and reports which files are in the way (no clobber).
94    #[arg(long)]
95    force: bool,
96}
97
98/// The backend kind to scaffold for. `openbao` and `vault` share one wire API
99/// (one [`BackendKind::Vault`]) and differ only in the bundle-bootstrap CLI; the
100/// distinction is kept so the printed commands name the right binary.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
102enum InitBackend {
103    /// `OpenBao` (the `bao` CLI) over the Vault-compatible transit engine.
104    Openbao,
105    /// `HashiCorp` Vault (the `vault` CLI) over its transit engine.
106    Vault,
107    /// The local materialize-to-use db-keystore backend (no external server).
108    Keystore,
109}
110
111/// The unlock method whose `bundle create` invocation the next-steps prints.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
113enum InitUnlock {
114    /// A 24-word `BIP39` break-glass phrase (shown once at `bundle create`).
115    Bip39,
116    /// A production passphrase file.
117    Passphrase,
118    /// A TPM2 sealed slot bound to host PCR state (broker built with the
119    /// `unlock-tpm` feature; sealed to the host `TPM` at `bundle create` time).
120    Tpm,
121    /// An age + age-plugin-yubikey hardware slot (enrolled out of band).
122    AgeYubikey,
123}
124
125impl InitBackend {
126    /// The catalog [`BackendKind`] this scaffolds.
127    const fn kind(self) -> BackendKind {
128        match self {
129            Self::Openbao | Self::Vault => BackendKind::Vault,
130            Self::Keystore => BackendKind::Keystore,
131        }
132    }
133
134    /// The server CLI binary that bootstraps the engine + writes the cred token,
135    /// for the vault-family backends. `None` for keystore (no server).
136    const fn server_cli(self) -> Option<&'static str> {
137        match self {
138            Self::Openbao => Some("bao"),
139            Self::Vault => Some("vault"),
140            Self::Keystore => None,
141        }
142    }
143
144    /// Human label for the next-steps banner.
145    const fn label(self) -> &'static str {
146        match self {
147            Self::Openbao => "OpenBao",
148            Self::Vault => "HashiCorp Vault",
149            Self::Keystore => "db-keystore",
150        }
151    }
152}
153
154/// Paths of everything `init` writes, all under the target directory.
155struct Layout {
156    dir: PathBuf,
157    catalog: PathBuf,
158    policy: PathBuf,
159    config: PathBuf,
160    /// Where the operator is told to write the sealed bundle (init does NOT
161    /// create it).
162    bundle: PathBuf,
163    /// The unix socket the generated config binds.
164    socket: PathBuf,
165    /// (keystore only) the local DB file the keystore backend `addr` points at.
166    keystore_db: PathBuf,
167}
168
169impl Layout {
170    /// Build the target layout. `socket` is the caller-resolved socket path (see
171    /// [`resolve_socket`]); when `None` the socket falls back to
172    /// `<dir>/basil.sock`.
173    fn new(dir: &Path, socket: Option<&str>) -> Self {
174        Self {
175            dir: dir.to_path_buf(),
176            catalog: dir.join("catalog.json"),
177            policy: dir.join("policy.json"),
178            config: dir.join("basil-agent.toml"),
179            bundle: dir.join("bundle.sealed"),
180            socket: socket.map_or_else(|| dir.join("basil.sock"), PathBuf::from),
181            keystore_db: dir.join("keystore.db"),
182        }
183    }
184
185    /// The three files `init` writes (in clobber-check order).
186    fn written(&self) -> [&Path; 3] {
187        [&self.catalog, &self.policy, &self.config]
188    }
189}
190
191/// Run `basil init`: build the scaffolding, refuse to clobber unless `--force`,
192/// write the files, and print the next-steps summary.
193///
194/// `socket` is the resolved global `--socket <path>` flag. The socket written
195/// into `basil-agent.toml` follows this precedence (highest first): explicit
196/// `--socket <path>` > `BASIL_SOCKET` env var > `<dir>/basil.sock`. The clap
197/// global flag already folds `BASIL_SOCKET` into `socket`; the direct env read
198/// here keeps the precedence correct for non-clap callers too.
199pub fn run(args: &InitArgs, socket: Option<&str>) -> Result<()> {
200    validate_args(args)?;
201
202    let env_socket = std::env::var("BASIL_SOCKET").ok();
203    let socket = resolve_socket(socket, env_socket.as_deref());
204    let layout = Layout::new(&args.dir, socket.as_deref());
205
206    std::fs::create_dir_all(&layout.dir)
207        .with_context(|| format!("creating target dir {}", layout.dir.display()))?;
208
209    refuse_clobber(&layout, args.force)?;
210
211    let uid = current_uid();
212
213    let mut catalog = build_catalog(args, &layout);
214    let mut policy = build_policy(uid);
215    let sops_secrets = match args.from_sops.as_deref() {
216        Some(path) => {
217            let secrets = sops_secret_names(path)?;
218            add_sops_entries(&mut catalog, &mut policy, args, &secrets)?;
219            secrets
220        }
221        None => Vec::new(),
222    };
223
224    // Serialize the REAL schema/wire types (pretty), then validate the pair
225    // through the SAME loader `check`/`run` use: fail closed if the scaffold is
226    // somehow invalid rather than writing a broken starter set.
227    let catalog_json = serde_json::to_string_pretty(&catalog).context("serializing catalog")?;
228    let policy_json = serde_json::to_string_pretty(&policy).context("serializing policy")?;
229    crate::load(&catalog_json, &policy_json)
230        .context("the generated catalog/policy did not pass loader validation (internal bug)")?;
231
232    let config_toml = build_config_toml(args, &layout);
233
234    write_file(&layout.catalog, &format!("{catalog_json}\n"))?;
235    write_file(&layout.policy, &format!("{policy_json}\n"))?;
236    write_file(&layout.config, &config_toml)?;
237
238    print_next_steps(args, &layout, uid);
239    if let Some(path) = args.from_sops.as_deref() {
240        print_sops_next_steps(path, &layout, &sops_secrets);
241    }
242    Ok(())
243}
244
245/// Refuse to overwrite any existing target file unless `--force`, listing every
246/// offending path so the operator sees them all at once.
247fn refuse_clobber(layout: &Layout, force: bool) -> Result<()> {
248    if force {
249        return Ok(());
250    }
251    let existing: Vec<String> = layout
252        .written()
253        .into_iter()
254        .filter(|p| p.exists())
255        .map(|p| p.display().to_string())
256        .collect();
257    if existing.is_empty() {
258        return Ok(());
259    }
260    bail!(
261        "refusing to overwrite existing file(s): {}\n(pass --force to overwrite)",
262        existing.join(", ")
263    );
264}
265
266/// Build the one-key example [`Catalog`] for the chosen backend.
267///
268/// - vault/openbao: a **transit** Ed25519 signing key with `missing: generate`,
269///   so startup reconcile creates it in place on the first run.
270/// - keystore: a `kind: keystore` backend with an Ed25519 signing key over its
271///   in-keystore `transit` engine, also `missing: generate`.
272fn build_catalog(args: &InitArgs, layout: &Layout) -> Catalog {
273    let (addr, engines) = match args.backend {
274        InitBackend::Openbao | InitBackend::Vault => (args.addr.clone(), vec![Engine::Transit]),
275        InitBackend::Keystore => (
276            layout.keystore_db.display().to_string(),
277            vec![Engine::Transit, Engine::Kv2],
278        ),
279    };
280
281    let mut backends = BTreeMap::new();
282    backends.insert(
283        BACKEND_NAME.to_string(),
284        BackendRef {
285            kind: args.backend.kind(),
286            addr,
287            engines,
288            capabilities: Vec::new(),
289            mint_key_types: vec![KeyAlgorithm::Ed25519],
290            requires: Vec::new(),
291        },
292    );
293
294    // The transit key path: a BARE key name for vault/openbao (the transit
295    // backend composes the verb sub-path `transit/<verb>/<name>` itself, and the
296    // configured `transit-mount` is prepended, and a `<mount>/keys/<name>` catalog
297    // path would double the mount and 404 with "unsupported path", `vault-w3n`);
298    // a slugged name for the keystore. The catalog `path` is the backend-native
299    // locator, opaque to policy.
300    let path = match args.backend {
301        InitBackend::Openbao | InitBackend::Vault => "example-signing-key".to_string(),
302        InitBackend::Keystore => "example/signing-key".to_string(),
303    };
304
305    let mut keys = BTreeMap::new();
306    keys.insert(
307        EXAMPLE_KEY.to_string(),
308        KeyEntry {
309            class: Class::Asymmetric,
310            key_type: Some(KeyAlgorithm::Ed25519),
311            backend: BACKEND_NAME.to_string(),
312            engine: Some(Engine::Transit),
313            path,
314            public_path: None,
315            writable: true,
316            // Created in place by startup reconcile on first run.
317            missing: MissingPolicy::Generate,
318            generate: None,
319            sealing_pin: None,
320            labels: Labels::default(),
321            description: "Example Ed25519 signing key scaffolded by `basil init`.".to_string(),
322        },
323    );
324
325    Catalog {
326        schema_version: 1,
327        backends,
328        keys,
329    }
330}
331
332/// Build the least-privilege [`RawPolicy`]: one `signer` role (sign + verify +
333/// the public-key read verify needs), granted to **only** the running uid over
334/// **only** the one example key. Everything else is default-deny.
335fn build_policy(uid: u32) -> RawPolicy {
336    let mut roles = BTreeMap::new();
337    roles.insert(
338        SIGNER_ROLE.to_string(),
339        BTreeSet::from([Op::Sign, Op::Verify, Op::GetPublicKey]),
340    );
341
342    let rule = RawRule {
343        id: "running-user-may-sign-example-key".to_string(),
344        subjects: vec!["init.user".to_string()],
345        action: vec![format!("role:{SIGNER_ROLE}")],
346        target: vec![EXAMPLE_KEY.to_string()],
347        comment: Some(
348            "Least-privilege: only the uid that ran `basil init` may sign/verify \
349             the one example key. Everything else is default-deny."
350                .to_string(),
351        ),
352    };
353
354    let mut names = NameTable::default();
355    names.users.insert(uid, "init-user".to_string());
356    let mut memberships = BTreeMap::new();
357    memberships.insert(uid, BTreeSet::new());
358    let mut subjects = BTreeMap::new();
359    subjects.insert(
360        "init.user".to_string(),
361        RawSubjectDefinition {
362            break_glass: false,
363            all_of: Some(vec![PrincipalSpec::Unix {
364                uid: Some(uid),
365                gid: None,
366            }]),
367            any_of: None,
368        },
369    );
370
371    RawPolicy {
372        schema_version: 2,
373        subjects,
374        unauthenticated_subject: None,
375        roles,
376        rules: vec![rule],
377        config: Config { names, memberships },
378    }
379}
380
381/// Build the commented TOML agent config pointing at everything `init` writes.
382/// Comments are allowed in TOML (the catalog/policy JSON round-trip through the
383/// real types); the keystore arm adds the `db-keystore-cipher` line.
384fn build_config_toml(args: &InitArgs, layout: &Layout) -> String {
385    let mut out = String::new();
386    out.push_str("# basil-agent config scaffolded by `basil init`.\n");
387    out.push_str("# Edit the placeholders, create the sealed bundle (see the printed\n");
388    out.push_str(
389        "# next-steps), then `basil doctor --keys -c this-file` and `run -c this-file`.\n\n",
390    );
391    let _ = writeln!(out, "catalog = {}", toml_str(&layout.catalog));
392    let _ = writeln!(out, "policy = {}", toml_str(&layout.policy));
393    out.push_str("# The sealed bundle is NOT created by init. Create it with `bundle create`.\n");
394    let _ = writeln!(out, "bundle = {}", toml_str(&layout.bundle));
395    let _ = writeln!(out, "socket = {}", toml_str(&layout.socket));
396    out.push_str("# Socket mode defaults to 0600 (owner-only); widen deliberately if a peer\n");
397    out.push_str("# group must connect, e.g. socket-mode = \"0660\" + socket-group = \"basil\".\n");
398    out.push_str("socket-mode = \"0600\"\n");
399
400    if args.backend == InitBackend::Keystore {
401        out.push_str("\n# db-keystore backend: the local AEAD cipher for the at-rest DB.\n");
402        out.push_str("db-keystore-cipher = \"aegis256\"\n");
403    } else {
404        let _ = writeln!(out, "vault-addr = {}", toml_str_s(&args.addr));
405        let _ = writeln!(
406            out,
407            "transit-mount = {}",
408            toml_str_s(trim_mount(&args.transit_mount))
409        );
410    }
411
412    out.push('\n');
413    out.push_str("[unlock]\n");
414    match args.unlock {
415        InitUnlock::Bip39 => {
416            out.push_str(
417                "# Unlock with the `BIP39` break-glass phrase from `bundle create --slot bip39`.\n",
418            );
419            out.push_str(
420                "# TODO: point bip39-phrase-file at a 0600 file holding the 24-word phrase.\n",
421            );
422            out.push_str("bip39-phrase-file = \"REPLACE_WITH_PATH_TO_BIP39_PHRASE_FILE\"\n");
423        }
424        InitUnlock::Passphrase => {
425            out.push_str("# Unlock with a passphrase read from a 0600 file.\n");
426            out.push_str("# TODO: point unlock-passphrase-file at the runtime credential file.\n");
427            let passphrase_file = args.passphrase_file.as_deref().map_or_else(
428                || toml_str_s("REPLACE_WITH_PATH_TO_PASSPHRASE_FILE"),
429                toml_str,
430            );
431            let _ = writeln!(out, "unlock-passphrase-file = {passphrase_file}");
432        }
433        InitUnlock::Tpm => {
434            out.push_str("# Unlock with a TPM2 sealed slot bound to host PCR state.\n");
435            out.push_str("# Requires the broker built with --features unlock-tpm and a host TPM\n");
436            out.push_str("# (/dev/tpmrm0); availability is the runtime device probe, no secret.\n");
437            out.push_str("unlock-tpm = true\n");
438        }
439        InitUnlock::AgeYubikey => {
440            out.push_str("# Unlock with an enrolled age + age-plugin-yubikey hardware slot.\n");
441            out.push_str("age-yubikey = true\n");
442        }
443    }
444    out.push('\n');
445    out.push_str("[broker-identity]\n");
446    out.push_str("# Required when [invocation] enable = true.\n");
447    out.push_str("# id = \"basil://prod/us-east-1/agent-a\"\n");
448    out.push_str("# response-signing-key-id = \"broker.response_signing.2026q3\"\n");
449    out.push('\n');
450    out.push_str("[invocation]\n");
451    out.push_str("# Sealed bridged invocation is compiled in but disabled by default.\n");
452    out.push_str("enable = false\n");
453    out.push_str("# audience = [\"basil://prod/us-east-1/agent-a\"]\n");
454    out.push_str("# request-encryption-key-id = \"broker.request_encryption.2026q3\"\n");
455    out.push_str("# max-ttl-secs = 60\n");
456    out.push_str("# clock-skew-secs = 30\n");
457    out.push_str("# replay-cache-capacity = 4096\n");
458    out
459}
460
461/// Print the concrete next-steps: the exact `bundle create` for the chosen unlock
462/// method + backend cred, then `check`, `run`, and a `basil sign` round-trip.
463fn print_next_steps(args: &InitArgs, layout: &Layout, uid: u32) {
464    let cfg = layout.config.display();
465    println!(
466        "Scaffolded a {} starter set in {}:",
467        args.backend.label(),
468        layout.dir.display()
469    );
470    println!("  catalog: {}", layout.catalog.display());
471    println!(
472        "  policy:  {} (grants only uid {uid} sign/verify over `{EXAMPLE_KEY}`)",
473        layout.policy.display()
474    );
475    println!("  config:  {cfg}");
476    println!();
477    println!("init writes config/scaffolding ONLY: no secret material, and NOT the sealed bundle.");
478    println!();
479
480    println!("Next steps:");
481    println!();
482    println!("1. Create the sealed credential bundle (init cannot: it needs unlock material):");
483    print_bundle_init(args, layout);
484    println!();
485
486    if let Some(cli) = args.backend.server_cli() {
487        println!(
488            "   The bundle's backend credential must be a token for a running {}",
489            args.backend.label()
490        );
491        println!(
492            "   with the `{}` transit mount enabled. For a dev server:",
493            trim_mount(&args.transit_mount)
494        );
495        println!("       {cli} secrets enable transit");
496        println!(
497            "   (reconcile will create the `{EXAMPLE_KEY}` key on first run, missing=generate.)"
498        );
499    } else {
500        println!("   Build the agent with the keystore backend: --features db-keystore");
501        println!("   and seed a 32-byte DEK file for the bundle's DbKeystoreDek credential.");
502    }
503    println!();
504
505    println!("2. Validate the config (offline + authenticated key probe):");
506    println!("       basil doctor --keys -c {cfg}");
507    println!();
508    println!("3. Run the broker:");
509    println!("       basil agent -c {cfg}");
510    println!();
511    println!("4. Exercise the example key over the socket:");
512    println!(
513        "       basil --socket {} sign --key-id {EXAMPLE_KEY} 'hello basil'",
514        layout.socket.display()
515    );
516}
517
518/// Print the exact `basil bundle create ...` command for the chosen unlock
519/// method + backend, using only real flags.
520fn print_bundle_init(args: &InitArgs, layout: &Layout) {
521    let out = layout.bundle.display();
522    let slot = bundle_init_slot_flag(args);
523    let cred = match args.backend {
524        InitBackend::Openbao | InitBackend::Vault => {
525            format!(
526                "--backend id={BACKEND_NAME},type=openbao,addr=REPLACE_WITH_BACKEND_ADDR,token-file=REPLACE_WITH_BACKEND_TOKEN_FILE"
527            )
528        }
529        InitBackend::Keystore => {
530            format!(
531                "--backend id={BACKEND_NAME},type=db-keystore,path=REPLACE_WITH_DB_PATH,dek-file=REPLACE_WITH_PATH_TO_32BYTE_DEK_FILE"
532            )
533        }
534    };
535    println!("       basil bundle create {out} \\");
536    println!("           --slot {slot} \\");
537    println!("           {cred}");
538    if args.unlock == InitUnlock::Tpm {
539        println!(
540            "   (the TPM slot seals to THIS host's TPM at `bundle create` time; run it on \
541             the target host with /dev/tpmrm0 and a broker built with --features unlock-tpm.)"
542        );
543    }
544    if args.unlock == InitUnlock::AgeYubikey {
545        println!(
546            "   (age-yubikey needs a recipient in `--slot age-yubikey:recipient=...`; \
547             a bip39 break-glass slot is shown above so the bundle is creatable.)"
548        );
549    }
550}
551
552/// The `bundle create --slot` value the next-steps prints for the chosen unlock
553/// method.
554fn bundle_init_slot_flag(args: &InitArgs) -> String {
555    match args.unlock {
556        InitUnlock::Passphrase => {
557            let path = args.passphrase_file.as_deref().map_or_else(
558                || "REPLACE_WITH_PATH_TO_PASSPHRASE_FILE".to_string(),
559                |path| path.display().to_string(),
560            );
561            format!("passphrase:file={path}")
562        }
563        InitUnlock::Tpm => "tpm".to_string(),
564        InitUnlock::Bip39 | InitUnlock::AgeYubikey => "bip39".to_string(),
565    }
566}
567
568/// One secret discovered in a sops file: the original key path (for the
569/// printed `sops -d --extract` hand-off) and the derived catalog name.
570#[derive(Debug)]
571struct SopsSecret {
572    /// The key path inside the sops document, outermost first.
573    segments: Vec<String>,
574    /// The sanitized dotted catalog key name (`app.db_password`).
575    name: String,
576}
577
578/// Read the secret NAMES out of a sops file (YAML or JSON; YAML is a strict
579/// superset, so one parser covers both). Only the mapping structure is used;
580/// the encrypted values are never interpreted. The top-level `sops` metadata
581/// block is skipped.
582fn sops_secret_names(path: &Path) -> Result<Vec<SopsSecret>> {
583    let raw = std::fs::read_to_string(path)
584        .with_context(|| format!("reading sops file {}", path.display()))?;
585    let doc: serde_yaml::Value = serde_yaml::from_str(&raw)
586        .with_context(|| format!("parsing {} as YAML/JSON", path.display()))?;
587    let serde_yaml::Value::Mapping(mapping) = doc else {
588        bail!(
589            "{} is not a mapping at the top level; sops secrets files are key/value documents",
590            path.display()
591        );
592    };
593
594    let mut out = Vec::new();
595    for (key, value) in &mapping {
596        let Some(key) = key.as_str() else { continue };
597        // The sops envelope's own metadata is not a secret.
598        if key == "sops" {
599            continue;
600        }
601        flatten_sops(value, &[key.to_string()], &mut out);
602    }
603    if out.is_empty() {
604        bail!(
605            "{} holds no secrets to import (nothing but the sops metadata block?)",
606            path.display()
607        );
608    }
609    Ok(out)
610}
611
612/// Depth-first flatten: nested mappings extend the dotted name; every other
613/// node (scalar, sequence) is one secret leaf.
614fn flatten_sops(value: &serde_yaml::Value, segments: &[String], out: &mut Vec<SopsSecret>) {
615    if let serde_yaml::Value::Mapping(mapping) = value {
616        for (key, child) in mapping {
617            let Some(key) = key.as_str() else { continue };
618            let mut next = segments.to_vec();
619            next.push(key.to_string());
620            flatten_sops(child, &next, out);
621        }
622        return;
623    }
624    let name = segments
625        .iter()
626        .map(|s| sanitize_sops_segment(s))
627        .collect::<Vec<_>>()
628        .join(".");
629    out.push(SopsSecret {
630        segments: segments.to_vec(),
631        name,
632    });
633}
634
635/// Catalog key names stay on a conservative charset; anything else becomes `-`.
636fn sanitize_sops_segment(segment: &str) -> String {
637    segment
638        .chars()
639        .map(|c| {
640            if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
641                c
642            } else {
643                '-'
644            }
645        })
646        .collect()
647}
648
649/// Add one `value` catalog entry + a get/set grant per imported sops secret.
650/// Entries are `missing: warn` stubs: the broker boots before the values are
651/// migrated, and `doctor --keys` lists what is still absent.
652fn add_sops_entries(
653    catalog: &mut Catalog,
654    policy: &mut RawPolicy,
655    args: &InitArgs,
656    secrets: &[SopsSecret],
657) -> Result<()> {
658    let backend = catalog
659        .backends
660        .get_mut(BACKEND_NAME)
661        .context("init catalog is missing its own backend (internal bug)")?;
662    if !backend.engines.contains(&Engine::Kv2) {
663        backend.engines.push(Engine::Kv2);
664    }
665
666    for secret in secrets {
667        let slug = secret
668            .segments
669            .iter()
670            .map(|s| sanitize_sops_segment(s))
671            .collect::<Vec<_>>()
672            .join("/");
673        // The backend-native locator: mount-qualified for the vault-family KV
674        // v2 engine, a bare path for the keystore.
675        let path = match args.backend {
676            InitBackend::Openbao | InitBackend::Vault => format!("secret/data/sops/{slug}"),
677            InitBackend::Keystore => format!("sops/{slug}"),
678        };
679        if catalog
680            .keys
681            .insert(
682                secret.name.clone(),
683                KeyEntry {
684                    class: Class::Value,
685                    key_type: None,
686                    backend: BACKEND_NAME.to_string(),
687                    engine: Some(Engine::Kv2),
688                    path,
689                    public_path: None,
690                    writable: true,
691                    missing: MissingPolicy::Warn,
692                    generate: None,
693                    sealing_pin: None,
694                    labels: Labels::default(),
695                    description: format!(
696                        "Imported from sops key `{}` by `basil init --from-sops`; value still \
697                         lives in sops until migrated with `basil set`.",
698                        secret.segments.join(".")
699                    ),
700                },
701            )
702            .is_some()
703        {
704            bail!(
705                "sops import produced the duplicate catalog key `{}` (two sops paths sanitize \
706                 to the same name); rename one in the sops file first",
707                secret.name
708            );
709        }
710    }
711
712    policy
713        .roles
714        .insert(SOPS_ROLE.to_string(), BTreeSet::from([Op::Get, Op::Set]));
715    policy.rules.push(RawRule {
716        id: "sops-migration-read-write".to_string(),
717        subjects: vec!["init.user".to_string()],
718        action: vec![format!("role:{SOPS_ROLE}")],
719        target: secrets.iter().map(|s| s.name.clone()).collect(),
720        comment: Some(
721            "Migration grant: the uid that ran `basil init --from-sops` may write (migrate) \
722             and read the imported secrets. Drop `set` from the role once migration is done."
723                .to_string(),
724        ),
725    });
726    Ok(())
727}
728
729/// Print the per-secret migration hand-off: `sops -d --extract` piped into
730/// `basil set`, so the plaintext only ever transits the operator's shell.
731fn print_sops_next_steps(sops_path: &Path, layout: &Layout, secrets: &[SopsSecret]) {
732    println!();
733    println!(
734        "Imported {} secret name(s) from {} as `value` catalog stubs (missing: warn).",
735        secrets.len(),
736        sops_path.display()
737    );
738    println!("The encrypted values stay in sops until you migrate each one:");
739    println!();
740    for secret in secrets {
741        let mut extract = String::new();
742        for segment in &secret.segments {
743            let _ = write!(extract, "[\"{segment}\"]");
744        }
745        println!(
746            "    basil --socket {} set --key-id {} \"$(sops -d --extract '{extract}' {})\"",
747            layout.socket.display(),
748            secret.name,
749            sops_path.display()
750        );
751    }
752    println!();
753    println!("Then verify with `basil doctor --keys` and retire the sops entries.");
754}
755
756/// Resolve the unix-socket path written into the generated `basil-agent.toml`.
757///
758/// Precedence, highest first: `explicit` (the global `--socket <path>` flag),
759/// then `env` (the `BASIL_SOCKET` variable), then `None` so [`Layout::new`]
760/// falls back to `<dir>/basil.sock`. Kept as a pure two-argument function so the
761/// precedence is unit-testable without touching the process environment.
762fn resolve_socket(explicit: Option<&str>, env: Option<&str>) -> Option<String> {
763    explicit.or(env).map(str::to_owned)
764}
765
766/// Validate argument combinations before writing any scaffold files.
767fn validate_args(args: &InitArgs) -> Result<()> {
768    if args.passphrase_file.is_some() && args.unlock != InitUnlock::Passphrase {
769        bail!("--passphrase-file can only be used with --unlock passphrase");
770    }
771    Ok(())
772}
773
774/// Write a scaffold file (config/catalog/policy are non-secret; default perms).
775fn write_file(path: &Path, contents: &str) -> Result<()> {
776    std::fs::write(path, contents).with_context(|| format!("writing {}", path.display()))
777}
778
779/// Resolve the real uid of the running process, the authorization anchor the
780/// policy grant binds to, the same identity the broker proves at runtime via
781/// `SO_PEERCRED`. Uses `rustix`'s safe `getuid()` so it works on Linux and
782/// macOS alike (no `/proc` dependency) and never panics.
783fn current_uid() -> u32 {
784    rustix::process::getuid().as_raw()
785}
786
787/// Strip a single trailing `/` from a mount path so `path = "<mount>/keys/<k>"`
788/// never doubles the separator.
789fn trim_mount(mount: &str) -> &str {
790    mount.strip_suffix('/').unwrap_or(mount)
791}
792
793/// TOML-quote a path value.
794fn toml_str(path: &Path) -> String {
795    toml_str_s(&path.display().to_string())
796}
797
798/// TOML-quote a string value (basic-string escaping of `\` and `"`).
799fn toml_str_s(s: &str) -> String {
800    let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
801    format!("\"{escaped}\"")
802}
803
804#[cfg(test)]
805mod tests {
806    use super::*;
807
808    fn args_for(backend: InitBackend, unlock: InitUnlock, dir: &Path) -> InitArgs {
809        InitArgs {
810            backend,
811            unlock,
812            dir: dir.to_path_buf(),
813            addr: "http://127.0.0.1:8200".to_string(),
814            transit_mount: "transit".to_string(),
815            passphrase_file: None,
816            from_sops: None,
817            force: false,
818        }
819    }
820
821    fn temp_dir() -> PathBuf {
822        let p = std::env::temp_dir().join(format!(
823            "basil-init-test-{}-{}",
824            std::process::id(),
825            uuid::Uuid::new_v4()
826        ));
827        std::fs::create_dir_all(&p).expect("mk temp dir");
828        p
829    }
830
831    /// The load-bearing test: the generated catalog + policy for EVERY backend
832    /// kind pass the REAL loader/validation (the same `load` path `check` uses).
833    #[test]
834    fn generated_pair_passes_real_loader_for_every_backend() {
835        for backend in [
836            InitBackend::Openbao,
837            InitBackend::Vault,
838            InitBackend::Keystore,
839        ] {
840            let dir = temp_dir();
841            let layout = Layout::new(&dir, None);
842            let args = args_for(backend, InitUnlock::Bip39, &dir);
843            let catalog = build_catalog(&args, &layout);
844            let policy = build_policy(4242);
845
846            let catalog_json = serde_json::to_string_pretty(&catalog).expect("ser catalog");
847            let policy_json = serde_json::to_string_pretty(&policy).expect("ser policy");
848            let loaded = crate::load(&catalog_json, &policy_json)
849                .unwrap_or_else(|e| panic!("{backend:?} pair must load clean: {e}"));
850            let warnings = loaded.3;
851            assert!(
852                warnings.is_empty(),
853                "{backend:?} pair should load without warnings, got {warnings:?}"
854            );
855            std::fs::remove_dir_all(&dir).ok();
856        }
857    }
858
859    /// The policy grants ONLY the generated Unix subject for the running uid and
860    /// only over the one example key.
861    #[test]
862    fn policy_grants_only_the_running_uid() {
863        let uid = 9931;
864        let policy = build_policy(uid);
865        assert_eq!(policy.rules.len(), 1);
866        let rule = policy.rules.first().expect("one rule");
867        assert_eq!(rule.subjects, vec!["init.user".to_string()]);
868        assert_eq!(rule.target, vec![EXAMPLE_KEY.to_string()]);
869        assert_eq!(policy.subjects.len(), 1);
870        // The signer role is sign/verify/get_public_key only. No write ops.
871        let signer = policy.roles.get(SIGNER_ROLE).expect("signer role present");
872        assert!(
873            !signer.iter().any(|op| op.is_write()),
874            "signer role must hold no write op"
875        );
876    }
877
878    /// `run` refuses to clobber an existing target file without `--force`, and
879    /// overwrites with it.
880    #[test]
881    fn refuses_to_clobber_without_force() {
882        let dir = temp_dir();
883        let args = args_for(InitBackend::Openbao, InitUnlock::Bip39, &dir);
884
885        run(&args, None).expect("first init writes clean");
886        let layout = Layout::new(&dir, None);
887        assert!(layout.catalog.exists() && layout.policy.exists() && layout.config.exists());
888
889        // Second run without --force must refuse (and name the offenders).
890        let err = run(&args, None).expect_err("second init must refuse");
891        let msg = err.to_string();
892        assert!(msg.contains("refusing to overwrite"), "got: {msg}");
893
894        // With --force it overwrites.
895        let forced = InitArgs {
896            force: true,
897            ..args_for(InitBackend::Openbao, InitUnlock::Bip39, &dir)
898        };
899        run(&forced, None).expect("forced init overwrites");
900
901        std::fs::remove_dir_all(&dir).ok();
902    }
903
904    /// The written catalog/policy files on disk reload through the real loader
905    /// (end-to-end through `run`, not just the in-memory structs).
906    #[test]
907    fn written_files_reload_through_the_loader() {
908        let dir = temp_dir();
909        let args = args_for(InitBackend::Keystore, InitUnlock::Passphrase, &dir);
910        run(&args, None).expect("init writes");
911        let layout = Layout::new(&dir, None);
912
913        let catalog_json = std::fs::read_to_string(&layout.catalog).expect("read catalog");
914        let policy_json = std::fs::read_to_string(&layout.policy).expect("read policy");
915        crate::load(&catalog_json, &policy_json).expect("written pair must reload");
916
917        // The TOML config parses and points at the files init wrote.
918        let config = std::fs::read_to_string(&layout.config).expect("read config");
919        let parsed: toml::Value = toml::from_str(&config).expect("config is valid TOML");
920        assert_eq!(
921            parsed.get("catalog").and_then(toml::Value::as_str),
922            Some(layout.catalog.display().to_string().as_str())
923        );
924        // Socket mode defaults to 0600 (owner-only) in the generated config.
925        assert_eq!(
926            parsed.get("socket-mode").and_then(toml::Value::as_str),
927            Some("0600")
928        );
929
930        std::fs::remove_dir_all(&dir).ok();
931    }
932
933    /// The generated TOML config parses through the REAL `AgentConfigFile`
934    /// loader the daemon uses (`crate::load_config_file`), and resolves to the
935    /// catalog/policy/bundle/socket paths init wrote with the 0600 socket mode.
936    /// The vault/openbao arm needs no feature; the keystore arm emits a
937    /// feature-gated `db-keystore-cipher` so it is gated to match.
938    #[test]
939    fn generated_config_loads_through_agent_config_file() {
940        let dir = temp_dir();
941        let args = args_for(InitBackend::Openbao, InitUnlock::Bip39, &dir);
942        run(&args, None).expect("init writes");
943        let layout = Layout::new(&dir, None);
944
945        let overrides = crate::agent_cli::ConfigOverrides {
946            config: Some(layout.config.clone()),
947            catalog: None,
948            policy: None,
949            bundle: None,
950            socket: None,
951            vault_addr: None,
952        };
953        let file =
954            crate::agent_cli::load_config_file(&overrides).expect("agent parses generated config");
955        assert_eq!(file.catalog.as_deref(), Some(layout.catalog.as_path()));
956        assert_eq!(file.policy.as_deref(), Some(layout.policy.as_path()));
957        assert_eq!(file.bundle.as_deref(), Some(layout.bundle.as_path()));
958        assert_eq!(
959            file.socket.as_deref(),
960            Some(layout.socket.display().to_string().as_str())
961        );
962        // Socket mode default is 0600 (owner-only).
963        let mode = file.socket_mode.expect("socket-mode set");
964        assert_eq!(mode.0, 0o600);
965
966        std::fs::remove_dir_all(&dir).ok();
967    }
968
969    #[cfg(feature = "keystore-backend")]
970    #[test]
971    fn generated_keystore_config_loads_through_agent_config_file() {
972        let dir = temp_dir();
973        let args = args_for(InitBackend::Keystore, InitUnlock::AgeYubikey, &dir);
974        run(&args, None).expect("init writes");
975        let layout = Layout::new(&dir, None);
976
977        let overrides = crate::agent_cli::ConfigOverrides {
978            config: Some(layout.config),
979            catalog: None,
980            policy: None,
981            bundle: None,
982            socket: None,
983            vault_addr: None,
984        };
985        crate::agent_cli::load_config_file(&overrides)
986            .expect("agent parses generated keystore config (db-keystore-cipher key)");
987
988        std::fs::remove_dir_all(&dir).ok();
989    }
990
991    /// `--unlock tpm` generates `unlock-tpm = true` in the `[unlock]` section and
992    /// the next-steps prints a `bundle create ... --slot tpm` command.
993    #[test]
994    fn tpm_unlock_generates_config_and_bundle_command() {
995        let dir = Path::new("/unused-init-dir");
996        let layout = Layout::new(dir, None);
997        let args = args_for(InitBackend::Openbao, InitUnlock::Tpm, dir);
998
999        let toml = build_config_toml(&args, &layout);
1000        assert!(
1001            toml.contains("unlock-tpm = true"),
1002            "tpm config must set unlock-tpm = true, got:\n{toml}"
1003        );
1004        assert!(!toml.contains("unlock-passphrase-file"), "got:\n{toml}");
1005
1006        // The printed `bundle create` command uses the real `tpm` slot value.
1007        assert_eq!(bundle_init_slot_flag(&args), "tpm");
1008    }
1009
1010    #[test]
1011    fn passphrase_file_is_baked_into_config_and_bundle_command() {
1012        let dir = Path::new("/unused-init-dir");
1013        let layout = Layout::new(dir, None);
1014        let passphrase = dir.join("passphrase.txt");
1015        let args = InitArgs {
1016            passphrase_file: Some(passphrase.clone()),
1017            ..args_for(InitBackend::Openbao, InitUnlock::Passphrase, dir)
1018        };
1019
1020        let toml = build_config_toml(&args, &layout);
1021        assert!(
1022            toml.contains(&format!(
1023                "unlock-passphrase-file = \"{}\"",
1024                passphrase.display()
1025            )),
1026            "passphrase config must use the provided file, got:\n{toml}"
1027        );
1028        assert_eq!(
1029            bundle_init_slot_flag(&args),
1030            format!("passphrase:file={}", passphrase.display())
1031        );
1032    }
1033
1034    #[test]
1035    fn passphrase_file_requires_passphrase_unlock() {
1036        let dir = temp_dir();
1037        let args = InitArgs {
1038            passphrase_file: Some(dir.join("passphrase.txt")),
1039            ..args_for(InitBackend::Openbao, InitUnlock::Bip39, &dir)
1040        };
1041
1042        let err = run(&args, None).expect_err("invalid unlock combination must fail");
1043        assert!(
1044            err.to_string().contains("--unlock passphrase"),
1045            "got: {err}"
1046        );
1047    }
1048
1049    /// A realistic sops YAML: nested keys flatten to dotted names, the `sops`
1050    /// metadata block is skipped, and odd characters sanitize to `-`.
1051    #[test]
1052    fn sops_names_flatten_skip_metadata_and_sanitize() {
1053        let dir = temp_dir();
1054        let sops = dir.join("secrets.yaml");
1055        std::fs::write(
1056            &sops,
1057            concat!(
1058                "db_password: ENC[AES256_GCM,data:abc,type:str]\n",
1059                "app:\n",
1060                "  api/token: ENC[AES256_GCM,data:def,type:str]\n",
1061                "  nested:\n",
1062                "    deep: ENC[AES256_GCM,data:ghi,type:str]\n",
1063                "sops:\n",
1064                "  kms: []\n",
1065                "  lastmodified: \"2026-07-01T00:00:00Z\"\n",
1066            ),
1067        )
1068        .expect("write sops fixture");
1069
1070        let secrets = sops_secret_names(&sops).expect("parse sops fixture");
1071        let names: Vec<&str> = secrets.iter().map(|s| s.name.as_str()).collect();
1072        assert_eq!(names, ["db_password", "app.api-token", "app.nested.deep"]);
1073        // The extract path keeps the ORIGINAL segments, not the sanitized ones.
1074        let token = secrets.get(1).expect("second secret");
1075        assert_eq!(token.segments, ["app", "api/token"]);
1076
1077        std::fs::remove_dir_all(&dir).ok();
1078    }
1079
1080    /// `--from-sops` catalogs pass the real loader for every backend, carry
1081    /// `value`/kv2 stubs with `missing: warn`, and grant get+set to only the
1082    /// init user.
1083    #[test]
1084    fn from_sops_pair_passes_loader_for_every_backend() {
1085        for backend in [
1086            InitBackend::Openbao,
1087            InitBackend::Vault,
1088            InitBackend::Keystore,
1089        ] {
1090            let dir = temp_dir();
1091            let sops = dir.join("secrets.yaml");
1092            std::fs::write(&sops, "wg_key: ENC[...]\napp:\n  db: ENC[...]\n")
1093                .expect("write sops fixture");
1094            let args = InitArgs {
1095                from_sops: Some(sops.clone()),
1096                ..args_for(backend, InitUnlock::Bip39, &dir)
1097            };
1098            let layout = Layout::new(&dir, None);
1099            let mut catalog = build_catalog(&args, &layout);
1100            let mut policy = build_policy(4242);
1101            let secrets = sops_secret_names(&sops).expect("names");
1102            add_sops_entries(&mut catalog, &mut policy, &args, &secrets).expect("augment");
1103
1104            let entry = catalog.keys.get("app.db").expect("imported entry");
1105            assert_eq!(entry.class, Class::Value);
1106            assert_eq!(entry.engine, Some(Engine::Kv2));
1107            assert_eq!(entry.missing, MissingPolicy::Warn);
1108            let backend_ref = catalog.backends.get(BACKEND_NAME).expect("backend");
1109            assert!(backend_ref.engines.contains(&Engine::Kv2));
1110
1111            let catalog_json = serde_json::to_string_pretty(&catalog).expect("ser catalog");
1112            let policy_json = serde_json::to_string_pretty(&policy).expect("ser policy");
1113            crate::load(&catalog_json, &policy_json)
1114                .unwrap_or_else(|e| panic!("{backend:?} sops pair must load clean: {e}"));
1115
1116            std::fs::remove_dir_all(&dir).ok();
1117        }
1118    }
1119
1120    /// Two sops paths sanitizing to one catalog name is an error, not a silent
1121    /// overwrite.
1122    #[test]
1123    fn from_sops_rejects_colliding_names() {
1124        let dir = temp_dir();
1125        let sops = dir.join("secrets.yaml");
1126        std::fs::write(&sops, "a/b: ENC[...]\na-b: ENC[...]\n").expect("write sops fixture");
1127        let args = InitArgs {
1128            from_sops: Some(sops.clone()),
1129            ..args_for(InitBackend::Keystore, InitUnlock::Bip39, &dir)
1130        };
1131        let layout = Layout::new(&dir, None);
1132        let mut catalog = build_catalog(&args, &layout);
1133        let mut policy = build_policy(4242);
1134        let secrets = sops_secret_names(&sops).expect("names");
1135        let err = add_sops_entries(&mut catalog, &mut policy, &args, &secrets)
1136            .expect_err("collision must be rejected");
1137        assert!(err.to_string().contains("duplicate catalog key"), "{err}");
1138        std::fs::remove_dir_all(&dir).ok();
1139    }
1140
1141    /// A sops file with nothing but the metadata block imports nothing and
1142    /// says so.
1143    #[test]
1144    fn from_sops_rejects_metadata_only_files() {
1145        let dir = temp_dir();
1146        let sops = dir.join("secrets.yaml");
1147        std::fs::write(&sops, "sops:\n  kms: []\n").expect("write sops fixture");
1148        let err = sops_secret_names(&sops).expect_err("metadata-only file must be rejected");
1149        assert!(err.to_string().contains("no secrets"), "{err}");
1150        std::fs::remove_dir_all(&dir).ok();
1151    }
1152
1153    #[test]
1154    fn current_uid_resolves_to_a_number() {
1155        // Smoke: the real-uid resolver returns *some* uid and never panics.
1156        // Mostly just asserting it ran; any u32 is acceptable.
1157        let _uid = current_uid();
1158    }
1159
1160    /// Socket precedence in the generated `basil-agent.toml` (basil-u00):
1161    /// explicit `--socket <path>` > `BASIL_SOCKET` env var > `<dir>/basil.sock`.
1162    /// Driven through the pure `resolve_socket` + `build_config_toml` so no
1163    /// process env is touched (env-var tests are otherwise order-sensitive).
1164    #[test]
1165    fn socket_precedence_in_generated_config() {
1166        let dir = Path::new("/unused-init-dir");
1167        // (explicit --socket flag, BASIL_SOCKET env, expected socket path)
1168        let cases = [
1169            (
1170                Some("/run/explicit.sock"),
1171                Some("/run/env.sock"),
1172                "/run/explicit.sock",
1173            ),
1174            (None, Some("/run/env.sock"), "/run/env.sock"),
1175            (None, None, "/unused-init-dir/basil.sock"),
1176        ];
1177        for (flag, env, expected) in cases {
1178            let resolved = resolve_socket(flag, env);
1179            let layout = Layout::new(dir, resolved.as_deref());
1180            assert_eq!(
1181                layout.socket,
1182                PathBuf::from(expected),
1183                "resolve for flag={flag:?} env={env:?}"
1184            );
1185            let args = args_for(InitBackend::Openbao, InitUnlock::Bip39, dir);
1186            let toml = build_config_toml(&args, &layout);
1187            assert!(
1188                toml.contains(&format!("socket = \"{expected}\"")),
1189                "generated TOML must write socket = \"{expected}\" \
1190                 for flag={flag:?} env={env:?}, got:\n{toml}"
1191            );
1192        }
1193    }
1194}