secretspec 0.14.0

Declarative secrets, every environment, any provider
Documentation
//! The resolution report: a value-free, versioned description of how every
//! declared secret resolved.
//!
//! This is the stable, machine-readable contract that surfaces the resolution
//! waterfall the resolver already computes (which provider answered, whether a
//! value was generated, whether a default was applied, whether a required
//! secret is missing) without ever exposing a secret value. It is emitted by
//! `secretspec check --json` and rendered by `secretspec check --explain`.
//!
//! The shape is versioned via [`RESOLUTION_REPORT_SCHEMA_VERSION`] so that
//! out-of-process consumers (other-language SDKs, CI tooling) can refuse a
//! mismatched version rather than silently misparse. The canonical JSON Schema
//! lives at `schema/resolution-report.schema.json` in the repository root.

use serde::{Deserialize, Serialize};

/// Version of the [`ResolutionReport`] wire format.
///
/// Bump this whenever the serialized shape changes in a way that is not purely
/// additive-and-optional, and update `schema/resolution-report.schema.json`.
pub const RESOLUTION_REPORT_SCHEMA_VERSION: u32 = 1;

/// How a single declared secret resolved.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResolutionStatus {
    /// A value was produced (from a provider, a generator, or a default).
    Resolved,
    /// Required by the active profile but not found anywhere.
    MissingRequired,
    /// Optional and not found; resolution still succeeds overall.
    MissingOptional,
}

/// The resolution outcome for one declared secret. Never carries the value.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SecretResolution {
    /// The declared secret name (the `UPPER_SNAKE` key from the manifest).
    pub name: String,
    /// Whether the secret resolved, and if not, whether that is an error.
    pub status: ResolutionStatus,
    /// Whether the active profile marks this secret as required.
    pub required: bool,
    /// Credential-free URI of the provider that actually answered, when the
    /// value came from a provider. `None` when generated, defaulted, or missing.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub source_provider: Option<String>,
    /// Whether the value came from the manifest's committed `default`.
    pub default_applied: bool,
    /// Whether the value was freshly minted by the secret's `generate` config.
    pub generated: bool,
    /// Whether the value is materialized to a temp file and exposed as a path.
    pub as_path: bool,
}

/// A complete, value-free snapshot of one resolution pass over a profile.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolutionReport {
    /// Wire-format version; see [`RESOLUTION_REPORT_SCHEMA_VERSION`].
    pub schema_version: u32,
    /// Credential-free URI of the provider resolution reported against.
    pub provider: String,
    /// The profile that was resolved.
    pub profile: String,
    /// One entry per declared secret, sorted by name for deterministic output.
    pub secrets: Vec<SecretResolution>,
}

impl ResolutionReport {
    /// Build a report from its parts, stamping the current schema version and
    /// sorting entries by name so the output is deterministic (important for
    /// golden conformance vectors).
    pub fn new(provider: String, profile: String, mut secrets: Vec<SecretResolution>) -> Self {
        secrets.sort_by(|a, b| a.name.cmp(&b.name));
        Self {
            schema_version: RESOLUTION_REPORT_SCHEMA_VERSION,
            provider,
            profile,
            secrets,
        }
    }

    /// True when no required secret is missing (i.e. resolution would succeed).
    pub fn all_required_present(&self) -> bool {
        !self
            .secrets
            .iter()
            .any(|s| s.status == ResolutionStatus::MissingRequired)
    }

    /// Render a human-readable resolution trace. Value-free, word-based status
    /// (no reliance on color) for accessibility.
    pub fn to_explain_string(&self) -> String {
        let mut out = String::new();
        out.push_str(&format!("profile:  {}\n", self.profile));
        out.push_str(&format!("provider: {}\n", self.provider));

        let width = self.secrets.iter().map(|s| s.name.len()).max().unwrap_or(0);

        for s in &self.secrets {
            let detail = match s.status {
                ResolutionStatus::Resolved => {
                    if s.generated {
                        "ok        generated".to_string()
                    } else if s.default_applied {
                        "ok        default value".to_string()
                    } else if let Some(uri) = &s.source_provider {
                        format!("ok        source {}", uri)
                    } else {
                        "ok".to_string()
                    }
                }
                ResolutionStatus::MissingRequired => "MISSING   required".to_string(),
                ResolutionStatus::MissingOptional => "missing   optional".to_string(),
            };
            let path = if s.as_path { "  (as path)" } else { "" };
            out.push_str(&format!(
                "  {:width$}  {}{}\n",
                s.name,
                detail,
                path,
                width = width
            ));
        }
        out
    }
}

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

    fn sample() -> ResolutionReport {
        // Deliberately unsorted input to exercise the sort in `new`.
        ResolutionReport::new(
            "keyring://".to_string(),
            "production".to_string(),
            vec![
                SecretResolution {
                    name: "STRIPE_KEY".to_string(),
                    status: ResolutionStatus::MissingRequired,
                    required: true,
                    source_provider: None,
                    default_applied: false,
                    generated: false,
                    as_path: false,
                },
                SecretResolution {
                    name: "DATABASE_URL".to_string(),
                    status: ResolutionStatus::Resolved,
                    required: true,
                    source_provider: Some("keyring://".to_string()),
                    default_applied: false,
                    generated: false,
                    as_path: false,
                },
                SecretResolution {
                    name: "JWT_SECRET".to_string(),
                    status: ResolutionStatus::Resolved,
                    required: true,
                    source_provider: None,
                    default_applied: false,
                    generated: true,
                    as_path: false,
                },
                SecretResolution {
                    name: "LOG_LEVEL".to_string(),
                    status: ResolutionStatus::Resolved,
                    required: false,
                    source_provider: None,
                    default_applied: true,
                    generated: false,
                    as_path: false,
                },
                SecretResolution {
                    name: "SENTRY_DSN".to_string(),
                    status: ResolutionStatus::MissingOptional,
                    required: false,
                    source_provider: None,
                    default_applied: false,
                    generated: false,
                    as_path: false,
                },
            ],
        )
    }

    #[test]
    fn entries_are_sorted_by_name() {
        let report = sample();
        let names: Vec<&str> = report.secrets.iter().map(|s| s.name.as_str()).collect();
        assert_eq!(
            names,
            vec![
                "DATABASE_URL",
                "JWT_SECRET",
                "LOG_LEVEL",
                "SENTRY_DSN",
                "STRIPE_KEY"
            ]
        );
    }

    #[test]
    fn all_required_present_tracks_missing_required() {
        assert!(!sample().all_required_present());
        let mut report = sample();
        report
            .secrets
            .retain(|s| s.status != ResolutionStatus::MissingRequired);
        assert!(report.all_required_present());
    }

    /// Locks the wire format. The golden file is the contract other-language
    /// SDKs and CI consumers parse; any change here is a deliberate contract
    /// change that must bump `RESOLUTION_REPORT_SCHEMA_VERSION` and the schema.
    #[test]
    fn serializes_to_golden_wire_format() {
        let golden = include_str!("../tests/fixtures/resolution_report.golden.json");
        let actual = serde_json::to_string_pretty(&sample()).unwrap();
        // Normalize line endings: on Windows the golden file is checked out with
        // CRLF, while serde always emits LF.
        assert_eq!(
            actual.replace("\r\n", "\n").trim(),
            golden.replace("\r\n", "\n").trim()
        );
    }

    #[test]
    fn round_trips_through_json() {
        let report = sample();
        let json = serde_json::to_string(&report).unwrap();
        let back: ResolutionReport = serde_json::from_str(&json).unwrap();
        assert_eq!(report, back);
    }
}