monosecret 0.3.3

Declarative secrets, every environment, any provider
Documentation
//! The value-carrying resolve payload: the FFI/SDK boundary.
//!
//! Unlike the value-free [`crate::report::ResolutionReport`] (which powers
//! `check --json` and must never expose a value), this payload **does** carry
//! the resolved secret values. It is the single authoritative output that any
//! other-language SDK consumes over the C ABI. Producing it deliberately exposes
//! secrets, so it is only built at an explicit resolve boundary and its bytes
//! must be treated as sensitive by the caller.
//!
//! On a successful resolution `secrets` holds one entry per declared secret
//! that produced a value; `missing_optional` lists optional secrets with no
//! value. When a required secret is missing, resolution is an error: `secrets`
//! is empty and `missing_required` is populated, mirroring the derive crate's
//! `load()` which fails rather than returning partial secrets.
//!
//! The `no_values` request variant (and [`crate::Secrets::resolve_without_values`])
//! produces the same shape with every `value`/`path` set to `None`, and is
//! additionally side-effect-free: it never mints a generated secret and never
//! writes an `as_path` temp file.
//!
//! The shape is versioned via [`RESOLVE_SCHEMA_VERSION`]. The canonical JSON
//! Schema lives at `schema/resolve-response.schema.json`.

use std::collections::BTreeMap;

use serde::Deserialize;
use serde::Serialize;

/// Version of the [`ResolveResponse`] wire format.
pub const RESOLVE_SCHEMA_VERSION: u32 = 2;

/// Where a resolved value came from.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResolvedSource {
	/// Returned by a storage provider.
	Provider,
	/// Freshly minted by the secret's `generate` config.
	Generated,
	/// The manifest's committed `default` value.
	Default,
	/// Derived from other declared secrets using a strict template.
	///
	/// Available since Monosecret 0.16.
	Composed,
}

/// One resolved secret. Exactly one of `value` or `path` is set: `path` when
/// the secret is materialized to a temp file (`as_path`), `value` otherwise.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolvedSecret {
	/// The secret value, when exposed inline.
	#[serde(skip_serializing_if = "Option::is_none")]
	pub value: Option<String>,
	/// Path to the temp file holding the value, when `as_path` is set.
	#[serde(skip_serializing_if = "Option::is_none")]
	pub path: Option<String>,
	/// Whether this secret is exposed as a file path rather than inline.
	pub as_path: bool,
	/// Whether the value came from a provider, a generator, or a default.
	pub source: ResolvedSource,
	/// Credential-free URI of the provider that answered, when `source` is
	/// `provider`.
	#[serde(skip_serializing_if = "Option::is_none")]
	pub source_provider: Option<String>,
}

/// A complete value-carrying resolution result for one profile.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolveResponse {
	/// Wire-format version; see [`RESOLVE_SCHEMA_VERSION`].
	pub schema_version: u32,
	/// Credential-free URI of the provider the resolution reported against.
	/// Empty when no provider was contacted, which happens when a scope's
	/// intersection with the selected profile is empty and there is nothing to
	/// resolve.
	pub provider: String,
	/// The profile that was resolved.
	pub profile: String,
	/// The active secret scope, when resolution was scoped (`--scope`,
	/// `MONOSECRET_SCOPE`, or the SDK builder). `None` — the whole profile
	/// resolved — is omitted from JSON, so unscoped output is unchanged.
	#[serde(default, skip_serializing_if = "Option::is_none")]
	pub scope: Option<String>,
	/// Resolved secrets by name. Empty when a required secret is missing.
	/// `BTreeMap` keeps the JSON object key order deterministic.
	pub secrets: BTreeMap<String, ResolvedSecret>,
	/// Required secrets that were not found anywhere. Non-empty means the
	/// resolution failed; `secrets` is then empty.
	pub missing_required: Vec<String>,
	/// Optional secrets that were not found.
	pub missing_optional: Vec<String>,
}

impl ResolveResponse {
	/// True when no required secret is missing (the resolution succeeded).
	pub fn is_ok(&self) -> bool {
		self.missing_required.is_empty()
	}

	/// Drop every inline value, keeping structure and provenance. Useful for an
	/// inventory/policy consumer that wants the resolve shape without secrets.
	#[must_use]
	pub fn without_values(mut self) -> Self {
		for secret in self.secrets.values_mut() {
			secret.value = None;
			secret.path = None;
		}
		self
	}
}

/// The outcome of resolving one secret by name with
/// [`crate::Secrets::resolve_named`].
///
/// The three variants separate the questions a batch resolve conflates: whether
/// the name exists on the active surface at all, whether it produced a value,
/// and whether its absence is an error. A genuine provider or configuration
/// failure is never one of these variants; it surfaces as `Err` from the call.
///
/// Available since Monosecret 0.19.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NamedResolution {
	/// The name is not declared on the active resolution surface: either absent
	/// from the merged profile, or declared but hidden by the active scope. The
	/// caller asked about a secret this configuration does not offer, which is
	/// usually a bug in the caller rather than a missing value.
	Undeclared,
	/// The secret is declared but produced no value.
	Missing {
		/// Whether the profile declares this secret required, which is what
		/// makes the absence an error for a whole-profile resolve. Reported here
		/// rather than decided: a named resolve returns this variant either way
		/// and leaves the policy to the caller.
		required: bool,
	},
	/// The secret produced a value.
	Resolved(ResolvedSecret),
}

/// Which resolution shape a request asks for.
#[derive(Debug, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
enum RequestMode {
	/// The value-carrying [`ResolveResponse`] (the default).
	#[default]
	Resolve,
	/// The value-free [`crate::report::ResolutionReport`]: per-secret status and
	/// provenance, never a value, and a missing required secret is reported as a
	/// status rather than failing the call. This is the inventory/preflight view
	/// the CLI exposes as `check --json`.
	Report,
}

#[derive(Debug, Default, Deserialize)]
struct JsonRequest {
	#[serde(default)]
	path: Option<String>,
	#[serde(default)]
	provider: Option<String>,
	#[serde(default)]
	profile: Option<String>,
	#[serde(default)]
	scope: Option<String>,
	#[serde(default)]
	reason: Option<String>,
	/// Structured software-integration identity (Monosecret 0.20+). This is
	/// informational context and does not satisfy `require_reason`.
	#[serde(default)]
	caller: Option<crate::CallerContext>,
	#[serde(default)]
	no_values: bool,
	#[serde(default)]
	mode: RequestMode,
	/// Ephemeral `--include` selection (CLI/SDK). A secret outside the union of
	/// `include` and `groups` is neither resolved nor reported missing.
	#[serde(default)]
	include: Vec<String>,
	/// Ephemeral `--group` selection (CLI/SDK). See [`JsonRequest::include`].
	#[serde(default)]
	groups: Vec<String>,
}

fn error_envelope(kind: &str, message: impl Into<String>) -> serde_json::Value {
	serde_json::json!({
		"ok": false,
		"error": { "kind": kind, "message": message.into() },
	})
}

fn ok_envelope(response: impl Serialize) -> serde_json::Value {
	serde_json::json!({ "ok": true, "response": response })
}

fn dispatch(request_json: &str) -> serde_json::Value {
	let request: JsonRequest = match serde_json::from_str(request_json) {
		Ok(request) => request,
		Err(e) => return error_envelope("invalid_request", format!("invalid request JSON: {e}")),
	};

	let loaded = match &request.path {
		Some(path) => crate::Secrets::load_from(std::path::Path::new(path)),
		None => crate::Secrets::load(),
	};
	let mut app = match loaded {
		Ok(app) => app,
		Err(e) => return error_envelope(e.kind(), crate::error::display_error_chain(&e)),
	};

	if let Some(provider) = request.provider {
		app.set_provider(provider);
	}
	if let Some(profile) = request.profile {
		app.set_profile(profile);
	}
	if let Some(scope) = request.scope {
		app.set_scope(scope);
	}
	if let Some(reason) = request.reason {
		app = app.with_reason(reason);
	}
	if let Some(caller) = request.caller {
		app = app.with_caller(caller);
	}

	match request.mode {
		// Value-free report: never fails on a missing required secret, so an
		// inventory/preflight consumer always gets the shape back.
		RequestMode::Report => {
			let report = if request.include.is_empty() && request.groups.is_empty() {
				app.report()
			} else {
				app.report_filtered(&request.include, &request.groups)
			};
			match report {
				Ok(report) => ok_envelope(report),
				Err(e) => error_envelope(e.kind(), crate::error::display_error_chain(&e)),
			}
		}
		// Value-carrying resolve. `no_values` takes the path that never copies a
		// secret value into the response (and persists no temp file).
		RequestMode::Resolve => {
			let resolved = if request.no_values {
				if request.include.is_empty() && request.groups.is_empty() {
					app.resolve_without_values()
				} else {
					app.resolve_without_values_filtered(&request.include, &request.groups)
				}
			} else if request.include.is_empty() && request.groups.is_empty() {
				app.resolve()
			} else {
				app.resolve_filtered(&request.include, &request.groups)
			};
			match resolved {
				Ok(response) => ok_envelope(response),
				Err(e) => error_envelope(e.kind(), crate::error::display_error_chain(&e)),
			}
		}
	}
}

/// Resolve secrets from a JSON request string and return the JSON response
/// envelope: `{"ok": true, "response": <ResolveResponse | ResolutionReport>}` or
/// `{"ok": false, "error": {"kind", "message"}}`.
///
/// This is the shared JSON boundary used by every native binding (the C ABI in
/// `monosecret_ffi` and the napi-rs Node addon), so the envelope contract is
/// defined in exactly one place. The request accepts optional `path`,
/// `provider`, `profile`, `scope`, `reason`, `caller` (Monosecret 0.20+),
/// `no_values`, and `mode`
/// (`"resolve"` by default, or `"report"` for the value-free
/// [`crate::report::ResolutionReport`]).
/// A `resolve` response carries secret values; treat its bytes as sensitive. A
/// `report` response never does.
pub fn resolve_json(request_json: &str) -> String {
	// Catch panics here, at the one place both native boundaries funnel through
	// (the C ABI in `monosecret_ffi` and the napi-rs Node addon). Unwinding across
	// either is undefined behavior, and turning a panic into the same
	// `{"ok":false,"error":...}` envelope every binding already parses means all
	// bindings behave identically — the C ABI no longer needs to be the only one
	// guarding the boundary.
	let envelope =
		std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| dispatch(request_json)))
			.unwrap_or_else(|_| error_envelope("internal", "internal panic during resolve"));

	serde_json::to_string(&envelope).unwrap_or_else(|_| {
        "{\"ok\":false,\"error\":{\"kind\":\"serialize\",\"message\":\"failed to serialize response\"}}".to_string()
    })
}

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

	#[test]
	fn json_caller_context_is_structured_and_separate_from_reason() {
		let request: JsonRequest = serde_json::from_value(serde_json::json!({
			"caller": {
				"name": "git",
				"version": "2.51.0",
				"operation": "credential_get",
				"resource": "github.com"
			}
		}))
		.unwrap();
		assert_eq!(
			request.caller,
			Some(
				crate::CallerContext::new("git")
					.with_version("2.51.0")
					.with_operation("credential_get")
					.with_resource("github.com")
			)
		);
		assert_eq!(request.reason, None);
	}

	#[test]
	fn json_caller_requires_a_name() {
		let response: serde_json::Value = serde_json::from_str(&resolve_json(
			r#"{"caller":{"operation":"credential_get"}}"#,
		))
		.unwrap();
		assert_eq!(response.get("ok"), Some(&serde_json::Value::Bool(false)));
		assert_eq!(
			response
				.get("error")
				.and_then(|error| error.get("kind"))
				.and_then(serde_json::Value::as_str),
			Some("invalid_request")
		);
	}
}