use crate::composition::Template;
use crate::manifest::CompiledManifest;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap, HashSet, hash_map};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::str::FromStr;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CredentialSource {
pub provider: String,
pub reference: Option<NativeAddress>,
}
impl CredentialSource {
pub fn from_provider(provider: impl Into<String>) -> Self {
Self {
provider: provider.into(),
reference: None,
}
}
}
impl From<String> for CredentialSource {
fn from(provider: String) -> Self {
Self::from_provider(provider)
}
}
impl From<&str> for CredentialSource {
fn from(provider: &str) -> Self {
Self::from_provider(provider)
}
}
impl Serialize for CredentialSource {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match &self.reference {
None => serializer.serialize_str(&self.provider),
Some(reference) => {
use serde::ser::SerializeStruct;
let mut table = serializer.serialize_struct("CredentialSource", 2)?;
table.serialize_field("provider", &self.provider)?;
table.serialize_field("ref", reference)?;
table.end()
}
}
}
}
impl<'de> Deserialize<'de> for CredentialSource {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct SourceVisitor;
impl<'de> serde::de::Visitor<'de> for SourceVisitor {
type Value = CredentialSource;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("a provider spec string or a { provider, ref } table")
}
fn visit_str<E: serde::de::Error>(self, provider: &str) -> Result<CredentialSource, E> {
Ok(CredentialSource::from_provider(provider))
}
fn visit_map<M: serde::de::MapAccess<'de>>(
self,
map: M,
) -> Result<CredentialSource, M::Error> {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Table {
provider: String,
#[serde(default, rename = "ref")]
reference: Option<NativeAddress>,
}
let table = Table::deserialize(serde::de::value::MapAccessDeserializer::new(map))?;
Ok(CredentialSource {
provider: table.provider,
reference: table.reference,
})
}
}
deserializer.deserialize_any(SourceVisitor)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ProviderCache {
provider: String,
max_age: String,
#[serde(skip)]
max_age_secs: u64,
}
impl ProviderCache {
pub fn new(
provider: impl Into<String>,
max_age: impl Into<String>,
) -> std::result::Result<Self, String> {
let provider = provider.into();
let max_age = max_age.into();
if provider.trim().is_empty() {
return Err("cache.provider must be a non-empty provider spec".to_string());
}
let max_age_secs = parse_cache_max_age(&max_age)?;
Ok(Self {
provider,
max_age,
max_age_secs,
})
}
pub fn max_age_secs(&self) -> u64 {
self.max_age_secs
}
pub fn provider(&self) -> &str {
&self.provider
}
pub fn max_age(&self) -> &str {
&self.max_age
}
}
impl<'de> Deserialize<'de> for ProviderCache {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Table {
provider: String,
max_age: String,
}
let table = Table::deserialize(deserializer)?;
ProviderCache::new(table.provider, table.max_age).map_err(serde::de::Error::custom)
}
}
pub(crate) fn parse_cache_max_age(value: &str) -> std::result::Result<u64, String> {
let value = value.trim();
if value.is_empty() {
return Err("cache max_age must not be empty".to_string());
}
let bytes = value.as_bytes();
let mut index = 0;
let mut total = 0_u64;
while index < bytes.len() {
let digits_start = index;
while index < bytes.len() && bytes[index].is_ascii_digit() {
index += 1;
}
if digits_start == index {
return Err(format!(
"invalid cache max_age '{value}'; expected a duration such as '30m', '8h', or '1d'"
));
}
let amount: u64 = value[digits_start..index]
.parse()
.map_err(|_| format!("cache max_age '{value}' is too large"))?;
if index == bytes.len() {
return Err(format!(
"invalid cache max_age '{value}'; every number needs a unit (s, m, h, d, or w)"
));
}
let multiplier = match bytes[index] {
b's' => 1,
b'm' => 60,
b'h' => 60 * 60,
b'd' => 24 * 60 * 60,
b'w' => 7 * 24 * 60 * 60,
_ => {
return Err(format!(
"invalid cache max_age '{value}'; supported units are s, m, h, d, and w"
));
}
};
index += 1;
total = total
.checked_add(
amount
.checked_mul(multiplier)
.ok_or_else(|| format!("cache max_age '{value}' is too large"))?,
)
.ok_or_else(|| format!("cache max_age '{value}' is too large"))?;
}
if total == 0 {
return Err("cache max_age must be greater than zero".to_string());
}
Ok(total)
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[non_exhaustive]
pub struct ProviderAlias {
pub uri: String,
pub credentials: HashMap<String, CredentialSource>,
pub(crate) fallback: Vec<String>,
pub(crate) cache: Option<ProviderCache>,
}
impl ProviderAlias {
pub fn from_uri(uri: impl Into<String>) -> Self {
Self {
uri: uri.into(),
credentials: HashMap::new(),
fallback: Vec::new(),
cache: None,
}
}
pub fn cached(
fallback: Vec<String>,
cache: ProviderCache,
) -> std::result::Result<Self, String> {
if fallback.is_empty() || fallback.iter().any(|spec| spec.trim().is_empty()) {
return Err(
"a cached provider alias requires at least one non-empty fallback".to_string(),
);
}
Ok(Self {
uri: String::new(),
credentials: HashMap::new(),
fallback,
cache: Some(cache),
})
}
pub fn is_cached(&self) -> bool {
self.cache.is_some()
}
pub fn fallback(&self) -> &[String] {
&self.fallback
}
pub fn cache(&self) -> Option<&ProviderCache> {
self.cache.as_ref()
}
}
impl From<String> for ProviderAlias {
fn from(uri: String) -> Self {
Self::from_uri(uri)
}
}
impl From<&str> for ProviderAlias {
fn from(uri: &str) -> Self {
Self::from_uri(uri)
}
}
impl std::fmt::Display for ProviderAlias {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(cache) = &self.cache {
return write!(
f,
"fallback [{}], cached in {} for {}",
self.fallback.join(", "),
cache.provider,
cache.max_age
);
}
write!(f, "{}", self.uri)?;
if !self.credentials.is_empty() {
let mut names: Vec<&str> = self.credentials.keys().map(String::as_str).collect();
names.sort();
write!(f, " (credentials: {})", names.join(", "))?;
}
Ok(())
}
}
impl Serialize for ProviderAlias {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
if let Some(cache) = &self.cache {
use serde::ser::SerializeStruct;
let mut table = serializer.serialize_struct("ProviderAlias", 2)?;
table.serialize_field("fallback", &self.fallback)?;
table.serialize_field("cache", cache)?;
return table.end();
}
if self.credentials.is_empty() {
serializer.serialize_str(&self.uri)
} else {
use serde::ser::SerializeStruct;
let mut table = serializer.serialize_struct("ProviderAlias", 2)?;
table.serialize_field("uri", &self.uri)?;
table.serialize_field("credentials", &self.credentials)?;
table.end()
}
}
}
impl<'de> Deserialize<'de> for ProviderAlias {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct AliasVisitor;
impl<'de> serde::de::Visitor<'de> for AliasVisitor {
type Value = ProviderAlias;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(
"a provider URI string, a { uri, credentials } table, or a \
{ fallback, cache } table",
)
}
fn visit_str<E: serde::de::Error>(self, uri: &str) -> Result<ProviderAlias, E> {
Ok(ProviderAlias::from_uri(uri))
}
fn visit_map<M: serde::de::MapAccess<'de>>(
self,
map: M,
) -> Result<ProviderAlias, M::Error> {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Table {
#[serde(default)]
uri: Option<String>,
#[serde(default)]
credentials: Option<HashMap<String, CredentialSource>>,
#[serde(default)]
fallback: Option<Vec<String>>,
#[serde(default)]
cache: Option<ProviderCache>,
}
let table = Table::deserialize(serde::de::value::MapAccessDeserializer::new(map))?;
match (table.uri, table.fallback, table.cache) {
(Some(uri), None, None) => Ok(ProviderAlias {
uri,
credentials: table.credentials.unwrap_or_default(),
fallback: Vec::new(),
cache: None,
}),
(None, Some(fallback), Some(cache)) => {
if table.credentials.is_some() {
return Err(serde::de::Error::custom(
"a cached provider alias cannot declare credentials; \
put credentials on its leaf fallback aliases",
));
}
ProviderAlias::cached(fallback, cache).map_err(serde::de::Error::custom)
}
(Some(_), Some(_), _) | (Some(_), _, Some(_)) => Err(serde::de::Error::custom(
"a provider alias must use either { uri, credentials } or \
{ fallback, cache }, not both",
)),
(None, Some(_), None) => Err(serde::de::Error::custom(
"a cached provider alias with fallback also requires cache",
)),
(None, None, Some(_)) => Err(serde::de::Error::custom(
"a cached provider alias with cache also requires fallback",
)),
(None, None, None) => Err(serde::de::Error::custom(
"a provider alias table requires uri or fallback plus cache",
)),
}
}
}
deserializer.deserialize_any(AliasVisitor)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub project: Project,
pub profiles: HashMap<String, Profile>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub providers: Option<HashMap<String, ProviderAlias>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scopes: Option<HashMap<String, Scope>>,
}
impl Config {
pub fn validate(&self) -> Result<(), ParseError> {
self.validate_and_compile().map(|_| ())
}
pub(crate) fn validate_and_compile(&self) -> Result<CompiledManifest, ParseError> {
if self.project.name.is_empty() {
return Err(ParseError::Validation(
"Project name cannot be empty".into(),
));
}
if self.profiles.is_empty() {
return Err(ParseError::Validation(
"At least one profile must be defined".into(),
));
}
let compiled = CompiledManifest::compile(self);
let default_profile = self.profiles.get("default");
if let Some(default_profile) = default_profile {
default_profile
.validate_raw(false)
.map_err(|e| ParseError::Validation(format!("Profile 'default': {}", e)))?;
validate_compiled_profile(&compiled, "default")?;
}
let mut profile_names: Vec<&String> = self
.profiles
.keys()
.filter(|name| name.as_str() != "default")
.collect();
profile_names.sort();
for profile_name in profile_names {
self.profiles[profile_name]
.validate_raw(default_profile.is_some())
.map_err(|e| {
ParseError::Validation(format!("Profile '{}': {}", profile_name, e))
})?;
validate_compiled_profile(&compiled, profile_name)?;
}
self.validate_scopes(&compiled)?;
Ok(compiled)
}
fn validate_scopes(&self, compiled: &CompiledManifest) -> Result<(), ParseError> {
let Some(scopes) = &self.scopes else {
return Ok(());
};
let declared: std::collections::BTreeSet<&str> = compiled
.profiles
.values()
.flat_map(|profile| profile.secrets.keys())
.map(String::as_str)
.collect();
let mut scope_names: Vec<&String> = scopes.keys().collect();
scope_names.sort();
for scope_name in scope_names {
if scope_name.trim().is_empty() {
return Err(ParseError::Validation(
"Scope names cannot be empty".to_string(),
));
}
let secrets = &scopes[scope_name].secrets;
if secrets.is_empty() {
return Err(ParseError::Validation(format!(
"Scope '{}' lists no secrets; a scope must name at least one",
scope_name
)));
}
let mut seen: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
for secret in secrets {
if secret.trim().is_empty() {
return Err(ParseError::Validation(format!(
"Scope '{}' lists an empty secret name",
scope_name
)));
}
if !seen.insert(secret.as_str()) {
return Err(ParseError::Validation(format!(
"Scope '{}' lists secret '{}' more than once",
scope_name, secret
)));
}
if !declared.contains(secret.as_str()) {
return Err(ParseError::Validation(format!(
"Scope '{}' references secret '{}', which is not declared in any profile",
scope_name, secret
)));
}
}
}
Ok(())
}
pub fn get_profile(&self, name: &str) -> Option<&Profile> {
self.profiles.get(name)
}
pub fn get_profile_mut(&mut self, name: &str) -> Option<&mut Profile> {
self.profiles.get_mut(name)
}
fn overlay_with(&mut self, later: Config) {
let inherited_require_reason = self.project.require_reason;
self.project = later.project;
if self.project.require_reason.is_none() {
self.project.require_reason = inherited_require_reason;
}
for (profile_name, later_profile) in later.profiles {
match self.profiles.get_mut(&profile_name) {
Some(profile) => profile.overlay_with(later_profile),
None => {
self.profiles.insert(profile_name, later_profile);
}
}
}
if let Some(later_providers) = later.providers {
self.providers
.get_or_insert_with(HashMap::new)
.extend(later_providers);
}
if let Some(later_scopes) = later.scopes {
self.scopes
.get_or_insert_with(HashMap::new)
.extend(later_scopes);
}
}
fn parse_document(content: &str) -> Result<Self, ParseError> {
let config: Config = toml::from_str(content)?;
if config.project.revision != "1.0" {
return Err(ParseError::UnsupportedRevision(config.project.revision));
}
Ok(config)
}
}
fn validate_compiled_profile(
manifest: &CompiledManifest,
profile_name: &str,
) -> Result<(), ParseError> {
let profile = manifest
.profile(profile_name)
.expect("compiled profiles mirror parsed profiles");
for (name, secret) in &profile.secrets {
secret.config.validate_effective().map_err(|e| {
ParseError::Validation(format!(
"Profile '{}': Secret '{}': {}",
profile_name, name, e
))
})?;
}
validate_profile_constraints(profile_name, profile)?;
validate_composition_graph(profile_name, profile)?;
Ok(())
}
fn validate_profile_constraints(
profile_name: &str,
profile: &crate::manifest::CompiledProfile,
) -> Result<(), ParseError> {
fn validate_groups(
profile_name: &str,
kind: &str,
groups: &[crate::manifest::CompiledConstraintGroup],
) -> Result<(), ParseError> {
for group in groups {
if group.members.len() < 2 {
return Err(ParseError::Validation(format!(
"Profile '{}': {} group '{}' must contain at least two secrets",
profile_name, kind, group.name
)));
}
}
Ok(())
}
let at_least_names: HashSet<&str> = profile
.constraints
.at_least_one
.iter()
.map(|group| group.name.as_str())
.collect();
if let Some(group) = profile
.constraints
.exactly_one
.iter()
.find(|group| at_least_names.contains(group.name.as_str()))
{
return Err(ParseError::Validation(format!(
"Profile '{}': group '{}' cannot mix at_least_one and exactly_one membership",
profile_name, group.name
)));
}
validate_groups(
profile_name,
"at_least_one",
&profile.constraints.at_least_one,
)?;
validate_groups(
profile_name,
"exactly_one",
&profile.constraints.exactly_one,
)?;
Ok(())
}
fn validate_composition_graph(
profile_name: &str,
profile: &crate::manifest::CompiledProfile,
) -> Result<(), ParseError> {
let mut graph: BTreeMap<&str, &[String]> = BTreeMap::new();
for (name, secret) in &profile.secrets {
let Some(template) = &secret.composition else {
continue;
};
for dependency in template.dependencies() {
if !profile.secrets.contains_key(dependency) {
return Err(ParseError::Validation(format!(
"Profile '{}': Secret '{}': composed reference `${{{}}}` does not name a declared secret",
profile_name, name, dependency
)));
}
}
graph.insert(name.as_str(), template.dependencies());
}
fn visit<'a>(
name: &'a str,
graph: &BTreeMap<&'a str, &'a [String]>,
state: &mut HashMap<&'a str, u8>,
stack: &mut Vec<&'a str>,
) -> Result<(), Vec<String>> {
match state.get(name).copied() {
Some(2) => return Ok(()),
Some(1) => {
let start = stack.iter().position(|item| *item == name).unwrap_or(0);
let mut cycle: Vec<String> = stack[start..].iter().map(|s| s.to_string()).collect();
cycle.push(name.to_string());
return Err(cycle);
}
_ => {}
}
state.insert(name, 1);
stack.push(name);
if let Some(dependencies) = graph.get(name) {
for dependency in *dependencies {
if graph.contains_key(dependency.as_str()) {
visit(dependency, graph, state, stack)?;
}
}
}
stack.pop();
state.insert(name, 2);
Ok(())
}
let mut state = HashMap::new();
for name in graph.keys() {
if let Err(cycle) = visit(name, &graph, &mut state, &mut Vec::new()) {
return Err(ParseError::Validation(format!(
"Profile '{}': composed secret cycle: {}",
profile_name,
cycle.join(" -> ")
)));
}
}
Ok(())
}
struct ConfigGraphLoader {
active: HashSet<PathBuf>,
emitted: HashSet<PathBuf>,
documents: Vec<Config>,
}
impl ConfigGraphLoader {
fn load(path: &Path) -> Result<Config, ParseError> {
let mut loader = Self {
active: HashSet::new(),
emitted: HashSet::new(),
documents: Vec::new(),
};
loader.visit(path)?;
let mut documents = loader.documents.into_iter();
let mut merged = documents
.next()
.expect("visiting a root always emits at least one document");
for document in documents {
merged.overlay_with(document);
}
Ok(merged)
}
fn visit(&mut self, path: &Path) -> Result<(), ParseError> {
let canonical_path = path.canonicalize().map_err(|e| {
ParseError::Io(io::Error::new(
e.kind(),
format!("Failed to resolve path {}: {}", path.display(), e),
))
})?;
if self.emitted.contains(&canonical_path) {
return Ok(());
}
if !self.active.insert(canonical_path.clone()) {
return Err(ParseError::CircularDependency(format!(
"Configuration file {} is part of a circular dependency chain",
canonical_path.display()
)));
}
let content = fs::read_to_string(&canonical_path)?;
let config = Config::parse_document(&content)?;
let base_dir = path.parent().unwrap_or(Path::new("."));
for extend_path in config.project.extends.iter().flatten() {
let joined_path = base_dir.join(extend_path);
let full_path = if extend_path.ends_with(".toml") {
joined_path
} else {
joined_path.join("secretspec.toml")
};
if !full_path.exists() {
return Err(ParseError::ExtendedConfigNotFound(
full_path.display().to_string(),
));
}
self.visit(&full_path)?;
}
self.active.remove(&canonical_path);
self.emitted.insert(canonical_path);
self.documents.push(config);
Ok(())
}
}
impl FromStr for Config {
type Err = ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse_document(s)
}
}
impl TryFrom<&Path> for Config {
type Error = ParseError;
fn try_from(path: &Path) -> Result<Self, Self::Error> {
ConfigGraphLoader::load(path)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RequireReason {
Never,
#[default]
Agents,
Always,
}
impl Serialize for RequireReason {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
RequireReason::Never => serializer.serialize_bool(false),
RequireReason::Always => serializer.serialize_bool(true),
RequireReason::Agents => serializer.serialize_str("agents"),
}
}
}
impl<'de> Deserialize<'de> for RequireReason {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct RequireReasonVisitor;
impl serde::de::Visitor<'_> for RequireReasonVisitor {
type Value = RequireReason;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str(r#"a boolean or the string "agents""#)
}
fn visit_bool<E: serde::de::Error>(self, v: bool) -> Result<RequireReason, E> {
Ok(if v {
RequireReason::Always
} else {
RequireReason::Never
})
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<RequireReason, E> {
match v {
"agents" => Ok(RequireReason::Agents),
other => Err(E::custom(format!(
"invalid require_reason value '{other}': expected true, false, or \"agents\""
))),
}
}
}
deserializer.deserialize_any(RequireReasonVisitor)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Project {
pub name: String,
pub revision: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub extends: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub require_reason: Option<RequireReason>,
}
impl Default for Project {
fn default() -> Self {
Self {
name: String::new(),
revision: "1.0".to_string(),
extends: None,
require_reason: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct AuditConfig {
pub enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<PathBuf>,
pub max_size_bytes: u64,
}
impl Default for AuditConfig {
fn default() -> Self {
Self {
enabled: true,
path: None,
max_size_bytes: 1_048_576,
}
}
}
impl AuditConfig {
pub fn resolved_path(&self) -> Option<PathBuf> {
match self.path.clone() {
Some(path) => Some(expand_tilde(path)).filter(|p| p.is_absolute()),
None => default_audit_path(),
}
}
pub fn has_relative_path(&self) -> bool {
self.path
.as_ref()
.is_some_and(|p| !expand_tilde(p.clone()).is_absolute())
}
}
fn app_strategy_args() -> etcetera::app_strategy::AppStrategyArgs {
etcetera::app_strategy::AppStrategyArgs {
top_level_domain: String::new(),
author: String::new(),
app_name: "secretspec".into(),
}
}
fn default_audit_path() -> Option<PathBuf> {
use etcetera::app_strategy::{AppStrategy, choose_app_strategy};
let strategy = choose_app_strategy(app_strategy_args()).ok()?;
let dir = strategy.state_dir().unwrap_or_else(|| strategy.data_dir());
Some(dir.join("audit.log"))
}
fn expand_tilde(path: PathBuf) -> PathBuf {
let Ok(rest) = path.strip_prefix("~") else {
return path;
};
let Some(home) = home_dir() else {
return path;
};
home.join(rest)
}
fn home_dir() -> Option<PathBuf> {
etcetera::home_dir()
.ok()
.or_else(|| std::env::var_os("HOME").map(PathBuf::from))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Profile {
#[serde(skip_serializing_if = "Option::is_none")]
pub defaults: Option<ProfileDefaults>,
#[serde(flatten)]
pub secrets: HashMap<String, Secret>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Scope {
pub secrets: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileDefaults {
#[serde(skip_serializing_if = "Option::is_none")]
pub required: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub providers: Option<Vec<String>>,
}
impl ProfileDefaults {
fn inherit_missing_from(&mut self, earlier: &ProfileDefaults) {
self.required = self.required.or(earlier.required);
if self.default.is_none() {
self.default = earlier.default.clone();
}
if self.providers.is_none() {
self.providers = earlier.providers.clone();
}
}
}
impl Profile {
pub fn new() -> Self {
Self {
defaults: None,
secrets: HashMap::new(),
}
}
fn validate_raw(&self, can_inherit_secrets: bool) -> Result<(), String> {
if self.secrets.is_empty() && !can_inherit_secrets {
return Err("Profile must define at least one secret".into());
}
for name in self.sorted_secret_names() {
let secret = &self.secrets[&name];
if !is_valid_identifier(&name) {
return Err(format!(
"Invalid secret name '{}': must be a valid identifier (alphanumeric and underscores, not starting with a number)",
name
));
}
secret
.validate_required_default()
.map_err(|e| format!("Secret '{}': {}", name, e))?;
}
Ok(())
}
fn overlay_with(&mut self, later: Profile) {
if let Some(mut later_defaults) = later.defaults {
if let Some(earlier_defaults) = &self.defaults {
later_defaults.inherit_missing_from(earlier_defaults);
}
self.defaults = Some(later_defaults);
}
self.secrets.extend(later.secrets);
}
pub fn iter(&self) -> hash_map::Iter<'_, String, Secret> {
self.secrets.iter()
}
pub(crate) fn sorted_secret_names(&self) -> Vec<String> {
let mut names: Vec<String> = self.secrets.keys().cloned().collect();
names.sort();
names
}
}
impl Default for Profile {
fn default() -> Self {
Self::new()
}
}
impl<'a> IntoIterator for &'a Profile {
type Item = (&'a String, &'a Secret);
type IntoIter = hash_map::Iter<'a, String, Secret>;
#[inline]
fn into_iter(self) -> Self::IntoIter {
self.secrets.iter()
}
}
impl IntoIterator for Profile {
type Item = (String, Secret);
type IntoIter = hash_map::IntoIter<String, Secret>;
#[inline]
fn into_iter(self) -> Self::IntoIter {
self.secrets.into_iter()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum GenerateConfig {
Bool(bool),
Options(GenerateOptions),
}
impl GenerateConfig {
pub fn is_enabled(&self) -> bool {
match self {
GenerateConfig::Bool(b) => *b,
GenerateConfig::Options(_) => true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GenerateOptions {
#[serde(skip_serializing_if = "Option::is_none")]
pub length: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bytes: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub charset: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bits: Option<usize>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize)]
pub struct NativeAddress {
pub item: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub field: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub vault: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub section: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
impl NativeAddress {
pub(crate) fn coordinates(&self) -> [(&'static str, Option<&str>); 5] {
[
("vault", self.vault.as_deref()),
("item", Some(self.item.as_str())),
("section", self.section.as_deref()),
("field", self.field.as_deref()),
("version", self.version.as_deref()),
]
}
pub fn render(&self) -> String {
let mut out = String::new();
for (name, value) in self.coordinates() {
if let Some(value) = value {
if !out.is_empty() {
out.push(' ');
}
out.push_str(name);
out.push('=');
out.push_str(value);
}
}
out
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct NativeAddressFields {
item: String,
field: Option<String>,
vault: Option<String>,
section: Option<String>,
version: Option<String>,
}
impl From<NativeAddressFields> for NativeAddress {
fn from(f: NativeAddressFields) -> Self {
NativeAddress {
item: f.item,
field: f.field,
vault: f.vault,
section: f.section,
version: f.version,
}
}
}
pub(crate) fn ref_table_hint(
vault: Option<&str>,
item: &str,
section: Option<&str>,
field: Option<&str>,
) -> String {
let coords = NativeAddress {
item: item.to_string(),
field: field.map(str::to_string),
vault: vault.map(str::to_string),
section: section.map(str::to_string),
version: None,
};
let rendered: Vec<String> = coords
.coordinates()
.into_iter()
.filter_map(|(name, value)| value.map(|v| format!("{name} = \"{v}\"")))
.collect();
format!("ref = {{ {} }}", rendered.join(", "))
}
fn ref_string_hint(s: &str) -> String {
if let Some(rest) = s.strip_prefix("op://") {
let segments: Vec<&str> = rest.split('/').collect();
match segments[..] {
[vault, item, field] if !vault.is_empty() && !item.is_empty() && !field.is_empty() => {
return format!(
"`ref` takes a table of coordinates, not a URI. Use: {}",
ref_table_hint(Some(vault), item, None, Some(field))
);
}
[vault, item, section, field]
if !vault.is_empty()
&& !item.is_empty()
&& !section.is_empty()
&& !field.is_empty() =>
{
return format!(
"`ref` takes a table of coordinates, not a URI. Use: {}",
ref_table_hint(Some(vault), item, Some(section), Some(field))
);
}
_ => {}
}
}
format!(
"`ref` takes a table of native secret coordinates, not a string: got '{s}'. \
Write e.g. {}; which store resolves \
the coordinates comes from `providers` (or the default provider).",
ref_table_hint(None, "db", None, Some("password"))
)
}
fn deserialize_group_names<'de, D>(
deserializer: D,
) -> std::result::Result<Option<Vec<String>>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMany {
One(String),
Many(Vec<String>),
}
Ok(Some(match OneOrMany::deserialize(deserializer)? {
OneOrMany::One(name) => vec![name],
OneOrMany::Many(names) => names,
}))
}
fn serialize_group_names<S>(
groups: &Option<Vec<String>>,
serializer: S,
) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match groups.as_deref() {
Some([group]) => serializer.serialize_str(group),
Some(groups) => groups.serialize(serializer),
None => serializer.serialize_none(),
}
}
impl<'de> Deserialize<'de> for NativeAddress {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct AddressVisitor;
impl<'de> serde::de::Visitor<'de> for AddressVisitor {
type Value = NativeAddress;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(
f,
"a table of native secret coordinates like {{ item = \"db\", field = \"password\" }}"
)
}
fn visit_map<A>(self, map: A) -> std::result::Result<Self::Value, A::Error>
where
A: serde::de::MapAccess<'de>,
{
NativeAddressFields::deserialize(serde::de::value::MapAccessDeserializer::new(map))
.map(NativeAddress::from)
}
fn visit_str<E>(self, s: &str) -> std::result::Result<Self::Value, E>
where
E: serde::de::Error,
{
Err(E::custom(ref_string_hint(s)))
}
}
deserializer.deserialize_any(AddressVisitor)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
enum RequiredSetting {
Bool(bool),
Groups(RequiredGroups),
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct RequiredGroups {
#[serde(
default,
deserialize_with = "deserialize_group_names",
serialize_with = "serialize_group_names",
skip_serializing_if = "Option::is_none"
)]
at_least_one: Option<Vec<String>>,
#[serde(
default,
deserialize_with = "deserialize_group_names",
serialize_with = "serialize_group_names",
skip_serializing_if = "Option::is_none"
)]
exactly_one: Option<Vec<String>>,
}
#[derive(Serialize, Deserialize)]
struct SecretSerde {
description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
required: Option<RequiredSetting>,
#[serde(skip_serializing_if = "Option::is_none")]
default: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
composed: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
providers: Option<Vec<String>>,
#[serde(rename = "ref", skip_serializing_if = "Option::is_none")]
reference: Option<NativeAddress>,
#[serde(skip_serializing_if = "Option::is_none")]
as_path: Option<bool>,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
secret_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
generate: Option<GenerateConfig>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(try_from = "SecretSerde", into = "SecretSerde")]
pub struct Secret {
pub description: Option<String>,
pub required: Option<bool>,
pub at_least_one: Option<Vec<String>>,
pub exactly_one: Option<Vec<String>>,
pub default: Option<String>,
pub composed: Option<String>,
pub providers: Option<Vec<String>>,
pub reference: Option<NativeAddress>,
pub as_path: Option<bool>,
pub secret_type: Option<String>,
pub generate: Option<GenerateConfig>,
}
impl TryFrom<SecretSerde> for Secret {
type Error = String;
fn try_from(value: SecretSerde) -> Result<Self, Self::Error> {
let (required, at_least_one, exactly_one) = match value.required {
Some(RequiredSetting::Bool(required)) => (Some(required), None, None),
Some(RequiredSetting::Groups(groups)) => {
if groups.at_least_one.is_none() && groups.exactly_one.is_none() {
return Err("`required` table must set `at_least_one` or `exactly_one`".into());
}
(None, groups.at_least_one, groups.exactly_one)
}
None => (None, None, None),
};
Ok(Self {
description: value.description,
required,
at_least_one,
exactly_one,
default: value.default,
composed: value.composed,
providers: value.providers,
reference: value.reference,
as_path: value.as_path,
secret_type: value.secret_type,
generate: value.generate,
})
}
}
impl From<Secret> for SecretSerde {
fn from(value: Secret) -> Self {
let required = if value.at_least_one.is_some() || value.exactly_one.is_some() {
Some(RequiredSetting::Groups(RequiredGroups {
at_least_one: value.at_least_one,
exactly_one: value.exactly_one,
}))
} else {
value.required.map(RequiredSetting::Bool)
};
Self {
description: value.description,
required,
default: value.default,
composed: value.composed,
providers: value.providers,
reference: value.reference,
as_path: value.as_path,
secret_type: value.secret_type,
generate: value.generate,
}
}
}
impl Secret {
pub fn validate(&self) -> Result<(), String> {
self.validate_description()?;
self.validate_required_default()?;
self.validate_semantics()
}
fn validate_effective(&self) -> Result<(), String> {
self.validate_description()?;
self.validate_semantics()
}
fn validate_description(&self) -> Result<(), String> {
match self.description.as_deref() {
Some("") => Err("description cannot be empty".into()),
None => Err("missing description".into()),
Some(_) => Ok(()),
}
}
fn validate_required_default(&self) -> Result<(), String> {
if self.required == Some(true) && self.default.is_some() {
return Err("Required secrets cannot have default values".into());
}
Ok(())
}
pub(crate) fn would_generate(&self) -> bool {
self.generate.as_ref().is_some_and(|g| g.is_enabled())
}
fn has_required_setting(&self) -> bool {
self.required.is_some() || self.at_least_one.is_some() || self.exactly_one.is_some()
}
fn validate_semantics(&self) -> Result<(), String> {
for (field, groups) in [
("at_least_one", self.at_least_one.as_deref()),
("exactly_one", self.exactly_one.as_deref()),
] {
let Some(groups) = groups else {
continue;
};
if groups.is_empty() {
return Err(format!("`{field}` must name at least one group"));
}
let mut unique = HashSet::new();
for group in groups {
if group.trim().is_empty() {
return Err(format!(
"`{field}` group name cannot be empty or whitespace"
));
}
if !unique.insert(group) {
return Err(format!("`{field}` contains duplicate group name '{group}'"));
}
}
}
if self.required == Some(true)
&& (self.at_least_one.is_some() || self.exactly_one.is_some())
{
return Err(
"`required = true` cannot be combined with `at_least_one` or `exactly_one`; group membership governs the secret's presence, so drop `required` or set it to false"
.into(),
);
}
if let Some(composed) = &self.composed {
Template::parse(composed)?;
if self.default.is_some()
|| self.providers.is_some()
|| self.reference.is_some()
|| self.secret_type.is_some()
|| self.would_generate()
{
return Err(
"`composed` secrets cannot also set `default`, `providers`, `ref`, `type`, or enabled `generate`"
.into(),
);
}
}
if let Some(reference) = &self.reference {
for (name, value) in reference.coordinates() {
if value.is_some_and(|v| v.trim().is_empty()) {
return Err(format!(
"`ref` coordinate `{}` cannot be empty or whitespace",
name
));
}
}
}
if let Some(ref gen_config) = self.generate
&& gen_config.is_enabled()
{
if self.secret_type.is_none() {
return Err(
"'generate' requires 'type' to be set (e.g., type = \"password\")".into(),
);
}
if self.default.is_some() {
return Err("'generate' and 'default' cannot both be set".into());
}
if self.secret_type.as_deref() == Some("command") {
match gen_config {
GenerateConfig::Bool(true) => {
return Err(
"type = \"command\" requires generate = { command = \"...\" }".into(),
);
}
GenerateConfig::Options(opts) if opts.command.is_none() => {
return Err(
"type = \"command\" requires generate = { command = \"...\" }".into(),
);
}
_ => {}
}
}
if let Some(ref t) = self.secret_type {
match t.as_str() {
"password" | "hex" | "base64" | "uuid" | "command" | "rsa_private_key" => {}
unknown => {
return Err(format!("unknown secret type '{}'", unknown));
}
}
}
}
if let Some(ref t) = self.secret_type
&& !self.would_generate()
{
match t.as_str() {
"password" | "hex" | "base64" | "uuid" | "command" | "rsa_private_key" => {}
unknown => {
return Err(format!("unknown secret type '{}'", unknown));
}
}
}
Ok(())
}
pub(crate) fn resolved(
current: Option<&Secret>,
default: Option<&Secret>,
defaults: Option<&ProfileDefaults>,
) -> Option<Secret> {
if current.is_none() && default.is_none() {
return None;
}
fn inherit<T>(
current: Option<&Secret>,
default: Option<&Secret>,
field: impl Fn(&Secret) -> Option<T>,
) -> Option<T> {
current
.and_then(&field)
.or_else(|| default.and_then(&field))
}
let composed = inherit(current, default, |s| s.composed.clone());
let required_source = current
.filter(|secret| secret.has_required_setting())
.or_else(|| default.filter(|secret| secret.has_required_setting()));
let (required, at_least_one, exactly_one) = if let Some(secret) = required_source {
(
secret.required,
secret.at_least_one.clone(),
secret.exactly_one.clone(),
)
} else {
(defaults.and_then(|d| d.required), None, None)
};
let storage_defaults = if composed.is_some() { None } else { defaults };
Some(Secret {
description: inherit(current, default, |s| s.description.clone()),
required,
at_least_one,
exactly_one,
default: inherit(current, default, |s| s.default.clone())
.or_else(|| storage_defaults.and_then(|d| d.default.clone())),
composed,
providers: inherit(current, default, |s| s.providers.clone())
.or_else(|| storage_defaults.and_then(|d| d.providers.clone())),
reference: inherit(current, default, |s| s.reference.clone()),
as_path: inherit(current, default, |s| s.as_path),
secret_type: inherit(current, default, |s| s.secret_type.clone()),
generate: inherit(current, default, |s| s.generate.clone()),
})
}
}
pub(crate) fn is_valid_identifier(s: &str) -> bool {
if s.is_empty() {
return false;
}
let mut chars = s.chars();
if let Some(first) = chars.next()
&& !first.is_alphabetic()
&& first != '_'
{
return false;
}
chars.all(|c| c.is_alphanumeric() || c == '_')
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[doc(hidden)]
pub struct GlobalConfig {
#[serde(default)]
pub defaults: GlobalDefaults,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub audit: Option<AuditConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[doc(hidden)]
pub struct GlobalDefaults {
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub providers: Option<HashMap<String, ProviderAlias>>,
}
impl GlobalConfig {
pub fn path() -> Result<PathBuf, io::Error> {
use etcetera::app_strategy::{AppStrategy, choose_app_strategy};
let strategy = choose_app_strategy(app_strategy_args())
.map_err(|e| io::Error::new(io::ErrorKind::NotFound, e.to_string()))?;
Ok(strategy.config_dir().join("config.toml"))
}
pub fn load() -> Result<Option<Self>, ParseError> {
let config_path = Self::path().map_err(ParseError::Io)?;
#[cfg(target_os = "macos")]
let config_path = Self::migrate_macos_config(&config_path).map_err(ParseError::Io)?;
if !config_path.try_exists().map_err(ParseError::Io)? {
return Ok(None);
}
let content = std::fs::read_to_string(&config_path).map_err(ParseError::Io)?;
toml::from_str(&content).map(Some).map_err(ParseError::Toml)
}
pub fn save(&self) -> Result<(), io::Error> {
let config_path = Self::path()?;
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = toml::to_string_pretty(self)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
std::fs::write(&config_path, content)?;
Ok(())
}
#[cfg(target_os = "macos")]
fn migrate_macos_config(new_path: &Path) -> Result<PathBuf, io::Error> {
match new_path.try_exists() {
Ok(true) => return Ok(new_path.to_path_buf()),
Ok(false) => {}
Err(err) => {
if let Ok(home) = etcetera::home_dir() {
let old_path = home
.join("Library/Application Support/secretspec")
.join("config.toml");
if old_path.exists() {
return Ok(old_path);
}
}
return Err(err);
}
}
let old_path = match etcetera::home_dir() {
Ok(home) => home
.join("Library/Application Support/secretspec")
.join("config.toml"),
Err(_) => return Ok(new_path.to_path_buf()),
};
match old_path.try_exists() {
Ok(true) => {}
Ok(false) => return Ok(new_path.to_path_buf()),
Err(err) => {
eprintln!(
"Warning: failed to check legacy config path {}: {}. Continuing to use legacy path.",
old_path.display(),
err
);
return Ok(old_path);
}
}
if let Some(parent) = new_path.parent() {
if let Err(err) = std::fs::create_dir_all(parent) {
eprintln!(
"Warning: failed to create config directory {} while migrating from {}: {}. Continuing to use legacy config path.",
parent.display(),
old_path.display(),
err
);
return Ok(old_path);
}
}
if let Err(err) = std::fs::copy(&old_path, new_path) {
eprintln!(
"Warning: failed to migrate config from {} to {}: {}. Continuing to use legacy config path.",
old_path.display(),
new_path.display(),
err
);
return Ok(old_path);
}
let old_backup = old_path.with_extension("toml.old");
if let Err(err) = std::fs::rename(&old_path, &old_backup) {
eprintln!(
"Warning: migrated config to {}, but failed to back up {} to {}: {}",
new_path.display(),
old_path.display(),
old_backup.display(),
err
);
}
eprintln!(
"Migrated config from {} to {}",
old_path.display(),
new_path.display()
);
Ok(new_path.to_path_buf())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Resolved<T> {
pub secrets: T,
pub provider: String,
pub profile: String,
}
impl<T> Resolved<T> {
pub fn new(secrets: T, provider: String, profile: String) -> Self {
Self {
secrets,
provider,
profile,
}
}
}
#[derive(Debug)]
pub enum ParseError {
Io(io::Error),
Toml(toml::de::Error),
UnsupportedRevision(String),
CircularDependency(String),
Validation(String),
ExtendedConfigNotFound(String),
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ParseError::Io(e) => write!(f, "I/O error: {}", e),
ParseError::Toml(e) => write!(f, "TOML parsing error: {}", e),
ParseError::UnsupportedRevision(rev) => {
write!(
f,
"Unsupported revision '{}'. Only '1.0' is supported.",
rev
)
}
ParseError::CircularDependency(msg) => {
write!(f, "Circular dependency detected: {}", msg)
}
ParseError::Validation(msg) => write!(f, "Validation error: {}", msg),
ParseError::ExtendedConfigNotFound(path) => {
write!(f, "Extended config file not found: {}", path)
}
}
}
}
impl std::error::Error for ParseError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ParseError::Io(e) => Some(e),
ParseError::Toml(e) => Some(e),
_ => None,
}
}
}
impl From<io::Error> for ParseError {
fn from(e: io::Error) -> Self {
ParseError::Io(e)
}
}
impl From<toml::de::Error> for ParseError {
fn from(e: toml::de::Error) -> Self {
ParseError::Toml(e)
}
}
#[cfg(test)]
mod require_reason_tests {
use super::*;
fn parse(line: &str) -> Option<RequireReason> {
let toml = format!("name = \"t\"\nrevision = \"1.0\"\n{line}");
toml::from_str::<Project>(&toml).unwrap().require_reason
}
#[test]
fn accepts_bool_and_agents_string() {
assert_eq!(parse("require_reason = true"), Some(RequireReason::Always));
assert_eq!(parse("require_reason = false"), Some(RequireReason::Never));
assert_eq!(
parse("require_reason = \"agents\""),
Some(RequireReason::Agents)
);
}
#[test]
fn unspecified_require_reason_is_none_and_resolves_to_agents() {
assert_eq!(parse(""), None);
assert_eq!(parse("").unwrap_or_default(), RequireReason::Agents);
}
#[test]
fn extends_inherits_parent_require_reason_when_unspecified() {
use std::collections::HashMap;
let cfg = |rr: Option<RequireReason>| Config {
project: Project {
name: "t".to_string(),
require_reason: rr,
..Default::default()
},
profiles: HashMap::new(),
providers: None,
scopes: None,
};
let mut merged = cfg(Some(RequireReason::Always));
merged.overlay_with(cfg(None));
assert_eq!(merged.project.require_reason, Some(RequireReason::Always));
let mut merged = cfg(Some(RequireReason::Always));
merged.overlay_with(cfg(Some(RequireReason::Never)));
assert_eq!(merged.project.require_reason, Some(RequireReason::Never));
}
#[test]
fn rejects_unknown_or_wrong_typed_values() {
let base = "name = \"t\"\nrevision = \"1.0\"\n";
let err = toml::from_str::<Project>(&format!("{base}require_reason = \"nope\""))
.unwrap_err()
.to_string();
assert!(
err.contains("expected true, false, or \"agents\""),
"unexpected error: {err}"
);
let err = toml::from_str::<Project>(&format!("{base}require_reason = 1"))
.unwrap_err()
.to_string();
assert!(
err.contains("invalid type") && err.contains("boolean or the string"),
"unexpected error: {err}"
);
}
#[test]
fn round_trips_through_serialize() {
let toml = toml::to_string(&Project {
name: "t".to_string(),
revision: "1.0".to_string(),
extends: None,
require_reason: None,
})
.unwrap();
assert!(!toml.contains("require_reason"));
let toml = toml::to_string(&Project {
name: "t".to_string(),
revision: "1.0".to_string(),
extends: None,
require_reason: Some(RequireReason::Always),
})
.unwrap();
assert_eq!(
toml::from_str::<Project>(&toml).unwrap().require_reason,
Some(RequireReason::Always)
);
}
}
#[cfg(test)]
mod audit_config_tests {
use super::*;
fn with_path(path: &str) -> AuditConfig {
AuditConfig {
path: Some(PathBuf::from(path)),
..Default::default()
}
}
#[test]
fn resolved_path_keeps_absolute_and_rejects_relative() {
let abs_path = if cfg!(windows) {
r"C:\var\log\secretspec\audit.log"
} else {
"/var/log/secretspec/audit.log"
};
let abs = with_path(abs_path);
assert_eq!(abs.resolved_path(), Some(PathBuf::from(abs_path)));
assert!(!abs.has_relative_path());
for rel in ["audit.log", "logs/audit.log", "./audit.log"] {
let cfg = with_path(rel);
assert_eq!(
cfg.resolved_path(),
None,
"relative path {rel:?} must reject"
);
assert!(
cfg.has_relative_path(),
"{rel:?} should be flagged relative"
);
}
}
#[test]
fn unset_path_is_not_flagged_relative() {
let cfg = AuditConfig::default();
assert!(!cfg.has_relative_path());
}
#[test]
fn expand_tilde_expands_leading_tilde_only() {
assert_eq!(
expand_tilde(PathBuf::from("/abs/path")),
PathBuf::from("/abs/path")
);
assert_eq!(
expand_tilde(PathBuf::from("relative/path")),
PathBuf::from("relative/path")
);
assert_eq!(
expand_tilde(PathBuf::from("/a/~/b")),
PathBuf::from("/a/~/b")
);
if let Some(home) = home_dir() {
assert_eq!(
expand_tilde(PathBuf::from("~/.local/state/secretspec/audit.log")),
home.join(".local/state/secretspec/audit.log")
);
}
}
#[test]
fn audit_config_omitted_fields_default_to_on() {
let cfg: AuditConfig = toml::from_str("").unwrap();
assert!(cfg.enabled);
assert_eq!(cfg.path, None);
assert_eq!(cfg.max_size_bytes, 1_048_576);
}
#[test]
fn global_config_wires_audit_table() {
let g: GlobalConfig =
toml::from_str("[defaults]\nprovider = \"keyring\"\n\n[audit]\nenabled = false\n")
.unwrap();
assert_eq!(g.audit.map(|a| a.enabled), Some(false));
let g: GlobalConfig = toml::from_str("[defaults]\nprovider = \"keyring\"\n").unwrap();
assert!(g.audit.is_none());
}
}
#[cfg(test)]
mod validation_tests {
use super::*;
fn secret(description: Option<&str>) -> Secret {
Secret {
description: description.map(String::from),
..Default::default()
}
}
fn config_with(name: &str, profiles: Vec<(&str, Vec<(&str, Secret)>)>) -> Config {
let profiles = profiles
.into_iter()
.map(|(pname, secrets)| {
let secrets = secrets
.into_iter()
.map(|(k, v)| (k.to_string(), v))
.collect();
(
pname.to_string(),
Profile {
defaults: None,
secrets,
},
)
})
.collect();
Config {
project: Project {
name: name.to_string(),
..Default::default()
},
profiles,
providers: None,
scopes: None,
}
}
#[test]
fn is_valid_identifier_accepts_and_rejects() {
for ok in ["ok", "_ok", "VALID_NAME9", "a"] {
assert!(is_valid_identifier(ok), "expected valid: {ok}");
}
for bad in ["", "1abc", "a-b", "has space", "a.b"] {
assert!(!is_valid_identifier(bad), "expected invalid: {bad}");
}
}
#[test]
fn config_validate_rejects_empty_name() {
let err = config_with("", vec![("default", vec![("A", secret(Some("d")))])])
.validate()
.unwrap_err();
assert!(matches!(err, ParseError::Validation(_)));
assert!(err.to_string().contains("name cannot be empty"));
}
#[test]
fn config_validate_rejects_no_profiles() {
let err = config_with("proj", vec![]).validate().unwrap_err();
assert!(err.to_string().contains("At least one profile"));
}
#[test]
fn config_validate_rejects_empty_profile() {
let err = config_with("proj", vec![("default", vec![])])
.validate()
.unwrap_err();
assert!(err.to_string().contains("at least one secret"));
}
#[test]
fn config_validate_allows_empty_profile_to_inherit_default_secrets() {
let config: Config = toml::from_str(
r#"
[project]
name = "lm04-stats"
revision = "1.0"
[profiles.default]
ADMIN_PASSWORD = { description = "Password securing the admin page", required = true, type = "password" }
[profiles.production]
"#,
)
.unwrap();
config.validate().unwrap();
let spec = crate::Secrets::new(config, None, None, Some("production".to_string()));
let resolved = spec
.resolve_secret_config("ADMIN_PASSWORD", Some("production"))
.expect("production should inherit ADMIN_PASSWORD from default");
assert_eq!(
resolved.description.as_deref(),
Some("Password securing the admin page")
);
assert_eq!(resolved.required, Some(true));
assert_eq!(resolved.secret_type.as_deref(), Some("password"));
}
#[test]
fn config_validate_accepts_presence_constraints_and_default_inheritance() {
let config: Config = toml::from_str(
r#"
[project]
name = "auth"
revision = "1.0"
[profiles.default]
PASSWORD = { description = "Password", required = { at_least_one = ["auth", "fallback_auth"], exactly_one = "exclusive_auth" } }
ACCESS_TOKEN = { description = "Access token", required = { at_least_one = ["auth", "fallback_auth"], exactly_one = "exclusive_auth" } }
[profiles.production]
"#,
)
.unwrap();
config.validate().unwrap();
let compiled = CompiledManifest::compile(&config);
let production = compiled.profile("production").unwrap();
assert_eq!(production.constraints.at_least_one[0].name, "auth");
assert_eq!(
production.constraints.at_least_one[0].members,
vec!["ACCESS_TOKEN".to_string(), "PASSWORD".to_string()]
);
assert_eq!(production.constraints.at_least_one[1].name, "fallback_auth");
assert_eq!(production.constraints.exactly_one[0].name, "exclusive_auth");
assert_eq!(production.constraints.exactly_one.len(), 1);
let rendered = toml::to_string(&config).unwrap();
assert!(
rendered.contains("[profiles.default.ACCESS_TOKEN.required]"),
"{rendered}"
);
assert!(rendered.contains(r#"at_least_one = ["auth", "fallback_auth"]"#));
assert!(rendered.contains(r#"exactly_one = "exclusive_auth""#));
}
#[test]
fn config_validate_rejects_invalid_presence_constraints() {
for (secrets, expected) in [
(
r#"PASSWORD = { description = "Password", required = { at_least_one = "auth" } }"#,
"at_least_one group 'auth' must contain at least two secrets",
),
(
r#"
PASSWORD = { description = "Password", required = { at_least_one = " " } }
ACCESS_TOKEN = { description = "Access token", required = { at_least_one = " " } }
"#,
"`at_least_one` group name cannot be empty or whitespace",
),
(
r#"
PASSWORD = { description = "Password", required = { at_least_one = [] } }
ACCESS_TOKEN = { description = "Access token", required = { at_least_one = [] } }
"#,
"`at_least_one` must name at least one group",
),
(
r#"
PASSWORD = { description = "Password", required = { at_least_one = ["auth", "auth"] } }
ACCESS_TOKEN = { description = "Access token", required = { at_least_one = "auth" } }
"#,
"`at_least_one` contains duplicate group name 'auth'",
),
(
r#"
PASSWORD = { description = "Password", required = { at_least_one = "auth" } }
ACCESS_TOKEN = { description = "Access token", required = { exactly_one = "auth" } }
"#,
"group 'auth' cannot mix at_least_one and exactly_one membership",
),
] {
let source = format!(
r#"
[project]
name = "auth"
revision = "1.0"
[profiles.default]
{secrets}
"#
);
let config: Config = toml::from_str(&source).unwrap();
let error = config.validate().unwrap_err().to_string();
assert!(error.contains(expected), "{error}");
}
}
#[test]
fn required_group_table_must_name_a_constraint() {
let error = toml::from_str::<Secret>(
r#"description = "d"
required = {}"#,
)
.unwrap_err()
.to_string();
assert!(
error.contains("`required` table must set `at_least_one` or `exactly_one`"),
"{error}"
);
}
#[test]
fn grouped_requiredness_replaces_inherited_boolean_requiredness() {
let config: Config = toml::from_str(
r#"
[project]
name = "auth"
revision = "1.0"
[profiles.default]
PASSWORD = { description = "Password", required = true }
ACCESS_TOKEN = { description = "Access token" }
[profiles.production]
PASSWORD = { required = { at_least_one = "auth" } }
ACCESS_TOKEN = { required = { at_least_one = "auth" } }
"#,
)
.unwrap();
config.validate().unwrap();
let compiled = CompiledManifest::compile(&config);
let password = &compiled.profile("production").unwrap().secrets["PASSWORD"];
assert!(!password.declared_required);
assert_eq!(password.config.required, None);
assert_eq!(
password.config.at_least_one.as_deref(),
Some(&["auth".into()][..])
);
}
#[test]
fn config_validate_rejects_invalid_secret_name() {
let err = config_with("proj", vec![("default", vec![("1BAD", secret(Some("d")))])])
.validate()
.unwrap_err();
assert!(err.to_string().contains("Invalid secret name"));
}
#[test]
fn composed_references_are_validated_as_a_static_graph() {
let parse = |body: &str| {
toml::from_str::<Config>(&format!(
r#"
[project]
name = "composed"
revision = "1.0"
[profiles.default]
{body}
"#
))
.unwrap()
};
parse(
r#"
USER = { description = "user" }
HOST = { description = "host" }
DSN = { description = "dsn", composed = "db://${USER}@${HOST}" }
"#,
)
.validate()
.unwrap();
let unknown = parse(
r#"
DSN = { description = "dsn", composed = "db://${AMBIENT_ENV}" }
"#,
)
.validate()
.unwrap_err()
.to_string();
assert!(
unknown.contains("does not name a declared secret"),
"{unknown}"
);
let cycle = parse(
r#"
A = { description = "a", composed = "${B}" }
B = { description = "b", composed = "${C}" }
C = { description = "c", composed = "${A}" }
"#,
)
.validate()
.unwrap_err()
.to_string();
assert!(cycle.contains("A -> B -> C -> A"), "{cycle}");
}
#[test]
fn composed_rejects_operators_and_storage_sources() {
let invalid: Config = toml::from_str(
r#"
[project]
name = "composed"
revision = "1.0"
[profiles.default]
A = { description = "a" }
BAD = { description = "bad", composed = "${A:-fallback}" }
"#,
)
.unwrap();
let error = invalid.validate().unwrap_err().to_string();
assert!(
error.contains("names must match `[A-Z][A-Z0-9_]*`"),
"{error}"
);
let conflicting: Config = toml::from_str(
r#"
[project]
name = "composed"
revision = "1.0"
[profiles.default]
A = { description = "a" }
BAD = { description = "bad", composed = "${A}", providers = ["keyring"] }
"#,
)
.unwrap();
let error = conflicting.validate().unwrap_err().to_string();
assert!(error.contains("cannot also set"), "{error}");
}
#[test]
fn composed_secrets_do_not_inherit_storage_profile_defaults() {
let config: Config = toml::from_str(
r#"
[project]
name = "composed"
revision = "1.0"
[profiles.default]
PART = { description = "part" }
RESULT = { description = "result", composed = "${PART}" }
[profiles.default.defaults]
default = "fallback"
providers = ["keyring"]
"#,
)
.unwrap();
let compiled = config.validate_and_compile().unwrap();
let result = &compiled.profile("default").unwrap().secrets["RESULT"].config;
assert!(result.default.is_none());
assert!(result.providers.is_none());
}
#[test]
fn config_validate_accepts_valid_config() {
assert!(
config_with(
"proj",
vec![("default", vec![("API_KEY", secret(Some("d")))])]
)
.validate()
.is_ok()
);
}
#[test]
fn config_validate_allows_profile_override_to_inherit_description() {
let config: Config = toml::from_str(
r#"
[project]
name = "tmp"
revision = "1.0"
[profiles.default]
DATABASE_URL = { description = "Database connection string", required = true }
[profiles.development]
DATABASE_URL = { default = "sqlite:///dev.db" }
"#,
)
.unwrap();
config.validate().unwrap();
let spec = crate::Secrets::new(config, None, None, Some("development".to_string()));
let resolved = spec
.resolve_secret_config("DATABASE_URL", Some("development"))
.unwrap();
assert_eq!(
resolved.description.as_deref(),
Some("Database connection string")
);
assert_eq!(resolved.default.as_deref(), Some("sqlite:///dev.db"));
}
#[test]
fn config_validate_requires_description_for_profile_only_secret() {
let config = config_with(
"proj",
vec![
("default", vec![("API_KEY", secret(Some("API key")))]),
("development", vec![("DATABASE_URL", secret(None))]),
],
);
let err = config.validate().unwrap_err();
assert!(err.to_string().contains("missing description"));
}
#[test]
fn config_validate_rejects_generate_and_default_split_across_profiles() {
let config: Config = toml::from_str(
r#"
[project]
name = "tmp"
revision = "1.0"
[profiles.default]
API_TOKEN = { description = "t", type = "password", generate = true }
[profiles.production]
API_TOKEN = { default = "placeholder" }
"#,
)
.unwrap();
let err = config.validate().unwrap_err().to_string();
assert!(err.contains("Profile 'production'"), "{err}");
assert!(
err.contains("'generate' and 'default' cannot both be set"),
"{err}"
);
}
#[test]
fn config_validate_allows_generate_with_type_inherited_from_default_profile() {
let config: Config = toml::from_str(
r#"
[project]
name = "tmp"
revision = "1.0"
[profiles.default]
TOKEN = { description = "t", type = "password" }
[profiles.production]
TOKEN = { generate = true }
"#,
)
.unwrap();
config.validate().unwrap();
}
#[test]
fn config_validate_blames_default_profile_for_empty_inherited_description() {
for _ in 0..8 {
let config = config_with(
"proj",
vec![
("default", vec![("DB", secret(Some("")))]),
("development", vec![("DB", secret(None))]),
],
);
let err = config.validate().unwrap_err().to_string();
assert!(err.contains("Profile 'default'"), "{err}");
assert!(err.contains("description cannot be empty"), "{err}");
}
}
#[test]
fn config_validate_checks_profile_defaults_in_merged_config() {
let config: Config = toml::from_str(
r#"
[project]
name = "tmp"
revision = "1.0"
[profiles.default]
API_TOKEN = { description = "t", type = "password", generate = true }
[profiles.production]
OTHER = { description = "o" }
[profiles.production.defaults]
default = "placeholder"
"#,
)
.unwrap();
let err = config.validate().unwrap_err().to_string();
assert!(err.contains("Profile 'production'"), "{err}");
assert!(
err.contains("'generate' and 'default' cannot both be set"),
"{err}"
);
}
#[test]
fn secret_validate_requires_nonempty_description() {
assert_eq!(secret(None).validate().unwrap_err(), "missing description");
assert_eq!(
secret(Some("")).validate().unwrap_err(),
"description cannot be empty"
);
}
#[test]
fn secret_validate_rejects_required_with_default() {
let s = Secret {
description: Some("d".to_string()),
required: Some(true),
default: Some("v".to_string()),
..Default::default()
};
assert!(
s.validate()
.unwrap_err()
.contains("Required secrets cannot have default")
);
}
#[test]
fn secret_validate_generate_requires_type() {
let s = Secret {
description: Some("d".to_string()),
generate: Some(GenerateConfig::Bool(true)),
..Default::default()
};
assert!(s.validate().unwrap_err().contains("requires 'type'"));
}
#[test]
fn secret_validate_rejects_unknown_type() {
let s = Secret {
description: Some("d".to_string()),
secret_type: Some("banana".to_string()),
..Default::default()
};
assert!(s.validate().unwrap_err().contains("unknown secret type"));
}
#[test]
fn secret_validate_command_type_requires_command() {
let s = Secret {
description: Some("d".to_string()),
secret_type: Some("command".to_string()),
generate: Some(GenerateConfig::Bool(true)),
..Default::default()
};
assert!(
s.validate()
.unwrap_err()
.contains("requires generate = { command")
);
}
fn addr(item: &str, field: Option<&str>) -> NativeAddress {
NativeAddress {
item: item.to_string(),
field: field.map(str::to_string),
..Default::default()
}
}
#[test]
fn secret_validate_accepts_reference() {
let s = Secret {
description: Some("Sentry DSN".to_string()),
reference: Some(addr("shared", Some("SENTRY_DSN"))),
..Default::default()
};
assert!(s.validate().is_ok());
}
#[test]
fn secret_validate_accepts_ref_with_providers() {
let s = Secret {
description: Some("d".to_string()),
reference: Some(addr("db", Some("password"))),
providers: Some(vec!["keyring".to_string()]),
..Default::default()
};
assert!(s.validate().is_ok());
}
#[test]
fn secret_validate_allows_ref_with_generate() {
let s = Secret {
description: Some("d".to_string()),
reference: Some(addr("db", Some("password"))),
secret_type: Some("password".to_string()),
generate: Some(GenerateConfig::Bool(true)),
..Default::default()
};
assert!(s.validate().is_ok());
}
#[test]
fn secret_validate_rejects_empty_ref_coordinates() {
let s = Secret {
description: Some("d".to_string()),
reference: Some(addr("", None)),
..Default::default()
};
assert!(s.validate().unwrap_err().contains("`item` cannot be empty"));
let s = Secret {
description: Some("d".to_string()),
reference: Some(addr("db", Some(""))),
..Default::default()
};
assert!(
s.validate()
.unwrap_err()
.contains("`field` cannot be empty")
);
for blank in [" ", "\t", "\n"] {
let s = Secret {
description: Some("d".to_string()),
reference: Some(addr(blank, None)),
..Default::default()
};
assert!(
s.validate().unwrap_err().contains("`item` cannot be empty"),
"item {blank:?} should be rejected"
);
let s = Secret {
description: Some("d".to_string()),
reference: Some(addr("db", Some(blank))),
..Default::default()
};
assert!(
s.validate()
.unwrap_err()
.contains("`field` cannot be empty"),
"field {blank:?} should be rejected"
);
}
}
#[test]
fn secret_reference_round_trips_as_ref_in_toml() {
let s = Secret {
description: Some("d".to_string()),
reference: Some(NativeAddress {
item: "db".to_string(),
field: Some("password".to_string()),
vault: Some("Production".to_string()),
..Default::default()
}),
..Default::default()
};
let toml = toml::to_string(&s).unwrap();
assert!(toml.contains("item = \"db\""), "{toml}");
let parsed = toml::from_str::<Secret>(&toml).unwrap();
assert_eq!(parsed.reference, s.reference);
let toml = toml::to_string(&Secret {
description: Some("d".to_string()),
..Default::default()
})
.unwrap();
assert!(!toml.contains("ref"));
}
#[test]
fn ref_table_parses_every_coordinate() {
let s: Secret = toml::from_str(
r#"description = "d"
ref = { vault = "Production", item = "db", section = "api", field = "password", version = "3" }"#,
)
.unwrap();
let reference = s.reference.unwrap();
assert_eq!(reference.vault.as_deref(), Some("Production"));
assert_eq!(reference.item, "db");
assert_eq!(reference.section.as_deref(), Some("api"));
assert_eq!(reference.field.as_deref(), Some("password"));
assert_eq!(reference.version.as_deref(), Some("3"));
}
#[test]
fn ref_table_rejects_unknown_keys() {
let err = toml::from_str::<Secret>(
r#"description = "d"
ref = { item = "db", filed = "password" }"#,
)
.unwrap_err();
assert!(err.to_string().contains("unknown field `filed`"), "{err}");
}
#[test]
fn ref_string_gets_translation_hint() {
let err = toml::from_str::<Secret>(
r#"description = "d"
ref = "op://Production/db/password""#,
)
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("ref = { vault = \"Production\", item = \"db\", field = \"password\" }"),
"{msg}"
);
let err = toml::from_str::<Secret>(
r#"description = "d"
ref = "just-a-string""#,
)
.unwrap_err();
assert!(
err.to_string()
.contains("table of native secret coordinates"),
"{err}"
);
}
#[test]
fn ref_wrong_type_reports_expected_shape() {
let err = toml::from_str::<Secret>(
r#"description = "d"
ref = 3"#,
)
.unwrap_err();
assert!(
err.to_string().contains("native secret coordinates"),
"{err}"
);
}
#[test]
fn generate_config_is_enabled() {
assert!(!GenerateConfig::Bool(false).is_enabled());
assert!(GenerateConfig::Bool(true).is_enabled());
assert!(GenerateConfig::Options(GenerateOptions::default()).is_enabled());
}
}
#[cfg(test)]
mod provider_alias_tests {
use super::*;
fn parse(providers_toml: &str) -> HashMap<String, ProviderAlias> {
toml::from_str(providers_toml).expect("valid [providers] table")
}
#[test]
fn bare_string_parses_as_uri_without_credentials() {
let map = parse(r#"keyring = "keyring://""#);
assert_eq!(map["keyring"], ProviderAlias::from("keyring://"));
assert!(map["keyring"].credentials.is_empty());
}
#[test]
fn table_with_credentials_parses_uri_and_credentials() {
let map =
parse(r#"bws = { uri = "bws://proj", credentials = { access_token = "keyring" } }"#);
let alias = &map["bws"];
assert_eq!(alias.uri, "bws://proj");
let source = alias
.credentials
.get("access_token")
.expect("credentials carries the semantic name");
assert_eq!(source, &CredentialSource::from("keyring"));
}
#[test]
fn credential_source_with_ref_parses_provider_and_coordinates() {
let map = parse(
r#"vault = { uri = "vault://kv", credentials = { role_id = { provider = "onepassword", ref = { vault = "Infra", item = "approle", field = "role_id" } } } }"#,
);
let source = map["vault"].credentials["role_id"].clone();
assert_eq!(source.provider, "onepassword");
let reference = source.reference.expect("ref present");
assert_eq!(reference.vault.as_deref(), Some("Infra"));
assert_eq!(reference.item, "approle");
assert_eq!(reference.field.as_deref(), Some("role_id"));
}
#[test]
fn credential_source_round_trips() {
let bare = CredentialSource::from("keyring");
let with_ref = CredentialSource {
provider: "onepassword".to_string(),
reference: Some(NativeAddress {
item: "approle".to_string(),
field: Some("role_id".to_string()),
..Default::default()
}),
};
for source in [bare, with_ref] {
let alias = ProviderAlias {
uri: "vault://kv".to_string(),
credentials: HashMap::from([("role_id".to_string(), source.clone())]),
..Default::default()
};
let map = HashMap::from([("vault".to_string(), alias.clone())]);
let serialized = toml::to_string(&map).unwrap();
assert_eq!(parse(&serialized)["vault"], alias);
}
}
#[test]
fn table_without_credentials_is_equivalent_to_bare_string() {
let map = parse(r#"bws = { uri = "bws://proj" }"#);
assert_eq!(map["bws"], ProviderAlias::from("bws://proj"));
}
#[test]
fn empty_credentials_table_is_equivalent_to_no_credentials() {
let map = parse(r#"keyring = { uri = "keyring://", credentials = {} }"#);
assert_eq!(map["keyring"], ProviderAlias::from("keyring://"));
}
#[test]
fn cached_alias_parses_and_round_trips() {
let map = parse(
r#"myprovider = { fallback = ["azure", "env"], cache = { provider = "local", max_age = "8h" } }"#,
);
let alias = &map["myprovider"];
assert!(alias.is_cached());
assert_eq!(alias.fallback, ["azure", "env"]);
assert_eq!(
alias.cache.as_ref(),
Some(&ProviderCache::new("local", "8h").unwrap())
);
assert_eq!(alias.cache.as_ref().unwrap().max_age_secs(), 8 * 60 * 60);
let serialized = toml::to_string(&map).unwrap();
assert_eq!(parse(&serialized), map);
}
#[test]
fn cache_duration_supports_compound_values() {
assert_eq!(parse_cache_max_age("1h30m"), Ok(5_400));
assert_eq!(parse_cache_max_age("2d"), Ok(172_800));
}
#[test]
fn malformed_cached_aliases_are_rejected_precisely() {
for (toml, expected) in [
(
r#"p = { fallback = [], cache = { provider = "local", max_age = "1h" } }"#,
"at least one",
),
(
r#"p = { fallback = ["source"], cache = { provider = "", max_age = "1h" } }"#,
"cache.provider",
),
(
r#"p = { fallback = ["source"], cache = { provider = "local", max_age = "3600" } }"#,
"needs a unit",
),
(
r#"p = { uri = "env://", fallback = ["source"], cache = { provider = "local", max_age = "1h" } }"#,
"either",
),
(
r#"p = { fallback = ["source"], cache = { provider = "local", max_age = "1h" }, credentials = { token = "env" } }"#,
"cannot declare credentials",
),
] {
let error = toml::from_str::<HashMap<String, ProviderAlias>>(toml).unwrap_err();
assert!(
error.to_string().contains(expected),
"expected {expected:?} in {error}"
);
}
}
#[test]
fn unknown_table_field_is_rejected() {
let err = toml::from_str::<HashMap<String, ProviderAlias>>(
r#"bws = { uri = "bws://proj", oops = "x" }"#,
)
.unwrap_err();
assert!(
err.to_string().contains("oops") || err.to_string().contains("unknown"),
"error should point at the unknown field, got: {err}"
);
}
#[test]
fn environment_shaped_credential_field_is_rejected() {
let error = toml::from_str::<HashMap<String, ProviderAlias>>(
r#"bws = { uri = "bws://proj", env = { BWS_ACCESS_TOKEN = "keyring" } }"#,
)
.unwrap_err();
assert!(error.to_string().contains("env"), "{error}");
}
#[test]
fn credential_less_alias_round_trips_as_a_bare_string() {
let alias = ProviderAlias::from("keyring://");
let map = HashMap::from([("keyring".to_string(), alias.clone())]);
let serialized = toml::to_string(&map).unwrap();
assert_eq!(serialized.trim(), r#"keyring = "keyring://""#);
assert_eq!(parse(&serialized)["keyring"], alias);
}
#[test]
fn alias_with_credentials_round_trips_through_toml() {
let alias = ProviderAlias {
uri: "bws://proj".to_string(),
credentials: HashMap::from([(
"access_token".to_string(),
CredentialSource::from("keyring"),
)]),
..Default::default()
};
let map = HashMap::from([("bws".to_string(), alias.clone())]);
let serialized = toml::to_string(&map).unwrap();
assert_eq!(parse(&serialized)["bws"], alias);
}
#[test]
fn config_providers_accepts_both_forms_end_to_end() {
let config: Config = toml::from_str(
r#"
[project]
name = "app"
revision = "1.0"
[providers]
keyring = "keyring://"
bws = { uri = "bws://proj", credentials = { access_token = "keyring" } }
[profiles.default]
API_KEY = { description = "key", required = true }
"#,
)
.unwrap();
let providers = config.providers.expect("[providers] present");
assert_eq!(providers["keyring"], ProviderAlias::from("keyring://"));
assert_eq!(providers["bws"].uri, "bws://proj");
assert!(providers["bws"].credentials.contains_key("access_token"));
}
}
#[cfg(test)]
mod scope_tests {
use super::*;
fn parse(toml: &str) -> Result<Config, ParseError> {
Config::parse_document(toml)
}
const WITH_SCOPES: &str = r#"
[project]
name = "app"
revision = "1.0"
[profiles.default]
DATABASE_URL = { description = "DB", required = true }
API_KEY = { description = "API key", required = true }
QUEUE_TOKEN = { description = "Queue token", required = true }
[scopes.api]
secrets = ["DATABASE_URL", "API_KEY"]
[scopes.worker]
secrets = ["DATABASE_URL", "QUEUE_TOKEN"]
"#;
#[test]
fn scopes_parse_as_named_membership_lists() {
let config = parse(WITH_SCOPES).unwrap();
let scopes = config.scopes.as_ref().expect("[scopes] present");
assert_eq!(scopes["api"].secrets, vec!["DATABASE_URL", "API_KEY"]);
assert_eq!(
scopes["worker"].secrets,
vec!["DATABASE_URL", "QUEUE_TOKEN"]
);
}
#[test]
fn scopes_validate_against_the_union_of_all_profiles() {
let config = parse(
r#"
[project]
name = "app"
revision = "1.0"
[profiles.default]
DATABASE_URL = { description = "DB", required = true }
[profiles.production]
SENTRY_DSN = { description = "Sentry", required = true }
[scopes.observability]
secrets = ["SENTRY_DSN"]
"#,
)
.unwrap();
assert!(config.validate().is_ok());
}
#[test]
fn scope_referencing_an_undeclared_secret_is_rejected() {
let err = parse(
r#"
[project]
name = "app"
revision = "1.0"
[profiles.default]
DATABASE_URL = { description = "DB", required = true }
[scopes.api]
secrets = ["DATABASE_URL", "TYPO_KEY"]
"#,
)
.unwrap()
.validate()
.expect_err("undeclared secret in a scope is a config error");
let ParseError::Validation(msg) = err else {
panic!("expected a validation error, got {err:?}");
};
assert!(msg.contains("api"), "names the offending scope: {msg}");
assert!(
msg.contains("TYPO_KEY"),
"names the undeclared secret: {msg}"
);
}
#[test]
fn scope_with_no_secrets_is_rejected() {
let err = parse(
r#"
[project]
name = "app"
revision = "1.0"
[profiles.default]
DATABASE_URL = { description = "DB", required = true }
[scopes.api]
secrets = []
"#,
)
.unwrap()
.validate()
.expect_err("an empty scope is a config error");
let ParseError::Validation(msg) = err else {
panic!("expected a validation error, got {err:?}");
};
assert!(msg.contains("api"), "names the offending scope: {msg}");
assert!(
msg.contains("at least one"),
"explains the requirement: {msg}"
);
}
#[test]
fn scope_listing_a_secret_twice_is_rejected() {
let err = parse(
r#"
[project]
name = "app"
revision = "1.0"
[profiles.default]
DATABASE_URL = { description = "DB", required = true }
API_KEY = { description = "API key", required = true }
[scopes.api]
secrets = ["DATABASE_URL", "API_KEY", "DATABASE_URL"]
"#,
)
.unwrap()
.validate()
.expect_err("a repeated member is a config error");
let ParseError::Validation(msg) = err else {
panic!("expected a validation error, got {err:?}");
};
assert!(
msg.contains("api") && msg.contains("DATABASE_URL"),
"names the scope and the repeated secret: {msg}"
);
}
#[test]
fn scope_listing_a_blank_secret_name_is_rejected() {
let err = parse(
r#"
[project]
name = "app"
revision = "1.0"
[profiles.default]
DATABASE_URL = { description = "DB", required = true }
[scopes.api]
secrets = ["DATABASE_URL", " "]
"#,
)
.unwrap()
.validate()
.expect_err("a blank member is a config error");
let ParseError::Validation(msg) = err else {
panic!("expected a validation error, got {err:?}");
};
assert!(msg.contains("api"), "names the offending scope: {msg}");
}
#[test]
fn blank_scope_name_is_rejected() {
let err = parse(
r#"
[project]
name = "app"
revision = "1.0"
[profiles.default]
DATABASE_URL = { description = "DB", required = true }
[scopes.""]
secrets = ["DATABASE_URL"]
"#,
)
.unwrap()
.validate()
.expect_err("a blank scope name is a config error");
assert!(matches!(err, ParseError::Validation(_)));
}
#[test]
fn valid_scopes_pass_validation() {
assert!(parse(WITH_SCOPES).unwrap().validate().is_ok());
}
#[test]
fn a_manifest_without_scopes_stays_valid_and_scope_free() {
let config = parse(
r#"
[project]
name = "app"
revision = "1.0"
[profiles.default]
DATABASE_URL = { description = "DB", required = true }
"#,
)
.unwrap();
assert!(config.scopes.is_none());
assert!(config.validate().is_ok());
}
#[test]
fn later_documents_merge_scopes_like_providers() {
let mut base = parse(WITH_SCOPES).unwrap();
let overlay = parse(
r#"
[project]
name = "app"
revision = "1.0"
[profiles.default]
DATABASE_URL = { description = "DB", required = true }
[scopes.api]
secrets = ["DATABASE_URL"]
[scopes.migration]
secrets = ["DATABASE_URL"]
"#,
)
.unwrap();
base.overlay_with(overlay);
let scopes = base.scopes.expect("scopes present after overlay");
assert_eq!(scopes["api"].secrets, vec!["DATABASE_URL"]);
assert_eq!(
scopes["worker"].secrets,
vec!["DATABASE_URL", "QUEUE_TOKEN"]
);
assert_eq!(scopes["migration"].secrets, vec!["DATABASE_URL"]);
}
}