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";
46const SOPS_ROLE: &str = "sops-migrator";
48
49#[derive(Debug, Args)]
51pub struct InitArgs {
52 #[arg(long, value_enum, default_value_t = InitBackend::Openbao)]
54 backend: InitBackend,
55
56 #[arg(long, value_enum, default_value_t = InitUnlock::Bip39)]
59 unlock: InitUnlock,
60
61 #[arg(long, value_name = "DIR", default_value = "./basil")]
65 dir: PathBuf,
66
67 #[arg(long, default_value = "http://127.0.0.1:8200")]
71 addr: String,
72
73 #[arg(long, default_value = "transit")]
76 transit_mount: String,
77
78 #[arg(long, value_name = "PATH")]
82 passphrase_file: Option<PathBuf>,
83
84 #[arg(long, value_name = "PATH")]
90 from_sops: Option<PathBuf>,
91
92 #[arg(long)]
95 force: bool,
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
102enum InitBackend {
103 Openbao,
105 Vault,
107 Keystore,
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
113enum InitUnlock {
114 Bip39,
116 Passphrase,
118 Tpm,
121 AgeYubikey,
123}
124
125impl InitBackend {
126 const fn kind(self) -> BackendKind {
128 match self {
129 Self::Openbao | Self::Vault => BackendKind::Vault,
130 Self::Keystore => BackendKind::Keystore,
131 }
132 }
133
134 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 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
154struct Layout {
156 dir: PathBuf,
157 catalog: PathBuf,
158 policy: PathBuf,
159 config: PathBuf,
160 bundle: PathBuf,
163 socket: PathBuf,
165 keystore_db: PathBuf,
167}
168
169impl Layout {
170 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 fn written(&self) -> [&Path; 3] {
187 [&self.catalog, &self.policy, &self.config]
188 }
189}
190
191pub 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 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
245fn 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
266fn 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 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 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
332fn 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
381fn 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
461fn 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
518fn 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
552fn 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#[derive(Debug)]
571struct SopsSecret {
572 segments: Vec<String>,
574 name: String,
576}
577
578fn 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 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
612fn 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
635fn 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
649fn 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 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
729fn 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
756fn resolve_socket(explicit: Option<&str>, env: Option<&str>) -> Option<String> {
763 explicit.or(env).map(str::to_owned)
764}
765
766fn 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
774fn write_file(path: &Path, contents: &str) -> Result<()> {
776 std::fs::write(path, contents).with_context(|| format!("writing {}", path.display()))
777}
778
779fn current_uid() -> u32 {
784 rustix::process::getuid().as_raw()
785}
786
787fn trim_mount(mount: &str) -> &str {
790 mount.strip_suffix('/').unwrap_or(mount)
791}
792
793fn toml_str(path: &Path) -> String {
795 toml_str_s(&path.display().to_string())
796}
797
798fn 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 #[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 #[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 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 #[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 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 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 #[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 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 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 #[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 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 #[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 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 #[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 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 #[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 #[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 #[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 let _uid = current_uid();
1158 }
1159
1160 #[test]
1165 fn socket_precedence_in_generated_config() {
1166 let dir = Path::new("/unused-init-dir");
1167 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}