Skip to main content

devboy_storage/
index.rs

1//! Global secret-metadata index per [ADR-020] §3.
2//!
3//! The global index lives at `<config-dir>/secrets/index.toml` and
4//! holds **metadata, never values**: human description, retrieval URL,
5//! format regex, expiry/rotation hints, optional pattern reference.
6//! It is the canonical, cross-project source of truth for everything
7//! about a secret *except* the value itself.
8//!
9//! # File layout
10//!
11//! ```toml
12//! [secret."team/gitlab/token-deploy"]
13//! description       = "Deploy token for the team GitLab"
14//! retrieval_url     = "https://gitlab.example.internal/-/profile/personal_access_tokens"
15//! format_regex      = "^glpat-[A-Za-z0-9_-]{20,}$"
16//! default_gate      = "auto"            # auto | confirm | touchid
17//! expires_at        = "2026-08-01"      # ISO 8601 date, optional
18//! last_rotated_at   = "2026-05-02"      # ISO 8601 date, optional
19//! rotate_every_days = 90                # advisory, drives doctor warnings
20//! rotation_method   = "manual"          # manual | provider-ui | provider-api
21//! required_scopes   = ["api", "read_repository"]
22//! pattern_id        = "gitlab-pat"      # devboy-secret-patterns id
23//! env_var           = "GITLAB_TOKEN_DEPLOY"  # env-store override (ADR-021 §8)
24//! cache_ttl_seconds_max = 60            # bound on adaptive TTL (ADR-021 §7)
25//! ```
26//!
27//! # Path semantics
28//!
29//! Keys are typed as [`SecretPath`]. Loading rejects any non-conforming
30//! key with [`IndexError::Path`] — a typo in a path turns into a hard
31//! load-time failure, not a silent miss at lookup time.
32//!
33//! [ADR-020]: https://github.com/meteora-pro/devboy-tools/blob/main/docs/architecture/adr/ADR-020-secret-manifest-and-alias-resolution.md
34
35use 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
46/// Subdirectory under the user's config directory that holds the
47/// secret-framework configuration files (this index, the source router
48/// config, the local vault file).
49pub const SECRETS_SUBDIR: &str = "secrets";
50
51/// Filename of the global metadata index inside [`SECRETS_SUBDIR`].
52pub const INDEX_FILENAME: &str = "index.toml";
53
54/// Failure modes when loading or operating on a [`GlobalIndex`].
55#[derive(Debug, Error)]
56pub enum IndexError {
57    /// I/O error reading the index file.
58    #[error("failed to read global index at {path}: {source}")]
59    Read {
60        /// File the loader tried to read.
61        path: PathBuf,
62        /// Underlying I/O error.
63        #[source]
64        source: std::io::Error,
65    },
66
67    /// TOML deserialization error.
68    #[error("failed to parse global index at {path}: {source}")]
69    Parse {
70        /// File the parser tried to read.
71        path: PathBuf,
72        /// Underlying TOML deserialization error.
73        #[source]
74        source: toml::de::Error,
75    },
76
77    /// TOML serialization error — surfaced by
78    /// [`GlobalIndex::save_to`].
79    #[error("failed to serialize global index for {path}: {source}")]
80    Serialize {
81        /// File the writer was about to write to.
82        path: PathBuf,
83        /// Underlying TOML serialization error.
84        #[source]
85        source: toml::ser::Error,
86    },
87
88    /// One of the keys in the index did not satisfy the path
89    /// convention from ADR-020 §2.
90    #[error("invalid secret path in index: {source}")]
91    Path {
92        /// Underlying path-validation error.
93        #[source]
94        source: PathError,
95    },
96
97    /// `dirs::config_dir()` returned `None` — extremely unusual on
98    /// supported platforms but worth surfacing rather than panicking.
99    #[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/// User-controllable interaction gate for a secret.
110///
111/// Drives whether `secret.get` requires confirmation or biometric
112/// unlock. Per-project manifests may tighten this through
113/// `[overrides]` — see ADR-020 §4.
114#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(rename_all = "lowercase")]
116pub enum Gate {
117    /// No interactive confirmation required.
118    #[default]
119    Auto,
120    /// Show a confirmation dialog before yielding the value.
121    Confirm,
122    /// Require Touch ID / biometric unlock before yielding the value.
123    Touchid,
124}
125
126/// How the secret is rotated.
127///
128/// `Manual` is the only method that ships in the first release;
129/// `ProviderUi` and `ProviderApi` are reserved for future ADRs (see
130/// Per-path policy for AI-agent secret USE (not provision).
131///
132/// Controls whether the framework demands an interactive user
133/// approval each time an agent's high-level provider tool resolves
134/// a `@secret:<path>` alias backed by this entry. Designed for the
135/// case where the user wants to provision once but still wants
136/// fine-grained control over which agent calls "spend" sensitive
137/// credentials. See ADR-023 §3.7 (P25 phase) for the protocol.
138///
139/// Default — `Never` — preserves the existing zero-prompt
140/// resolve path so most paths stay frictionless.
141#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(rename_all = "kebab-case")]
143pub enum ApproveOnUse {
144    /// No approval required — alias resolves silently. The
145    /// default for every existing manifest.
146    #[default]
147    Never,
148    /// First use this session opens an approval dialog; once
149    /// the user clicks "Allow always (this session)" further
150    /// uses skip the prompt for the remainder of the session.
151    Session,
152    /// Every use opens an approval dialog. Right for high-
153    /// stakes paths (prod DB password, signing keys).
154    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/// ADR-023 §3.5 — provider-driven rotation is deferred).
169#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
170#[serde(rename_all = "kebab-case")]
171pub enum RotationMethod {
172    /// User rotates the secret themselves through the upstream UI;
173    /// `devboy-tools` records the new value and validates liveness.
174    #[default]
175    Manual,
176    /// Reserved — `devboy-tools` opens the provider's UI and accepts
177    /// the new value through the rotation flow (ADR-023 §3.5 future
178    /// work).
179    ProviderUi,
180    /// Reserved — `devboy-tools` calls the provider's rotation API
181    /// directly (deferred per ADR-023 §3.5).
182    ProviderApi,
183}
184
185/// Metadata for a single secret stored in the global index.
186///
187/// Every field is optional: missing fields fall back to defaults from
188/// the linked [`pattern_id`](IndexEntry::pattern_id) (when set; that
189/// inheritance is wired up in epic phase P2.4) or to the framework's
190/// own defaults.
191#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
192#[serde(deny_unknown_fields)]
193pub struct IndexEntry {
194    /// Free-text description shown in `secrets describe`, the UI's
195    /// inventory view, and the agent's `secrets.describe` reply.
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub description: Option<String>,
198
199    /// URL the user opens to obtain a fresh value (browser link).
200    /// Used by the rotation flow's `[Open URL]` button.
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub retrieval_url: Option<String>,
203
204    /// Regular expression the value must match. Validation is lazy —
205    /// the regex is only compiled when `secrets validate --format` or
206    /// the format-validation hook runs (epic phase P9.1).
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub format_regex: Option<String>,
209
210    /// Confirmation gate applied at value-read time.
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub default_gate: Option<Gate>,
213
214    /// ISO 8601 (`YYYY-MM-DD`) expiry date. Populated by the liveness
215    /// validator (P9.2) when the upstream API exposes a `valid_until`
216    /// field; can also be set by hand.
217    ///
218    /// Stored as `String` rather than a typed date so the round-trip
219    /// through TOML is lossless without pulling in a date crate. The
220    /// validation framework parses on demand.
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub expires_at: Option<String>,
223
224    /// ISO 8601 date of the last successful rotation. Drives advisory
225    /// warnings via `rotate_every_days`.
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub last_rotated_at: Option<String>,
228
229    /// Recommended rotation cadence in days. `doctor` warns when
230    /// `now > last_rotated_at + rotate_every_days - 7d`.
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub rotate_every_days: Option<u32>,
233
234    /// How the secret is rotated. Defaults to [`RotationMethod::Manual`]
235    /// at consumption time when absent.
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub rotation_method: Option<RotationMethod>,
238
239    /// Advisory list of API scopes required for this token to function.
240    #[serde(default, skip_serializing_if = "Vec::is_empty")]
241    pub required_scopes: Vec<String>,
242
243    /// Reference into the `devboy-secret-patterns` catalogue. When
244    /// set, the catalogue supplies sensible defaults for `format_regex`,
245    /// `retrieval_url`, `rotation_method`, and `default_expiry_days`
246    /// — see ADR-020 §3 and epic phase P2.4 for the wiring.
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub pattern_id: Option<String>,
249
250    /// CI/headless override for the env-store source: when present,
251    /// the env-store reads `<env_var>` verbatim instead of the
252    /// convention-based name. See ADR-021 §8.
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub env_var: Option<String>,
255
256    /// Per-secret upper bound on the source router's adaptive cache
257    /// TTL. Cannot raise it above the source default — see ADR-021 §7.
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub cache_ttl_seconds_max: Option<u64>,
260
261    /// Approve-on-use policy (P25). Defaults to `Never` at the
262    /// consumer side when absent — see [`ApproveOnUse`].
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    pub approve_on_use: Option<ApproveOnUse>,
265}
266
267/// In-memory representation of the global index.
268///
269/// Backed by a [`BTreeMap`] so iteration order is deterministic
270/// (paths sort lexicographically), which keeps `secrets list` output
271/// stable across runs.
272#[derive(Debug, Clone, Default)]
273pub struct GlobalIndex {
274    entries: BTreeMap<SecretPath, IndexEntry>,
275}
276
277/// Internal serde shape for the on-disk file.
278///
279/// We can't deserialize directly into `BTreeMap<SecretPath, IndexEntry>`
280/// because TOML key types are limited to strings: a custom flow is
281/// needed to convert `String` → `SecretPath` with the validator.
282#[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    /// Build an empty index. Useful for tests and as the default when
290    /// the on-disk file is absent.
291    pub fn new() -> Self {
292        Self::default()
293    }
294
295    /// Resolve the canonical on-disk path
296    /// (`<config-dir>/devboy-tools/secrets/index.toml`).
297    ///
298    /// Errors only if `dirs::config_dir()` returns `None`.
299    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    /// Load the index from the canonical default path.
308    ///
309    /// Returns an empty index if the file does not exist (the global
310    /// index is opt-in).
311    pub fn load() -> Result<Self, IndexError> {
312        let path = Self::default_path()?;
313        Self::load_from(&path)
314    }
315
316    /// Load the index from a specific path. Returns an empty index if
317    /// the file does not exist.
318    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    /// Parse the index from a TOML string with no associated path
331    /// (used in tests and when the caller already has the bytes in
332    /// memory).
333    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    /// Persist this index to `path`, creating parent directories
353    /// as needed. Used by the liveness flow (P9.2 / P9.3) to
354    /// write upstream-reported `expires_at` back into the global
355    /// metadata.
356    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    /// Persist to the default path. Returns the path written for
374    /// the caller's logging convenience.
375    pub fn save(&self) -> Result<PathBuf, IndexError> {
376        let path = Self::default_path()?;
377        self.save_to(&path)?;
378        Ok(path)
379    }
380
381    /// Update `entry.expires_at` for `path`. Returns `Ok(true)`
382    /// when an entry existed and the field changed; `Ok(false)`
383    /// when no entry exists at that path. The caller persists
384    /// via [`save`](Self::save) / [`save_to`](Self::save_to).
385    ///
386    /// Used by the liveness flow (P9.2): a probe that returns
387    /// [`LivenessResult::expires_at`] hands the value here, and
388    /// the next `doctor` run sees the freshened expiry.
389    ///
390    /// [`LivenessResult::expires_at`]: devboy_core::liveness::LivenessResult
391    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    /// Update `entry.last_rotated_at` for `path`. Same shape as
407    /// [`record_expiry`](Self::record_expiry); used by the rotation
408    /// flow (P13.1) after a successful rotation.
409    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    /// Serialize back to TOML. Round-trips bit-for-bit with what
425    /// `from_toml_str` would parse (modulo whitespace and key order —
426    /// the latter is stable thanks to [`BTreeMap`]).
427    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    /// Look up a single entry by path.
439    pub fn get(&self, path: &SecretPath) -> Option<&IndexEntry> {
440        self.entries.get(path)
441    }
442
443    /// Insert or replace an entry. Returns the previous entry if any.
444    pub fn insert(&mut self, path: SecretPath, entry: IndexEntry) -> Option<IndexEntry> {
445        self.entries.insert(path, entry)
446    }
447
448    /// Remove an entry. Returns the removed entry if present.
449    pub fn remove(&mut self, path: &SecretPath) -> Option<IndexEntry> {
450        self.entries.remove(path)
451    }
452
453    /// Total number of entries.
454    pub fn len(&self) -> usize {
455        self.entries.len()
456    }
457
458    /// `true` when the index has no entries.
459    pub fn is_empty(&self) -> bool {
460        self.entries.is_empty()
461    }
462
463    /// Iterate over `(path, entry)` pairs in sorted order.
464    pub fn iter(&self) -> impl Iterator<Item = (&SecretPath, &IndexEntry)> {
465        self.entries.iter()
466    }
467}
468
469// =============================================================================
470// Tests
471// =============================================================================
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    /// A complete entry with every field populated, used to exercise
478    /// the round-trip and field-by-field deserialization paths.
479    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        // BTreeMap iteration is sorted lexicographically.
571        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        // Key violates ADR-020 §2 (only two segments).
584        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        // `deny_unknown_fields` guards against typos like `retrieval_hint`
619        // (the ADR-020 v1 spelling that was renamed to `retrieval_url`).
620        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        // Default (None) round-trips as missing field.
673        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        // Per-call policy round-trips.
683        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        // Session policy round-trips.
698        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        // Pointing at a *directory* triggers a read error.
744        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        // Indirectly exercises Serialize/Deserialize on SecretPath.
763        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}