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