use std::collections::BTreeMap;
use serde::Serialize;
use thiserror::Error;
use crate::index::{GlobalIndex, IndexEntry};
use crate::manifest::{OverrideEntry, PathRole, ProjectManifest};
use crate::secret_path::SecretPath;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum OverrideField {
Gate,
RotateEveryDays,
Description,
ApproveOnUse,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SecretOrigin {
ProjectLocal,
Global {
overrides_applied: Vec<OverrideField>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedSecret {
pub path: SecretPath,
pub required: bool,
pub origin: SecretOrigin,
pub metadata: IndexEntry,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct MergeOutput {
pub secrets: BTreeMap<SecretPath, ResolvedSecret>,
pub warnings: Vec<MergeWarning>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct MergeWarning {
pub kind: MergeWarningKind,
pub path: SecretPath,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum MergeWarningKind {
NoOpOverride {
field: OverrideField,
},
OverrideForUndeclaredPath,
ProjectLocalForUndeclaredPath,
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum MergeError {
#[error(
"secret path '{path}' is declared in {role} but no metadata is registered for it; \
add a `[secret.\"{path}\"]` block to the manifest or register it in the global index \
(run `devboy secrets describe {path}` for guidance)"
)]
UnknownPath {
path: SecretPath,
role: PathRole,
},
#[error(
"secret path '{path}' has both an `[overrides.\"{path}\"]` block and a project-local \
`[secret.\"{path}\"]` block; remove the override (project-local entries already carry \
the full metadata, so an override is ambiguous)"
)]
OverrideOnProjectLocal {
path: SecretPath,
},
}
pub fn merge_manifest(
global: &GlobalIndex,
manifest: &ProjectManifest,
) -> Result<MergeOutput, MergeError> {
for path in manifest.secrets.keys() {
if manifest.overrides.contains_key(path) {
return Err(MergeError::OverrideOnProjectLocal { path: path.clone() });
}
}
let mut output = MergeOutput::default();
for (path, is_required) in manifest
.required
.iter()
.map(|p| (p, true))
.chain(manifest.optional.iter().map(|p| (p, false)))
{
let resolved = resolve_one(path, is_required, global, manifest, &mut output.warnings)?;
match output.secrets.get(path) {
Some(existing) if existing.required && !is_required => continue,
_ => {
output.secrets.insert(path.clone(), resolved);
}
}
}
for path in manifest.overrides.keys() {
if !is_declared(path, manifest) {
output.warnings.push(MergeWarning {
kind: MergeWarningKind::OverrideForUndeclaredPath,
path: path.clone(),
});
}
}
for path in manifest.secrets.keys() {
if !is_declared(path, manifest) {
output.warnings.push(MergeWarning {
kind: MergeWarningKind::ProjectLocalForUndeclaredPath,
path: path.clone(),
});
}
}
Ok(output)
}
fn is_declared(path: &SecretPath, manifest: &ProjectManifest) -> bool {
manifest.required.iter().any(|p| p == path) || manifest.optional.iter().any(|p| p == path)
}
fn resolve_one(
path: &SecretPath,
is_required: bool,
global: &GlobalIndex,
manifest: &ProjectManifest,
warnings: &mut Vec<MergeWarning>,
) -> Result<ResolvedSecret, MergeError> {
if let Some(local) = manifest.secrets.get(path) {
return Ok(ResolvedSecret {
path: path.clone(),
required: is_required,
origin: SecretOrigin::ProjectLocal,
metadata: local.clone(),
});
}
if let Some(global_entry) = global.get(path) {
let mut metadata = global_entry.clone();
let mut applied = Vec::new();
if let Some(over) = manifest.overrides.get(path) {
apply_overrides(&mut metadata, over, path, &mut applied, warnings);
}
return Ok(ResolvedSecret {
path: path.clone(),
required: is_required,
origin: SecretOrigin::Global {
overrides_applied: applied,
},
metadata,
});
}
Err(MergeError::UnknownPath {
path: path.clone(),
role: if is_required {
PathRole::Required
} else {
PathRole::Optional
},
})
}
fn apply_overrides(
metadata: &mut IndexEntry,
over: &OverrideEntry,
path: &SecretPath,
applied: &mut Vec<OverrideField>,
warnings: &mut Vec<MergeWarning>,
) {
if let Some(g) = over.gate {
if metadata.default_gate == Some(g) {
warnings.push(MergeWarning {
kind: MergeWarningKind::NoOpOverride {
field: OverrideField::Gate,
},
path: path.clone(),
});
}
metadata.default_gate = Some(g);
applied.push(OverrideField::Gate);
}
if let Some(d) = over.rotate_every_days {
if metadata.rotate_every_days == Some(d) {
warnings.push(MergeWarning {
kind: MergeWarningKind::NoOpOverride {
field: OverrideField::RotateEveryDays,
},
path: path.clone(),
});
}
metadata.rotate_every_days = Some(d);
applied.push(OverrideField::RotateEveryDays);
}
if let Some(desc) = &over.description {
if metadata.description.as_ref() == Some(desc) {
warnings.push(MergeWarning {
kind: MergeWarningKind::NoOpOverride {
field: OverrideField::Description,
},
path: path.clone(),
});
}
metadata.description = Some(desc.clone());
applied.push(OverrideField::Description);
}
if let Some(policy) = over.approve_on_use {
if metadata.approve_on_use == Some(policy) {
warnings.push(MergeWarning {
kind: MergeWarningKind::NoOpOverride {
field: OverrideField::ApproveOnUse,
},
path: path.clone(),
});
}
metadata.approve_on_use = Some(policy);
applied.push(OverrideField::ApproveOnUse);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::index::{Gate, IndexEntry, RotationMethod};
fn p(s: &str) -> SecretPath {
SecretPath::parse(s).unwrap()
}
#[test]
fn resolves_global_entry_without_overrides() {
let mut global = GlobalIndex::new();
global.insert(
p("team/gitlab/token-deploy"),
IndexEntry {
description: Some("Team deploy token".to_owned()),
default_gate: Some(Gate::Auto),
rotation_method: Some(RotationMethod::Manual),
..IndexEntry::default()
},
);
let manifest = ProjectManifest {
required: vec![p("team/gitlab/token-deploy")],
..ProjectManifest::default()
};
let out = merge_manifest(&global, &manifest).unwrap();
let resolved = out.secrets.get(&p("team/gitlab/token-deploy")).unwrap();
assert!(resolved.required);
assert_eq!(
resolved.origin,
SecretOrigin::Global {
overrides_applied: vec![]
}
);
assert_eq!(
resolved.metadata.description.as_deref(),
Some("Team deploy token")
);
assert!(out.warnings.is_empty());
}
#[test]
fn applies_overrides_on_global_entry() {
let mut global = GlobalIndex::new();
global.insert(
p("team/gitlab/token-deploy"),
IndexEntry {
description: Some("Team deploy token".to_owned()),
default_gate: Some(Gate::Auto),
rotate_every_days: Some(90),
..IndexEntry::default()
},
);
let manifest = ProjectManifest {
required: vec![p("team/gitlab/token-deploy")],
overrides: BTreeMap::from([(
p("team/gitlab/token-deploy"),
OverrideEntry {
gate: Some(Gate::Touchid),
rotate_every_days: Some(30),
description: Some("Staging deploy only".to_owned()),
..OverrideEntry::default()
},
)]),
..ProjectManifest::default()
};
let out = merge_manifest(&global, &manifest).unwrap();
let resolved = out.secrets.get(&p("team/gitlab/token-deploy")).unwrap();
match &resolved.origin {
SecretOrigin::Global { overrides_applied } => {
assert_eq!(overrides_applied.len(), 3);
assert!(overrides_applied.contains(&OverrideField::Gate));
assert!(overrides_applied.contains(&OverrideField::RotateEveryDays));
assert!(overrides_applied.contains(&OverrideField::Description));
}
other => panic!("expected Global origin, got {other:?}"),
}
assert_eq!(resolved.metadata.default_gate, Some(Gate::Touchid));
assert_eq!(resolved.metadata.rotate_every_days, Some(30));
assert_eq!(
resolved.metadata.description.as_deref(),
Some("Staging deploy only")
);
assert!(out.warnings.is_empty());
}
#[test]
fn project_local_wins_over_absent_global() {
let global = GlobalIndex::new();
let manifest = ProjectManifest {
required: vec![p("sandbox/example/token")],
secrets: BTreeMap::from([(
p("sandbox/example/token"),
IndexEntry {
description: Some("Sandbox-only".to_owned()),
pattern_id: Some("generic-bearer".to_owned()),
..IndexEntry::default()
},
)]),
..ProjectManifest::default()
};
let out = merge_manifest(&global, &manifest).unwrap();
let r = out.secrets.get(&p("sandbox/example/token")).unwrap();
assert_eq!(r.origin, SecretOrigin::ProjectLocal);
assert_eq!(r.metadata.description.as_deref(), Some("Sandbox-only"));
}
#[test]
fn project_local_wins_over_global_when_both_present() {
let mut global = GlobalIndex::new();
global.insert(
p("team/foo/token"),
IndexEntry {
description: Some("Global description".to_owned()),
..IndexEntry::default()
},
);
let manifest = ProjectManifest {
required: vec![p("team/foo/token")],
secrets: BTreeMap::from([(
p("team/foo/token"),
IndexEntry {
description: Some("Project-local description".to_owned()),
..IndexEntry::default()
},
)]),
..ProjectManifest::default()
};
let out = merge_manifest(&global, &manifest).unwrap();
let r = out.secrets.get(&p("team/foo/token")).unwrap();
assert_eq!(r.origin, SecretOrigin::ProjectLocal);
assert_eq!(
r.metadata.description.as_deref(),
Some("Project-local description")
);
}
#[test]
fn optional_path_resolved_with_required_false() {
let mut global = GlobalIndex::new();
global.insert(p("personal/slack/notify-token"), IndexEntry::default());
let manifest = ProjectManifest {
optional: vec![p("personal/slack/notify-token")],
..ProjectManifest::default()
};
let out = merge_manifest(&global, &manifest).unwrap();
let r = out.secrets.get(&p("personal/slack/notify-token")).unwrap();
assert!(!r.required);
}
#[test]
fn path_in_both_required_and_optional_resolves_as_required() {
let mut global = GlobalIndex::new();
global.insert(p("team/foo/token"), IndexEntry::default());
let manifest = ProjectManifest {
required: vec![p("team/foo/token")],
optional: vec![p("team/foo/token")],
..ProjectManifest::default()
};
let out = merge_manifest(&global, &manifest).unwrap();
let r = out.secrets.get(&p("team/foo/token")).unwrap();
assert!(
r.required,
"required must win when path appears in both lists"
);
}
#[test]
fn unknown_required_path_errors() {
let global = GlobalIndex::new();
let manifest = ProjectManifest {
required: vec![p("team/gitlab/token-deploy")],
..ProjectManifest::default()
};
let err = merge_manifest(&global, &manifest).unwrap_err();
match err {
MergeError::UnknownPath { path, role } => {
assert_eq!(path.as_str(), "team/gitlab/token-deploy");
assert_eq!(role, PathRole::Required);
}
other => panic!("expected UnknownPath, got {other:?}"),
}
}
#[test]
fn unknown_optional_path_errors_with_optional_role() {
let global = GlobalIndex::new();
let manifest = ProjectManifest {
optional: vec![p("personal/slack/notify-token")],
..ProjectManifest::default()
};
let err = merge_manifest(&global, &manifest).unwrap_err();
assert!(matches!(
err,
MergeError::UnknownPath {
role: PathRole::Optional,
..
}
));
}
#[test]
fn override_on_project_local_path_errors() {
let manifest = ProjectManifest {
required: vec![p("sandbox/foo/token")],
secrets: BTreeMap::from([(p("sandbox/foo/token"), IndexEntry::default())]),
overrides: BTreeMap::from([(
p("sandbox/foo/token"),
OverrideEntry {
gate: Some(Gate::Touchid),
..OverrideEntry::default()
},
)]),
..ProjectManifest::default()
};
let err = merge_manifest(&GlobalIndex::new(), &manifest).unwrap_err();
match err {
MergeError::OverrideOnProjectLocal { path } => {
assert_eq!(path.as_str(), "sandbox/foo/token");
}
other => panic!("expected OverrideOnProjectLocal, got {other:?}"),
}
}
#[test]
fn no_op_override_emits_warning_per_field() {
let mut global = GlobalIndex::new();
global.insert(
p("team/foo/token"),
IndexEntry {
default_gate: Some(Gate::Touchid),
rotate_every_days: Some(30),
description: Some("matches".to_owned()),
..IndexEntry::default()
},
);
let manifest = ProjectManifest {
required: vec![p("team/foo/token")],
overrides: BTreeMap::from([(
p("team/foo/token"),
OverrideEntry {
gate: Some(Gate::Touchid),
rotate_every_days: Some(30),
description: Some("matches".to_owned()),
..OverrideEntry::default()
},
)]),
..ProjectManifest::default()
};
let out = merge_manifest(&global, &manifest).unwrap();
assert_eq!(out.warnings.len(), 3);
let kinds: Vec<&MergeWarningKind> = out.warnings.iter().map(|w| &w.kind).collect();
assert!(kinds.iter().any(|k| matches!(
k,
MergeWarningKind::NoOpOverride {
field: OverrideField::Gate
}
)));
assert!(kinds.iter().any(|k| matches!(
k,
MergeWarningKind::NoOpOverride {
field: OverrideField::RotateEveryDays
}
)));
assert!(kinds.iter().any(|k| matches!(
k,
MergeWarningKind::NoOpOverride {
field: OverrideField::Description
}
)));
}
#[test]
fn override_for_undeclared_path_emits_warning() {
let mut global = GlobalIndex::new();
global.insert(p("team/foo/token"), IndexEntry::default());
let manifest = ProjectManifest {
overrides: BTreeMap::from([(
p("team/foo/token"),
OverrideEntry {
gate: Some(Gate::Touchid),
..OverrideEntry::default()
},
)]),
..ProjectManifest::default()
};
let out = merge_manifest(&global, &manifest).unwrap();
assert!(out.secrets.is_empty());
assert_eq!(out.warnings.len(), 1);
assert!(matches!(
out.warnings[0].kind,
MergeWarningKind::OverrideForUndeclaredPath
));
assert_eq!(out.warnings[0].path.as_str(), "team/foo/token");
}
#[test]
fn project_local_for_undeclared_path_emits_warning() {
let manifest = ProjectManifest {
secrets: BTreeMap::from([(
p("sandbox/orphan/token"),
IndexEntry {
description: Some("orphan".to_owned()),
..IndexEntry::default()
},
)]),
..ProjectManifest::default()
};
let out = merge_manifest(&GlobalIndex::new(), &manifest).unwrap();
assert!(out.secrets.is_empty());
assert_eq!(out.warnings.len(), 1);
assert!(matches!(
out.warnings[0].kind,
MergeWarningKind::ProjectLocalForUndeclaredPath
));
}
#[test]
fn override_applies_approve_on_use_over_global_index() {
use crate::index::ApproveOnUse;
let mut global = GlobalIndex::new();
global.insert(
p("team/gitlab/token-deploy"),
IndexEntry {
approve_on_use: Some(ApproveOnUse::Never),
..IndexEntry::default()
},
);
let manifest = ProjectManifest {
required: vec![p("team/gitlab/token-deploy")],
overrides: BTreeMap::from([(
p("team/gitlab/token-deploy"),
OverrideEntry {
approve_on_use: Some(ApproveOnUse::PerCall),
..OverrideEntry::default()
},
)]),
..ProjectManifest::default()
};
let out = merge_manifest(&global, &manifest).unwrap();
let resolved = out.secrets.get(&p("team/gitlab/token-deploy")).unwrap();
assert_eq!(
resolved.metadata.approve_on_use,
Some(ApproveOnUse::PerCall)
);
match &resolved.origin {
SecretOrigin::Global { overrides_applied } => assert!(
overrides_applied.contains(&OverrideField::ApproveOnUse),
"expected ApproveOnUse in applied list: {overrides_applied:?}"
),
other => panic!("expected Global origin, got {other:?}"),
}
assert!(out.warnings.is_empty());
}
#[test]
fn override_approve_on_use_matching_global_emits_noop_warning() {
use crate::index::ApproveOnUse;
let mut global = GlobalIndex::new();
global.insert(
p("team/foo/token"),
IndexEntry {
approve_on_use: Some(ApproveOnUse::Session),
..IndexEntry::default()
},
);
let manifest = ProjectManifest {
required: vec![p("team/foo/token")],
overrides: BTreeMap::from([(
p("team/foo/token"),
OverrideEntry {
approve_on_use: Some(ApproveOnUse::Session),
..OverrideEntry::default()
},
)]),
..ProjectManifest::default()
};
let out = merge_manifest(&global, &manifest).unwrap();
assert_eq!(out.warnings.len(), 1);
assert!(matches!(
out.warnings[0].kind,
MergeWarningKind::NoOpOverride {
field: OverrideField::ApproveOnUse
}
));
}
#[test]
fn empty_manifest_yields_empty_output() {
let global = GlobalIndex::new();
let manifest = ProjectManifest::new();
let out = merge_manifest(&global, &manifest).unwrap();
assert!(out.secrets.is_empty());
assert!(out.warnings.is_empty());
}
#[test]
fn empty_global_with_only_project_local_required_works() {
let manifest = ProjectManifest {
required: vec![p("sandbox/example/token")],
secrets: BTreeMap::from([(
p("sandbox/example/token"),
IndexEntry {
description: Some("local".to_owned()),
..IndexEntry::default()
},
)]),
..ProjectManifest::default()
};
let out = merge_manifest(&GlobalIndex::new(), &manifest).unwrap();
assert_eq!(out.secrets.len(), 1);
assert_eq!(
out.secrets.get(&p("sandbox/example/token")).unwrap().origin,
SecretOrigin::ProjectLocal
);
}
#[test]
fn output_secrets_iter_sorted_by_path() {
let mut global = GlobalIndex::new();
global.insert(p("team/zoo/key"), IndexEntry::default());
global.insert(p("personal/foo/key"), IndexEntry::default());
global.insert(p("client-acme/bar/key"), IndexEntry::default());
let manifest = ProjectManifest {
required: vec![
p("team/zoo/key"),
p("personal/foo/key"),
p("client-acme/bar/key"),
],
..ProjectManifest::default()
};
let out = merge_manifest(&global, &manifest).unwrap();
let paths: Vec<&str> = out.secrets.keys().map(|p| p.as_str()).collect();
assert_eq!(
paths,
vec!["client-acme/bar/key", "personal/foo/key", "team/zoo/key"]
);
}
}