use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use devboy_core::Error as CoreError;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tracing::debug;
use crate::secret_path::{PathError, SecretPath};
pub const SECRETS_SUBDIR: &str = "secrets";
pub const INDEX_FILENAME: &str = "index.toml";
#[derive(Debug, Error)]
pub enum IndexError {
#[error("failed to read global index at {path}: {source}")]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to parse global index at {path}: {source}")]
Parse {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("failed to serialize global index for {path}: {source}")]
Serialize {
path: PathBuf,
#[source]
source: toml::ser::Error,
},
#[error("invalid secret path in index: {source}")]
Path {
#[source]
source: PathError,
},
#[error("could not resolve the user's config directory")]
NoConfigDir,
}
impl From<IndexError> for CoreError {
fn from(e: IndexError) -> Self {
CoreError::Storage(e.to_string())
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Gate {
#[default]
Auto,
Confirm,
Touchid,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ApproveOnUse {
#[default]
Never,
Session,
PerCall,
}
impl From<ApproveOnUse> for devboy_core::secret_approval::ApproveOnUsePolicy {
fn from(v: ApproveOnUse) -> Self {
use devboy_core::secret_approval::ApproveOnUsePolicy as Policy;
match v {
ApproveOnUse::Never => Policy::Never,
ApproveOnUse::Session => Policy::Session,
ApproveOnUse::PerCall => Policy::PerCall,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RotationMethod {
#[default]
Manual,
ProviderUi,
ProviderApi,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IndexEntry {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retrieval_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format_regex: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_gate: Option<Gate>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_rotated_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rotate_every_days: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rotation_method: Option<RotationMethod>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub required_scopes: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pattern_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub env_var: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_ttl_seconds_max: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approve_on_use: Option<ApproveOnUse>,
}
#[derive(Debug, Clone, Default)]
pub struct GlobalIndex {
entries: BTreeMap<SecretPath, IndexEntry>,
}
#[derive(Debug, Default, Deserialize, Serialize)]
struct RawIndex {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
secret: BTreeMap<String, IndexEntry>,
}
impl GlobalIndex {
pub fn new() -> Self {
Self::default()
}
pub fn default_path() -> Result<PathBuf, IndexError> {
let dir = dirs::config_dir().ok_or(IndexError::NoConfigDir)?;
Ok(dir
.join("devboy-tools")
.join(SECRETS_SUBDIR)
.join(INDEX_FILENAME))
}
pub fn load() -> Result<Self, IndexError> {
let path = Self::default_path()?;
Self::load_from(&path)
}
pub fn load_from(path: &Path) -> Result<Self, IndexError> {
if !path.exists() {
debug!(path = ?path, "global secrets index not present, using empty");
return Ok(Self::new());
}
let body = fs::read_to_string(path).map_err(|e| IndexError::Read {
path: path.to_path_buf(),
source: e,
})?;
Self::from_str_with_path(&body, path)
}
pub fn from_toml_str(body: &str) -> Result<Self, IndexError> {
Self::from_str_with_path(body, Path::new("<inline>"))
}
fn from_str_with_path(body: &str, path: &Path) -> Result<Self, IndexError> {
let raw: RawIndex = toml::from_str(body).map_err(|e| IndexError::Parse {
path: path.to_path_buf(),
source: e,
})?;
let mut entries = BTreeMap::new();
for (raw_path, entry) in raw.secret {
let p = SecretPath::parse(&raw_path).map_err(|e| IndexError::Path { source: e })?;
entries.insert(p, entry);
}
Ok(Self { entries })
}
pub fn save_to(&self, path: &Path) -> Result<(), IndexError> {
let body = self.to_toml_string().map_err(|e| IndexError::Serialize {
path: path.to_path_buf(),
source: e,
})?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| IndexError::Read {
path: parent.to_path_buf(),
source: e,
})?;
}
fs::write(path, body).map_err(|e| IndexError::Read {
path: path.to_path_buf(),
source: e,
})
}
pub fn save(&self) -> Result<PathBuf, IndexError> {
let path = Self::default_path()?;
self.save_to(&path)?;
Ok(path)
}
pub fn record_expiry(&mut self, path: &SecretPath, expires_at: &str) -> bool {
match self.entries.get_mut(path) {
Some(entry) => {
let new = Some(expires_at.to_owned());
if entry.expires_at != new {
entry.expires_at = new;
true
} else {
false
}
}
None => false,
}
}
pub fn record_rotation(&mut self, path: &SecretPath, last_rotated_at: &str) -> bool {
match self.entries.get_mut(path) {
Some(entry) => {
let new = Some(last_rotated_at.to_owned());
if entry.last_rotated_at != new {
entry.last_rotated_at = new;
true
} else {
false
}
}
None => false,
}
}
pub fn to_toml_string(&self) -> Result<String, toml::ser::Error> {
let raw = RawIndex {
secret: self
.entries
.iter()
.map(|(k, v)| (k.as_str().to_owned(), v.clone()))
.collect(),
};
toml::to_string_pretty(&raw)
}
pub fn get(&self, path: &SecretPath) -> Option<&IndexEntry> {
self.entries.get(path)
}
pub fn insert(&mut self, path: SecretPath, entry: IndexEntry) -> Option<IndexEntry> {
self.entries.insert(path, entry)
}
pub fn remove(&mut self, path: &SecretPath) -> Option<IndexEntry> {
self.entries.remove(path)
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = (&SecretPath, &IndexEntry)> {
self.entries.iter()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture_full_entry_toml() -> &'static str {
r#"
[secret."team/gitlab/token-deploy"]
description = "Deploy token for the team GitLab; used by CI mirrors and devboy plugins"
retrieval_url = "https://gitlab.example.internal/-/profile/personal_access_tokens"
format_regex = "^glpat-[A-Za-z0-9_-]{20,}$"
default_gate = "auto"
expires_at = "2026-08-01"
last_rotated_at = "2026-05-02"
rotate_every_days = 90
rotation_method = "manual"
required_scopes = ["api", "read_repository"]
pattern_id = "gitlab-pat"
env_var = "GITLAB_TOKEN_DEPLOY"
cache_ttl_seconds_max = 60
"#
}
#[test]
fn empty_string_yields_empty_index() {
let idx = GlobalIndex::from_toml_str("").unwrap();
assert!(idx.is_empty());
assert_eq!(idx.len(), 0);
}
#[test]
fn parses_full_entry() {
let idx = GlobalIndex::from_toml_str(fixture_full_entry_toml()).unwrap();
assert_eq!(idx.len(), 1);
let path: SecretPath = "team/gitlab/token-deploy".parse().unwrap();
let entry = idx.get(&path).expect("entry must be present");
assert_eq!(
entry.description.as_deref(),
Some("Deploy token for the team GitLab; used by CI mirrors and devboy plugins")
);
assert_eq!(
entry.retrieval_url.as_deref(),
Some("https://gitlab.example.internal/-/profile/personal_access_tokens")
);
assert_eq!(
entry.format_regex.as_deref(),
Some("^glpat-[A-Za-z0-9_-]{20,}$")
);
assert_eq!(entry.default_gate, Some(Gate::Auto));
assert_eq!(entry.expires_at.as_deref(), Some("2026-08-01"));
assert_eq!(entry.last_rotated_at.as_deref(), Some("2026-05-02"));
assert_eq!(entry.rotate_every_days, Some(90));
assert_eq!(entry.rotation_method, Some(RotationMethod::Manual));
assert_eq!(entry.required_scopes, vec!["api", "read_repository"]);
assert_eq!(entry.pattern_id.as_deref(), Some("gitlab-pat"));
assert_eq!(entry.env_var.as_deref(), Some("GITLAB_TOKEN_DEPLOY"));
assert_eq!(entry.cache_ttl_seconds_max, Some(60));
}
#[test]
fn parses_minimal_entry_with_defaults() {
let idx = GlobalIndex::from_toml_str(
r#"
[secret."personal/github/pat"]
description = "Personal GitHub PAT"
"#,
)
.unwrap();
let p: SecretPath = "personal/github/pat".parse().unwrap();
let e = idx.get(&p).unwrap();
assert_eq!(e.description.as_deref(), Some("Personal GitHub PAT"));
assert!(e.retrieval_url.is_none());
assert!(e.format_regex.is_none());
assert!(e.default_gate.is_none());
assert!(e.required_scopes.is_empty());
}
#[test]
fn parses_multiple_entries_sorted() {
let idx = GlobalIndex::from_toml_str(
r#"
[secret."team/openai/api-key"]
description = "Team OpenAI"
[secret."personal/github/pat"]
description = "Personal GitHub"
[secret."client-acme/jira/api-key"]
description = "Acme Jira"
"#,
)
.unwrap();
assert_eq!(idx.len(), 3);
let paths: Vec<&str> = idx.iter().map(|(p, _)| p.as_str()).collect();
assert_eq!(
paths,
vec![
"client-acme/jira/api-key",
"personal/github/pat",
"team/openai/api-key",
]
);
}
#[test]
fn rejects_invalid_path_in_key() {
let err = GlobalIndex::from_toml_str(
r#"
[secret."gitlab/token"]
description = "wrong"
"#,
)
.unwrap_err();
match err {
IndexError::Path { source } => {
assert!(matches!(source, PathError::TooFewSegments { found: 2, .. }));
}
other => panic!("expected Path error, got {other:?}"),
}
}
#[test]
fn rejects_reserved_prefix_in_key() {
let err = GlobalIndex::from_toml_str(
r#"
[secret."__sources/vault/deploy"]
description = "internal"
"#,
)
.unwrap_err();
assert!(matches!(
err,
IndexError::Path {
source: PathError::ReservedPrefix { .. }
}
));
}
#[test]
fn rejects_unknown_field() {
let err = GlobalIndex::from_toml_str(
r#"
[secret."team/gitlab/token-deploy"]
retrieval_hint = "wrong field name"
"#,
)
.unwrap_err();
assert!(matches!(err, IndexError::Parse { .. }));
}
#[test]
fn parses_each_gate_value() {
for (literal, expected) in [
("auto", Gate::Auto),
("confirm", Gate::Confirm),
("touchid", Gate::Touchid),
] {
let toml =
format!("[secret.\"team/gitlab/token-deploy\"]\ndefault_gate = \"{literal}\"\n");
let idx = GlobalIndex::from_toml_str(&toml).unwrap();
let p: SecretPath = "team/gitlab/token-deploy".parse().unwrap();
assert_eq!(idx.get(&p).unwrap().default_gate, Some(expected));
}
}
#[test]
fn parses_each_rotation_method_value() {
for (literal, expected) in [
("manual", RotationMethod::Manual),
("provider-ui", RotationMethod::ProviderUi),
("provider-api", RotationMethod::ProviderApi),
] {
let toml =
format!("[secret.\"team/gitlab/token-deploy\"]\nrotation_method = \"{literal}\"\n");
let idx = GlobalIndex::from_toml_str(&toml).unwrap();
let p: SecretPath = "team/gitlab/token-deploy".parse().unwrap();
assert_eq!(idx.get(&p).unwrap().rotation_method, Some(expected));
}
}
#[test]
fn round_trip_full_entry() {
let idx = GlobalIndex::from_toml_str(fixture_full_entry_toml()).unwrap();
let serialized = idx.to_toml_string().unwrap();
let reparsed = GlobalIndex::from_toml_str(&serialized).unwrap();
assert_eq!(idx.len(), reparsed.len());
let p: SecretPath = "team/gitlab/token-deploy".parse().unwrap();
assert_eq!(idx.get(&p), reparsed.get(&p));
}
#[test]
fn approve_on_use_round_trips_through_toml() {
let mut idx = GlobalIndex::new();
let p: SecretPath = "team/gitlab/token-deploy".parse().unwrap();
idx.insert(p.clone(), IndexEntry::default());
let body = idx.to_toml_string().unwrap();
assert!(
!body.contains("approve_on_use"),
"missing approve_on_use should not be serialised: {body}"
);
let entry = IndexEntry {
approve_on_use: Some(ApproveOnUse::PerCall),
..IndexEntry::default()
};
let mut idx = GlobalIndex::new();
idx.insert(p.clone(), entry.clone());
let body = idx.to_toml_string().unwrap();
assert!(
body.contains("approve_on_use = \"per-call\""),
"expected kebab-case `per-call` in: {body}"
);
let reparsed = GlobalIndex::from_toml_str(&body).unwrap();
assert_eq!(reparsed.get(&p), Some(&entry));
let entry = IndexEntry {
approve_on_use: Some(ApproveOnUse::Session),
..IndexEntry::default()
};
let mut idx = GlobalIndex::new();
idx.insert(p.clone(), entry.clone());
let body = idx.to_toml_string().unwrap();
assert!(body.contains("approve_on_use = \"session\""));
let reparsed = GlobalIndex::from_toml_str(&body).unwrap();
assert_eq!(reparsed.get(&p), Some(&entry));
}
#[test]
fn insert_remove_get() {
let mut idx = GlobalIndex::new();
let p: SecretPath = "team/gitlab/token-deploy".parse().unwrap();
let entry = IndexEntry {
description: Some("test".to_owned()),
..IndexEntry::default()
};
assert!(idx.insert(p.clone(), entry.clone()).is_none());
assert_eq!(idx.get(&p), Some(&entry));
assert_eq!(idx.remove(&p), Some(entry));
assert!(idx.get(&p).is_none());
}
#[test]
fn load_from_returns_empty_when_file_missing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("never-existed.toml");
let idx = GlobalIndex::load_from(&path).unwrap();
assert!(idx.is_empty());
}
#[test]
fn load_from_real_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("index.toml");
std::fs::write(&path, fixture_full_entry_toml()).unwrap();
let idx = GlobalIndex::load_from(&path).unwrap();
assert_eq!(idx.len(), 1);
}
#[test]
fn load_from_io_error_surfaces_path() {
let dir = tempfile::tempdir().unwrap();
let err = GlobalIndex::load_from(dir.path()).unwrap_err();
match err {
IndexError::Read { path, .. } => assert_eq!(path, dir.path()),
other => panic!("expected Read, got {other:?}"),
}
}
#[test]
fn default_path_includes_secrets_subdir_and_index_filename() {
let p = GlobalIndex::default_path().unwrap();
let s = p.to_string_lossy();
assert!(s.ends_with("/secrets/index.toml") || s.ends_with("\\secrets\\index.toml"));
assert!(s.contains("devboy-tools"));
}
#[test]
fn secret_path_serde_roundtrip_via_index() {
let idx = GlobalIndex::from_toml_str(
r#"
[secret."team/gitlab/token-deploy"]
description = "x"
"#,
)
.unwrap();
let s = idx.to_toml_string().unwrap();
assert!(s.contains("team/gitlab/token-deploy"));
}
}