Skip to main content

devboy_storage/
manifest.rs

1//! Per-project secret manifest per [ADR-020] §4.
2//!
3//! A project that uses `devboy-tools` declares its dependency on
4//! secrets in `.devboy/secrets.toml` checked into the repository.
5//! Three categories of declarations are recognised:
6//!
7//! 1. `required` and `optional` — *references* into the secret
8//!    namespace. `doctor` fails the exit code on a missing required
9//!    path; missing optional paths surface as informational.
10//! 2. `[overrides."<path>"]` — behavioural overrides applied on top
11//!    of the global-index entry for that path. Only three fields
12//!    may be overridden (`gate`, `rotate_every_days`, `description`);
13//!    attempts to override anything else are rejected at parse time
14//!    with `deny_unknown_fields` so drift between project and
15//!    global cannot grow silently.
16//! 3. `[secret."<path>"]` — *project-local* metadata for a path that
17//!    does not exist in the global index. The loader treats such a
18//!    path as if its global entry were absent (the merge logic in
19//!    P1.4 reads from the manifest exclusively for these paths).
20//!
21//! # File layout
22//!
23//! ```toml
24//! # .devboy/secrets.toml
25//!
26//! required = [
27//!     "team/gitlab/token-deploy",
28//!     "personal/github/pat",
29//! ]
30//!
31//! optional = ["personal/slack/notify-token"]
32//!
33//! [overrides."team/gitlab/token-deploy"]
34//! gate              = "touchid"
35//! rotate_every_days = 30
36//! description       = "Used by the staging deploy pipeline"
37//!
38//! [secret."sandbox/example-provider/token"]
39//! description   = "Sandbox-only; recreated per-developer"
40//! retrieval_url = "https://example-provider.dev/account/api-tokens"
41//! pattern_id    = "generic-bearer"
42//! ```
43//!
44//! # Path validation
45//!
46//! Every path that appears in any of the four positions is parsed
47//! through [`SecretPath::parse`] at load time. Invalid paths produce
48//! [`ManifestError::Path`] with a [`PathRole`] tag identifying *which*
49//! position the bad path appeared in, so error messages can point at
50//! the right TOML location.
51//!
52//! [ADR-020]: https://github.com/meteora-pro/devboy-tools/blob/main/docs/architecture/adr/ADR-020-secret-manifest-and-alias-resolution.md
53
54use std::collections::BTreeMap;
55use std::fmt;
56use std::fs;
57use std::path::{Path, PathBuf};
58
59use serde::{Deserialize, Serialize};
60use thiserror::Error;
61use tracing::debug;
62
63use crate::index::{Gate, IndexEntry};
64use crate::secret_path::{PathError, SecretPath};
65
66/// Conventional path of the per-project manifest, relative to the
67/// project root.
68pub const MANIFEST_RELATIVE_PATH: &str = ".devboy/secrets.toml";
69
70/// Position in the manifest where a bad path was encountered.
71///
72/// Surfaced through [`ManifestError::Path`] so the user sees *where*
73/// to look in the TOML file, not just *what* the violation was.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum PathRole {
76    /// Inside the top-level `required = [...]` list.
77    Required,
78    /// Inside the top-level `optional = [...]` list.
79    Optional,
80    /// Key of an `[overrides."<path>"]` table.
81    OverrideKey,
82    /// Key of a `[secret."<path>"]` table.
83    SecretKey,
84}
85
86impl fmt::Display for PathRole {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        let s = match self {
89            Self::Required => "required[]",
90            Self::Optional => "optional[]",
91            Self::OverrideKey => "[overrides.\"...\"]",
92            Self::SecretKey => "[secret.\"...\"]",
93        };
94        f.write_str(s)
95    }
96}
97
98/// Failure modes when loading or parsing a [`ProjectManifest`].
99#[derive(Debug, Error)]
100pub enum ManifestError {
101    /// I/O error reading the manifest file.
102    #[error("failed to read project manifest at {path}: {source}")]
103    Read {
104        /// File the loader tried to read.
105        path: PathBuf,
106        /// Underlying I/O error.
107        #[source]
108        source: std::io::Error,
109    },
110
111    /// TOML deserialization error. Triggered by syntax errors and by
112    /// `deny_unknown_fields` violations such as putting `format_regex`
113    /// inside an `[overrides."..."]` block.
114    #[error("failed to parse project manifest at {path}: {source}")]
115    Parse {
116        /// File the parser tried to read.
117        path: PathBuf,
118        /// Underlying TOML deserialization error.
119        #[source]
120        source: toml::de::Error,
121    },
122
123    /// One of the paths in the manifest did not satisfy the path
124    /// convention from ADR-020 §2.
125    #[error("invalid path in manifest position {role}: {source}")]
126    Path {
127        /// The position in which the bad path appeared.
128        role: PathRole,
129        /// Underlying path-validation error.
130        #[source]
131        source: PathError,
132    },
133}
134
135/// Behavioural override applied to a path whose canonical metadata
136/// lives in the global index.
137///
138/// Per ADR-020 §4, **only** `gate`, `rotate_every_days`, and
139/// `description` may be overridden — everything else (regex, retrieval
140/// URL, rotation method, …) is read from the global index only. The
141/// `deny_unknown_fields` attribute enforces this: writing
142/// `retrieval_url` here is a parse error rather than a silently-ignored
143/// no-op.
144#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(deny_unknown_fields)]
146pub struct OverrideEntry {
147    /// Tightens the default gate from the global index for this
148    /// project.
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub gate: Option<Gate>,
151
152    /// Project-specific rotation cadence in days. Tightens whatever
153    /// the global index recommends.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub rotate_every_days: Option<u32>,
156
157    /// Project-local note that appears alongside the global
158    /// description in `secrets describe` and the inventory view
159    /// (e.g. "in this repo this is the staging deploy token").
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub description: Option<String>,
162
163    /// Approve-on-use policy override (P25). Lets a project
164    /// tighten (or relax) the global index's `approve_on_use`
165    /// without rewriting the index entry. See
166    /// [`crate::index::ApproveOnUse`].
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub approve_on_use: Option<crate::index::ApproveOnUse>,
169}
170
171impl OverrideEntry {
172    /// `true` when no field is set — useful for collapsing the
173    /// no-op-override warning (`doctor` flags blocks where every
174    /// override matches the global value).
175    pub fn is_empty(&self) -> bool {
176        self.gate.is_none()
177            && self.rotate_every_days.is_none()
178            && self.description.is_none()
179            && self.approve_on_use.is_none()
180    }
181}
182
183/// In-memory representation of `.devboy/secrets.toml`.
184///
185/// This loader does **not** merge with the global index — it is the
186/// raw view of what the project committed. The merge logic (precedence
187/// rules, `E_OVERRIDE_FIELD_NOT_ALLOWED` for non-allowed fields,
188/// `E_SECRET_UNKNOWN_PATH` for paths missing from both global and
189/// project-local) lands in epic phase P1.4 (#18).
190#[derive(Debug, Clone, Default, PartialEq, Eq)]
191pub struct ProjectManifest {
192    /// Paths the project requires to function. `doctor` returns
193    /// non-zero if any are missing.
194    pub required: Vec<SecretPath>,
195
196    /// Paths the project can use but does not require.
197    pub optional: Vec<SecretPath>,
198
199    /// Behavioural overrides keyed by path.
200    pub overrides: BTreeMap<SecretPath, OverrideEntry>,
201
202    /// Project-local entries — paths that do not appear in the global
203    /// index and whose metadata lives in this manifest.
204    pub secrets: BTreeMap<SecretPath, IndexEntry>,
205}
206
207/// Internal serde shape — every key is a raw `String` so the loader
208/// can convert to `SecretPath` with role-tagged error reporting.
209#[derive(Debug, Default, Deserialize, Serialize)]
210#[serde(deny_unknown_fields)]
211struct RawManifest {
212    #[serde(default, skip_serializing_if = "Vec::is_empty")]
213    required: Vec<String>,
214
215    #[serde(default, skip_serializing_if = "Vec::is_empty")]
216    optional: Vec<String>,
217
218    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
219    overrides: BTreeMap<String, OverrideEntry>,
220
221    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
222    secret: BTreeMap<String, IndexEntry>,
223}
224
225impl ProjectManifest {
226    /// Build an empty manifest. Useful for tests and as the default
227    /// when `.devboy/secrets.toml` is absent (the manifest is opt-in
228    /// per ADR-020 §4).
229    pub fn new() -> Self {
230        Self::default()
231    }
232
233    /// Load the manifest from `<cwd>/.devboy/secrets.toml`. Returns
234    /// an empty manifest if the file does not exist.
235    pub fn load() -> Result<Self, ManifestError> {
236        let cwd = std::env::current_dir().map_err(|e| ManifestError::Read {
237            path: PathBuf::from(MANIFEST_RELATIVE_PATH),
238            source: e,
239        })?;
240        Self::load_from_project_root(&cwd)
241    }
242
243    /// Load the manifest from `<project_root>/.devboy/secrets.toml`.
244    pub fn load_from_project_root(root: &Path) -> Result<Self, ManifestError> {
245        Self::load_from(&root.join(MANIFEST_RELATIVE_PATH))
246    }
247
248    /// Load the manifest from a specific file path. Returns an empty
249    /// manifest if the file does not exist.
250    pub fn load_from(path: &Path) -> Result<Self, ManifestError> {
251        if !path.exists() {
252            debug!(path = ?path, "project manifest not present, using empty");
253            return Ok(Self::new());
254        }
255        let body = fs::read_to_string(path).map_err(|e| ManifestError::Read {
256            path: path.to_path_buf(),
257            source: e,
258        })?;
259        Self::from_str_with_path(&body, path)
260    }
261
262    /// Parse from a TOML string with no associated file path (used in
263    /// tests and when the caller has bytes in memory).
264    pub fn from_toml_str(body: &str) -> Result<Self, ManifestError> {
265        Self::from_str_with_path(body, Path::new("<inline>"))
266    }
267
268    fn from_str_with_path(body: &str, path: &Path) -> Result<Self, ManifestError> {
269        let raw: RawManifest = toml::from_str(body).map_err(|e| ManifestError::Parse {
270            path: path.to_path_buf(),
271            source: e,
272        })?;
273
274        let required = parse_path_list(&raw.required, PathRole::Required)?;
275        let optional = parse_path_list(&raw.optional, PathRole::Optional)?;
276        let overrides = parse_path_map(raw.overrides, PathRole::OverrideKey)?;
277        let secrets = parse_path_map(raw.secret, PathRole::SecretKey)?;
278
279        Ok(Self {
280            required,
281            optional,
282            overrides,
283            secrets,
284        })
285    }
286
287    /// `true` when no required, optional, override, or project-local
288    /// entry is declared. `doctor` treats this as "the project does
289    /// not currently depend on any managed secret".
290    pub fn is_empty(&self) -> bool {
291        self.required.is_empty()
292            && self.optional.is_empty()
293            && self.overrides.is_empty()
294            && self.secrets.is_empty()
295    }
296
297    /// Iterate over every path mentioned by the manifest in any role
298    /// (required, optional, override key, project-local secret key),
299    /// each tagged with the role it appeared in. Useful for `doctor`
300    /// and the merge logic.
301    pub fn referenced_paths(&self) -> impl Iterator<Item = (&SecretPath, PathRole)> {
302        self.required
303            .iter()
304            .map(|p| (p, PathRole::Required))
305            .chain(self.optional.iter().map(|p| (p, PathRole::Optional)))
306            .chain(self.overrides.keys().map(|p| (p, PathRole::OverrideKey)))
307            .chain(self.secrets.keys().map(|p| (p, PathRole::SecretKey)))
308    }
309}
310
311fn parse_path_list(raw: &[String], role: PathRole) -> Result<Vec<SecretPath>, ManifestError> {
312    raw.iter()
313        .map(|s| SecretPath::parse(s).map_err(|source| ManifestError::Path { role, source }))
314        .collect()
315}
316
317fn parse_path_map<V>(
318    raw: BTreeMap<String, V>,
319    role: PathRole,
320) -> Result<BTreeMap<SecretPath, V>, ManifestError> {
321    let mut out = BTreeMap::new();
322    for (k, v) in raw {
323        let p = SecretPath::parse(&k).map_err(|source| ManifestError::Path { role, source })?;
324        out.insert(p, v);
325    }
326    Ok(out)
327}
328
329// =============================================================================
330// Tests
331// =============================================================================
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use crate::index::RotationMethod;
337
338    fn fixture_full_manifest() -> &'static str {
339        r#"
340required = [
341    "team/gitlab/token-deploy",
342    "personal/github/pat",
343]
344
345optional = ["personal/slack/notify-token"]
346
347[overrides."team/gitlab/token-deploy"]
348gate              = "touchid"
349rotate_every_days = 30
350description       = "Used by the staging deploy pipeline"
351
352[secret."sandbox/example-provider/token"]
353description   = "Sandbox-only token recreated per developer"
354retrieval_url = "https://example-provider.dev/account/api-tokens"
355pattern_id    = "generic-bearer"
356rotation_method = "manual"
357"#
358    }
359
360    #[test]
361    fn empty_string_yields_empty_manifest() {
362        let m = ProjectManifest::from_toml_str("").unwrap();
363        assert!(m.is_empty());
364        assert!(m.required.is_empty());
365        assert!(m.optional.is_empty());
366        assert!(m.overrides.is_empty());
367        assert!(m.secrets.is_empty());
368    }
369
370    #[test]
371    fn parses_full_manifest() {
372        let m = ProjectManifest::from_toml_str(fixture_full_manifest()).unwrap();
373
374        assert_eq!(m.required.len(), 2);
375        assert_eq!(m.required[0].as_str(), "team/gitlab/token-deploy");
376        assert_eq!(m.required[1].as_str(), "personal/github/pat");
377
378        assert_eq!(m.optional.len(), 1);
379        assert_eq!(m.optional[0].as_str(), "personal/slack/notify-token");
380
381        let override_path: SecretPath = "team/gitlab/token-deploy".parse().unwrap();
382        let ov = m.overrides.get(&override_path).expect("override present");
383        assert_eq!(ov.gate, Some(Gate::Touchid));
384        assert_eq!(ov.rotate_every_days, Some(30));
385        assert_eq!(
386            ov.description.as_deref(),
387            Some("Used by the staging deploy pipeline")
388        );
389
390        let local_path: SecretPath = "sandbox/example-provider/token".parse().unwrap();
391        let sec = m.secrets.get(&local_path).expect("project-local present");
392        assert_eq!(
393            sec.description.as_deref(),
394            Some("Sandbox-only token recreated per developer")
395        );
396        assert_eq!(
397            sec.retrieval_url.as_deref(),
398            Some("https://example-provider.dev/account/api-tokens")
399        );
400        assert_eq!(sec.pattern_id.as_deref(), Some("generic-bearer"));
401        assert_eq!(sec.rotation_method, Some(RotationMethod::Manual));
402    }
403
404    #[test]
405    fn parses_only_required() {
406        let m = ProjectManifest::from_toml_str(
407            r#"
408required = ["team/gitlab/token-deploy"]
409"#,
410        )
411        .unwrap();
412        assert_eq!(m.required.len(), 1);
413        assert!(m.optional.is_empty());
414        assert!(m.overrides.is_empty());
415        assert!(m.secrets.is_empty());
416        assert!(!m.is_empty());
417    }
418
419    #[test]
420    fn parses_only_overrides() {
421        let m = ProjectManifest::from_toml_str(
422            r#"
423[overrides."team/gitlab/token-deploy"]
424gate = "confirm"
425"#,
426        )
427        .unwrap();
428        let p: SecretPath = "team/gitlab/token-deploy".parse().unwrap();
429        assert_eq!(m.overrides.get(&p).unwrap().gate, Some(Gate::Confirm));
430    }
431
432    // -- Path validation -------------------------------------------------------
433
434    #[test]
435    fn rejects_invalid_path_in_required() {
436        let err = ProjectManifest::from_toml_str(
437            r#"
438required = ["gitlab.token"]
439"#,
440        )
441        .unwrap_err();
442        match err {
443            ManifestError::Path { role, source } => {
444                assert_eq!(role, PathRole::Required);
445                assert!(matches!(source, PathError::TooFewSegments { .. }));
446            }
447            other => panic!("expected Path error, got {other:?}"),
448        }
449    }
450
451    #[test]
452    fn rejects_invalid_path_in_optional() {
453        let err = ProjectManifest::from_toml_str(
454            r#"
455optional = ["Bad/Path/Format"]
456"#,
457        )
458        .unwrap_err();
459        match err {
460            ManifestError::Path { role, source } => {
461                assert_eq!(role, PathRole::Optional);
462                assert!(matches!(source, PathError::BadSegment { .. }));
463            }
464            other => panic!("expected Path error, got {other:?}"),
465        }
466    }
467
468    #[test]
469    fn rejects_invalid_path_in_override_key() {
470        let err = ProjectManifest::from_toml_str(
471            r#"
472[overrides."team/gitlab"]
473gate = "auto"
474"#,
475        )
476        .unwrap_err();
477        match err {
478            ManifestError::Path { role, source } => {
479                assert_eq!(role, PathRole::OverrideKey);
480                assert!(matches!(source, PathError::TooFewSegments { found: 2, .. }));
481            }
482            other => panic!("expected Path error, got {other:?}"),
483        }
484    }
485
486    #[test]
487    fn rejects_invalid_path_in_secret_key() {
488        let err = ProjectManifest::from_toml_str(
489            r#"
490[secret."__sources/vault/token"]
491description = "internal"
492"#,
493        )
494        .unwrap_err();
495        match err {
496            ManifestError::Path { role, source } => {
497                assert_eq!(role, PathRole::SecretKey);
498                assert!(matches!(source, PathError::ReservedPrefix { .. }));
499            }
500            other => panic!("expected Path error, got {other:?}"),
501        }
502    }
503
504    // -- Override field validation --------------------------------------------
505
506    #[test]
507    fn rejects_disallowed_override_field() {
508        // ADR-020 §4: only behavioural fields may live in [overrides].
509        // Non-behavioural metadata (format_regex, retrieval_url, rotation_method,
510        // pattern_id, ...) must come from the global index only.
511        for bad_field in [
512            "format_regex = \"^x$\"",
513            "retrieval_url = \"https://example.com\"",
514            "rotation_method = \"manual\"",
515            "pattern_id = \"x\"",
516            "expires_at = \"2026-08-01\"",
517        ] {
518            let toml = format!("[overrides.\"team/gitlab/token-deploy\"]\n{bad_field}\n");
519            let err = ProjectManifest::from_toml_str(&toml).unwrap_err();
520            assert!(
521                matches!(err, ManifestError::Parse { .. }),
522                "field {bad_field:?} should be rejected at parse time, got {err:?}"
523            );
524        }
525    }
526
527    #[test]
528    fn override_entry_is_empty_when_all_fields_missing() {
529        let m = ProjectManifest::from_toml_str(
530            r#"
531[overrides."team/gitlab/token-deploy"]
532"#,
533        )
534        .unwrap();
535        let p: SecretPath = "team/gitlab/token-deploy".parse().unwrap();
536        let ov = m.overrides.get(&p).unwrap();
537        assert!(ov.is_empty());
538    }
539
540    #[test]
541    fn override_entry_is_not_empty_when_any_field_set() {
542        let m = ProjectManifest::from_toml_str(
543            r#"
544[overrides."team/gitlab/token-deploy"]
545gate = "auto"
546"#,
547        )
548        .unwrap();
549        let p: SecretPath = "team/gitlab/token-deploy".parse().unwrap();
550        assert!(!m.overrides.get(&p).unwrap().is_empty());
551    }
552
553    // -- Top-level unknown-field rejection ------------------------------------
554
555    #[test]
556    fn rejects_unknown_top_level_field() {
557        let err = ProjectManifest::from_toml_str(
558            r#"
559required = []
560extra_field = "something"
561"#,
562        )
563        .unwrap_err();
564        assert!(matches!(err, ManifestError::Parse { .. }));
565    }
566
567    // -- File-loading paths ---------------------------------------------------
568
569    #[test]
570    fn load_from_returns_empty_when_file_missing() {
571        let dir = tempfile::tempdir().unwrap();
572        let path = dir.path().join("missing.toml");
573        let m = ProjectManifest::load_from(&path).unwrap();
574        assert!(m.is_empty());
575    }
576
577    #[test]
578    fn load_from_real_file() {
579        let dir = tempfile::tempdir().unwrap();
580        let path = dir.path().join("secrets.toml");
581        std::fs::write(&path, fixture_full_manifest()).unwrap();
582        let m = ProjectManifest::load_from(&path).unwrap();
583        assert_eq!(m.required.len(), 2);
584        assert_eq!(m.secrets.len(), 1);
585    }
586
587    #[test]
588    fn load_from_project_root_resolves_dot_devboy_path() {
589        let dir = tempfile::tempdir().unwrap();
590        std::fs::create_dir(dir.path().join(".devboy")).unwrap();
591        let manifest_path = dir.path().join(MANIFEST_RELATIVE_PATH);
592        std::fs::write(&manifest_path, fixture_full_manifest()).unwrap();
593        let m = ProjectManifest::load_from_project_root(dir.path()).unwrap();
594        assert_eq!(m.required.len(), 2);
595    }
596
597    #[test]
598    fn load_from_returns_empty_when_dot_devboy_dir_missing() {
599        let dir = tempfile::tempdir().unwrap();
600        let m = ProjectManifest::load_from_project_root(dir.path()).unwrap();
601        assert!(m.is_empty());
602    }
603
604    #[test]
605    fn load_io_error_surfaces_path() {
606        let dir = tempfile::tempdir().unwrap();
607        let err = ProjectManifest::load_from(dir.path()).unwrap_err();
608        match err {
609            ManifestError::Read { path, .. } => assert_eq!(path, dir.path()),
610            other => panic!("expected Read, got {other:?}"),
611        }
612    }
613
614    // -- referenced_paths iterator --------------------------------------------
615
616    #[test]
617    fn referenced_paths_yields_each_role() {
618        let m = ProjectManifest::from_toml_str(fixture_full_manifest()).unwrap();
619        let paths: Vec<(String, PathRole)> = m
620            .referenced_paths()
621            .map(|(p, r)| (p.as_str().to_owned(), r))
622            .collect();
623
624        // Two required, one optional, one override key, one project-local key.
625        assert_eq!(paths.len(), 5);
626        assert!(
627            paths
628                .iter()
629                .any(|(p, r)| p == "team/gitlab/token-deploy" && *r == PathRole::Required)
630        );
631        assert!(
632            paths
633                .iter()
634                .any(|(p, r)| p == "personal/github/pat" && *r == PathRole::Required)
635        );
636        assert!(
637            paths
638                .iter()
639                .any(|(p, r)| p == "personal/slack/notify-token" && *r == PathRole::Optional)
640        );
641        assert!(
642            paths
643                .iter()
644                .any(|(p, r)| p == "team/gitlab/token-deploy" && *r == PathRole::OverrideKey)
645        );
646        assert!(
647            paths
648                .iter()
649                .any(|(p, r)| p == "sandbox/example-provider/token" && *r == PathRole::SecretKey)
650        );
651    }
652
653    #[test]
654    fn path_role_display() {
655        assert_eq!(PathRole::Required.to_string(), "required[]");
656        assert_eq!(PathRole::Optional.to_string(), "optional[]");
657        assert_eq!(PathRole::OverrideKey.to_string(), "[overrides.\"...\"]");
658        assert_eq!(PathRole::SecretKey.to_string(), "[secret.\"...\"]");
659    }
660}