use crate::composition::Template;
use crate::config::{NativeAddress, Secret};
use crate::error::Result;
use crate::manifest::CompiledSecret;
use crate::provider::Address;
use crate::secrets::Secrets;
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ResolvedPrimary {
pub spec: String,
pub uri: String,
}
#[derive(Debug)]
pub(crate) struct Route {
pub primary: Option<ResolvedPrimary>,
pub fallback: Vec<String>,
}
impl Route {
pub(crate) fn primary(&self) -> Option<&str> {
self.primary.as_ref().map(|primary| primary.uri.as_str())
}
pub(crate) fn group_key(&self) -> Option<&str> {
self.primary.as_ref().map(|primary| primary.spec.as_str())
}
pub(crate) fn fallback_specs(&self) -> Option<&[String]> {
(!self.fallback.is_empty()).then_some(self.fallback.as_slice())
}
pub(crate) fn specs(&self) -> Option<Vec<String>> {
self.primary.as_ref().map(|primary| {
let mut specs = Vec::with_capacity(1 + self.fallback.len());
specs.push(primary.spec.clone());
specs.extend(self.fallback.iter().cloned());
specs
})
}
}
#[derive(Debug)]
pub(crate) struct PlannedSecret {
pub name: String,
pub secret: CompiledSecret,
pub route: Option<Route>,
}
impl PlannedSecret {
pub(crate) fn config(&self) -> &Secret {
&self.secret.config
}
pub(crate) fn as_address<'a>(&'a self, project: &'a str, profile: &'a str) -> Address<'a> {
match &self.secret.config.reference {
Some(native) => Address::Native(native),
None => Address::convention(project, profile, &self.name),
}
}
pub(crate) fn reference(&self) -> Option<&NativeAddress> {
self.secret.config.reference.as_ref()
}
pub(crate) fn required(&self) -> bool {
self.secret.declared_required
}
pub(crate) fn as_path(&self) -> bool {
self.secret.config.as_path.unwrap_or(false)
}
pub(crate) fn is_composed(&self) -> bool {
self.secret.composition.is_some()
}
pub(crate) fn composition(&self) -> Option<&Template> {
self.secret.composition.as_ref()
}
}
#[derive(Debug)]
pub(crate) struct ResolutionPlan {
pub profile: String,
pub override_uri: Option<String>,
pub secrets: Vec<PlannedSecret>,
}
impl ResolutionPlan {
pub(crate) fn groups(&self) -> Vec<(Option<&str>, Vec<&PlannedSecret>)> {
let mut groups: Vec<(Option<&str>, Vec<&PlannedSecret>)> = Vec::new();
let mut group_index: HashMap<Option<&str>, usize> = HashMap::new();
for secret in &self.secrets {
let Some(route) = &secret.route else {
continue;
};
let primary = route.group_key();
match group_index.get(&primary) {
Some(&idx) => groups[idx].1.push(secret),
None => {
group_index.insert(primary, groups.len());
groups.push((primary, vec![secret]));
}
}
}
groups
}
}
impl Secrets {
#[cfg(test)]
pub(crate) fn build_plan(&self, profile: Option<&str>) -> Result<ResolutionPlan> {
let profile_name = self.resolve_profile_name(profile);
let names = self.resolve_profile_secret_names(Some(&profile_name))?;
self.build_plan_from_names(profile_name, names)
}
pub(crate) fn build_plan_from_names(
&self,
profile_name: String,
names: Vec<String>,
) -> Result<ResolutionPlan> {
let override_spec = self.explicit_provider_spec(None);
let mut secrets = Vec::with_capacity(names.len());
let profile = self
.manifest
.profile(&profile_name)
.expect("profile names are validated before planning");
for name in names {
let secret = profile
.secrets
.get(&name)
.expect("planned names come from the compiled profile");
secrets.push(self.plan_one_secret(name, secret, &override_spec)?);
}
Ok(ResolutionPlan {
profile: profile_name,
override_uri: override_spec.map(|spec| self.resolve_provider_spec(spec)),
secrets,
})
}
pub(crate) fn plan_secret(
&self,
name: &str,
profile_name: &str,
override_arg: Option<&str>,
) -> Result<Option<PlannedSecret>> {
let Some(secret) = self
.manifest
.profile(profile_name)
.and_then(|profile| profile.secrets.get(name))
else {
return Ok(None);
};
let override_spec = self.explicit_provider_spec(override_arg);
Ok(Some(self.plan_one_secret(
name.to_string(),
secret,
&override_spec,
)?))
}
fn plan_one_secret(
&self,
name: String,
secret: &CompiledSecret,
override_spec: &Option<String>,
) -> Result<PlannedSecret> {
let route = if secret.composition.is_some() {
None
} else {
Some(self.route_for(&secret.config, override_spec)?)
};
Ok(PlannedSecret {
name,
secret: secret.clone(),
route,
})
}
pub(crate) fn route_for(
&self,
config: &Secret,
override_spec: &Option<String>,
) -> Result<Route> {
if let Some(spec) = override_spec {
self.validate_credential_sources(spec)?;
return Ok(Route {
primary: Some(ResolvedPrimary {
spec: spec.clone(),
uri: self.resolve_provider_spec(spec.clone()),
}),
fallback: Vec::new(),
});
}
match config.providers.as_deref() {
Some([first, fallback @ ..]) => {
let uri = self.resolve_one_provider(first)?;
self.validate_credential_sources(first)?;
Ok(Route {
primary: Some(ResolvedPrimary {
spec: first.clone(),
uri,
}),
fallback: fallback.to_vec(),
})
}
_ => Ok(Route {
primary: None,
fallback: Vec::new(),
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::SecretSpecError;
use crate::tests::{global_config_with_aliases, scrub_resolution_env};
use std::collections::HashMap;
fn secret(providers: Option<Vec<&str>>) -> Secret {
Secret {
description: Some("a secret".to_string()),
providers: providers.map(|p| p.into_iter().map(String::from).collect()),
..Default::default()
}
}
fn spec(
secrets: HashMap<String, Secret>,
provider: Option<&str>,
aliases: &[(&str, &str)],
) -> Secrets {
let config = crate::tests::resolve_test_config(secrets);
let global_config = (!aliases.is_empty()).then(|| global_config_with_aliases(aliases));
Secrets::new(config, global_config, provider.map(String::from), None)
}
fn plan(spec: &Secrets) -> ResolutionPlan {
spec.build_plan(None).unwrap()
}
fn find<'a>(plan: &'a ResolutionPlan, name: &str) -> &'a PlannedSecret {
plan.secrets
.iter()
.find(|s| s.name == name)
.expect("secret in plan")
}
fn route(planned: &PlannedSecret) -> &Route {
planned.route.as_ref().expect("a provider route")
}
fn group_names(plan: &ResolutionPlan) -> Vec<(Option<&str>, Vec<&str>)> {
plan.groups()
.into_iter()
.map(|(uri, group)| (uri, group.iter().map(|s| s.name.as_str()).collect()))
.collect()
}
#[test]
fn no_routing_plans_the_default_store() {
let _env = scrub_resolution_env();
let secrets = HashMap::from([("DATABASE_URL".to_string(), secret(None))]);
let plan = plan(&spec(secrets, None, &[]));
let planned = find(&plan, "DATABASE_URL");
assert_eq!(route(planned).primary(), None);
assert!(route(planned).fallback.is_empty());
assert_eq!(group_names(&plan), vec![(None, vec!["DATABASE_URL"])]);
}
#[test]
fn composed_secrets_have_no_provider_route_or_fetch_group() {
let _env = scrub_resolution_env();
let secrets = HashMap::from([
("PART".to_string(), secret(None)),
(
"RESULT".to_string(),
Secret {
description: Some("derived".to_string()),
composed: Some("prefix-{PART}".to_string()),
..Default::default()
},
),
]);
let plan = plan(&spec(secrets, Some("dotenv://.env.mock"), &[]));
assert!(find(&plan, "RESULT").is_composed());
assert!(find(&plan, "RESULT").route.is_none());
assert_eq!(
group_names(&plan),
vec![(Some("dotenv://.env.mock"), vec!["PART"])]
);
}
#[test]
fn override_collapses_the_chain_to_one_store() {
let _env = scrub_resolution_env();
let secrets = HashMap::from([(
"API_KEY".to_string(),
secret(Some(vec!["onepassword://Production", "keyring://"])),
)]);
let mut spec = spec(secrets, None, &[]);
spec.set_provider("dotenv://.env.mock");
let plan = plan(&spec);
let planned = find(&plan, "API_KEY");
assert_eq!(route(planned).primary(), Some("dotenv://.env.mock"));
assert!(
route(planned).fallback.is_empty(),
"the override must collapse the chain: no fallback survives"
);
assert_eq!(plan.override_uri, Some("dotenv://.env.mock".to_string()));
}
#[test]
fn override_alias_keeps_the_raw_spec_as_build_key() {
let _env = scrub_resolution_env();
let secrets = HashMap::from([("API_KEY".to_string(), secret(None))]);
let spec = spec(secrets, Some("mock"), &[("mock", "dotenv://.env.mock")]);
let plan = plan(&spec);
let planned = find(&plan, "API_KEY");
assert_eq!(route(planned).group_key(), Some("mock"));
assert_eq!(route(planned).primary(), Some("dotenv://.env.mock"));
assert_eq!(plan.override_uri, Some("dotenv://.env.mock".to_string()));
}
#[test]
fn providers_chain_resolves_the_primary_and_carries_the_fallback_raw() {
let _env = scrub_resolution_env();
let secrets = HashMap::from([("API_KEY".to_string(), secret(Some(vec!["shared", "kr"])))]);
let plan = plan(&spec(
secrets,
None,
&[("shared", "onepassword://Shared"), ("kr", "keyring://")],
));
let planned = find(&plan, "API_KEY");
assert_eq!(route(planned).primary(), Some("onepassword://Shared"));
assert_eq!(route(planned).fallback, vec!["kr".to_string()]);
}
#[test]
fn an_undefined_fallback_alias_does_not_fail_the_plan() {
let _env = scrub_resolution_env();
let secrets = HashMap::from([("API_KEY".to_string(), secret(Some(vec!["kr", "ghost"])))]);
let plan = plan(&spec(secrets, None, &[("kr", "keyring://")]));
let planned = find(&plan, "API_KEY");
assert_eq!(route(planned).primary(), Some("keyring://"));
assert_eq!(route(planned).fallback, vec!["ghost".to_string()]);
}
#[test]
fn inline_uri_in_chain_passes_through_without_an_alias() {
let _env = scrub_resolution_env();
let secrets = HashMap::from([(
"API_KEY".to_string(),
secret(Some(vec!["onepassword://Production"])),
)]);
let plan = plan(&spec(secrets, None, &[]));
let planned = find(&plan, "API_KEY");
assert_eq!(route(planned).primary(), Some("onepassword://Production"));
}
#[test]
fn undefined_alias_fails_the_plan() {
let _env = scrub_resolution_env();
let secrets = HashMap::from([("API_KEY".to_string(), secret(Some(vec!["nope"])))]);
let spec = spec(secrets, None, &[]);
let err = spec.build_plan(None).unwrap_err();
assert!(matches!(err, SecretSpecError::ProviderNotFound(_)));
}
#[test]
fn bare_provider_name_in_chain_passes_through() {
let _env = scrub_resolution_env();
let secrets = HashMap::from([("API_KEY".to_string(), secret(Some(vec!["keyring"])))]);
let plan = plan(&spec(secrets, None, &[]));
assert_eq!(route(find(&plan, "API_KEY")).primary(), Some("keyring"));
}
#[test]
fn a_ref_addresses_native_coordinates_convention_otherwise() {
let _env = scrub_resolution_env();
let mut referenced = secret(None);
referenced.reference = Some(NativeAddress {
item: "db".to_string(),
field: Some("password".to_string()),
..Default::default()
});
let secrets = HashMap::from([
("REFERENCED".to_string(), referenced),
("PLAIN".to_string(), secret(None)),
]);
let plan = plan(&spec(secrets, None, &[]));
match find(&plan, "REFERENCED").as_address("proj", "default") {
Address::Native(native) => {
assert_eq!(native.item, "db");
assert_eq!(native.field.as_deref(), Some("password"));
}
Address::Convention { .. } => panic!("a ref should address native coordinates"),
}
match find(&plan, "PLAIN").as_address("proj", "default") {
Address::Convention { key, .. } => assert_eq!(key, "PLAIN"),
Address::Native(_) => panic!("no ref should address the naming convention"),
}
}
#[test]
fn secrets_are_sorted_and_grouped_by_primary_store() {
let _env = scrub_resolution_env();
let secrets = HashMap::from([
("B".to_string(), secret(Some(vec!["keyring://"]))),
("A".to_string(), secret(None)),
("C".to_string(), secret(Some(vec!["keyring://"]))),
]);
let plan = plan(&spec(secrets, None, &[]));
let names: Vec<&str> = plan.secrets.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, vec!["A", "B", "C"]);
assert_eq!(
group_names(&plan),
vec![(None, vec!["A"]), (Some("keyring://"), vec!["B", "C"])]
);
}
#[test]
fn distinct_aliases_sharing_a_uri_do_not_merge() {
let _env = scrub_resolution_env();
let secrets = HashMap::from([
("A".to_string(), secret(Some(vec!["one"]))),
("B".to_string(), secret(Some(vec!["two"]))),
]);
let plan = plan(&spec(
secrets,
None,
&[("one", "keyring://"), ("two", "keyring://")],
));
assert_eq!(
group_names(&plan),
vec![(Some("one"), vec!["A"]), (Some("two"), vec!["B"])]
);
}
#[test]
fn plan_secret_is_none_for_an_undeclared_secret() {
let _env = scrub_resolution_env();
let secrets = HashMap::from([("DECLARED".to_string(), secret(None))]);
let spec = spec(secrets, None, &[]);
assert!(
spec.plan_secret("NOPE", "default", None).unwrap().is_none(),
"an undeclared secret must not plan"
);
}
#[test]
fn plan_secret_matches_the_batch_plan_for_a_declared_secret() {
let _env = scrub_resolution_env();
let secrets = HashMap::from([(
"API_KEY".to_string(),
secret(Some(vec!["onepassword://Production", "keyring://"])),
)]);
let spec = spec(secrets, None, &[]);
let one = spec
.plan_secret("API_KEY", "default", None)
.unwrap()
.unwrap();
assert_eq!(route(&one).primary(), Some("onepassword://Production"));
assert_eq!(route(&one).fallback, vec!["keyring://".to_string()]);
let batch = plan(&spec);
assert_eq!(
route(&one).primary(),
route(find(&batch, "API_KEY")).primary()
);
}
}