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_for(ctx).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
231pub fn offline(target: &camino::Utf8PathBuf) -> Result<Standing, crate::error::RkError> {
241 let ctx = Ctx::offline(target)?;
242 Ok(judge(&ctx.target, &subject(&ctx)))
243}
244
245#[must_use]
249pub fn judge(target: &Utf8Path, current: &Subject) -> Standing {
250 let path = target.join(PROOF_PATH);
251 let text = match std::fs::read_to_string(&path) {
252 Ok(text) => text,
253 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Standing::Absent,
254 Err(err) => {
255 return Standing::Invalid {
256 reason: "unreadable",
257 detail: format!("{PROOF_PATH} is unreadable: {err}"),
258 };
259 }
260 };
261 let proof = match parse(&text) {
262 Ok(proof) => proof,
263 Err((reason, detail)) => return Standing::Invalid { reason, detail },
264 };
265 let differences = differences(&proof.subject, current);
266 if differences.is_empty() {
267 Standing::Compatible(proof)
268 } else {
269 Standing::Stale { proof, differences }
270 }
271}
272
273fn parse(text: &str) -> Result<Proof, (&'static str, String)> {
276 #[derive(Deserialize)]
278 struct Envelope {
279 schema: String,
280 }
281 let malformed = |err: serde_json::Error| {
282 (
283 "malformed",
284 format!("{PROOF_PATH} is not a setup proof: {err}"),
285 )
286 };
287 let envelope: Envelope = serde_json::from_str(text).map_err(malformed)?;
288 if envelope.schema != SCHEMA {
289 let newer = envelope
290 .schema
291 .strip_prefix(SCHEMA_FAMILY)
292 .and_then(|version| version.parse::<u32>().ok())
293 .is_some_and(|version| version > 1);
294 return Err(if newer {
295 (
296 "future-schema",
297 format!(
298 "{PROOF_PATH} is {}, newer than the {SCHEMA} this binary reads; a newer rk reads it",
299 envelope.schema
300 ),
301 )
302 } else {
303 (
304 "unsupported-schema",
305 format!(
306 "{PROOF_PATH} declares {}, and this binary reads {SCHEMA}",
307 envelope.schema
308 ),
309 )
310 });
311 }
312 let proof: Proof = serde_json::from_str(text).map_err(malformed)?;
313 if digest(&proof.subject) != proof.subject_digest {
314 return Err((
315 "digest-mismatch",
316 format!(
317 "{PROOF_PATH} names a subject digest its own subject does not produce, so the record was edited after it was written"
318 ),
319 ));
320 }
321 Ok(proof)
322}
323
324#[must_use]
328pub fn differences(recorded: &Subject, current: &Subject) -> Vec<String> {
329 let mut found = Vec::new();
330 let keys: std::collections::BTreeSet<&String> = recorded
331 .target
332 .keys()
333 .chain(current.target.keys())
334 .collect();
335 for key in keys {
336 if recorded.target.get(key) != current.target.get(key) {
337 found.push(format!("target.{key}"));
338 }
339 }
340 for step in &recorded.steps {
341 match current.steps.iter().find(|now| now.name == step.name) {
342 None => found.push(format!("step {} is no longer in the step table", step.name)),
343 Some(now) => {
344 for (part, changed) in [
345 ("proves", step.proves != now.proves),
346 ("prerequisites", step.prerequisites != now.prerequisites),
347 (
348 "stance",
349 step.stance != now.stance || step.reason != now.reason,
350 ),
351 ] {
352 if changed {
353 found.push(format!("step {} {part}", step.name));
354 }
355 }
356 }
357 }
358 }
359 for now in ¤t.steps {
360 if !recorded.steps.iter().any(|step| step.name == now.name) {
361 found.push(format!("step {} is new", now.name));
362 }
363 }
364 found
365}
366
367#[derive(Debug, Serialize)]
369pub struct StatusDocument {
370 pub schema: &'static str,
372 pub state: &'static str,
374 #[serde(skip_serializing_if = "Option::is_none")]
376 pub checkpoint: Option<Checkpoint>,
377 #[serde(skip_serializing_if = "Vec::is_empty")]
379 pub differences: Vec<String>,
380 #[serde(skip_serializing_if = "Vec::is_empty")]
382 pub steps: Vec<ProvenStep>,
383 #[serde(skip_serializing_if = "Option::is_none")]
385 pub invalid: Option<InvalidProof>,
386}
387
388#[derive(Debug, Serialize)]
390pub struct Checkpoint {
391 pub rk_version: String,
393 pub verified_at: String,
395 pub subject_digest: String,
397}
398
399#[derive(Debug, Serialize)]
401pub struct InvalidProof {
402 pub reason: &'static str,
404 pub detail: String,
406}
407
408impl StatusDocument {
409 #[must_use]
411 pub fn of(standing: &Standing) -> Self {
412 let mut document = Self {
413 schema: "rk.setup-status/1",
414 state: standing.word(),
415 checkpoint: None,
416 differences: Vec::new(),
417 steps: Vec::new(),
418 invalid: None,
419 };
420 let proof = match standing {
421 Standing::Absent => None,
422 Standing::Compatible(proof) => Some(proof),
423 Standing::Stale { proof, differences } => {
424 document.differences.clone_from(differences);
425 Some(proof)
426 }
427 Standing::Invalid { reason, detail } => {
428 document.invalid = Some(InvalidProof {
429 reason,
430 detail: detail.clone(),
431 });
432 None
433 }
434 };
435 if let Some(proof) = proof {
436 document.checkpoint = Some(Checkpoint {
437 rk_version: proof.rk_version.clone(),
438 verified_at: proof.verified_at.clone(),
439 subject_digest: proof.subject_digest.clone(),
440 });
441 document.steps.clone_from(&proof.steps);
442 }
443 document
444 }
445}
446
447#[cfg(test)]
448mod tests {
449 use super::*;
450
451 fn sample_subject() -> Subject {
452 Subject {
453 target: BTreeMap::from([
454 ("forge".to_owned(), "github".to_owned()),
455 ("repo".to_owned(), "acme/widget".to_owned()),
456 ]),
457 steps: vec![SubjectStep {
458 name: "default-branch".to_owned(),
459 proves: "the trunk is the default branch".to_owned(),
460 prerequisites: Vec::new(),
461 stance: "applicable".to_owned(),
462 reason: String::new(),
463 }],
464 }
465 }
466
467 fn sample() -> Proof {
468 let subject = sample_subject();
469 Proof {
470 schema: SCHEMA.to_owned(),
471 rk_version: "0.0.0".to_owned(),
472 verified_at: "2026-09-27T00:00:00Z".to_owned(),
473 subject_digest: digest(&subject),
474 subject,
475 steps: vec![
476 ProvenStep {
477 name: "default-branch".to_owned(),
478 state: "satisfied".to_owned(),
479 limitation: None,
480 },
481 ProvenStep {
482 name: "auto-merge".to_owned(),
483 state: "satisfied-with-limitation".to_owned(),
484 limitation: Some("weaker".to_owned()),
485 },
486 ],
487 }
488 }
489
490 #[test]
495 fn the_setup_proof_shape_is_held() {
496 let text = serde_json::to_string(&sample()).expect("serializes");
497 assert_eq!(
498 text,
499 format!(
500 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"}}]}}"#,
501 digest(&sample_subject())
502 )
503 );
504 }
505
506 #[test]
510 fn the_setup_status_shape_is_held() {
511 let absent = serde_json::to_string(&StatusDocument::of(&Standing::Absent)).expect("ok");
512 assert_eq!(absent, r#"{"schema":"rk.setup-status/1","state":"absent"}"#);
513 let stale = serde_json::to_string(&StatusDocument::of(&Standing::Stale {
514 proof: sample(),
515 differences: vec!["target.repo".to_owned()],
516 }))
517 .expect("ok");
518 assert_eq!(
519 stale,
520 format!(
521 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"}}]}}"#,
522 digest(&sample_subject())
523 )
524 );
525 let invalid = serde_json::to_string(&StatusDocument::of(&Standing::Invalid {
526 reason: "malformed",
527 detail: "not json".to_owned(),
528 }))
529 .expect("ok");
530 assert_eq!(
531 invalid,
532 r#"{"schema":"rk.setup-status/1","state":"invalid","invalid":{"reason":"malformed","detail":"not json"}}"#
533 );
534 }
535
536 #[test]
538 fn each_unreadable_proof_names_its_own_reason() {
539 let reason = |text: &str| match parse(text) {
540 Err((reason, _)) => reason,
541 Ok(proof) => panic!("{proof:?}"),
542 };
543 assert_eq!(reason("not json"), "malformed");
544 assert_eq!(reason(r#"{"schema":"rk.setup-proof/1"}"#), "malformed");
545 assert_eq!(reason(r#"{"schema":"rk.setup-proof/2"}"#), "future-schema");
546 assert_eq!(reason(r#"{"schema":"rk.status/12"}"#), "unsupported-schema");
547 let mut edited = sample();
548 edited
549 .subject
550 .target
551 .insert("repo".into(), "acme/other".into());
552 assert_eq!(reason(&render(&edited)), "digest-mismatch");
553 assert_eq!(parse(&render(&sample())), Ok(sample()));
554 }
555
556 #[test]
558 fn a_changed_contract_names_each_field() {
559 let recorded = sample_subject();
560 assert!(differences(&recorded, &recorded).is_empty());
561 let mut current = recorded.clone();
562 current.target.insert("repo".into(), "acme/other".into());
563 current.steps[0].proves = "something else".into();
564 current.steps[0].stance = "excluded".into();
565 assert_eq!(
566 differences(&recorded, ¤t),
567 [
568 "target.repo",
569 "step default-branch proves",
570 "step default-branch stance"
571 ]
572 );
573 current.steps.clear();
574 assert!(
575 differences(&recorded, ¤t)
576 .contains(&"step default-branch is no longer in the step table".to_owned())
577 );
578 }
579}