1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
//! 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 crate::validation::ConstraintViolation;
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 secret is *declared* required in the active profile: `true`
/// when it is marked `required = true` or has neither a `default` nor a
/// `generate`. A secret carrying a committed `default`/`generate` is not
/// required (it always resolves), even when written as `required = true` in
/// one profile and overridden with a default in another. Orthogonal to
/// [`status`](Self::status), which reports whether it actually resolved.
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.
///
/// A value-free report mints nothing, so there it means the value *would* be
/// generated by a real resolve. That is reported only when generation is how
/// the value is meant to appear: an optional secret, or a store that never
/// retains a generated value. A required secret backed by a store that keeps
/// what it mints is reported `missing_required` until a pass provisions it.
pub generated: bool,
/// Whether the value was derived from other declared secrets.
/// Internal provenance used by human-readable output and the value-carrying
/// resolve response; omitted from the report v1 wire format.
#[serde(skip)]
pub composed: 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. 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`,
/// `SECRETSPEC_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>,
/// One entry per declared secret, sorted by name for deterministic output.
pub secrets: Vec<SecretResolution>,
/// Cross-secret presence constraints that failed.
///
/// Available since SecretSpec 0.17. Omitted when all constraints pass.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub constraint_violations: Vec<ConstraintViolation>,
}
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,
// Set by `Secrets::report` for a scoped resolution; unscoped stays None.
scope: None,
secrets,
constraint_violations: Vec::new(),
}
}
pub(crate) fn with_constraint_violations(
mut self,
violations: Vec<ConstraintViolation>,
) -> Self {
self.constraint_violations = violations;
self
}
/// True when no required secret is missing (i.e. resolution would succeed).
pub fn all_required_present(&self) -> bool {
self.constraint_violations.is_empty()
&& !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.
///
/// This renders the preflight the CLI exposes as `check --explain`, which
/// always describes a value-free pass: a `generated` entry reads as *will
/// generate*, because nothing was minted to produce this report.
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));
if let Some(scope) = &self.scope {
out.push_str(&format!("scope: {}\n", scope));
}
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 => {
// Same provenance order as `resolve_impl`'s `ResolvedSource`
// mapping; the flags are mutually exclusive.
if s.generated {
"ok will generate".to_string()
} else if s.default_applied {
"ok default value".to_string()
} else if s.composed {
"ok composed".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
));
}
for violation in &self.constraint_violations {
out.push_str(&format!(" CONSTRAINT FAILED {}\n", violation));
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> ResolutionReport {
// Deliberately unsorted input to exercise the sort in `new`.
ResolutionReport::new(
"keyring://".to_string(),
"development".to_string(),
vec![
SecretResolution {
name: "STRIPE_KEY".to_string(),
status: ResolutionStatus::MissingRequired,
required: true,
source_provider: None,
default_applied: false,
generated: false,
composed: 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,
composed: false,
as_path: false,
},
SecretResolution {
name: "JWT_SECRET".to_string(),
status: ResolutionStatus::Resolved,
required: true,
source_provider: None,
default_applied: false,
generated: true,
composed: false,
as_path: false,
},
SecretResolution {
name: "DEV_SESSION_SECRET".to_string(),
status: ResolutionStatus::Resolved,
required: false,
source_provider: None,
default_applied: true,
generated: false,
composed: false,
as_path: false,
},
SecretResolution {
name: "SENTRY_DSN".to_string(),
status: ResolutionStatus::MissingOptional,
required: false,
source_provider: None,
default_applied: false,
generated: false,
composed: 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",
"DEV_SESSION_SECRET",
"JWT_SECRET",
"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());
}
#[test]
fn explain_string_renders_resolution_details() {
assert_eq!(
sample().to_explain_string(),
concat!(
"profile: development\n",
"provider: keyring://\n",
" DATABASE_URL ok source keyring://\n",
" DEV_SESSION_SECRET ok default value\n",
" JWT_SECRET ok will generate\n",
" SENTRY_DSN missing optional\n",
" STRIPE_KEY MISSING required\n",
)
);
}
#[test]
fn explain_string_marks_plain_resolved_secrets_exposed_as_paths() {
let report = ResolutionReport::new(
"env://".to_string(),
"development".to_string(),
vec![SecretResolution {
name: "FILE".to_string(),
status: ResolutionStatus::Resolved,
required: true,
source_provider: None,
default_applied: false,
generated: false,
composed: false,
as_path: true,
}],
);
assert_eq!(
report.to_explain_string(),
"profile: development\nprovider: env://\n FILE ok (as path)\n"
);
}
#[test]
fn explain_string_handles_a_report_without_secrets() {
let report = ResolutionReport::new(
"dotenv://.env".to_string(),
"default".to_string(),
Vec::new(),
);
assert_eq!(
report.to_explain_string(),
"profile: default\nprovider: dotenv://.env\n"
);
}
/// 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);
}
}