1use 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
66pub const MANIFEST_RELATIVE_PATH: &str = ".devboy/secrets.toml";
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum PathRole {
76 Required,
78 Optional,
80 OverrideKey,
82 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#[derive(Debug, Error)]
100pub enum ManifestError {
101 #[error("failed to read project manifest at {path}: {source}")]
103 Read {
104 path: PathBuf,
106 #[source]
108 source: std::io::Error,
109 },
110
111 #[error("failed to parse project manifest at {path}: {source}")]
115 Parse {
116 path: PathBuf,
118 #[source]
120 source: toml::de::Error,
121 },
122
123 #[error("invalid path in manifest position {role}: {source}")]
126 Path {
127 role: PathRole,
129 #[source]
131 source: PathError,
132 },
133}
134
135#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(deny_unknown_fields)]
146pub struct OverrideEntry {
147 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub gate: Option<Gate>,
151
152 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub rotate_every_days: Option<u32>,
156
157 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub description: Option<String>,
162
163 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub approve_on_use: Option<crate::index::ApproveOnUse>,
169}
170
171impl OverrideEntry {
172 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
191pub struct ProjectManifest {
192 pub required: Vec<SecretPath>,
195
196 pub optional: Vec<SecretPath>,
198
199 pub overrides: BTreeMap<SecretPath, OverrideEntry>,
201
202 pub secrets: BTreeMap<SecretPath, IndexEntry>,
205}
206
207#[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 pub fn new() -> Self {
230 Self::default()
231 }
232
233 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 pub fn load_from_project_root(root: &Path) -> Result<Self, ManifestError> {
245 Self::load_from(&root.join(MANIFEST_RELATIVE_PATH))
246 }
247
248 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 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 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 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#[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 #[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 #[test]
507 fn rejects_disallowed_override_field() {
508 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 #[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 #[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 #[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 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}