use crate::manifest::CompiledManifest;
use serde::{Deserialize, Serialize};
use std::collections::{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, Default)]
pub struct ProviderAlias {
pub uri: String,
pub credentials: HashMap<String, CredentialSource>,
}
impl ProviderAlias {
pub fn from_uri(uri: impl Into<String>) -> Self {
Self {
uri: uri.into(),
credentials: HashMap::new(),
}
}
}
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 {
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 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 or a { uri, credentials } 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 {
uri: String,
#[serde(default)]
credentials: Option<HashMap<String, CredentialSource>>,
}
let table = Table::deserialize(serde::de::value::MapAccessDeserializer::new(map))?;
Ok(ProviderAlias {
uri: table.uri,
credentials: table.credentials.unwrap_or_default(),
})
}
}
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>>,
}
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)?;
}
Ok(compiled)
}
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);
}
}
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
))
})?;
}
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 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"))
)
}
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, Default, Serialize, Deserialize)]
pub struct Secret {
pub description: Option<String>,
#[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>>,
#[serde(rename = "ref", skip_serializing_if = "Option::is_none")]
pub reference: Option<NativeAddress>,
#[serde(skip_serializing_if = "Option::is_none")]
pub as_path: Option<bool>,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub secret_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub generate: Option<GenerateConfig>,
}
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 validate_semantics(&self) -> Result<(), String> {
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))
}
Some(Secret {
description: inherit(current, default, |s| s.description.clone()),
required: inherit(current, default, |s| s.required)
.or(defaults.and_then(|d| d.required)),
default: inherit(current, default, |s| s.default.clone())
.or_else(|| defaults.and_then(|d| d.default.clone())),
providers: inherit(current, default, |s| s.providers.clone())
.or_else(|| 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()),
})
}
}
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,
};
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,
}
}
#[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_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 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())]),
};
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 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"),
)]),
};
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"));
}
}