1use std::collections::BTreeMap;
36use std::fs;
37use std::path::{Path, PathBuf};
38
39use devboy_core::Error as CoreError;
40use serde::{Deserialize, Serialize};
41use thiserror::Error;
42use tracing::debug;
43
44use crate::secret_path::{PathError, SecretPath};
45
46pub const SECRETS_SUBDIR: &str = "secrets";
50
51pub const INDEX_FILENAME: &str = "index.toml";
53
54#[derive(Debug, Error)]
56pub enum IndexError {
57 #[error("failed to read global index at {path}: {source}")]
59 Read {
60 path: PathBuf,
62 #[source]
64 source: std::io::Error,
65 },
66
67 #[error("failed to parse global index at {path}: {source}")]
69 Parse {
70 path: PathBuf,
72 #[source]
74 source: toml::de::Error,
75 },
76
77 #[error("failed to serialize global index for {path}: {source}")]
80 Serialize {
81 path: PathBuf,
83 #[source]
85 source: toml::ser::Error,
86 },
87
88 #[error("invalid secret path in index: {source}")]
91 Path {
92 #[source]
94 source: PathError,
95 },
96
97 #[error("could not resolve the user's config directory")]
100 NoConfigDir,
101}
102
103impl From<IndexError> for CoreError {
104 fn from(e: IndexError) -> Self {
105 CoreError::Storage(e.to_string())
106 }
107}
108
109#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(rename_all = "lowercase")]
116pub enum Gate {
117 #[default]
119 Auto,
120 Confirm,
122 Touchid,
124}
125
126#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(rename_all = "kebab-case")]
143pub enum ApproveOnUse {
144 #[default]
147 Never,
148 Session,
152 PerCall,
155}
156
157impl From<ApproveOnUse> for devboy_core::secret_approval::ApproveOnUsePolicy {
158 fn from(v: ApproveOnUse) -> Self {
159 use devboy_core::secret_approval::ApproveOnUsePolicy as Policy;
160 match v {
161 ApproveOnUse::Never => Policy::Never,
162 ApproveOnUse::Session => Policy::Session,
163 ApproveOnUse::PerCall => Policy::PerCall,
164 }
165 }
166}
167
168#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
170#[serde(rename_all = "kebab-case")]
171pub enum RotationMethod {
172 #[default]
175 Manual,
176 ProviderUi,
180 ProviderApi,
183}
184
185#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
192#[serde(deny_unknown_fields)]
193pub struct IndexEntry {
194 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub description: Option<String>,
198
199 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub retrieval_url: Option<String>,
203
204 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub format_regex: Option<String>,
209
210 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub default_gate: Option<Gate>,
213
214 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub expires_at: Option<String>,
223
224 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub last_rotated_at: Option<String>,
228
229 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub rotate_every_days: Option<u32>,
233
234 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub rotation_method: Option<RotationMethod>,
238
239 #[serde(default, skip_serializing_if = "Vec::is_empty")]
241 pub required_scopes: Vec<String>,
242
243 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub pattern_id: Option<String>,
249
250 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub env_var: Option<String>,
255
256 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub cache_ttl_seconds_max: Option<u64>,
260
261 #[serde(default, skip_serializing_if = "Option::is_none")]
264 pub approve_on_use: Option<ApproveOnUse>,
265}
266
267#[derive(Debug, Clone, Default)]
273pub struct GlobalIndex {
274 entries: BTreeMap<SecretPath, IndexEntry>,
275}
276
277#[derive(Debug, Default, Deserialize, Serialize)]
283struct RawIndex {
284 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
285 secret: BTreeMap<String, IndexEntry>,
286}
287
288impl GlobalIndex {
289 pub fn new() -> Self {
292 Self::default()
293 }
294
295 pub fn default_path() -> Result<PathBuf, IndexError> {
300 let dir = dirs::config_dir().ok_or(IndexError::NoConfigDir)?;
301 Ok(dir
302 .join("devboy-tools")
303 .join(SECRETS_SUBDIR)
304 .join(INDEX_FILENAME))
305 }
306
307 pub fn load() -> Result<Self, IndexError> {
312 let path = Self::default_path()?;
313 Self::load_from(&path)
314 }
315
316 pub fn load_from(path: &Path) -> Result<Self, IndexError> {
319 if !path.exists() {
320 debug!(path = ?path, "global secrets index not present, using empty");
321 return Ok(Self::new());
322 }
323 let body = fs::read_to_string(path).map_err(|e| IndexError::Read {
324 path: path.to_path_buf(),
325 source: e,
326 })?;
327 Self::from_str_with_path(&body, path)
328 }
329
330 pub fn from_toml_str(body: &str) -> Result<Self, IndexError> {
334 Self::from_str_with_path(body, Path::new("<inline>"))
335 }
336
337 fn from_str_with_path(body: &str, path: &Path) -> Result<Self, IndexError> {
338 let raw: RawIndex = toml::from_str(body).map_err(|e| IndexError::Parse {
339 path: path.to_path_buf(),
340 source: e,
341 })?;
342
343 let mut entries = BTreeMap::new();
344 for (raw_path, entry) in raw.secret {
345 let p = SecretPath::parse(&raw_path).map_err(|e| IndexError::Path { source: e })?;
346 entries.insert(p, entry);
347 }
348
349 Ok(Self { entries })
350 }
351
352 pub fn save_to(&self, path: &Path) -> Result<(), IndexError> {
357 let body = self.to_toml_string().map_err(|e| IndexError::Serialize {
358 path: path.to_path_buf(),
359 source: e,
360 })?;
361 if let Some(parent) = path.parent() {
362 fs::create_dir_all(parent).map_err(|e| IndexError::Read {
363 path: parent.to_path_buf(),
364 source: e,
365 })?;
366 }
367 fs::write(path, body).map_err(|e| IndexError::Read {
368 path: path.to_path_buf(),
369 source: e,
370 })
371 }
372
373 pub fn save(&self) -> Result<PathBuf, IndexError> {
376 let path = Self::default_path()?;
377 self.save_to(&path)?;
378 Ok(path)
379 }
380
381 pub fn record_expiry(&mut self, path: &SecretPath, expires_at: &str) -> bool {
392 match self.entries.get_mut(path) {
393 Some(entry) => {
394 let new = Some(expires_at.to_owned());
395 if entry.expires_at != new {
396 entry.expires_at = new;
397 true
398 } else {
399 false
400 }
401 }
402 None => false,
403 }
404 }
405
406 pub fn record_rotation(&mut self, path: &SecretPath, last_rotated_at: &str) -> bool {
410 match self.entries.get_mut(path) {
411 Some(entry) => {
412 let new = Some(last_rotated_at.to_owned());
413 if entry.last_rotated_at != new {
414 entry.last_rotated_at = new;
415 true
416 } else {
417 false
418 }
419 }
420 None => false,
421 }
422 }
423
424 pub fn to_toml_string(&self) -> Result<String, toml::ser::Error> {
428 let raw = RawIndex {
429 secret: self
430 .entries
431 .iter()
432 .map(|(k, v)| (k.as_str().to_owned(), v.clone()))
433 .collect(),
434 };
435 toml::to_string_pretty(&raw)
436 }
437
438 pub fn get(&self, path: &SecretPath) -> Option<&IndexEntry> {
440 self.entries.get(path)
441 }
442
443 pub fn insert(&mut self, path: SecretPath, entry: IndexEntry) -> Option<IndexEntry> {
445 self.entries.insert(path, entry)
446 }
447
448 pub fn remove(&mut self, path: &SecretPath) -> Option<IndexEntry> {
450 self.entries.remove(path)
451 }
452
453 pub fn len(&self) -> usize {
455 self.entries.len()
456 }
457
458 pub fn is_empty(&self) -> bool {
460 self.entries.is_empty()
461 }
462
463 pub fn iter(&self) -> impl Iterator<Item = (&SecretPath, &IndexEntry)> {
465 self.entries.iter()
466 }
467}
468
469#[cfg(test)]
474mod tests {
475 use super::*;
476
477 fn fixture_full_entry_toml() -> &'static str {
480 r#"
481[secret."team/gitlab/token-deploy"]
482description = "Deploy token for the team GitLab; used by CI mirrors and devboy plugins"
483retrieval_url = "https://gitlab.example.internal/-/profile/personal_access_tokens"
484format_regex = "^glpat-[A-Za-z0-9_-]{20,}$"
485default_gate = "auto"
486expires_at = "2026-08-01"
487last_rotated_at = "2026-05-02"
488rotate_every_days = 90
489rotation_method = "manual"
490required_scopes = ["api", "read_repository"]
491pattern_id = "gitlab-pat"
492env_var = "GITLAB_TOKEN_DEPLOY"
493cache_ttl_seconds_max = 60
494"#
495 }
496
497 #[test]
498 fn empty_string_yields_empty_index() {
499 let idx = GlobalIndex::from_toml_str("").unwrap();
500 assert!(idx.is_empty());
501 assert_eq!(idx.len(), 0);
502 }
503
504 #[test]
505 fn parses_full_entry() {
506 let idx = GlobalIndex::from_toml_str(fixture_full_entry_toml()).unwrap();
507 assert_eq!(idx.len(), 1);
508 let path: SecretPath = "team/gitlab/token-deploy".parse().unwrap();
509 let entry = idx.get(&path).expect("entry must be present");
510
511 assert_eq!(
512 entry.description.as_deref(),
513 Some("Deploy token for the team GitLab; used by CI mirrors and devboy plugins")
514 );
515 assert_eq!(
516 entry.retrieval_url.as_deref(),
517 Some("https://gitlab.example.internal/-/profile/personal_access_tokens")
518 );
519 assert_eq!(
520 entry.format_regex.as_deref(),
521 Some("^glpat-[A-Za-z0-9_-]{20,}$")
522 );
523 assert_eq!(entry.default_gate, Some(Gate::Auto));
524 assert_eq!(entry.expires_at.as_deref(), Some("2026-08-01"));
525 assert_eq!(entry.last_rotated_at.as_deref(), Some("2026-05-02"));
526 assert_eq!(entry.rotate_every_days, Some(90));
527 assert_eq!(entry.rotation_method, Some(RotationMethod::Manual));
528 assert_eq!(entry.required_scopes, vec!["api", "read_repository"]);
529 assert_eq!(entry.pattern_id.as_deref(), Some("gitlab-pat"));
530 assert_eq!(entry.env_var.as_deref(), Some("GITLAB_TOKEN_DEPLOY"));
531 assert_eq!(entry.cache_ttl_seconds_max, Some(60));
532 }
533
534 #[test]
535 fn parses_minimal_entry_with_defaults() {
536 let idx = GlobalIndex::from_toml_str(
537 r#"
538[secret."personal/github/pat"]
539description = "Personal GitHub PAT"
540"#,
541 )
542 .unwrap();
543 let p: SecretPath = "personal/github/pat".parse().unwrap();
544 let e = idx.get(&p).unwrap();
545 assert_eq!(e.description.as_deref(), Some("Personal GitHub PAT"));
546 assert!(e.retrieval_url.is_none());
547 assert!(e.format_regex.is_none());
548 assert!(e.default_gate.is_none());
549 assert!(e.required_scopes.is_empty());
550 }
551
552 #[test]
553 fn parses_multiple_entries_sorted() {
554 let idx = GlobalIndex::from_toml_str(
555 r#"
556[secret."team/openai/api-key"]
557description = "Team OpenAI"
558
559[secret."personal/github/pat"]
560description = "Personal GitHub"
561
562[secret."client-acme/jira/api-key"]
563description = "Acme Jira"
564"#,
565 )
566 .unwrap();
567 assert_eq!(idx.len(), 3);
568
569 let paths: Vec<&str> = idx.iter().map(|(p, _)| p.as_str()).collect();
570 assert_eq!(
572 paths,
573 vec![
574 "client-acme/jira/api-key",
575 "personal/github/pat",
576 "team/openai/api-key",
577 ]
578 );
579 }
580
581 #[test]
582 fn rejects_invalid_path_in_key() {
583 let err = GlobalIndex::from_toml_str(
585 r#"
586[secret."gitlab/token"]
587description = "wrong"
588"#,
589 )
590 .unwrap_err();
591 match err {
592 IndexError::Path { source } => {
593 assert!(matches!(source, PathError::TooFewSegments { found: 2, .. }));
594 }
595 other => panic!("expected Path error, got {other:?}"),
596 }
597 }
598
599 #[test]
600 fn rejects_reserved_prefix_in_key() {
601 let err = GlobalIndex::from_toml_str(
602 r#"
603[secret."__sources/vault/deploy"]
604description = "internal"
605"#,
606 )
607 .unwrap_err();
608 assert!(matches!(
609 err,
610 IndexError::Path {
611 source: PathError::ReservedPrefix { .. }
612 }
613 ));
614 }
615
616 #[test]
617 fn rejects_unknown_field() {
618 let err = GlobalIndex::from_toml_str(
621 r#"
622[secret."team/gitlab/token-deploy"]
623retrieval_hint = "wrong field name"
624"#,
625 )
626 .unwrap_err();
627 assert!(matches!(err, IndexError::Parse { .. }));
628 }
629
630 #[test]
631 fn parses_each_gate_value() {
632 for (literal, expected) in [
633 ("auto", Gate::Auto),
634 ("confirm", Gate::Confirm),
635 ("touchid", Gate::Touchid),
636 ] {
637 let toml =
638 format!("[secret.\"team/gitlab/token-deploy\"]\ndefault_gate = \"{literal}\"\n");
639 let idx = GlobalIndex::from_toml_str(&toml).unwrap();
640 let p: SecretPath = "team/gitlab/token-deploy".parse().unwrap();
641 assert_eq!(idx.get(&p).unwrap().default_gate, Some(expected));
642 }
643 }
644
645 #[test]
646 fn parses_each_rotation_method_value() {
647 for (literal, expected) in [
648 ("manual", RotationMethod::Manual),
649 ("provider-ui", RotationMethod::ProviderUi),
650 ("provider-api", RotationMethod::ProviderApi),
651 ] {
652 let toml =
653 format!("[secret.\"team/gitlab/token-deploy\"]\nrotation_method = \"{literal}\"\n");
654 let idx = GlobalIndex::from_toml_str(&toml).unwrap();
655 let p: SecretPath = "team/gitlab/token-deploy".parse().unwrap();
656 assert_eq!(idx.get(&p).unwrap().rotation_method, Some(expected));
657 }
658 }
659
660 #[test]
661 fn round_trip_full_entry() {
662 let idx = GlobalIndex::from_toml_str(fixture_full_entry_toml()).unwrap();
663 let serialized = idx.to_toml_string().unwrap();
664 let reparsed = GlobalIndex::from_toml_str(&serialized).unwrap();
665 assert_eq!(idx.len(), reparsed.len());
666 let p: SecretPath = "team/gitlab/token-deploy".parse().unwrap();
667 assert_eq!(idx.get(&p), reparsed.get(&p));
668 }
669
670 #[test]
671 fn approve_on_use_round_trips_through_toml() {
672 let mut idx = GlobalIndex::new();
674 let p: SecretPath = "team/gitlab/token-deploy".parse().unwrap();
675 idx.insert(p.clone(), IndexEntry::default());
676 let body = idx.to_toml_string().unwrap();
677 assert!(
678 !body.contains("approve_on_use"),
679 "missing approve_on_use should not be serialised: {body}"
680 );
681
682 let entry = IndexEntry {
684 approve_on_use: Some(ApproveOnUse::PerCall),
685 ..IndexEntry::default()
686 };
687 let mut idx = GlobalIndex::new();
688 idx.insert(p.clone(), entry.clone());
689 let body = idx.to_toml_string().unwrap();
690 assert!(
691 body.contains("approve_on_use = \"per-call\""),
692 "expected kebab-case `per-call` in: {body}"
693 );
694 let reparsed = GlobalIndex::from_toml_str(&body).unwrap();
695 assert_eq!(reparsed.get(&p), Some(&entry));
696
697 let entry = IndexEntry {
699 approve_on_use: Some(ApproveOnUse::Session),
700 ..IndexEntry::default()
701 };
702 let mut idx = GlobalIndex::new();
703 idx.insert(p.clone(), entry.clone());
704 let body = idx.to_toml_string().unwrap();
705 assert!(body.contains("approve_on_use = \"session\""));
706 let reparsed = GlobalIndex::from_toml_str(&body).unwrap();
707 assert_eq!(reparsed.get(&p), Some(&entry));
708 }
709
710 #[test]
711 fn insert_remove_get() {
712 let mut idx = GlobalIndex::new();
713 let p: SecretPath = "team/gitlab/token-deploy".parse().unwrap();
714 let entry = IndexEntry {
715 description: Some("test".to_owned()),
716 ..IndexEntry::default()
717 };
718 assert!(idx.insert(p.clone(), entry.clone()).is_none());
719 assert_eq!(idx.get(&p), Some(&entry));
720 assert_eq!(idx.remove(&p), Some(entry));
721 assert!(idx.get(&p).is_none());
722 }
723
724 #[test]
725 fn load_from_returns_empty_when_file_missing() {
726 let dir = tempfile::tempdir().unwrap();
727 let path = dir.path().join("never-existed.toml");
728 let idx = GlobalIndex::load_from(&path).unwrap();
729 assert!(idx.is_empty());
730 }
731
732 #[test]
733 fn load_from_real_file() {
734 let dir = tempfile::tempdir().unwrap();
735 let path = dir.path().join("index.toml");
736 std::fs::write(&path, fixture_full_entry_toml()).unwrap();
737 let idx = GlobalIndex::load_from(&path).unwrap();
738 assert_eq!(idx.len(), 1);
739 }
740
741 #[test]
742 fn load_from_io_error_surfaces_path() {
743 let dir = tempfile::tempdir().unwrap();
745 let err = GlobalIndex::load_from(dir.path()).unwrap_err();
746 match err {
747 IndexError::Read { path, .. } => assert_eq!(path, dir.path()),
748 other => panic!("expected Read, got {other:?}"),
749 }
750 }
751
752 #[test]
753 fn default_path_includes_secrets_subdir_and_index_filename() {
754 let p = GlobalIndex::default_path().unwrap();
755 let s = p.to_string_lossy();
756 assert!(s.ends_with("/secrets/index.toml") || s.ends_with("\\secrets\\index.toml"));
757 assert!(s.contains("devboy-tools"));
758 }
759
760 #[test]
761 fn secret_path_serde_roundtrip_via_index() {
762 let idx = GlobalIndex::from_toml_str(
764 r#"
765[secret."team/gitlab/token-deploy"]
766description = "x"
767"#,
768 )
769 .unwrap();
770 let s = idx.to_toml_string().unwrap();
771 assert!(s.contains("team/gitlab/token-deploy"));
772 }
773}