1use 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
38const EXAMPLE_KEY: &str = "example.signing_key";
41const BACKEND_NAME: &str = "primary";
43const SIGNER_ROLE: &str = "example-signer";
46
47#[derive(Debug, Args)]
49pub struct InitArgs {
50 #[arg(long, value_enum, default_value_t = InitBackend::Openbao)]
52 backend: InitBackend,
53
54 #[arg(long, value_enum, default_value_t = InitUnlock::Bip39)]
57 unlock: InitUnlock,
58
59 #[arg(long, value_name = "DIR", default_value = "./basil")]
63 dir: PathBuf,
64
65 #[arg(long, default_value = "http://127.0.0.1:8200")]
69 addr: String,
70
71 #[arg(long, default_value = "transit")]
74 transit_mount: String,
75
76 #[arg(long, value_name = "PATH")]
80 passphrase_file: Option<PathBuf>,
81
82 #[arg(long)]
85 force: bool,
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
92enum InitBackend {
93 Openbao,
95 Vault,
97 Keystore,
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
103enum InitUnlock {
104 Bip39,
106 Passphrase,
108 Tpm,
111 AgeYubikey,
113}
114
115impl InitBackend {
116 const fn kind(self) -> BackendKind {
118 match self {
119 Self::Openbao | Self::Vault => BackendKind::Vault,
120 Self::Keystore => BackendKind::Keystore,
121 }
122 }
123
124 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 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
144struct Layout {
146 dir: PathBuf,
147 catalog: PathBuf,
148 policy: PathBuf,
149 config: PathBuf,
150 bundle: PathBuf,
153 socket: PathBuf,
155 keystore_db: PathBuf,
157}
158
159impl Layout {
160 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 fn written(&self) -> [&Path; 3] {
177 [&self.catalog, &self.policy, &self.config]
178 }
179}
180
181pub 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 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
224fn 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
245fn 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 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 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
312fn 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
361fn 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
441fn 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
498fn 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
532fn 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
548fn resolve_socket(explicit: Option<&str>, env: Option<&str>) -> Option<String> {
555 explicit.or(env).map(str::to_owned)
556}
557
558fn 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
566fn write_file(path: &Path, contents: &str) -> Result<()> {
568 std::fs::write(path, contents).with_context(|| format!("writing {}", path.display()))
569}
570
571fn current_uid() -> u32 {
576 rustix::process::getuid().as_raw()
577}
578
579fn trim_mount(mount: &str) -> &str {
582 mount.strip_suffix('/').unwrap_or(mount)
583}
584
585fn toml_str(path: &Path) -> String {
587 toml_str_s(&path.display().to_string())
588}
589
590fn 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 #[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 #[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 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 #[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 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 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 #[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 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 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 #[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 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 #[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 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 let _uid = current_uid();
845 }
846
847 #[test]
852 fn socket_precedence_in_generated_config() {
853 let dir = Path::new("/unused-init-dir");
854 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}