1use std::collections::BTreeMap;
51
52use serde::Serialize;
53use thiserror::Error;
54
55use crate::index::{GlobalIndex, IndexEntry};
56use crate::manifest::{OverrideEntry, PathRole, ProjectManifest};
57use crate::secret_path::SecretPath;
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
62#[serde(rename_all = "snake_case")]
63pub enum OverrideField {
64 Gate,
66 RotateEveryDays,
68 Description,
70 ApproveOnUse,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum SecretOrigin {
77 ProjectLocal,
80 Global {
83 overrides_applied: Vec<OverrideField>,
86 },
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct ResolvedSecret {
93 pub path: SecretPath,
96 pub required: bool,
99 pub origin: SecretOrigin,
101 pub metadata: IndexEntry,
103}
104
105#[derive(Debug, Default, Clone, PartialEq, Eq)]
108pub struct MergeOutput {
109 pub secrets: BTreeMap<SecretPath, ResolvedSecret>,
111 pub warnings: Vec<MergeWarning>,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
120pub struct MergeWarning {
121 pub kind: MergeWarningKind,
123 pub path: SecretPath,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
130#[serde(tag = "kind", rename_all = "snake_case")]
131pub enum MergeWarningKind {
132 NoOpOverride {
136 field: OverrideField,
138 },
139 OverrideForUndeclaredPath,
142 ProjectLocalForUndeclaredPath,
146}
147
148#[derive(Debug, Error, PartialEq, Eq)]
150pub enum MergeError {
151 #[error(
155 "secret path '{path}' is declared in {role} but no metadata is registered for it; \
156 add a `[secret.\"{path}\"]` block to the manifest or register it in the global index \
157 (run `devboy secrets describe {path}` for guidance)"
158 )]
159 UnknownPath {
160 path: SecretPath,
162 role: PathRole,
164 },
165
166 #[error(
174 "secret path '{path}' has both an `[overrides.\"{path}\"]` block and a project-local \
175 `[secret.\"{path}\"]` block; remove the override (project-local entries already carry \
176 the full metadata, so an override is ambiguous)"
177 )]
178 OverrideOnProjectLocal {
179 path: SecretPath,
181 },
182}
183
184pub fn merge_manifest(
190 global: &GlobalIndex,
191 manifest: &ProjectManifest,
192) -> Result<MergeOutput, MergeError> {
193 for path in manifest.secrets.keys() {
197 if manifest.overrides.contains_key(path) {
198 return Err(MergeError::OverrideOnProjectLocal { path: path.clone() });
199 }
200 }
201
202 let mut output = MergeOutput::default();
203
204 for (path, is_required) in manifest
209 .required
210 .iter()
211 .map(|p| (p, true))
212 .chain(manifest.optional.iter().map(|p| (p, false)))
213 {
214 let resolved = resolve_one(path, is_required, global, manifest, &mut output.warnings)?;
215 match output.secrets.get(path) {
219 Some(existing) if existing.required && !is_required => continue,
220 _ => {
221 output.secrets.insert(path.clone(), resolved);
222 }
223 }
224 }
225
226 for path in manifest.overrides.keys() {
229 if !is_declared(path, manifest) {
230 output.warnings.push(MergeWarning {
231 kind: MergeWarningKind::OverrideForUndeclaredPath,
232 path: path.clone(),
233 });
234 }
235 }
236 for path in manifest.secrets.keys() {
237 if !is_declared(path, manifest) {
238 output.warnings.push(MergeWarning {
239 kind: MergeWarningKind::ProjectLocalForUndeclaredPath,
240 path: path.clone(),
241 });
242 }
243 }
244
245 Ok(output)
246}
247
248fn is_declared(path: &SecretPath, manifest: &ProjectManifest) -> bool {
249 manifest.required.iter().any(|p| p == path) || manifest.optional.iter().any(|p| p == path)
250}
251
252fn resolve_one(
253 path: &SecretPath,
254 is_required: bool,
255 global: &GlobalIndex,
256 manifest: &ProjectManifest,
257 warnings: &mut Vec<MergeWarning>,
258) -> Result<ResolvedSecret, MergeError> {
259 if let Some(local) = manifest.secrets.get(path) {
261 return Ok(ResolvedSecret {
262 path: path.clone(),
263 required: is_required,
264 origin: SecretOrigin::ProjectLocal,
265 metadata: local.clone(),
266 });
267 }
268
269 if let Some(global_entry) = global.get(path) {
271 let mut metadata = global_entry.clone();
272 let mut applied = Vec::new();
273 if let Some(over) = manifest.overrides.get(path) {
274 apply_overrides(&mut metadata, over, path, &mut applied, warnings);
275 }
276 return Ok(ResolvedSecret {
277 path: path.clone(),
278 required: is_required,
279 origin: SecretOrigin::Global {
280 overrides_applied: applied,
281 },
282 metadata,
283 });
284 }
285
286 Err(MergeError::UnknownPath {
288 path: path.clone(),
289 role: if is_required {
290 PathRole::Required
291 } else {
292 PathRole::Optional
293 },
294 })
295}
296
297fn apply_overrides(
298 metadata: &mut IndexEntry,
299 over: &OverrideEntry,
300 path: &SecretPath,
301 applied: &mut Vec<OverrideField>,
302 warnings: &mut Vec<MergeWarning>,
303) {
304 if let Some(g) = over.gate {
305 if metadata.default_gate == Some(g) {
306 warnings.push(MergeWarning {
307 kind: MergeWarningKind::NoOpOverride {
308 field: OverrideField::Gate,
309 },
310 path: path.clone(),
311 });
312 }
313 metadata.default_gate = Some(g);
314 applied.push(OverrideField::Gate);
315 }
316 if let Some(d) = over.rotate_every_days {
317 if metadata.rotate_every_days == Some(d) {
318 warnings.push(MergeWarning {
319 kind: MergeWarningKind::NoOpOverride {
320 field: OverrideField::RotateEveryDays,
321 },
322 path: path.clone(),
323 });
324 }
325 metadata.rotate_every_days = Some(d);
326 applied.push(OverrideField::RotateEveryDays);
327 }
328 if let Some(desc) = &over.description {
329 if metadata.description.as_ref() == Some(desc) {
330 warnings.push(MergeWarning {
331 kind: MergeWarningKind::NoOpOverride {
332 field: OverrideField::Description,
333 },
334 path: path.clone(),
335 });
336 }
337 metadata.description = Some(desc.clone());
338 applied.push(OverrideField::Description);
339 }
340 if let Some(policy) = over.approve_on_use {
341 if metadata.approve_on_use == Some(policy) {
342 warnings.push(MergeWarning {
343 kind: MergeWarningKind::NoOpOverride {
344 field: OverrideField::ApproveOnUse,
345 },
346 path: path.clone(),
347 });
348 }
349 metadata.approve_on_use = Some(policy);
350 applied.push(OverrideField::ApproveOnUse);
351 }
352}
353
354#[cfg(test)]
359mod tests {
360 use super::*;
361 use crate::index::{Gate, IndexEntry, RotationMethod};
362
363 fn p(s: &str) -> SecretPath {
364 SecretPath::parse(s).unwrap()
365 }
366
367 #[test]
370 fn resolves_global_entry_without_overrides() {
371 let mut global = GlobalIndex::new();
372 global.insert(
373 p("team/gitlab/token-deploy"),
374 IndexEntry {
375 description: Some("Team deploy token".to_owned()),
376 default_gate: Some(Gate::Auto),
377 rotation_method: Some(RotationMethod::Manual),
378 ..IndexEntry::default()
379 },
380 );
381 let manifest = ProjectManifest {
382 required: vec![p("team/gitlab/token-deploy")],
383 ..ProjectManifest::default()
384 };
385
386 let out = merge_manifest(&global, &manifest).unwrap();
387 let resolved = out.secrets.get(&p("team/gitlab/token-deploy")).unwrap();
388 assert!(resolved.required);
389 assert_eq!(
390 resolved.origin,
391 SecretOrigin::Global {
392 overrides_applied: vec![]
393 }
394 );
395 assert_eq!(
396 resolved.metadata.description.as_deref(),
397 Some("Team deploy token")
398 );
399 assert!(out.warnings.is_empty());
400 }
401
402 #[test]
403 fn applies_overrides_on_global_entry() {
404 let mut global = GlobalIndex::new();
405 global.insert(
406 p("team/gitlab/token-deploy"),
407 IndexEntry {
408 description: Some("Team deploy token".to_owned()),
409 default_gate: Some(Gate::Auto),
410 rotate_every_days: Some(90),
411 ..IndexEntry::default()
412 },
413 );
414 let manifest = ProjectManifest {
415 required: vec![p("team/gitlab/token-deploy")],
416 overrides: BTreeMap::from([(
417 p("team/gitlab/token-deploy"),
418 OverrideEntry {
419 gate: Some(Gate::Touchid),
420 rotate_every_days: Some(30),
421 description: Some("Staging deploy only".to_owned()),
422 ..OverrideEntry::default()
423 },
424 )]),
425 ..ProjectManifest::default()
426 };
427
428 let out = merge_manifest(&global, &manifest).unwrap();
429 let resolved = out.secrets.get(&p("team/gitlab/token-deploy")).unwrap();
430
431 match &resolved.origin {
432 SecretOrigin::Global { overrides_applied } => {
433 assert_eq!(overrides_applied.len(), 3);
434 assert!(overrides_applied.contains(&OverrideField::Gate));
435 assert!(overrides_applied.contains(&OverrideField::RotateEveryDays));
436 assert!(overrides_applied.contains(&OverrideField::Description));
437 }
438 other => panic!("expected Global origin, got {other:?}"),
439 }
440 assert_eq!(resolved.metadata.default_gate, Some(Gate::Touchid));
441 assert_eq!(resolved.metadata.rotate_every_days, Some(30));
442 assert_eq!(
443 resolved.metadata.description.as_deref(),
444 Some("Staging deploy only")
445 );
446 assert!(out.warnings.is_empty());
447 }
448
449 #[test]
450 fn project_local_wins_over_absent_global() {
451 let global = GlobalIndex::new();
452 let manifest = ProjectManifest {
453 required: vec![p("sandbox/example/token")],
454 secrets: BTreeMap::from([(
455 p("sandbox/example/token"),
456 IndexEntry {
457 description: Some("Sandbox-only".to_owned()),
458 pattern_id: Some("generic-bearer".to_owned()),
459 ..IndexEntry::default()
460 },
461 )]),
462 ..ProjectManifest::default()
463 };
464
465 let out = merge_manifest(&global, &manifest).unwrap();
466 let r = out.secrets.get(&p("sandbox/example/token")).unwrap();
467 assert_eq!(r.origin, SecretOrigin::ProjectLocal);
468 assert_eq!(r.metadata.description.as_deref(), Some("Sandbox-only"));
469 }
470
471 #[test]
472 fn project_local_wins_over_global_when_both_present() {
473 let mut global = GlobalIndex::new();
475 global.insert(
476 p("team/foo/token"),
477 IndexEntry {
478 description: Some("Global description".to_owned()),
479 ..IndexEntry::default()
480 },
481 );
482 let manifest = ProjectManifest {
483 required: vec![p("team/foo/token")],
484 secrets: BTreeMap::from([(
485 p("team/foo/token"),
486 IndexEntry {
487 description: Some("Project-local description".to_owned()),
488 ..IndexEntry::default()
489 },
490 )]),
491 ..ProjectManifest::default()
492 };
493
494 let out = merge_manifest(&global, &manifest).unwrap();
495 let r = out.secrets.get(&p("team/foo/token")).unwrap();
496 assert_eq!(r.origin, SecretOrigin::ProjectLocal);
497 assert_eq!(
498 r.metadata.description.as_deref(),
499 Some("Project-local description")
500 );
501 }
502
503 #[test]
504 fn optional_path_resolved_with_required_false() {
505 let mut global = GlobalIndex::new();
506 global.insert(p("personal/slack/notify-token"), IndexEntry::default());
507 let manifest = ProjectManifest {
508 optional: vec![p("personal/slack/notify-token")],
509 ..ProjectManifest::default()
510 };
511
512 let out = merge_manifest(&global, &manifest).unwrap();
513 let r = out.secrets.get(&p("personal/slack/notify-token")).unwrap();
514 assert!(!r.required);
515 }
516
517 #[test]
518 fn path_in_both_required_and_optional_resolves_as_required() {
519 let mut global = GlobalIndex::new();
520 global.insert(p("team/foo/token"), IndexEntry::default());
521 let manifest = ProjectManifest {
522 required: vec![p("team/foo/token")],
523 optional: vec![p("team/foo/token")],
524 ..ProjectManifest::default()
525 };
526
527 let out = merge_manifest(&global, &manifest).unwrap();
528 let r = out.secrets.get(&p("team/foo/token")).unwrap();
529 assert!(
530 r.required,
531 "required must win when path appears in both lists"
532 );
533 }
534
535 #[test]
538 fn unknown_required_path_errors() {
539 let global = GlobalIndex::new();
540 let manifest = ProjectManifest {
541 required: vec![p("team/gitlab/token-deploy")],
542 ..ProjectManifest::default()
543 };
544
545 let err = merge_manifest(&global, &manifest).unwrap_err();
546 match err {
547 MergeError::UnknownPath { path, role } => {
548 assert_eq!(path.as_str(), "team/gitlab/token-deploy");
549 assert_eq!(role, PathRole::Required);
550 }
551 other => panic!("expected UnknownPath, got {other:?}"),
552 }
553 }
554
555 #[test]
556 fn unknown_optional_path_errors_with_optional_role() {
557 let global = GlobalIndex::new();
558 let manifest = ProjectManifest {
559 optional: vec![p("personal/slack/notify-token")],
560 ..ProjectManifest::default()
561 };
562
563 let err = merge_manifest(&global, &manifest).unwrap_err();
564 assert!(matches!(
565 err,
566 MergeError::UnknownPath {
567 role: PathRole::Optional,
568 ..
569 }
570 ));
571 }
572
573 #[test]
574 fn override_on_project_local_path_errors() {
575 let manifest = ProjectManifest {
576 required: vec![p("sandbox/foo/token")],
577 secrets: BTreeMap::from([(p("sandbox/foo/token"), IndexEntry::default())]),
578 overrides: BTreeMap::from([(
579 p("sandbox/foo/token"),
580 OverrideEntry {
581 gate: Some(Gate::Touchid),
582 ..OverrideEntry::default()
583 },
584 )]),
585 ..ProjectManifest::default()
586 };
587
588 let err = merge_manifest(&GlobalIndex::new(), &manifest).unwrap_err();
589 match err {
590 MergeError::OverrideOnProjectLocal { path } => {
591 assert_eq!(path.as_str(), "sandbox/foo/token");
592 }
593 other => panic!("expected OverrideOnProjectLocal, got {other:?}"),
594 }
595 }
596
597 #[test]
600 fn no_op_override_emits_warning_per_field() {
601 let mut global = GlobalIndex::new();
602 global.insert(
603 p("team/foo/token"),
604 IndexEntry {
605 default_gate: Some(Gate::Touchid),
606 rotate_every_days: Some(30),
607 description: Some("matches".to_owned()),
608 ..IndexEntry::default()
609 },
610 );
611 let manifest = ProjectManifest {
612 required: vec![p("team/foo/token")],
613 overrides: BTreeMap::from([(
614 p("team/foo/token"),
615 OverrideEntry {
616 gate: Some(Gate::Touchid),
617 rotate_every_days: Some(30),
618 description: Some("matches".to_owned()),
619 ..OverrideEntry::default()
620 },
621 )]),
622 ..ProjectManifest::default()
623 };
624
625 let out = merge_manifest(&global, &manifest).unwrap();
626 assert_eq!(out.warnings.len(), 3);
628 let kinds: Vec<&MergeWarningKind> = out.warnings.iter().map(|w| &w.kind).collect();
629 assert!(kinds.iter().any(|k| matches!(
630 k,
631 MergeWarningKind::NoOpOverride {
632 field: OverrideField::Gate
633 }
634 )));
635 assert!(kinds.iter().any(|k| matches!(
636 k,
637 MergeWarningKind::NoOpOverride {
638 field: OverrideField::RotateEveryDays
639 }
640 )));
641 assert!(kinds.iter().any(|k| matches!(
642 k,
643 MergeWarningKind::NoOpOverride {
644 field: OverrideField::Description
645 }
646 )));
647 }
648
649 #[test]
650 fn override_for_undeclared_path_emits_warning() {
651 let mut global = GlobalIndex::new();
654 global.insert(p("team/foo/token"), IndexEntry::default());
655 let manifest = ProjectManifest {
656 overrides: BTreeMap::from([(
657 p("team/foo/token"),
658 OverrideEntry {
659 gate: Some(Gate::Touchid),
660 ..OverrideEntry::default()
661 },
662 )]),
663 ..ProjectManifest::default()
664 };
665
666 let out = merge_manifest(&global, &manifest).unwrap();
667 assert!(out.secrets.is_empty());
668 assert_eq!(out.warnings.len(), 1);
669 assert!(matches!(
670 out.warnings[0].kind,
671 MergeWarningKind::OverrideForUndeclaredPath
672 ));
673 assert_eq!(out.warnings[0].path.as_str(), "team/foo/token");
674 }
675
676 #[test]
677 fn project_local_for_undeclared_path_emits_warning() {
678 let manifest = ProjectManifest {
679 secrets: BTreeMap::from([(
680 p("sandbox/orphan/token"),
681 IndexEntry {
682 description: Some("orphan".to_owned()),
683 ..IndexEntry::default()
684 },
685 )]),
686 ..ProjectManifest::default()
687 };
688
689 let out = merge_manifest(&GlobalIndex::new(), &manifest).unwrap();
690 assert!(out.secrets.is_empty());
691 assert_eq!(out.warnings.len(), 1);
692 assert!(matches!(
693 out.warnings[0].kind,
694 MergeWarningKind::ProjectLocalForUndeclaredPath
695 ));
696 }
697
698 #[test]
701 fn override_applies_approve_on_use_over_global_index() {
702 use crate::index::ApproveOnUse;
703
704 let mut global = GlobalIndex::new();
705 global.insert(
706 p("team/gitlab/token-deploy"),
707 IndexEntry {
708 approve_on_use: Some(ApproveOnUse::Never),
709 ..IndexEntry::default()
710 },
711 );
712 let manifest = ProjectManifest {
713 required: vec![p("team/gitlab/token-deploy")],
714 overrides: BTreeMap::from([(
715 p("team/gitlab/token-deploy"),
716 OverrideEntry {
717 approve_on_use: Some(ApproveOnUse::PerCall),
718 ..OverrideEntry::default()
719 },
720 )]),
721 ..ProjectManifest::default()
722 };
723
724 let out = merge_manifest(&global, &manifest).unwrap();
725 let resolved = out.secrets.get(&p("team/gitlab/token-deploy")).unwrap();
726 assert_eq!(
727 resolved.metadata.approve_on_use,
728 Some(ApproveOnUse::PerCall)
729 );
730 match &resolved.origin {
731 SecretOrigin::Global { overrides_applied } => assert!(
732 overrides_applied.contains(&OverrideField::ApproveOnUse),
733 "expected ApproveOnUse in applied list: {overrides_applied:?}"
734 ),
735 other => panic!("expected Global origin, got {other:?}"),
736 }
737 assert!(out.warnings.is_empty());
738 }
739
740 #[test]
741 fn override_approve_on_use_matching_global_emits_noop_warning() {
742 use crate::index::ApproveOnUse;
743
744 let mut global = GlobalIndex::new();
745 global.insert(
746 p("team/foo/token"),
747 IndexEntry {
748 approve_on_use: Some(ApproveOnUse::Session),
749 ..IndexEntry::default()
750 },
751 );
752 let manifest = ProjectManifest {
753 required: vec![p("team/foo/token")],
754 overrides: BTreeMap::from([(
755 p("team/foo/token"),
756 OverrideEntry {
757 approve_on_use: Some(ApproveOnUse::Session),
758 ..OverrideEntry::default()
759 },
760 )]),
761 ..ProjectManifest::default()
762 };
763
764 let out = merge_manifest(&global, &manifest).unwrap();
765 assert_eq!(out.warnings.len(), 1);
766 assert!(matches!(
767 out.warnings[0].kind,
768 MergeWarningKind::NoOpOverride {
769 field: OverrideField::ApproveOnUse
770 }
771 ));
772 }
773
774 #[test]
777 fn empty_manifest_yields_empty_output() {
778 let global = GlobalIndex::new();
779 let manifest = ProjectManifest::new();
780 let out = merge_manifest(&global, &manifest).unwrap();
781 assert!(out.secrets.is_empty());
782 assert!(out.warnings.is_empty());
783 }
784
785 #[test]
786 fn empty_global_with_only_project_local_required_works() {
787 let manifest = ProjectManifest {
788 required: vec![p("sandbox/example/token")],
789 secrets: BTreeMap::from([(
790 p("sandbox/example/token"),
791 IndexEntry {
792 description: Some("local".to_owned()),
793 ..IndexEntry::default()
794 },
795 )]),
796 ..ProjectManifest::default()
797 };
798
799 let out = merge_manifest(&GlobalIndex::new(), &manifest).unwrap();
800 assert_eq!(out.secrets.len(), 1);
801 assert_eq!(
802 out.secrets.get(&p("sandbox/example/token")).unwrap().origin,
803 SecretOrigin::ProjectLocal
804 );
805 }
806
807 #[test]
810 fn output_secrets_iter_sorted_by_path() {
811 let mut global = GlobalIndex::new();
812 global.insert(p("team/zoo/key"), IndexEntry::default());
813 global.insert(p("personal/foo/key"), IndexEntry::default());
814 global.insert(p("client-acme/bar/key"), IndexEntry::default());
815
816 let manifest = ProjectManifest {
817 required: vec![
818 p("team/zoo/key"),
819 p("personal/foo/key"),
820 p("client-acme/bar/key"),
821 ],
822 ..ProjectManifest::default()
823 };
824
825 let out = merge_manifest(&global, &manifest).unwrap();
826 let paths: Vec<&str> = out.secrets.keys().map(|p| p.as_str()).collect();
827 assert_eq!(
828 paths,
829 vec!["client-acme/bar/key", "personal/foo/key", "team/zoo/key"]
830 );
831 }
832}