1use std::collections::{BTreeMap, BTreeSet};
24use std::fmt::Write as _;
25use std::path::{Path, PathBuf};
26
27use crate::catalog::{
28 BackendKind, BackendRef, Catalog, Class, Config, Engine, KeyAlgorithm, KeyEntry, Labels,
29 MissingPolicy, NameTable, Op, PrincipalSpec, RawPolicy, RawRule, RawSubjectDefinition,
30};
31use anyhow::{Context, Result, bail};
32use clap::{Args, ValueEnum};
33
34const EXAMPLE_KEY: &str = "example.signing_key";
37const BACKEND_NAME: &str = "primary";
39const SIGNER_ROLE: &str = "example-signer";
42
43#[derive(Debug, Args)]
45pub struct InitArgs {
46 #[arg(long, value_enum, default_value_t = InitBackend::Openbao)]
48 backend: InitBackend,
49
50 #[arg(long, value_enum, default_value_t = InitUnlock::Bip39)]
53 unlock: InitUnlock,
54
55 #[arg(long, value_name = "DIR", default_value = "./basil")]
59 dir: PathBuf,
60
61 #[arg(long, default_value = "http://127.0.0.1:8200")]
65 addr: String,
66
67 #[arg(long, default_value = "transit")]
70 transit_mount: String,
71
72 #[arg(long, value_name = "PATH")]
76 passphrase_file: Option<PathBuf>,
77
78 #[arg(long)]
81 force: bool,
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
88enum InitBackend {
89 Openbao,
91 Vault,
93 Keystore,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
99enum InitUnlock {
100 Bip39,
102 Passphrase,
104 Tpm,
107 AgeYubikey,
109}
110
111impl InitBackend {
112 const fn kind(self) -> BackendKind {
114 match self {
115 Self::Openbao | Self::Vault => BackendKind::Vault,
116 Self::Keystore => BackendKind::Keystore,
117 }
118 }
119
120 const fn server_cli(self) -> Option<&'static str> {
123 match self {
124 Self::Openbao => Some("bao"),
125 Self::Vault => Some("vault"),
126 Self::Keystore => None,
127 }
128 }
129
130 const fn label(self) -> &'static str {
132 match self {
133 Self::Openbao => "OpenBao",
134 Self::Vault => "HashiCorp Vault",
135 Self::Keystore => "db-keystore",
136 }
137 }
138}
139
140struct Layout {
142 dir: PathBuf,
143 catalog: PathBuf,
144 policy: PathBuf,
145 config: PathBuf,
146 bundle: PathBuf,
149 socket: PathBuf,
151 keystore_db: PathBuf,
153}
154
155impl Layout {
156 fn new(dir: &Path) -> Self {
157 Self {
158 dir: dir.to_path_buf(),
159 catalog: dir.join("catalog.json"),
160 policy: dir.join("policy.json"),
161 config: dir.join("basil-agent.toml"),
162 bundle: dir.join("bundle.sealed"),
163 socket: dir.join("basil.sock"),
164 keystore_db: dir.join("keystore.db"),
165 }
166 }
167
168 fn written(&self) -> [&Path; 3] {
170 [&self.catalog, &self.policy, &self.config]
171 }
172}
173
174pub fn run(args: &InitArgs) -> Result<()> {
177 validate_args(args)?;
178
179 let layout = Layout::new(&args.dir);
180
181 std::fs::create_dir_all(&layout.dir)
182 .with_context(|| format!("creating target dir {}", layout.dir.display()))?;
183
184 refuse_clobber(&layout, args.force)?;
185
186 let uid = current_uid();
187
188 let catalog = build_catalog(args, &layout);
189 let policy = build_policy(uid);
190
191 let catalog_json = serde_json::to_string_pretty(&catalog).context("serializing catalog")?;
195 let policy_json = serde_json::to_string_pretty(&policy).context("serializing policy")?;
196 crate::load(&catalog_json, &policy_json)
197 .context("the generated catalog/policy did not pass loader validation (internal bug)")?;
198
199 let config_toml = build_config_toml(args, &layout);
200
201 write_file(&layout.catalog, &format!("{catalog_json}\n"))?;
202 write_file(&layout.policy, &format!("{policy_json}\n"))?;
203 write_file(&layout.config, &config_toml)?;
204
205 print_next_steps(args, &layout, uid);
206 Ok(())
207}
208
209fn refuse_clobber(layout: &Layout, force: bool) -> Result<()> {
212 if force {
213 return Ok(());
214 }
215 let existing: Vec<String> = layout
216 .written()
217 .into_iter()
218 .filter(|p| p.exists())
219 .map(|p| p.display().to_string())
220 .collect();
221 if existing.is_empty() {
222 return Ok(());
223 }
224 bail!(
225 "refusing to overwrite existing file(s): {}\n(pass --force to overwrite)",
226 existing.join(", ")
227 );
228}
229
230fn build_catalog(args: &InitArgs, layout: &Layout) -> Catalog {
237 let (addr, engines) = match args.backend {
238 InitBackend::Openbao | InitBackend::Vault => (args.addr.clone(), vec![Engine::Transit]),
239 InitBackend::Keystore => (
240 layout.keystore_db.display().to_string(),
241 vec![Engine::Transit, Engine::Kv2],
242 ),
243 };
244
245 let mut backends = BTreeMap::new();
246 backends.insert(
247 BACKEND_NAME.to_string(),
248 BackendRef {
249 kind: args.backend.kind(),
250 addr,
251 engines,
252 capabilities: Vec::new(),
253 mint_key_types: vec![KeyAlgorithm::Ed25519],
254 requires: Vec::new(),
255 },
256 );
257
258 let path = match args.backend {
265 InitBackend::Openbao | InitBackend::Vault => "example-signing-key".to_string(),
266 InitBackend::Keystore => "example/signing-key".to_string(),
267 };
268
269 let mut keys = BTreeMap::new();
270 keys.insert(
271 EXAMPLE_KEY.to_string(),
272 KeyEntry {
273 class: Class::Asymmetric,
274 key_type: Some(KeyAlgorithm::Ed25519),
275 backend: BACKEND_NAME.to_string(),
276 engine: Some(Engine::Transit),
277 path,
278 public_path: None,
279 writable: true,
280 missing: MissingPolicy::Generate,
282 generate: None,
283 sealing_pin: None,
284 labels: Labels::default(),
285 description: "Example Ed25519 signing key scaffolded by `basil config init`."
286 .to_string(),
287 },
288 );
289
290 Catalog {
291 schema_version: 1,
292 backends,
293 keys,
294 }
295}
296
297fn build_policy(uid: u32) -> RawPolicy {
301 let mut roles = BTreeMap::new();
302 roles.insert(
303 SIGNER_ROLE.to_string(),
304 BTreeSet::from([Op::Sign, Op::Verify, Op::GetPublicKey]),
305 );
306
307 let rule = RawRule {
308 id: "running-user-may-sign-example-key".to_string(),
309 subjects: vec!["init.user".to_string()],
310 action: vec![format!("role:{SIGNER_ROLE}")],
311 target: vec![EXAMPLE_KEY.to_string()],
312 comment: Some(
313 "Least-privilege: only the uid that ran `basil config init` may sign/verify \
314 the one example key. Everything else is default-deny."
315 .to_string(),
316 ),
317 };
318
319 let mut names = NameTable::default();
320 names.users.insert(uid, "init-user".to_string());
321 let mut memberships = BTreeMap::new();
322 memberships.insert(uid, BTreeSet::new());
323 let mut subjects = BTreeMap::new();
324 subjects.insert(
325 "init.user".to_string(),
326 RawSubjectDefinition {
327 break_glass: false,
328 all_of: Some(vec![PrincipalSpec::Unix {
329 uid: Some(uid),
330 gid: None,
331 }]),
332 any_of: None,
333 },
334 );
335
336 RawPolicy {
337 schema_version: 2,
338 subjects,
339 unauthenticated_subject: None,
340 roles,
341 rules: vec![rule],
342 config: Config { names, memberships },
343 }
344}
345
346fn build_config_toml(args: &InitArgs, layout: &Layout) -> String {
350 let mut out = String::new();
351 out.push_str("# basil-agent config scaffolded by `basil config init`.\n");
352 out.push_str("# Edit the placeholders, create the sealed bundle (see the printed\n");
353 out.push_str(
354 "# next-steps), then `basil config check -c this-file` and `run -c this-file`.\n\n",
355 );
356 let _ = writeln!(out, "catalog = {}", toml_str(&layout.catalog));
357 let _ = writeln!(out, "policy = {}", toml_str(&layout.policy));
358 out.push_str("# The sealed bundle is NOT created by init. Create it with `bundle create`.\n");
359 let _ = writeln!(out, "bundle = {}", toml_str(&layout.bundle));
360 let _ = writeln!(out, "socket = {}", toml_str(&layout.socket));
361 out.push_str("# Socket mode defaults to 0600 (owner-only); widen deliberately if a peer\n");
362 out.push_str("# group must connect, e.g. socket-mode = \"0660\" + socket-group = \"basil\".\n");
363 out.push_str("socket-mode = \"0600\"\n");
364
365 if args.backend == InitBackend::Keystore {
366 out.push_str("\n# db-keystore backend: the local AEAD cipher for the at-rest DB.\n");
367 out.push_str("db-keystore-cipher = \"aegis256\"\n");
368 } else {
369 let _ = writeln!(out, "vault-addr = {}", toml_str_s(&args.addr));
370 let _ = writeln!(
371 out,
372 "transit-mount = {}",
373 toml_str_s(trim_mount(&args.transit_mount))
374 );
375 }
376
377 out.push('\n');
378 out.push_str("[unlock]\n");
379 match args.unlock {
380 InitUnlock::Bip39 => {
381 out.push_str(
382 "# Unlock with the `BIP39` break-glass phrase from `bundle create --slot bip39`.\n",
383 );
384 out.push_str(
385 "# TODO: point bip39-phrase-file at a 0600 file holding the 24-word phrase.\n",
386 );
387 out.push_str("bip39-phrase-file = \"REPLACE_WITH_PATH_TO_BIP39_PHRASE_FILE\"\n");
388 }
389 InitUnlock::Passphrase => {
390 out.push_str("# Unlock with a passphrase read from a 0600 file.\n");
391 out.push_str("# TODO: point unlock-passphrase-file at the runtime credential file.\n");
392 let passphrase_file = args.passphrase_file.as_deref().map_or_else(
393 || toml_str_s("REPLACE_WITH_PATH_TO_PASSPHRASE_FILE"),
394 toml_str,
395 );
396 let _ = writeln!(out, "unlock-passphrase-file = {passphrase_file}");
397 }
398 InitUnlock::Tpm => {
399 out.push_str("# Unlock with a TPM2 sealed slot bound to host PCR state.\n");
400 out.push_str("# Requires the broker built with --features unlock-tpm and a host TPM\n");
401 out.push_str("# (/dev/tpmrm0); availability is the runtime device probe, no secret.\n");
402 out.push_str("unlock-tpm = true\n");
403 }
404 InitUnlock::AgeYubikey => {
405 out.push_str("# Unlock with an enrolled age + age-plugin-yubikey hardware slot.\n");
406 out.push_str("age-yubikey = true\n");
407 }
408 }
409 out.push('\n');
410 out.push_str("[broker-identity]\n");
411 out.push_str("# Required when [invocation] enable = true.\n");
412 out.push_str("# id = \"basil://prod/us-east-1/agent-a\"\n");
413 out.push_str("# response-signing-key-id = \"broker.response_signing.2026q3\"\n");
414 out.push('\n');
415 out.push_str("[invocation]\n");
416 out.push_str("# Sealed bridged invocation is compiled in but disabled by default.\n");
417 out.push_str("enable = false\n");
418 out.push_str("# audience = [\"basil://prod/us-east-1/agent-a\"]\n");
419 out.push_str("# request-encryption-key-id = \"broker.request_encryption.2026q3\"\n");
420 out.push_str("# max-ttl-secs = 60\n");
421 out.push_str("# clock-skew-secs = 30\n");
422 out.push_str("# replay-cache-capacity = 4096\n");
423 out
424}
425
426fn print_next_steps(args: &InitArgs, layout: &Layout, uid: u32) {
429 let cfg = layout.config.display();
430 println!(
431 "Scaffolded a {} starter set in {}:",
432 args.backend.label(),
433 layout.dir.display()
434 );
435 println!(" catalog: {}", layout.catalog.display());
436 println!(
437 " policy: {} (grants only uid {uid} sign/verify over `{EXAMPLE_KEY}`)",
438 layout.policy.display()
439 );
440 println!(" config: {cfg}");
441 println!();
442 println!("init writes config/scaffolding ONLY: no secret material, and NOT the sealed bundle.");
443 println!();
444
445 println!("Next steps:");
446 println!();
447 println!("1. Create the sealed credential bundle (init cannot: it needs unlock material):");
448 print_bundle_init(args, layout);
449 println!();
450
451 if let Some(cli) = args.backend.server_cli() {
452 println!(
453 " The bundle's backend credential must be a token for a running {}",
454 args.backend.label()
455 );
456 println!(
457 " with the `{}` transit mount enabled. For a dev server:",
458 trim_mount(&args.transit_mount)
459 );
460 println!(" {cli} secrets enable transit");
461 println!(
462 " (reconcile will create the `{EXAMPLE_KEY}` key on first run, missing=generate.)"
463 );
464 } else {
465 println!(" Build the agent with the keystore backend: --features db-keystore");
466 println!(" and seed a 32-byte DEK file for the bundle's DbKeystoreDek credential.");
467 }
468 println!();
469
470 println!("2. Validate the config (offline + backend probe):");
471 println!(" basil config check -c {cfg}");
472 println!();
473 println!("3. Run the broker:");
474 println!(" basil agent -c {cfg}");
475 println!();
476 println!("4. Exercise the example key over the socket:");
477 println!(
478 " basil --socket {} sign --key-id {EXAMPLE_KEY} 'hello basil'",
479 layout.socket.display()
480 );
481}
482
483fn print_bundle_init(args: &InitArgs, layout: &Layout) {
486 let out = layout.bundle.display();
487 let slot = bundle_init_slot_flag(args);
488 let cred = match args.backend {
489 InitBackend::Openbao | InitBackend::Vault => {
490 format!(
491 "--backend id={BACKEND_NAME},type=openbao,addr=REPLACE_WITH_BACKEND_ADDR,token-file=REPLACE_WITH_BACKEND_TOKEN_FILE"
492 )
493 }
494 InitBackend::Keystore => {
495 format!(
496 "--backend id={BACKEND_NAME},type=db-keystore,path=REPLACE_WITH_DB_PATH,dek-file=REPLACE_WITH_PATH_TO_32BYTE_DEK_FILE"
497 )
498 }
499 };
500 println!(" basil bundle create {out} \\");
501 println!(" --slot {slot} \\");
502 println!(" {cred}");
503 if args.unlock == InitUnlock::Tpm {
504 println!(
505 " (the TPM slot seals to THIS host's TPM at `bundle create` time; run it on \
506 the target host with /dev/tpmrm0 and a broker built with --features unlock-tpm.)"
507 );
508 }
509 if args.unlock == InitUnlock::AgeYubikey {
510 println!(
511 " (age-yubikey needs a recipient in `--slot age-yubikey:recipient=...`; \
512 a bip39 break-glass slot is shown above so the bundle is creatable.)"
513 );
514 }
515}
516
517fn bundle_init_slot_flag(args: &InitArgs) -> String {
520 match args.unlock {
521 InitUnlock::Passphrase => {
522 let path = args.passphrase_file.as_deref().map_or_else(
523 || "REPLACE_WITH_PATH_TO_PASSPHRASE_FILE".to_string(),
524 |path| path.display().to_string(),
525 );
526 format!("passphrase:file={path}")
527 }
528 InitUnlock::Tpm => "tpm".to_string(),
529 InitUnlock::Bip39 | InitUnlock::AgeYubikey => "bip39".to_string(),
530 }
531}
532
533fn validate_args(args: &InitArgs) -> Result<()> {
535 if args.passphrase_file.is_some() && args.unlock != InitUnlock::Passphrase {
536 bail!("--passphrase-file can only be used with --unlock passphrase");
537 }
538 Ok(())
539}
540
541fn write_file(path: &Path, contents: &str) -> Result<()> {
543 std::fs::write(path, contents).with_context(|| format!("writing {}", path.display()))
544}
545
546fn current_uid() -> u32 {
551 rustix::process::getuid().as_raw()
552}
553
554fn trim_mount(mount: &str) -> &str {
557 mount.strip_suffix('/').unwrap_or(mount)
558}
559
560fn toml_str(path: &Path) -> String {
562 toml_str_s(&path.display().to_string())
563}
564
565fn toml_str_s(s: &str) -> String {
567 let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
568 format!("\"{escaped}\"")
569}
570
571#[cfg(test)]
572mod tests {
573 use super::*;
574
575 fn args_for(backend: InitBackend, unlock: InitUnlock, dir: &Path) -> InitArgs {
576 InitArgs {
577 backend,
578 unlock,
579 dir: dir.to_path_buf(),
580 addr: "http://127.0.0.1:8200".to_string(),
581 transit_mount: "transit".to_string(),
582 passphrase_file: None,
583 force: false,
584 }
585 }
586
587 fn temp_dir() -> PathBuf {
588 let p = std::env::temp_dir().join(format!(
589 "basil-init-test-{}-{}",
590 std::process::id(),
591 uuid::Uuid::new_v4()
592 ));
593 std::fs::create_dir_all(&p).expect("mk temp dir");
594 p
595 }
596
597 #[test]
600 fn generated_pair_passes_real_loader_for_every_backend() {
601 for backend in [
602 InitBackend::Openbao,
603 InitBackend::Vault,
604 InitBackend::Keystore,
605 ] {
606 let dir = temp_dir();
607 let layout = Layout::new(&dir);
608 let args = args_for(backend, InitUnlock::Bip39, &dir);
609 let catalog = build_catalog(&args, &layout);
610 let policy = build_policy(4242);
611
612 let catalog_json = serde_json::to_string_pretty(&catalog).expect("ser catalog");
613 let policy_json = serde_json::to_string_pretty(&policy).expect("ser policy");
614 let loaded = crate::load(&catalog_json, &policy_json)
615 .unwrap_or_else(|e| panic!("{backend:?} pair must load clean: {e}"));
616 let warnings = loaded.3;
617 assert!(
618 warnings.is_empty(),
619 "{backend:?} pair should load without warnings, got {warnings:?}"
620 );
621 std::fs::remove_dir_all(&dir).ok();
622 }
623 }
624
625 #[test]
628 fn policy_grants_only_the_running_uid() {
629 let uid = 9931;
630 let policy = build_policy(uid);
631 assert_eq!(policy.rules.len(), 1);
632 let rule = policy.rules.first().expect("one rule");
633 assert_eq!(rule.subjects, vec!["init.user".to_string()]);
634 assert_eq!(rule.target, vec![EXAMPLE_KEY.to_string()]);
635 assert_eq!(policy.subjects.len(), 1);
636 let signer = policy.roles.get(SIGNER_ROLE).expect("signer role present");
638 assert!(
639 !signer.iter().any(|op| op.is_write()),
640 "signer role must hold no write op"
641 );
642 }
643
644 #[test]
647 fn refuses_to_clobber_without_force() {
648 let dir = temp_dir();
649 let args = args_for(InitBackend::Openbao, InitUnlock::Bip39, &dir);
650
651 run(&args).expect("first init writes clean");
652 let layout = Layout::new(&dir);
653 assert!(layout.catalog.exists() && layout.policy.exists() && layout.config.exists());
654
655 let err = run(&args).expect_err("second init must refuse");
657 let msg = err.to_string();
658 assert!(msg.contains("refusing to overwrite"), "got: {msg}");
659
660 let forced = InitArgs {
662 force: true,
663 ..args_for(InitBackend::Openbao, InitUnlock::Bip39, &dir)
664 };
665 run(&forced).expect("forced init overwrites");
666
667 std::fs::remove_dir_all(&dir).ok();
668 }
669
670 #[test]
673 fn written_files_reload_through_the_loader() {
674 let dir = temp_dir();
675 let args = args_for(InitBackend::Keystore, InitUnlock::Passphrase, &dir);
676 run(&args).expect("init writes");
677 let layout = Layout::new(&dir);
678
679 let catalog_json = std::fs::read_to_string(&layout.catalog).expect("read catalog");
680 let policy_json = std::fs::read_to_string(&layout.policy).expect("read policy");
681 crate::load(&catalog_json, &policy_json).expect("written pair must reload");
682
683 let config = std::fs::read_to_string(&layout.config).expect("read config");
685 let parsed: toml::Value = toml::from_str(&config).expect("config is valid TOML");
686 assert_eq!(
687 parsed.get("catalog").and_then(toml::Value::as_str),
688 Some(layout.catalog.display().to_string().as_str())
689 );
690 assert_eq!(
692 parsed.get("socket-mode").and_then(toml::Value::as_str),
693 Some("0600")
694 );
695
696 std::fs::remove_dir_all(&dir).ok();
697 }
698
699 #[test]
705 fn generated_config_loads_through_agent_config_file() {
706 let dir = temp_dir();
707 let args = args_for(InitBackend::Openbao, InitUnlock::Bip39, &dir);
708 run(&args).expect("init writes");
709 let layout = Layout::new(&dir);
710
711 let overrides = crate::agent_cli::ConfigOverrides {
712 config: Some(layout.config.clone()),
713 catalog: None,
714 policy: None,
715 bundle: None,
716 socket: None,
717 vault_addr: None,
718 };
719 let file =
720 crate::agent_cli::load_config_file(&overrides).expect("agent parses generated config");
721 assert_eq!(file.catalog.as_deref(), Some(layout.catalog.as_path()));
722 assert_eq!(file.policy.as_deref(), Some(layout.policy.as_path()));
723 assert_eq!(file.bundle.as_deref(), Some(layout.bundle.as_path()));
724 assert_eq!(
725 file.socket.as_deref(),
726 Some(layout.socket.display().to_string().as_str())
727 );
728 let mode = file.socket_mode.expect("socket-mode set");
730 assert_eq!(mode.0, 0o600);
731
732 std::fs::remove_dir_all(&dir).ok();
733 }
734
735 #[cfg(feature = "keystore-backend")]
736 #[test]
737 fn generated_keystore_config_loads_through_agent_config_file() {
738 let dir = temp_dir();
739 let args = args_for(InitBackend::Keystore, InitUnlock::AgeYubikey, &dir);
740 run(&args).expect("init writes");
741 let layout = Layout::new(&dir);
742
743 let overrides = crate::agent_cli::ConfigOverrides {
744 config: Some(layout.config),
745 catalog: None,
746 policy: None,
747 bundle: None,
748 socket: None,
749 vault_addr: None,
750 };
751 crate::agent_cli::load_config_file(&overrides)
752 .expect("agent parses generated keystore config (db-keystore-cipher key)");
753
754 std::fs::remove_dir_all(&dir).ok();
755 }
756
757 #[test]
760 fn tpm_unlock_generates_config_and_bundle_command() {
761 let dir = Path::new("/unused-init-dir");
762 let layout = Layout::new(dir);
763 let args = args_for(InitBackend::Openbao, InitUnlock::Tpm, dir);
764
765 let toml = build_config_toml(&args, &layout);
766 assert!(
767 toml.contains("unlock-tpm = true"),
768 "tpm config must set unlock-tpm = true, got:\n{toml}"
769 );
770 assert!(!toml.contains("unlock-passphrase-file"), "got:\n{toml}");
771
772 assert_eq!(bundle_init_slot_flag(&args), "tpm");
774 }
775
776 #[test]
777 fn passphrase_file_is_baked_into_config_and_bundle_command() {
778 let dir = Path::new("/unused-init-dir");
779 let layout = Layout::new(dir);
780 let passphrase = dir.join("passphrase.txt");
781 let args = InitArgs {
782 passphrase_file: Some(passphrase.clone()),
783 ..args_for(InitBackend::Openbao, InitUnlock::Passphrase, dir)
784 };
785
786 let toml = build_config_toml(&args, &layout);
787 assert!(
788 toml.contains(&format!(
789 "unlock-passphrase-file = \"{}\"",
790 passphrase.display()
791 )),
792 "passphrase config must use the provided file, got:\n{toml}"
793 );
794 assert_eq!(
795 bundle_init_slot_flag(&args),
796 format!("passphrase:file={}", passphrase.display())
797 );
798 }
799
800 #[test]
801 fn passphrase_file_requires_passphrase_unlock() {
802 let dir = temp_dir();
803 let args = InitArgs {
804 passphrase_file: Some(dir.join("passphrase.txt")),
805 ..args_for(InitBackend::Openbao, InitUnlock::Bip39, &dir)
806 };
807
808 let err = run(&args).expect_err("invalid unlock combination must fail");
809 assert!(
810 err.to_string().contains("--unlock passphrase"),
811 "got: {err}"
812 );
813 }
814
815 #[test]
816 fn current_uid_resolves_to_a_number() {
817 let _uid = current_uid();
820 }
821}