1use std::collections::BTreeMap;
15
16use camino::Utf8Path;
17use serde::{Deserialize, Serialize};
18
19use super::context::Ctx;
20use super::report::{Observed, Report, stance};
21use super::steps::STEPS;
22use crate::digest::Digest;
23
24pub const PROOF_PATH: &str = ".release-kit/setup-proof.json";
26
27pub const SCHEMA: &str = "rk.setup-proof/1";
29
30const SCHEMA_FAMILY: &str = "rk.setup-proof/";
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(deny_unknown_fields)]
37pub struct Subject {
38 pub target: BTreeMap<String, String>,
40 pub steps: Vec<SubjectStep>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct SubjectStep {
48 pub name: String,
50 pub proves: String,
52 pub prerequisites: Vec<String>,
54 pub stance: String,
56 #[serde(default, skip_serializing_if = "String::is_empty")]
58 pub reason: String,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(deny_unknown_fields)]
64pub struct ProvenStep {
65 pub name: String,
67 pub state: String,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub limitation: Option<String>,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(deny_unknown_fields)]
79pub struct Proof {
80 pub schema: String,
82 pub rk_version: String,
84 pub verified_at: String,
86 pub subject: Subject,
88 pub subject_digest: String,
90 pub steps: Vec<ProvenStep>,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
96pub enum Standing {
97 Absent,
99 Compatible(Proof),
101 Stale {
103 proof: Proof,
105 differences: Vec<String>,
107 },
108 Invalid {
110 reason: &'static str,
113 detail: String,
115 },
116}
117
118impl Standing {
119 #[must_use]
121 pub const fn word(&self) -> &'static str {
122 match self {
123 Self::Absent => "absent",
124 Self::Compatible(_) => "compatible",
125 Self::Stale { .. } => "stale",
126 Self::Invalid { .. } => "invalid",
127 }
128 }
129}
130
131#[must_use]
133pub fn subject(ctx: &Ctx) -> Subject {
134 let steps = STEPS
135 .iter()
136 .map(|step| {
137 let stance = stance(ctx, step);
138 SubjectStep {
139 name: step.name.to_owned(),
140 proves: step.proves.to_owned(),
141 prerequisites: step.prereqs.iter().map(|&name| name.to_owned()).collect(),
142 stance: stance.word().to_owned(),
143 reason: stance.detail(),
144 }
145 })
146 .collect();
147 Subject {
148 target: ctx.proof_fields(),
149 steps,
150 }
151}
152
153#[must_use]
156pub fn digest(subject: &Subject) -> String {
157 let bytes = serde_json::to_vec(subject).unwrap_or_default();
158 Digest::of(&bytes).to_string()
159}
160
161#[must_use]
171pub fn of(ctx: &Ctx, report: &Report, verified_at: String) -> Option<Proof> {
172 if !report.checkpointable() {
173 return None;
174 }
175 let steps = report
176 .rows
177 .iter()
178 .map(|row| {
179 let (state, limitation) = match &row.observed {
180 None => (row.stance.word().to_owned(), None),
181 Some(Observed::Satisfied { limitation }) => (
182 Observed::Satisfied {
183 limitation: limitation.clone(),
184 }
185 .wire()
186 .to_owned(),
187 limitation.clone(),
188 ),
189 Some(other) => (other.wire().to_owned(), None),
190 };
191 ProvenStep {
192 name: row.name.to_owned(),
193 state,
194 limitation,
195 }
196 })
197 .collect();
198 let subject = subject(ctx);
199 Some(Proof {
200 schema: SCHEMA.to_owned(),
201 rk_version: env!("CARGO_PKG_VERSION").to_owned(),
202 verified_at,
203 subject_digest: digest(&subject),
204 subject,
205 steps,
206 })
207}
208
209#[must_use]
211pub fn render(proof: &Proof) -> String {
212 let mut text = serde_json::to_string_pretty(proof).unwrap_or_default();
213 text.push('\n');
214 text
215}
216
217pub fn write(target: &Utf8Path, proof: &Proof) -> std::io::Result<()> {
224 let path = target.join(PROOF_PATH);
225 if let Some(parent) = path.parent() {
226 std::fs::create_dir_all(parent)?;
227 }
228 crate::atomic::write(path.as_std_path(), render(proof).as_bytes())
229}
230
231#[must_use]
235pub fn judge(target: &Utf8Path, current: &Subject) -> Standing {
236 let path = target.join(PROOF_PATH);
237 let text = match std::fs::read_to_string(&path) {
238 Ok(text) => text,
239 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Standing::Absent,
240 Err(err) => {
241 return Standing::Invalid {
242 reason: "unreadable",
243 detail: format!("{PROOF_PATH} is unreadable: {err}"),
244 };
245 }
246 };
247 let proof = match parse(&text) {
248 Ok(proof) => proof,
249 Err((reason, detail)) => return Standing::Invalid { reason, detail },
250 };
251 let differences = differences(&proof.subject, current);
252 if differences.is_empty() {
253 Standing::Compatible(proof)
254 } else {
255 Standing::Stale { proof, differences }
256 }
257}
258
259fn parse(text: &str) -> Result<Proof, (&'static str, String)> {
262 #[derive(Deserialize)]
264 struct Envelope {
265 schema: String,
266 }
267 let malformed = |err: serde_json::Error| {
268 (
269 "malformed",
270 format!("{PROOF_PATH} is not a setup proof: {err}"),
271 )
272 };
273 let envelope: Envelope = serde_json::from_str(text).map_err(malformed)?;
274 if envelope.schema != SCHEMA {
275 let newer = envelope
276 .schema
277 .strip_prefix(SCHEMA_FAMILY)
278 .and_then(|version| version.parse::<u32>().ok())
279 .is_some_and(|version| version > 1);
280 return Err(if newer {
281 (
282 "future-schema",
283 format!(
284 "{PROOF_PATH} is {}, newer than the {SCHEMA} this binary reads; a newer rk reads it",
285 envelope.schema
286 ),
287 )
288 } else {
289 (
290 "unsupported-schema",
291 format!(
292 "{PROOF_PATH} declares {}, and this binary reads {SCHEMA}",
293 envelope.schema
294 ),
295 )
296 });
297 }
298 let proof: Proof = serde_json::from_str(text).map_err(malformed)?;
299 if digest(&proof.subject) != proof.subject_digest {
300 return Err((
301 "digest-mismatch",
302 format!(
303 "{PROOF_PATH} names a subject digest its own subject does not produce, so the record was edited after it was written"
304 ),
305 ));
306 }
307 Ok(proof)
308}
309
310#[must_use]
314pub fn differences(recorded: &Subject, current: &Subject) -> Vec<String> {
315 let mut found = Vec::new();
316 let keys: std::collections::BTreeSet<&String> = recorded
317 .target
318 .keys()
319 .chain(current.target.keys())
320 .collect();
321 for key in keys {
322 if recorded.target.get(key) != current.target.get(key) {
323 found.push(format!("target.{key}"));
324 }
325 }
326 for step in &recorded.steps {
327 match current.steps.iter().find(|now| now.name == step.name) {
328 None => found.push(format!("step {} is no longer in the step table", step.name)),
329 Some(now) => {
330 for (part, changed) in [
331 ("proves", step.proves != now.proves),
332 ("prerequisites", step.prerequisites != now.prerequisites),
333 (
334 "stance",
335 step.stance != now.stance || step.reason != now.reason,
336 ),
337 ] {
338 if changed {
339 found.push(format!("step {} {part}", step.name));
340 }
341 }
342 }
343 }
344 }
345 for now in ¤t.steps {
346 if !recorded.steps.iter().any(|step| step.name == now.name) {
347 found.push(format!("step {} is new", now.name));
348 }
349 }
350 found
351}
352
353#[derive(Debug, Serialize)]
355pub struct StatusDocument {
356 pub schema: &'static str,
358 pub state: &'static str,
360 #[serde(skip_serializing_if = "Option::is_none")]
362 pub checkpoint: Option<Checkpoint>,
363 #[serde(skip_serializing_if = "Vec::is_empty")]
365 pub differences: Vec<String>,
366 #[serde(skip_serializing_if = "Vec::is_empty")]
368 pub steps: Vec<ProvenStep>,
369 #[serde(skip_serializing_if = "Option::is_none")]
371 pub invalid: Option<InvalidProof>,
372}
373
374#[derive(Debug, Serialize)]
376pub struct Checkpoint {
377 pub rk_version: String,
379 pub verified_at: String,
381 pub subject_digest: String,
383}
384
385#[derive(Debug, Serialize)]
387pub struct InvalidProof {
388 pub reason: &'static str,
390 pub detail: String,
392}
393
394impl StatusDocument {
395 #[must_use]
397 pub fn of(standing: &Standing) -> Self {
398 let mut document = Self {
399 schema: "rk.setup-status/1",
400 state: standing.word(),
401 checkpoint: None,
402 differences: Vec::new(),
403 steps: Vec::new(),
404 invalid: None,
405 };
406 let proof = match standing {
407 Standing::Absent => None,
408 Standing::Compatible(proof) => Some(proof),
409 Standing::Stale { proof, differences } => {
410 document.differences.clone_from(differences);
411 Some(proof)
412 }
413 Standing::Invalid { reason, detail } => {
414 document.invalid = Some(InvalidProof {
415 reason,
416 detail: detail.clone(),
417 });
418 None
419 }
420 };
421 if let Some(proof) = proof {
422 document.checkpoint = Some(Checkpoint {
423 rk_version: proof.rk_version.clone(),
424 verified_at: proof.verified_at.clone(),
425 subject_digest: proof.subject_digest.clone(),
426 });
427 document.steps.clone_from(&proof.steps);
428 }
429 document
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436
437 fn sample_subject() -> Subject {
438 Subject {
439 target: BTreeMap::from([
440 ("forge".to_owned(), "github".to_owned()),
441 ("repo".to_owned(), "acme/widget".to_owned()),
442 ]),
443 steps: vec![SubjectStep {
444 name: "default-branch".to_owned(),
445 proves: "the trunk is the default branch".to_owned(),
446 prerequisites: Vec::new(),
447 stance: "applicable".to_owned(),
448 reason: String::new(),
449 }],
450 }
451 }
452
453 fn sample() -> Proof {
454 let subject = sample_subject();
455 Proof {
456 schema: SCHEMA.to_owned(),
457 rk_version: "0.0.0".to_owned(),
458 verified_at: "2026-09-27T00:00:00Z".to_owned(),
459 subject_digest: digest(&subject),
460 subject,
461 steps: vec![
462 ProvenStep {
463 name: "default-branch".to_owned(),
464 state: "satisfied".to_owned(),
465 limitation: None,
466 },
467 ProvenStep {
468 name: "auto-merge".to_owned(),
469 state: "satisfied-with-limitation".to_owned(),
470 limitation: Some("weaker".to_owned()),
471 },
472 ],
473 }
474 }
475
476 #[test]
481 fn the_setup_proof_shape_is_held() {
482 let text = serde_json::to_string(&sample()).expect("serializes");
483 assert_eq!(
484 text,
485 format!(
486 r#"{{"schema":"rk.setup-proof/1","rk_version":"0.0.0","verified_at":"2026-09-27T00:00:00Z","subject":{{"target":{{"forge":"github","repo":"acme/widget"}},"steps":[{{"name":"default-branch","proves":"the trunk is the default branch","prerequisites":[],"stance":"applicable"}}]}},"subject_digest":"{}","steps":[{{"name":"default-branch","state":"satisfied"}},{{"name":"auto-merge","state":"satisfied-with-limitation","limitation":"weaker"}}]}}"#,
487 digest(&sample_subject())
488 )
489 );
490 }
491
492 #[test]
496 fn the_setup_status_shape_is_held() {
497 let absent = serde_json::to_string(&StatusDocument::of(&Standing::Absent)).expect("ok");
498 assert_eq!(absent, r#"{"schema":"rk.setup-status/1","state":"absent"}"#);
499 let stale = serde_json::to_string(&StatusDocument::of(&Standing::Stale {
500 proof: sample(),
501 differences: vec!["target.repo".to_owned()],
502 }))
503 .expect("ok");
504 assert_eq!(
505 stale,
506 format!(
507 r#"{{"schema":"rk.setup-status/1","state":"stale","checkpoint":{{"rk_version":"0.0.0","verified_at":"2026-09-27T00:00:00Z","subject_digest":"{}"}},"differences":["target.repo"],"steps":[{{"name":"default-branch","state":"satisfied"}},{{"name":"auto-merge","state":"satisfied-with-limitation","limitation":"weaker"}}]}}"#,
508 digest(&sample_subject())
509 )
510 );
511 let invalid = serde_json::to_string(&StatusDocument::of(&Standing::Invalid {
512 reason: "malformed",
513 detail: "not json".to_owned(),
514 }))
515 .expect("ok");
516 assert_eq!(
517 invalid,
518 r#"{"schema":"rk.setup-status/1","state":"invalid","invalid":{"reason":"malformed","detail":"not json"}}"#
519 );
520 }
521
522 #[test]
524 fn each_unreadable_proof_names_its_own_reason() {
525 let reason = |text: &str| match parse(text) {
526 Err((reason, _)) => reason,
527 Ok(proof) => panic!("{proof:?}"),
528 };
529 assert_eq!(reason("not json"), "malformed");
530 assert_eq!(reason(r#"{"schema":"rk.setup-proof/1"}"#), "malformed");
531 assert_eq!(reason(r#"{"schema":"rk.setup-proof/2"}"#), "future-schema");
532 assert_eq!(reason(r#"{"schema":"rk.status/12"}"#), "unsupported-schema");
533 let mut edited = sample();
534 edited
535 .subject
536 .target
537 .insert("repo".into(), "acme/other".into());
538 assert_eq!(reason(&render(&edited)), "digest-mismatch");
539 assert_eq!(parse(&render(&sample())), Ok(sample()));
540 }
541
542 #[test]
544 fn a_changed_contract_names_each_field() {
545 let recorded = sample_subject();
546 assert!(differences(&recorded, &recorded).is_empty());
547 let mut current = recorded.clone();
548 current.target.insert("repo".into(), "acme/other".into());
549 current.steps[0].proves = "something else".into();
550 current.steps[0].stance = "excluded".into();
551 assert_eq!(
552 differences(&recorded, ¤t),
553 [
554 "target.repo",
555 "step default-branch proves",
556 "step default-branch stance"
557 ]
558 );
559 current.steps.clear();
560 assert!(
561 differences(&recorded, ¤t)
562 .contains(&"step default-branch is no longer in the step table".to_owned())
563 );
564 }
565}