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, Serialize, Deserialize)]
pub struct Config {
pub project: Project,
pub profiles: HashMap<String, Profile>,
}
impl Config {
pub fn validate(&self) -> Result<(), 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(),
));
}
for (profile_name, profile) in &self.profiles {
profile.validate().map_err(|e| {
ParseError::Validation(format!("Profile '{}': {}", profile_name, e))
})?;
}
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)
}
pub fn merge_with(&mut self, other: Config) {
for (profile_name, profile_config) in other.profiles {
match self.profiles.get_mut(&profile_name) {
Some(existing_profile) => {
existing_profile.merge_with(profile_config);
}
None => {
self.profiles.insert(profile_name, profile_config);
}
}
}
}
fn from_path_with_visited(
path: &Path,
visited: &mut HashSet<PathBuf>,
) -> Result<Self, 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 !visited.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(path)?;
Self::from_str_with_visited(&content, Some(path), visited)
}
fn from_str_with_visited(
content: &str,
base_path: Option<&Path>,
visited: &mut HashSet<PathBuf>,
) -> Result<Self, ParseError> {
let mut config: Config = toml::from_str(content)?;
if config.project.revision != "1.0" {
return Err(ParseError::UnsupportedRevision(config.project.revision));
}
if let Some(extends_paths) = config.project.extends.clone()
&& let Some(base) = base_path
{
let base_dir = base.parent().unwrap_or(Path::new("."));
config = Self::merge_extended_configs(config, &extends_paths, base_dir, visited)?;
}
Ok(config)
}
fn merge_extended_configs(
mut base_config: Config,
extends_paths: &[String],
base_dir: &Path,
visited: &mut HashSet<PathBuf>,
) -> Result<Config, ParseError> {
for extend_path in extends_paths {
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(),
));
}
let extended_config = Self::from_path_with_visited(&full_path, visited)?;
base_config.merge_with(extended_config);
}
Ok(base_config)
}
}
impl FromStr for Config {
type Err = ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut visited = HashSet::new();
Self::from_str_with_visited(s, None, &mut visited)
}
}
impl TryFrom<&Path> for Config {
type Error = ParseError;
fn try_from(path: &Path) -> Result<Self, Self::Error> {
let mut visited = HashSet::new();
Self::from_path_with_visited(path, &mut visited)
}
}
#[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>>,
}
#[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 Profile {
pub fn new() -> Self {
Self {
defaults: None,
secrets: HashMap::new(),
}
}
pub fn validate(&self) -> Result<(), String> {
if self.secrets.is_empty() {
return Err("Profile must define at least one secret".into());
}
for (name, secret) in &self.secrets {
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()
.map_err(|e| format!("Secret '{}': {}", name, e))?;
}
Ok(())
}
pub fn merge_with(&mut self, other: Profile) {
for (secret_name, secret_config) in other.secrets {
self.secrets.entry(secret_name).or_insert(secret_config);
}
}
pub fn iter(&self) -> hash_map::Iter<'_, String, Secret> {
self.secrets.iter()
}
}
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, 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(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> {
if let Some(desc) = &self.description {
if desc.is_empty() {
return Err("description cannot be empty".into());
}
} else {
return Err("missing description".into());
}
if self.required == Some(true) && self.default.is_some() {
return Err("Required secrets cannot have default values".into());
}
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.generate.is_none() || self.generate.as_ref().is_some_and(|g| !g.is_enabled()))
{
match t.as_str() {
"password" | "hex" | "base64" | "uuid" | "command" | "rsa_private_key" => {}
unknown => {
return Err(format!("unknown secret type '{}'", unknown));
}
}
}
Ok(())
}
}
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,
}
#[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, String>>,
}
impl GlobalConfig {
pub fn path() -> Result<PathBuf, io::Error> {
use etcetera::app_strategy::{AppStrategy, AppStrategyArgs, choose_app_strategy};
let strategy = choose_app_strategy(AppStrategyArgs {
top_level_domain: String::new(),
author: String::new(),
app_name: "secretspec".into(),
})
.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)
}
}