monosecret 0.3.1

Declarative secrets, every environment, any provider
Documentation
//! Validation results for secret checking

use std::collections::HashMap;
use std::fmt;

use secrecy::SecretString;
use serde::Deserialize;
use serde::Serialize;
use tempfile::NamedTempFile;

use crate::config::Resolved;
use crate::report::ResolutionReport;
use crate::report::SecretResolution;

/// Container for validated secrets with metadata
///
/// This struct contains the validated secrets along with information about
/// which secrets are present, missing, or using default values.
pub struct ValidatedSecrets {
	/// Resolved secrets with provider and profile information
	pub resolved: Resolved<HashMap<String, SecretString>>,
	/// List of optional secrets that are missing
	pub missing_optional: Vec<String>,
	/// List of secrets using their default values (name, `default_value`)
	pub with_defaults: Vec<(String, String)>,
	/// Value-free per-secret resolution provenance (which provider answered,
	/// generated, defaulted, `as_path`). Drives `check --json`/`--explain`.
	pub resolution: Vec<SecretResolution>,
	/// Temporary files for secrets with as_path=true.
	/// These are kept alive for the lifetime of ValidatedSecrets and automatically
	/// cleaned up when dropped.
	#[doc(hidden)]
	pub(crate) temp_files: Vec<NamedTempFile>,
}

impl ValidatedSecrets {
	/// Convert this validation result into a typed resolved value.
	///
	/// This is used by code generated by `monosecret-derive`. It transfers the
	/// temporary-file owners for `as_path` secrets without retaining the
	/// untyped secret map or the rest of the validation metadata.
	#[doc(hidden)]
	pub fn into_resolved<T>(self, secrets: T) -> Resolved<T> {
		let Self {
			resolved,
			temp_files,
			..
		} = self;

		resolved
			.replace_secrets(secrets)
			.with_temp_files(temp_files)
	}

	/// Build the value-free [`ResolutionReport`] for this successful resolution.
	pub fn report(&self) -> ResolutionReport {
		ResolutionReport::new(
			self.resolved.provider.clone(),
			self.resolved.profile.clone(),
			self.resolution.clone(),
		)
	}

	/// Persist all temporary files, preventing automatic cleanup.
	///
	/// This method consumes the temporary file handles and persists them,
	/// so they won't be automatically deleted when this struct is dropped.
	/// This is useful when you want the temporary files to outlive the
	/// `ValidatedSecrets` instance, such as in CLI commands.
	///
	/// # Returns
	///
	/// A vector of paths to the persisted files
	///
	/// # Errors
	///
	/// Returns an error if any file cannot be persisted
	pub fn keep_temp_files(&mut self) -> Result<Vec<std::path::PathBuf>, std::io::Error> {
		let mut paths = Vec::new();
		let temp_files = std::mem::take(&mut self.temp_files);

		for temp_file in temp_files {
			let temp_path = temp_file.into_temp_path();
			let path = temp_path.keep().map_err(|e| {
				std::io::Error::other(format!("Failed to persist temporary file: {e}"))
			})?;
			paths.push(path);
		}

		Ok(paths)
	}
}

/// The kind of cross-secret presence constraint that failed.
///
/// Available since Monosecret 0.17.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConstraintKind {
	/// None of a group whose rule requires at least one member resolved.
	AtLeastOne,
	/// Zero or multiple members of a group whose rule requires exactly one
	/// member resolved.
	ExactlyOne,
}

/// A failed cross-secret presence constraint.
///
/// `secrets` is the configured group and `present` is the subset that resolved.
/// Values are never included.
///
/// Available since Monosecret 0.17.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConstraintViolation {
	/// Which presence rule failed.
	pub kind: ConstraintKind,
	/// The group name declared by its member secrets.
	pub group: String,
	/// All secret names in the configured group.
	pub secrets: Vec<String>,
	/// Group members that resolved.
	pub present: Vec<String>,
}

impl fmt::Display for ConstraintViolation {
	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
		match self.kind {
			ConstraintKind::AtLeastOne => {
				write!(
					f,
					"at least one secret in group '{}' must be provided ({})",
					self.group,
					self.secrets.join(", ")
				)
			}
			ConstraintKind::ExactlyOne if self.present.is_empty() => {
				write!(
					f,
					"exactly one secret in group '{}' must be provided ({})",
					self.group,
					self.secrets.join(", ")
				)
			}
			ConstraintKind::ExactlyOne => {
				write!(
					f,
					"exactly one secret in group '{}' must be provided ({}); found {}",
					self.group,
					self.secrets.join(", "),
					self.present.join(", ")
				)
			}
		}
	}
}

/// Container for validation errors
///
/// This struct contains all the validation errors that occurred when
/// validating secrets, including missing required secrets and other issues.
#[derive(Debug, Clone)]
pub struct ValidationErrors {
	/// List of required secrets that are missing
	pub missing_required: Vec<String>,
	/// List of optional secrets that are missing
	pub missing_optional: Vec<String>,
	/// List of secrets using their default values (name, `default_value`)
	pub with_defaults: Vec<(String, String)>,
	/// The provider name that was used
	pub provider: String,
	/// The profile that was used
	pub profile: String,
	/// Value-free per-secret resolution provenance, including the missing
	/// required secrets that caused this error. Empty unless populated by the
	/// resolver; see [`ValidationErrors::report`].
	pub resolution: Vec<SecretResolution>,
	/// Cross-secret presence constraints that failed.
	///
	/// Available since Monosecret 0.17.
	pub constraint_violations: Vec<ConstraintViolation>,
}

impl ValidationErrors {
	/// Create a new `ValidationErrors` instance
	pub fn new(
		missing_required: Vec<String>,
		missing_optional: Vec<String>,
		with_defaults: Vec<(String, String)>,
		provider: String,
		profile: String,
	) -> Self {
		Self {
			missing_required,
			missing_optional,
			with_defaults,
			provider,
			profile,
			resolution: Vec::new(),
			constraint_violations: Vec::new(),
		}
	}

	/// Check if there are any critical errors (missing required secrets)
	pub fn has_errors(&self) -> bool {
		!self.missing_required.is_empty() || !self.constraint_violations.is_empty()
	}

	/// Build the value-free [`ResolutionReport`] for this failed resolution.
	/// The report still describes every declared secret, including the ones
	/// that resolved, so consumers see the full picture, not just the gaps.
	pub fn report(&self) -> ResolutionReport {
		ResolutionReport::new(
			self.provider.clone(),
			self.profile.clone(),
			self.resolution.clone(),
		)
		.with_constraint_violations(self.constraint_violations.clone())
	}
}

impl fmt::Display for ValidationErrors {
	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
		if !self.missing_required.is_empty() {
			write!(
				f,
				"Missing required secrets: {}",
				self.missing_required.join(", ")
			)?;
		}
		if !self.constraint_violations.is_empty() {
			if !self.missing_required.is_empty() {
				write!(f, "; ")?;
			}
			let messages: Vec<String> = self
				.constraint_violations
				.iter()
				.map(ToString::to_string)
				.collect();
			write!(f, "Secret constraints failed: {}", messages.join("; "))?;
		}
		Ok(())
	}
}

impl std::error::Error for ValidationErrors {}

#[cfg(test)]
mod tests {
	use super::*;

	fn errors(missing_required: Vec<&str>) -> ValidationErrors {
		ValidationErrors::new(
			missing_required.into_iter().map(String::from).collect(),
			vec![],
			vec![],
			"keyring".to_string(),
			"default".to_string(),
		)
	}

	#[test]
	fn has_errors_true_only_when_required_missing() {
		assert!(errors(vec!["A", "B"]).has_errors());
		assert!(!errors(vec![]).has_errors());

		// Missing optional / defaults alone are not errors.
		let only_optional = ValidationErrors::new(
			vec![],
			vec!["OPT".to_string()],
			vec![("X".to_string(), "v".to_string())],
			"keyring".to_string(),
			"default".to_string(),
		);
		assert!(!only_optional.has_errors());
	}

	#[test]
	fn display_lists_missing_required_or_is_empty() {
		assert_eq!(
			errors(vec!["A", "B"]).to_string(),
			"Missing required secrets: A, B"
		);
		assert_eq!(errors(vec![]).to_string(), "");
	}
}