1use std::ffi::OsString;
12use std::fs;
13use std::path::PathBuf;
14use std::time::Instant;
15
16use zeroize::Zeroizing;
17
18use crate::cli::setup::{SetupAction, SetupArgs};
19use crate::detect::Forge;
20use crate::diagnostic::{Diagnostic, Reason};
21use crate::digest::Digest;
22use crate::embedded;
23use crate::error::RkError;
24use crate::events::{ChildStream, Event, EventKind};
25use crate::output::Output;
26use crate::setup::app_jwt::{self, AppApi};
27use crate::setup::context::{Ctx, SECRET_VARS};
28use crate::setup::journal::Journal;
29use crate::setup::observe::{self, StepState};
30use crate::setup::process::{self, Exec, Outcome};
31use crate::setup::proof::{self, Standing};
32use crate::setup::report::{Report, Row, Stance, stance};
33use crate::setup::secrets;
34use crate::setup::steps::{Mutates, STEPS, StepSpec, spec};
35
36pub fn run(args: &SetupArgs) -> Result<(), RkError> {
42 match &args.action {
43 Some(SetupAction::Script { name, forge }) => script(name, forge.as_deref()),
44 Some(SetupAction::Check {
45 target,
46 repo,
47 forge,
48 required_check,
49 json,
50 }) => check(
51 Output::new(*json),
52 observing(
53 target,
54 repo.as_deref(),
55 forge.as_deref(),
56 required_check.as_deref(),
57 )?,
58 ),
59 Some(SetupAction::Checkpoint {
60 target,
61 repo,
62 forge,
63 required_check,
64 json,
65 }) => checkpoint(
66 Output::new(*json),
67 observing(
68 target,
69 repo.as_deref(),
70 forge.as_deref(),
71 required_check.as_deref(),
72 )?,
73 ),
74 Some(SetupAction::Status {
77 target,
78 repo,
79 forge,
80 required_check,
81 json,
82 }) => status(
83 Output::new(*json),
84 &Ctx::resolve(
85 target,
86 repo.as_deref(),
87 forge.as_deref(),
88 required_check.as_deref(),
89 )?,
90 ),
91 Some(action @ SetupAction::Step { .. }) => step(action),
92 None if args.list => list(args.forge.as_deref()),
93 None => {
94 let target = args.target.clone().ok_or_else(|| {
95 RkError::Usage("name a --target, or pass --list to see the steps".into())
96 })?;
97 let all: Vec<&StepSpec> = STEPS.iter().collect();
98 let mut ctx = Ctx::resolve(
99 &target,
100 args.repo.as_deref(),
101 args.forge.as_deref(),
102 args.required_check.as_deref(),
103 )?;
104 reject_check_flag_on_gitlab(&ctx)?;
105 if args.apply {
106 require_check_for(&ctx, &all)?;
107 let acted: Vec<&StepSpec> = all
110 .iter()
111 .copied()
112 .filter(|step| !skipped_by_a_full_run(&ctx, step, all.len()))
113 .collect();
114 ctx.require_cli(&acted)?;
115 execute(Output::new(args.json), ctx, &all, "setup")
116 } else {
117 let acted: Vec<&StepSpec> = all
118 .iter()
119 .copied()
120 .filter(|step| !skipped_by_a_full_run(&ctx, step, all.len()))
121 .collect();
122 ctx.require_cli(&acted)?;
123 preview(Output::new(args.json), &ctx, &all)
124 }
125 }
126 }
127}
128
129fn step(action: &SetupAction) -> Result<(), RkError> {
131 let SetupAction::Step {
132 name,
133 target,
134 repo,
135 forge,
136 required_check,
137 apply,
138 json,
139 } = action
140 else {
141 unreachable!("the dispatch routes only a step here");
142 };
143 let selected = spec(name).ok_or_else(|| {
144 RkError::Usage(format!("unknown step '{name}'; rk setup --list names them"))
145 })?;
146 let mut ctx = Ctx::resolve(
147 target,
148 repo.as_deref(),
149 forge.as_deref(),
150 required_check.as_deref(),
151 )?;
152 reject_check_flag_on_gitlab(&ctx)?;
153 if *apply {
154 refuse_an_excluded_step(&ctx, selected)?;
155 require_check_for(&ctx, &[selected])?;
156 ctx.require_cli(&[selected])?;
159 execute(Output::new(*json), ctx, &[selected], "setup step")
160 } else {
161 ctx.require_cli(&[selected])?;
165 preview(Output::new(*json), &ctx, &[selected])
166 }
167}
168
169fn observing(
173 target: &camino::Utf8PathBuf,
174 repo: Option<&str>,
175 forge: Option<&str>,
176 required_check: Option<&str>,
177) -> Result<Ctx, RkError> {
178 let mut ctx = Ctx::resolve(target, repo, forge, required_check)?;
179 reject_check_flag_on_gitlab(&ctx)?;
180 let all: Vec<&StepSpec> = STEPS.iter().collect();
181 ctx.require_cli(&all)?;
182 Ok(ctx)
183}
184
185fn skipped_by_a_full_run(ctx: &Ctx, step: &StepSpec, selected: usize) -> bool {
193 if !step.optional || selected <= 1 {
194 return false;
195 }
196 !(step.name == "protect-release-lines" && ctx.release_lines())
197}
198
199fn refuse_an_excluded_step(ctx: &Ctx, step: &StepSpec) -> Result<(), RkError> {
205 match stance(ctx, step) {
206 Stance::Applies => Ok(()),
207 Stance::Excluded(reason) | Stance::Redundant { reason, .. } => {
208 Err(RkError::Usage(format!(
209 "{} is excluded by {}: {reason}; remove it from setup.excluded_steps to run it",
210 step.name,
211 crate::config::CONFIG_PATH
212 )))
213 }
214 Stance::NotApplicable(reason) => Err(RkError::refusal(
218 Diagnostic::new(
219 Reason::PrerequisiteUnmet,
220 format!("{} does not apply to this target: {reason}", step.name),
221 )
222 .expected("a target configuration that selects this step")
223 .action("rk profile --target . reports what this target resolves to")
224 .target_state("unchanged")
225 .step(step.name),
226 )),
227 }
228}
229
230fn reject_check_flag_on_gitlab(ctx: &Ctx) -> Result<(), RkError> {
238 if ctx.forge == Some(Forge::Gitlab) && ctx.required_check.is_some() {
239 return Err(RkError::Usage(
240 "--required-check is refused on gitlab: the forge requires the whole pipeline and names no individual check".into(),
241 ));
242 }
243 Ok(())
244}
245
246fn require_check_for(ctx: &Ctx, steps: &[&StepSpec]) -> Result<(), RkError> {
256 let needs = ctx.forge == Some(Forge::Github)
257 && ctx.required_check.is_none()
258 && ctx
259 .protection()
260 .owned_trunk_rules
261 .iter()
262 .any(|rule| rule == "required_status_checks")
263 && steps
264 .iter()
265 .any(|step| step.name == "protect-trunk" && stance(ctx, step).acts());
266 if needs {
267 return Err(RkError::refusal(
268 Diagnostic::new(
269 Reason::PrerequisiteUnmet,
270 "protect-trunk refuses until the required check is named, and nothing was written",
271 )
272 .expected("the name of the CI check the release merge must pass")
273 .action(format!(
274 "set setup.required_check in {}, or pass --required-check <name>; gh api repos/{}/commits/HEAD/check-runs lists the project's check names",
275 crate::config::CONFIG_PATH,
276 ctx.repo
277 ))
278 .step("protect-trunk"),
279 ));
280 }
281 Ok(())
282}
283
284fn list(forge: Option<&str>) -> Result<(), RkError> {
288 let forge = forge
289 .map(|name| {
290 Forge::parse(name).ok_or_else(|| {
291 RkError::Usage(format!(
292 "unknown forge '{name}'; the forges are: github, gitlab"
293 ))
294 })
295 })
296 .transpose()?;
297 let out = Output::human();
298 for (idx, step) in STEPS.iter().enumerate() {
299 let mut line = format!(
300 "{:2}. {} [{}] proves: {}",
301 idx + 1,
302 step.name,
303 step.chapter,
304 step.proves
305 );
306 if step.name == "protect-trunk" && forge != Some(Forge::Gitlab) {
307 line.push_str(" (needs --required-check on github)");
308 }
309 if step.destructive {
310 line.push_str(" (destructive)");
311 }
312 if step.optional {
313 line.push_str(" (optional; a full apply skips it)");
314 }
315 out.result_line(line);
316 }
317 out.next(&[
318 "rk setup --target . previews every step".to_owned(),
319 "rk setup script <name> prints one embedded script".to_owned(),
320 ]);
321 Ok(())
322}
323
324fn script(name: &str, forge: Option<&str>) -> Result<(), RkError> {
327 if name == "package-check" {
328 return Err(RkError::Usage(
329 "package-check reads its command from the technology binding and has no script".into(),
330 ));
331 }
332 if name == "branch-reminder" {
333 return Err(RkError::Usage(
334 "branch-reminder writes an embedded hook body and has no script; rk setup step branch-reminder previews the write".into(),
335 ));
336 }
337 if name == "forge-version" {
338 return Err(RkError::Usage(
339 "forge-version reads the forge's own version and has no script; rk setup step forge-version previews the read".into(),
340 ));
341 }
342 let forge = match forge {
343 Some(value) => Forge::parse(value).ok_or_else(|| {
344 RkError::Usage(format!(
345 "unknown forge '{value}'; the forges are: github, gitlab"
346 ))
347 })?,
348 None => Forge::Github,
349 };
350 let path = format!("{}/{name}", forge.as_str());
351 let file = embedded::SETUP.get_file(&path).ok_or(RkError::NotFound {
352 kind: "setup step",
353 name: name.to_owned(),
354 })?;
355 Output::human().result_raw(&String::from_utf8_lossy(file.contents()));
356 Ok(())
357}
358
359struct Engine {
362 out: Output,
363 ctx: Ctx,
364 journal: Option<Journal>,
365 secrets: Vec<Zeroizing<Vec<u8>>>,
366 key: Option<secrets::KeyFile>,
368 app_jwt: Option<String>,
370 key_boundary: bool,
373 key_unavailable: Option<String>,
375 seq: u64,
376 command: &'static str,
377 run_id: String,
378}
379
380impl Engine {
381 fn open(
386 out: Output,
387 ctx: Ctx,
388 command: &'static str,
389 journal_required: bool,
390 ) -> Result<Self, RkError> {
391 secrets::refuse_legacy_key()?;
394 let journal = match Journal::create(
395 command,
396 ctx.target.as_str(),
397 ctx.forge.map_or("none", Forge::as_str),
398 &ctx.repo,
399 ) {
400 Ok(journal) => Some(journal),
401 Err(source) if journal_required => {
402 return Err(RkError::refusal(
403 Diagnostic::new(
404 Reason::JournalUnavailable,
405 format!("the run journal cannot be created: {source}"),
406 )
407 .expected("a writable state root for the journal")
408 .target_state("nothing was run and nothing changed"),
409 ));
410 }
411 Err(source) => {
412 out.warn(format!("no run journal for this run: {source}"));
413 None
414 }
415 };
416 let run_id = journal
417 .as_ref()
418 .map_or_else(|| "unjournaled".to_owned(), |j| j.run_id().to_owned());
419 let mut engine = Self {
420 out,
421 ctx,
422 journal,
423 secrets: Ctx::secret_values(),
424 key: None,
425 app_jwt: None,
426 key_boundary: false,
427 key_unavailable: None,
428 seq: 0,
429 command,
430 run_id,
431 };
432 let opening = Event::opening(
433 engine.next_seq(),
434 crate::applog::now_utc(),
435 engine.run_id.clone(),
436 engine.command,
437 );
438 engine.emit(&opening);
439 if engine.ctx.self_hosted_gitlab() {
440 engine.out.warn(
441 "this remote is a self-hosted GitLab: registry trusted publishing covers GitLab.com only, so the OIDC invariant cannot be satisfied here",
442 );
443 }
444 Ok(engine)
445 }
446
447 const fn next_seq(&mut self) -> u64 {
448 let seq = self.seq;
449 self.seq += 1;
450 seq
451 }
452
453 fn event(&mut self, kind: EventKind, step: Option<&str>) -> Event {
454 let mut event = Event::opening(
455 self.next_seq(),
456 crate::applog::now_utc(),
457 self.run_id.clone(),
458 self.command,
459 );
460 event.kind = kind;
461 event.step = step.map(str::to_owned);
462 event
463 }
464
465 fn emit(&mut self, event: &Event) {
466 self.out.event(event);
467 if let Some(journal) = &mut self.journal
468 && let Ok(line) = serde_json::to_string(event)
469 {
470 journal.event_line(&line);
471 }
472 }
473
474 fn exec(&mut self, exec: &Exec, passthrough: bool) -> Result<Outcome, RkError> {
477 let echo = exec.echo();
478 self.out.frame(&echo);
479 if let Some(journal) = &mut self.journal {
480 journal.transcript(echo.as_bytes());
481 journal.transcript(b"\n");
482 }
483 let secrets = std::mem::take(&mut self.secrets);
484 let step_name: Option<String> = None;
485 let mut chunks: Vec<(ChildStream, Vec<u8>)> = Vec::new();
486 let spawned = process::run(exec, |stream, chunk| {
487 chunks.push((stream, process::redact(chunk, &secrets)));
488 });
489 self.secrets = secrets;
490 for (stream, chunk) in chunks {
491 if passthrough {
492 self.out.child_passthrough(stream, &chunk);
493 }
494 let event = self.event(EventKind::ChildOutput, step_name.as_deref());
495 let event = event.child_output(stream, &chunk);
496 self.emit(&event);
497 if let Some(journal) = &mut self.journal {
498 journal.transcript(&chunk);
499 }
500 }
501 spawned.map_err(|source| {
502 RkError::refusal(
503 Diagnostic::new(
504 Reason::SubprocessSpawn,
505 format!("{} did not spawn: {source}", exec.program.to_string_lossy()),
506 )
507 .expected("a POSIX sh and the forge CLI on PATH")
508 .run(self.run_path()),
509 )
510 })
511 }
512
513 fn run_path(&self) -> String {
514 self.journal.as_ref().map_or_else(
515 || "no journal was written".to_owned(),
516 |j| j.dir.display().to_string(),
517 )
518 }
519
520 fn finish(&mut self, exit_code: i32, reason: Option<&str>) {
521 let mut event = self.event(EventKind::RunFinished, None);
522 event.exit_code = Some(exit_code);
523 event.status = Some(if exit_code == 0 {
524 "ok".into()
525 } else {
526 "failed".into()
527 });
528 self.emit(&event);
529 if let Some(journal) = &mut self.journal {
530 journal.finish(exit_code, reason);
531 }
532 }
533}
534
535fn fail(engine: &mut Engine, error: RkError) -> RkError {
537 let error = match error {
538 RkError::Refusal(mut diagnostic) => {
539 diagnostic.run.get_or_insert_with(|| engine.run_path());
540 RkError::Refusal(diagnostic)
541 }
542 RkError::Subprocess(mut diagnostic) => {
543 diagnostic.run.get_or_insert_with(|| engine.run_path());
544 RkError::Subprocess(diagnostic)
545 }
546 RkError::CheckFailed(mut diagnostic) => {
547 diagnostic.run.get_or_insert_with(|| engine.run_path());
548 RkError::CheckFailed(diagnostic)
549 }
550 other => other,
551 };
552 engine.finish(i32::from(error.exit_code()), Some(error.reason().as_str()));
553 error
554}
555
556fn preview(out: Output, ctx: &Ctx, steps: &[&StepSpec]) -> Result<(), RkError> {
562 let mut engine = Engine::open(out, clone_ctx(ctx), "setup preview", false)?;
563 out.result_line(format!(
564 "DRY RUN: rk setup would run these steps against {} on {}; re-run with --apply",
565 engine.ctx.repo,
566 engine.ctx.forge.map_or("no forge", Forge::as_str)
567 ));
568 for (idx, step) in steps.iter().enumerate() {
569 out.result_line(format!(
570 "step {}/{} {} — proves {}",
571 idx + 1,
572 steps.len(),
573 step.name,
574 step.proves
575 ));
576 let stance = stance(&engine.ctx, step);
577 if !stance.acts() {
578 out.result_line(format!(" {}", stance.framed()));
579 let mut event = engine.event(EventKind::StepFinished, Some(step.name));
580 event.status = Some(stance.word().into());
581 event.detail = Some(stance.detail());
582 engine.emit(&event);
583 continue;
584 }
585 if step.name == "bot-secrets" && engine.ctx.forge == Some(Forge::Github) {
589 secrets::resolve_key_file(&engine.ctx.target)?;
590 }
591 out.result_line(format!(" {}", render_invocation(&engine.ctx, step)));
592 if step.name == "protect-trunk"
598 && engine.ctx.forge == Some(Forge::Github)
599 && engine.ctx.required_check.is_none()
600 && engine
601 .ctx
602 .protection()
603 .owned_trunk_rules
604 .iter()
605 .any(|rule| rule == "required_status_checks")
606 {
607 out.result_line(" needs: --required-check <name> before apply");
608 }
609 if skipped_by_a_full_run(&engine.ctx, step, steps.len()) {
610 out.result_line(format!(
611 " optional: a full apply skips it; set setup.release_lines, or rk setup step {} --apply runs it",
612 step.name
613 ));
614 }
615 let mut event = engine.event(EventKind::StepFinished, Some(step.name));
616 event.status = Some("previewed".into());
617 engine.emit(&event);
618 }
619 let next = next_for_apply(&engine.ctx, steps);
620 out.next(&[
621 next,
622 "rk setup check --target . proves what is already true".to_owned(),
623 ]);
624 engine.finish(0, None);
625 Ok(())
626}
627
628fn render_invocation(ctx: &Ctx, step: &StepSpec) -> String {
631 match step.name {
632 "branch-reminder" => {
633 "would write: the post-merge reminder hook at $(git rev-parse --git-path hooks)/post-merge".to_owned()
634 }
635 "package-check" => match ctx.tech {
636 Some("rust") if ctx.reporting_policy() => "would run: cargo publish --dry-run --allow-dirty, then cargo metadata --no-deps --format-version 1, then cargo package --list --allow-dirty for a single default package rooted at the target, asserting it carries SECURITY.md and none of release-kit's own files; another workspace shape reports both as unproved".to_owned(),
637 Some("rust") => "would run: cargo publish --dry-run --allow-dirty, then cargo metadata --no-deps --format-version 1, then cargo package --list --allow-dirty for a single default package rooted at the target, asserting it ships none of release-kit's own files; another workspace shape reports that as unproved".to_owned(),
638 Some("python") if ctx.reporting_policy() => "would run: python3 -m build; sdist and wheel SECURITY.md inclusion stays unproved".to_owned(),
639 Some("python") => "would run: python3 -m build".to_owned(),
640 Some("bash") if ctx.reporting_policy() => "nothing to run: no registry for this technology; the make dist tarball is not inspected".to_owned(),
641 Some("bash") => "nothing to run: no registry for this technology".to_owned(),
642 _ => "needs: a version file naming the technology".to_owned(),
643 },
644 "forge-version" => {
645 let (major, minor) = observe::GITLAB_VERSION_FLOOR;
646 match ctx.forge {
647 Some(Forge::Github) => {
648 "nothing to read: github.com is a rolling service and declares no version floor"
649 .to_owned()
650 }
651 Some(Forge::Gitlab) => format!(
652 "would read: GET /version, and compare it against the {major}.{minor} floor; nothing is written"
653 ),
654 None => "nothing to read: the profile names no forge".to_owned(),
655 }
656 }
657 name => {
658 let check = ctx
659 .required_check
660 .as_ref()
661 .filter(|_| ctx.forge == Some(Forge::Github) && name == "protect-trunk")
662 .map(|value| format!(" RK_REQUIRED_CHECK={value}"))
663 .unwrap_or_default();
664 let ruleset = match name {
668 "protect-trunk" => format!(
669 " RK_TRUNK_RULESET={} RK_SAFETY_RULESET={} RK_TITLE_CHECK={}",
670 ctx.trunk_ruleset(),
671 ctx.safety_ruleset(),
672 ctx.title_check()
673 ),
674 "protect-tags" => format!(" RK_TAG_RULESET={}", ctx.tag_ruleset()),
675 "protect-release-lines" => format!(" RK_LINES_RULESET={}", ctx.lines_ruleset()),
676 _ => String::new(),
677 };
678 format!(
679 "would run: sh <embedded setup/{}/{name}> with RK_REPO={} RK_TRUNK_BRANCH={}{ruleset}{check}",
680 ctx.forge.map_or("<no forge>", Forge::as_str),
681 ctx.repo,
682 ctx.trunk()
683 )
684 }
685 }
686}
687
688fn next_for_apply(ctx: &Ctx, steps: &[&StepSpec]) -> String {
689 let check = ctx
690 .required_check
691 .as_ref()
692 .map(|value| format!(" --required-check {value}"))
693 .unwrap_or_default();
694 if steps.len() == 1 {
695 format!(
696 "rk setup step {} --target {} --apply{check}",
697 steps[0].name, ctx.target
698 )
699 } else {
700 format!("rk setup --target {} --apply{check}", ctx.target)
701 }
702}
703
704fn clone_ctx(ctx: &Ctx) -> Ctx {
706 ctx.clone()
707}
708
709fn execute(
711 out: Output,
712 ctx: Ctx,
713 steps: &[&StepSpec],
714 command: &'static str,
715) -> Result<(), RkError> {
716 guard_sh()?;
717 let mut engine = Engine::open(out, ctx, command, true)?;
718 let mut done: Vec<(String, String)> = Vec::new();
719 for (idx, step) in steps.iter().enumerate() {
720 let stance = stance(&engine.ctx, step);
724 if !stance.acts() {
725 engine.out.frame(format!(
726 "step {}/{} {} — {}",
727 idx + 1,
728 steps.len(),
729 step.name,
730 stance.framed()
731 ));
732 let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
733 finished.status = Some(stance.word().into());
734 finished.detail = Some(stance.detail());
735 engine.emit(&finished);
736 done.push((step.name.to_owned(), stance.word().to_owned()));
737 continue;
738 }
739 if skipped_by_a_full_run(&engine.ctx, step, steps.len()) {
742 engine.out.frame(format!(
743 "step {}/{} {} — skipped (optional; set setup.release_lines, or rk setup step {} --apply runs it)",
744 idx + 1,
745 steps.len(),
746 step.name,
747 step.name
748 ));
749 let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
750 finished.status = Some("skipped".into());
751 engine.emit(&finished);
752 done.push((step.name.to_owned(), "skipped".to_owned()));
753 continue;
754 }
755 engine.out.frame(format!(
756 "step {}/{} {} — {}",
757 idx + 1,
758 steps.len(),
759 step.name,
760 step.proves
761 ));
762 let mut started = engine.event(EventKind::StepStarted, Some(step.name));
763 started.status = Some("running".into());
764 engine.emit(&started);
765 let clock = Instant::now();
766 let status = match apply_step(&mut engine, step) {
767 Ok(status) => status,
768 Err(error) => {
769 let error = attach_progress(error, &done, step, steps);
770 let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
771 finished.status = Some("failed".into());
772 finished.reason = Some(error.reason());
773 finished.duration_ms = Some(elapsed_ms(clock));
774 engine.emit(&finished);
775 return Err(fail(&mut engine, error));
776 }
777 };
778 engine.out.frame(format!(
779 "{} {}: {}",
780 if matches!(status, Done::Skipped(_)) {
781 "skipped"
782 } else {
783 "ok"
784 },
785 step.name,
786 status.line()
787 ));
788 let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
789 finished.status = Some(status.wire().into());
790 finished.detail = Some(status.line());
791 finished.exit_code = Some(0);
792 finished.duration_ms = Some(elapsed_ms(clock));
793 engine.emit(&finished);
794 done.push((step.name.to_owned(), status.wire().to_owned()));
795 }
796 engine.out.result_line(format!(
797 "setup: {} completed against {}",
798 step_count(done.len()),
799 engine.ctx.repo
800 ));
801 for (name, status) in &done {
802 engine.out.result_line(format!(" {status} {name}"));
803 }
804 engine.out.next(&[
805 format!("rk setup check --target {}", engine.ctx.target),
806 "rk guide setup orders what no command performs".to_owned(),
807 ]);
808 engine.finish(0, None);
809 Ok(())
810}
811
812fn step_count(count: usize) -> String {
815 format!("{count} {}", if count == 1 { "step" } else { "steps" })
816}
817
818fn elapsed_ms(clock: Instant) -> u64 {
819 u64::try_from(clock.elapsed().as_millis()).unwrap_or(u64::MAX)
820}
821
822enum Done {
824 Satisfied(String),
826 Skipped(String),
828 Changed(String, Option<String>),
830 Passed(String),
832}
833
834impl Done {
835 const fn wire(&self) -> &'static str {
836 match self {
837 Self::Satisfied(_) => "satisfied",
838 Self::Skipped(_) => "skipped",
839 Self::Changed(..) => "applied",
840 Self::Passed(_) => "passed",
841 }
842 }
843
844 fn line(&self) -> String {
845 match self {
846 Self::Satisfied(detail) | Self::Passed(detail) | Self::Skipped(detail) => {
847 detail.clone()
848 }
849 Self::Changed(detail, limitation) => limitation.as_ref().map_or_else(
850 || detail.clone(),
851 |limit| format!("{detail} (limitation: {limit})"),
852 ),
853 }
854 }
855}
856
857#[allow(
859 clippy::too_many_lines,
860 reason = "one step is observe, compare, apply, and verify in one place, and splitting it would separate a verdict from the observation it rests on"
861)]
862fn apply_step(engine: &mut Engine, step: &StepSpec) -> Result<Done, RkError> {
863 for prereq in step.prereqs {
866 let state = observe_with(engine, prereq)?;
867 if !state.satisfied() {
868 return Err(RkError::refusal(
869 Diagnostic::new(
870 Reason::PrerequisiteUnmet,
871 format!(
872 "{} requires {prereq} first: {}",
873 step.name,
874 state_detail(&state)
875 ),
876 )
877 .expected(format!("{prereq} satisfied before {}", step.name))
878 .action(format!(
879 "rk setup step {prereq} --target {} --apply",
880 engine.ctx.target
881 ))
882 .step(step.name),
883 ));
884 }
885 }
886 match step.name {
887 "package-check" => {
888 if engine.ctx.tech.is_none() {
889 return Err(RkError::Usage(
890 "no version file names a technology; rk binding --list names the bindings"
891 .into(),
892 ));
893 }
894 let state = observe_with(engine, "package-check")?;
895 match state {
896 StepState::Satisfied { detail, .. } => Ok(Done::Passed(detail)),
897 StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
898 Err(RkError::subprocess(
899 Diagnostic::new(
900 Reason::SubprocessFailed,
901 format!("package-check failed: {detail}"),
902 )
903 .expected(step.proves.to_owned())
904 .step(step.name),
905 ))
906 }
907 StepState::Unknown { detail } => Err(RkError::subprocess(
908 Diagnostic::new(
909 Reason::SubprocessFailed,
910 format!("package-check could not run: {detail}"),
911 )
912 .step(step.name),
913 )),
914 }
915 }
916 "forge-version" => match observe_with(engine, "forge-version")? {
922 StepState::Satisfied { detail, .. } => Ok(Done::Satisfied(detail)),
923 StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
924 Err(RkError::refusal(
925 Diagnostic::new(Reason::PrerequisiteUnmet, detail)
926 .expected(step.proves.to_owned())
927 .action("upgrade the instance, or host the project on gitlab.com")
928 .target_state("unchanged")
929 .step(step.name),
930 ))
931 }
932 StepState::Unknown { detail } => Err(RkError::refusal(
933 Diagnostic::new(Reason::ForgeTemporary, detail)
934 .expected("a readable forge version")
935 .action("glab auth login, then rerun")
936 .target_state("unchanged")
937 .step(step.name),
938 )),
939 },
940 "branch-reminder" => {
941 use crate::setup::branch_reminder::{HookState, hook_body, hook_path, observe_hook};
942 match observe_hook(&engine.ctx.target) {
943 HookState::Installed => Ok(Done::Satisfied(
944 "the post-merge reminder hook is installed".into(),
945 )),
946 HookState::Foreign => Err(RkError::refusal(
947 Diagnostic::new(
948 Reason::StateDrift,
949 "a foreign post-merge hook exists; the reminder is never written over it",
950 )
951 .expected("no post-merge hook, or one carrying the release-kit marker")
952 .action(
953 "merge by hand: guard each call behind its own capability probe inside the existing hook — `rk branches prune --help >/dev/null 2>&1` before `rk branches prune --quiet || :`, and the same pair for `rk worktree prune`",
954 )
955 .target_state("unchanged")
956 .step(step.name),
957 )),
958 HookState::Unreadable(detail) => Err(RkError::refusal(
959 Diagnostic::new(
960 Reason::StateDrift,
961 format!("the post-merge hook cannot be read: {detail}"),
962 )
963 .target_state("unchanged")
964 .step(step.name),
965 )),
966 HookState::Absent | HookState::Drifted => {
967 let path = hook_path(&engine.ctx.target).map_err(|detail| {
968 RkError::refusal(
969 Diagnostic::new(
970 Reason::PrerequisiteUnmet,
971 format!("the hooks directory cannot be resolved: {detail}"),
972 )
973 .expected("a git repository whose hooks directory git can name")
974 .step(step.name),
975 )
976 })?;
977 crate::atomic::write(&path, hook_body())?;
978 #[cfg(unix)]
979 {
980 use std::os::unix::fs::PermissionsExt as _;
981 std::fs::set_permissions(
982 &path,
983 std::fs::Permissions::from_mode(0o755),
984 )?;
985 }
986 Ok(Done::Changed(
987 "wrote the post-merge reminder hook".into(),
988 None,
989 ))
990 }
991 }
992 }
993 "single-trunk" => {
994 let guard = {
995 let ctx = clone_ctx(&engine.ctx);
996 let mut runner = |exec: &Exec| engine.exec(exec, false);
997 observe::single_trunk_guard(&ctx, &mut runner)?
998 };
999 match &guard {
1002 StepState::Satisfied { .. } => {}
1003 StepState::Unsatisfied { detail }
1004 | StepState::Inapplicable { detail }
1005 | StepState::Unknown { detail } => {
1006 return Err(RkError::refusal(
1007 Diagnostic::new(
1008 Reason::DestructiveRefusal,
1009 format!("single-trunk refuses: {detail}"),
1010 )
1011 .expected(
1012 "proof that every candidate branch is absent, or an ancestor of the trunk",
1013 )
1014 .step(step.name),
1015 ));
1016 }
1017 }
1018 run_forge_step(engine, step)
1019 }
1020 "bot-secrets" => {
1021 let adapter = engine.ctx.adapter()?;
1027 let key = match adapter {
1028 Forge::Github => key_file_for(engine)?.map(|key| key.bytes.clone()),
1032 Forge::Gitlab => None,
1033 };
1034 let provided = match adapter {
1035 Forge::Github => secrets::value_of("RK_BOT_APP_ID").is_some() && key.is_some(),
1038 Forge::Gitlab => secrets::value_of("RK_BOT_TOKEN").is_some(),
1039 };
1040 let state = observe_with(engine, step.name)?;
1041 if !provided {
1042 if state.satisfied() {
1043 return Ok(Done::Satisfied(state_detail(&state)));
1044 }
1045 let wanted = match adapter {
1046 Forge::Github => {
1047 "export RK_BOT_APP_ID and RK_BOT_PRIVATE_KEY_FILE, the second naming the .pem; rk forge github carries the walkthrough"
1048 }
1049 Forge::Gitlab => {
1050 "rk setup step install-bot --apply stores the token, or export RK_BOT_TOKEN to rotate one"
1051 }
1052 };
1053 return Err(RkError::refusal(
1054 Diagnostic::new(
1055 Reason::PrerequisiteUnmet,
1056 "bot-secrets has no credentials to store",
1057 )
1058 .expected("the bot credentials in the environment, the key as a path")
1059 .action(wanted.to_owned())
1060 .step(step.name),
1061 ));
1062 }
1063 if let Some(journal) = &mut engine.journal {
1064 for name in SECRET_VARS {
1065 if secrets::value_of(name).is_some() {
1066 journal.record_secret(name, true, "environment");
1067 }
1068 }
1069 if key.is_some() {
1070 journal.record_secret(secrets::PRIVATE_KEY_FILE, true, "file");
1071 }
1072 }
1073 let stdin = key;
1077 run_forge_step_with(engine, step, stdin, Vec::new())
1078 }
1079 "protections-check" => {
1080 let (outcome, _) = run_script(engine, step)?;
1081 if !outcome.success() {
1082 return Err(classify_failure(engine, step, &outcome));
1083 }
1084 match observe_with(engine, step.name)? {
1088 StepState::Satisfied { detail, limitation } => {
1089 Ok(Done::Passed(limitation.map_or_else(
1090 || detail.clone(),
1091 |limit| format!("{detail} (limitation: {limit})"),
1092 )))
1093 }
1094 StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
1095 Err(RkError::refusal(
1096 Diagnostic::new(
1097 Reason::StateDrift,
1098 format!("protections-check passed its script and the observation disagrees: {detail}"),
1099 )
1100 .expected(step.proves.to_owned())
1101 .step(step.name),
1102 ))
1103 }
1104 StepState::Unknown { detail } => Err(RkError::refusal(
1107 Diagnostic::new(
1108 Reason::ForgeTemporary,
1109 format!(
1110 "protections-check passed its script and the readback could not confirm it: {detail}"
1111 ),
1112 )
1113 .expected(step.proves.to_owned())
1114 .action("check authentication and connectivity, then rerun")
1115 .step(step.name),
1116 )),
1117 }
1118 }
1119 "install-bot" if engine.ctx.forge == Some(Forge::Github) => {
1125 match observe_with(engine, step.name)? {
1126 StepState::Satisfied { detail, .. } => {
1127 return Ok(Done::Satisfied(detail));
1128 }
1129 StepState::Unsatisfied { .. } | StepState::Inapplicable { .. } => {}
1130 StepState::Unknown { detail } => {
1131 return Err(RkError::refusal(
1132 Diagnostic::new(
1133 Reason::ForgeTemporary,
1134 format!("{} cannot observe the current state: {detail}", step.name),
1135 )
1136 .expected("a readable forge answer before anything mutates")
1137 .action("check the App credentials and connectivity, then rerun")
1138 .step(step.name),
1139 ));
1140 }
1141 }
1142 let installation = github_installation_id(engine, step)?;
1143 run_forge_step_with(
1144 engine,
1145 step,
1146 None,
1147 vec![("RK_BOT_INSTALLATION".into(), installation.into())],
1148 )
1149 }
1150 _ => {
1151 if step.mutates == Mutates::Forge {
1152 match observe_with(engine, step.name)? {
1157 StepState::Satisfied { detail, limitation } => {
1158 let detail = if step.name == "private-vulnerability-reporting" {
1159 limitation.map_or_else(
1160 || detail.clone(),
1161 |limit| format!("{detail} (limitation: {limit})"),
1162 )
1163 } else {
1164 detail
1165 };
1166 return Ok(Done::Satisfied(detail));
1167 }
1168 StepState::Inapplicable { detail }
1169 if step.name == "private-vulnerability-reporting" =>
1170 {
1171 return Ok(Done::Skipped(detail));
1172 }
1173 StepState::Unsatisfied { .. } | StepState::Inapplicable { .. } => {}
1174 StepState::Unknown { detail } => {
1175 return Err(RkError::refusal(
1176 Diagnostic::new(
1177 Reason::ForgeTemporary,
1178 format!("{} cannot observe the current state: {detail}", step.name),
1179 )
1180 .expected("a readable forge answer before anything mutates")
1181 .action("check authentication and connectivity, then rerun")
1182 .step(step.name),
1183 ));
1184 }
1185 }
1186 }
1187 run_forge_step(engine, step)
1188 }
1189 }
1190}
1191
1192fn github_installation_id(engine: &mut Engine, step: &StepSpec) -> Result<String, RkError> {
1200 let refuse = |message: String, action: &str| {
1201 RkError::refusal(
1202 Diagnostic::new(Reason::PrerequisiteUnmet, message)
1203 .expected("the App installed on the repository's owner")
1204 .action(action.to_owned())
1205 .step(step.name),
1206 )
1207 };
1208 let jwt = match app_jwt_for(engine)? {
1209 Ok(jwt) => jwt,
1210 Err(detail) => {
1211 return Err(refuse(
1212 format!("install-bot has no App token: {detail}"),
1213 app_jwt::REMEDIATION,
1214 ));
1215 }
1216 };
1217 let owner = engine
1218 .ctx
1219 .repo
1220 .split('/')
1221 .next()
1222 .unwrap_or_default()
1223 .to_owned();
1224 let ctx = clone_ctx(&engine.ctx);
1225 for path in [
1226 format!("users/{owner}/installation"),
1227 format!("orgs/{owner}/installation"),
1228 ] {
1229 match app_jwt::api_get(&ctx, &jwt, &path) {
1230 AppApi::Ok(body) => {
1231 return body["id"].as_i64().map(|id| id.to_string()).ok_or_else(|| {
1232 refuse(
1233 format!("the forge answered {path} without an installation id"),
1234 "check RK_BOT_APP_ID and the key file name the same App",
1235 )
1236 });
1237 }
1238 AppApi::Missing => {}
1239 AppApi::Refused(detail) => {
1240 return Err(refuse(
1241 detail,
1242 "check RK_BOT_APP_ID and the key file name the same App",
1243 ));
1244 }
1245 AppApi::Failed(detail) => {
1246 return Err(RkError::refusal(
1247 Diagnostic::new(
1248 Reason::ForgeTemporary,
1249 format!("install-bot cannot read the App's installation: {detail}"),
1250 )
1251 .action("check connectivity, then rerun")
1252 .step(step.name),
1253 ));
1254 }
1255 }
1256 }
1257 Err(refuse(
1258 format!("the App has no installation on {owner}"),
1259 "install the App on the account first; the setup guide's step 5 walks it",
1260 ))
1261}
1262
1263fn run_forge_step(engine: &mut Engine, step: &StepSpec) -> Result<Done, RkError> {
1265 run_forge_step_with(engine, step, None, Vec::new())
1266}
1267
1268fn run_forge_step_with(
1271 engine: &mut Engine,
1272 step: &StepSpec,
1273 stdin: Option<Zeroizing<Vec<u8>>>,
1274 extra_env: Vec<(OsString, OsString)>,
1275) -> Result<Done, RkError> {
1276 let (outcome, _) = run_script_with(engine, step, stdin, extra_env)?;
1277 if !outcome.success() {
1278 return Err(classify_failure(engine, step, &outcome));
1279 }
1280 let state = observe_with(engine, step.name)?;
1281 match state {
1282 StepState::Satisfied { detail, limitation } => Ok(Done::Changed(detail, limitation)),
1283 StepState::Inapplicable { detail } if step.name == "private-vulnerability-reporting" => {
1284 Ok(Done::Skipped(detail))
1285 }
1286 StepState::Unsatisfied { detail } | StepState::Inapplicable { detail } => {
1287 Err(RkError::refusal(
1288 Diagnostic::new(
1289 Reason::StateDrift,
1290 format!(
1291 "{} ran and its postcondition does not hold: {detail}",
1292 step.name
1293 ),
1294 )
1295 .expected(step.proves.to_owned())
1296 .step(step.name),
1297 ))
1298 }
1299 StepState::Unknown { detail } => Err(RkError::refusal(
1303 Diagnostic::new(
1304 Reason::ForgeTemporary,
1305 format!(
1306 "{} ran and the readback could not confirm it: {detail}",
1307 step.name
1308 ),
1309 )
1310 .expected(step.proves.to_owned())
1311 .action(format!(
1312 "rk setup step {} --target {} --apply re-asserts and re-proves it",
1313 step.name, engine.ctx.target
1314 ))
1315 .step(step.name),
1316 )),
1317 }
1318}
1319
1320fn observe_with(engine: &mut Engine, step: &str) -> Result<StepState, RkError> {
1327 if step == "install-bot" && engine.ctx.forge == Some(Forge::Github) {
1328 let jwt = match app_jwt_for(engine)? {
1329 Ok(jwt) => jwt,
1330 Err(detail) => return Ok(StepState::Unknown { detail }),
1331 };
1332 return Ok(observe::github_install_bot(&engine.ctx, &jwt));
1333 }
1334 let ctx = clone_ctx(&engine.ctx);
1335 let mut runner = |exec: &Exec| engine.exec(exec, false);
1336 observe::observe(&ctx, step, &mut runner)
1337}
1338
1339fn key_file_for(engine: &mut Engine) -> Result<Option<&secrets::KeyFile>, RkError> {
1345 if engine.key.is_none() && engine.key_unavailable.is_none() {
1346 match secrets::look_up_key_file(&engine.ctx.target)? {
1347 secrets::KeyLookup::Absent => {}
1348 secrets::KeyLookup::Found(key) => {
1349 engine.secrets.push(key.bytes.clone());
1350 engine.key = Some(key);
1351 }
1352 secrets::KeyLookup::Unavailable(unavailable) if engine.key_boundary => {
1357 engine.key_unavailable = Some(unavailable.message);
1358 }
1359 secrets::KeyLookup::Unavailable(unavailable) => return Err(unavailable.refusal()),
1360 }
1361 }
1362 Ok(engine.key.as_ref())
1363}
1364
1365fn app_jwt_for(engine: &mut Engine) -> Result<Result<String, String>, RkError> {
1375 if let Some(jwt) = &engine.app_jwt {
1376 return Ok(Ok(jwt.clone()));
1377 }
1378 let app_id = app_jwt::app_id(engine.ctx.bot_app_id())?;
1379 let key_bytes = key_file_for(engine)?.map(|key| key.bytes.clone());
1380 if let Some(unreachable) = &engine.key_unavailable {
1381 return Ok(Err(format!(
1382 "the installation is readable only to the App itself, and this runtime cannot reach the key {} names: {unreachable}; a host that holds the key re-verifies it",
1383 secrets::PRIVATE_KEY_FILE
1384 )));
1385 }
1386 let (Some(app_id), Some(key_bytes)) = (app_id, key_bytes) else {
1387 return Ok(Err(format!(
1388 "the installation is readable only to the App itself; {}",
1389 app_jwt::REMEDIATION
1390 )));
1391 };
1392 let credentials = app_jwt::AppCredentials { app_id, key_bytes };
1393 let ctx = clone_ctx(&engine.ctx);
1394 Ok(match app_jwt::mint(&ctx, &credentials) {
1395 Ok(jwt) => {
1396 engine
1397 .secrets
1398 .push(Zeroizing::new(jwt.clone().into_bytes()));
1399 if let Some(signature) = jwt.rsplit('.').next() {
1400 engine
1401 .secrets
1402 .push(Zeroizing::new(signature.as_bytes().to_vec()));
1403 }
1404 engine.app_jwt = Some(jwt.clone());
1405 Ok(jwt)
1406 }
1407 Err(detail) => Err(detail),
1408 })
1409}
1410
1411fn state_detail(state: &StepState) -> String {
1412 state.detail().to_owned()
1413}
1414
1415fn run_script(engine: &mut Engine, step: &StepSpec) -> Result<(Outcome, PathBuf), RkError> {
1418 run_script_with(engine, step, None, Vec::new())
1419}
1420
1421fn run_script_with(
1427 engine: &mut Engine,
1428 step: &StepSpec,
1429 stdin: Option<Zeroizing<Vec<u8>>>,
1430 extra_env: Vec<(OsString, OsString)>,
1431) -> Result<(Outcome, PathBuf), RkError> {
1432 let forge = engine.ctx.adapter()?;
1433 let rel = format!("{}/{}", forge.as_str(), step.name);
1434 let bytes = embedded::SETUP
1435 .get_file(&rel)
1436 .map(include_dir::File::contents)
1437 .ok_or_else(|| RkError::Other(anyhow::anyhow!("no embedded script at setup/{rel}")))?;
1438 let journal = engine
1439 .journal
1440 .as_mut()
1441 .ok_or_else(|| RkError::Other(anyhow::anyhow!("an apply always has a journal")))?;
1442 let dir = journal.scripts_dir().join(forge.as_str());
1443 fs::create_dir_all(&dir)?;
1444 restrict(&dir, 0o700);
1445 let path = dir.join(step.name);
1446 fs::write(&path, bytes)?;
1447 restrict(&path, 0o600);
1448 let written = fs::read(&path)?;
1449 let digest = Digest::of(&written);
1450 if digest != Digest::of(bytes) {
1451 return Err(RkError::Other(anyhow::anyhow!(
1452 "the materialized script at {} differs from the embedded bytes",
1453 path.display()
1454 )));
1455 }
1456 journal.record_script(format!("scripts/{rel}"), digest.to_string());
1457 let mut env = engine.ctx.child_env(step.name);
1458 env.extend(extra_env);
1459 let exec = Exec {
1460 program: crate::probes::sh_bin(),
1461 args: vec![path.clone().into_os_string()],
1462 env,
1463 cwd: engine.ctx.target.as_std_path().to_path_buf(),
1464 stdin,
1465 };
1466 let outcome = engine.exec(&exec, true)?;
1467 Ok((outcome, path))
1468}
1469
1470fn classify_failure(engine: &Engine, step: &StepSpec, outcome: &Outcome) -> RkError {
1475 let stderr = String::from_utf8_lossy(&outcome.stderr);
1476 let last = if outcome.exit_code >= 128 {
1480 format!("killed by signal {}", outcome.exit_code - 128)
1481 } else {
1482 stderr
1483 .lines()
1484 .rev()
1485 .find(|line| !line.trim().is_empty())
1486 .unwrap_or("no output")
1487 .to_owned()
1488 };
1489 let reason = if (engine.ctx.forge == Some(Forge::Github) && outcome.exit_code == 4)
1490 || stderr.contains("HTTP 401")
1491 {
1492 Reason::ForgeAuthentication
1493 } else if stderr.contains("HTTP 403") {
1494 Reason::ForgePermission
1495 } else if stderr.contains("HTTP 429") || stderr.contains("rate limit") {
1496 Reason::ForgeRateLimit
1497 } else {
1498 Reason::SubprocessFailed
1499 };
1500 let diagnostic = Diagnostic::new(reason, format!("the forge refused '{}': {last}", step.name))
1501 .expected(step.proves.to_owned())
1502 .action(format!(
1503 "rk setup step {} --target {} --apply",
1504 step.name, engine.ctx.target
1505 ))
1506 .step(step.name);
1507 let diagnostic = match reason {
1508 Reason::ForgePermission => diagnostic.expected(format!(
1509 "repository administration write on {} for the authenticated account",
1510 engine.ctx.repo
1511 )),
1512 _ => diagnostic,
1513 };
1514 match reason {
1515 Reason::SubprocessFailed => RkError::subprocess(diagnostic),
1516 _ => RkError::refusal(diagnostic),
1517 }
1518}
1519
1520fn attach_progress(
1523 error: RkError,
1524 done: &[(String, String)],
1525 failed: &StepSpec,
1526 steps: &[&StepSpec],
1527) -> RkError {
1528 let remaining = steps.len().saturating_sub(done.len() + 1);
1529 let state = format!(
1530 "{} completed; {} failed; {remaining} not attempted",
1531 step_count(done.len()),
1532 failed.name
1533 );
1534 match error {
1535 RkError::Refusal(mut diagnostic) => {
1536 diagnostic.target_state.get_or_insert(state);
1537 RkError::Refusal(diagnostic)
1538 }
1539 RkError::Subprocess(mut diagnostic) => {
1540 diagnostic.target_state.get_or_insert(state);
1541 RkError::Subprocess(diagnostic)
1542 }
1543 other => other,
1544 }
1545}
1546
1547fn observe_all(engine: &mut Engine) -> Result<Report, RkError> {
1553 let mut report = Report::default();
1554 for step in &STEPS {
1555 let clock = Instant::now();
1556 let stance = stance(&engine.ctx, step);
1561 let row = if stance.acts() {
1562 let state = observe_with(engine, step.name)?;
1563 Row::observed(step, stance, &state)
1564 } else {
1565 Row::stated(step, stance)
1566 };
1567 engine.out.result_line(row.line());
1568 let (status, detail) = row.event_fields();
1569 let mut finished = engine.event(EventKind::StepFinished, Some(step.name));
1570 finished.status = Some(status);
1571 finished.detail = Some(detail);
1572 finished.duration_ms = Some(elapsed_ms(clock));
1573 engine.emit(&finished);
1574 report.rows.push(row);
1575 }
1576 if report.judged() < STEPS.len() {
1577 engine.out.result_line(format!(
1578 "{} judged; the rest do not apply to this target or {} excludes them",
1579 step_count(report.judged()),
1580 crate::config::CONFIG_PATH
1581 ));
1582 }
1583 Ok(report)
1584}
1585
1586fn check(out: Output, ctx: Ctx) -> Result<(), RkError> {
1590 let mut engine = Engine::open(out, ctx, "setup check", false)?;
1591 engine.key_boundary = true;
1592 let report = observe_all(&mut engine)?;
1593 if !report.checkpointable() {
1594 let error = verdict(&engine.ctx, &report);
1595 return Err(fail(&mut engine, error));
1596 }
1597 engine
1598 .out
1599 .next(&["rk guide release orders the first release".to_owned()]);
1600 engine.finish(0, None);
1601 Ok(())
1602}
1603
1604fn verdict(ctx: &Ctx, report: &Report) -> RkError {
1614 let unsatisfied = report.unsatisfied();
1615 let unknown = report.unknown();
1616 let standing = proof::judge(&ctx.target, &proof::subject(ctx));
1617 let proven = match &standing {
1618 Standing::Compatible(proof) => Some(proof),
1619 _ => None,
1620 };
1621 if unsatisfied == 0 {
1622 let state = proven.map_or_else(
1623 || {
1624 format!(
1625 "no setup defect was inferred; no compatible {} is committed, so the current state is unverified",
1626 proof::PROOF_PATH
1627 )
1628 },
1629 |proof| {
1630 format!(
1631 "no setup defect was inferred; the committed setup proof from {} by rk {} still matches this target's setup contract",
1632 proof.verified_at, proof.rk_version
1633 )
1634 },
1635 );
1636 return RkError::check_failed(
1637 Diagnostic::new(
1638 Reason::ObservationIncomplete,
1639 format!(
1640 "{} could not be verified from this runtime, and none is unsatisfied",
1641 step_count(unknown)
1642 ),
1643 )
1644 .expected("every applicable step readable from where the check runs")
1645 .action("rerun rk setup check where each unknown step can be observed; its line names what it needs")
1646 .target_state(state),
1647 );
1648 }
1649 let mut message = format!(
1650 "{} {} not satisfied",
1651 step_count(unsatisfied),
1652 if unsatisfied == 1 { "is" } else { "are" }
1653 );
1654 let mut action = format!(
1655 "rk setup --target {} --apply re-asserts the unsatisfied ones",
1656 ctx.target
1657 );
1658 if unknown > 0 {
1659 use std::fmt::Write as _;
1660 let _ = write!(
1661 message,
1662 ", and {unknown} could not be verified from this runtime"
1663 );
1664 action.push_str("; the unknown ones need a runtime that can observe what each line names");
1665 }
1666 let mut diagnostic = Diagnostic::new(Reason::StateDrift, message)
1667 .expected("every step's proof column to hold")
1668 .action(action);
1669 if let Some(proof) = proven {
1670 diagnostic = diagnostic.target_state(format!(
1671 "this observation supersedes the committed setup proof from {} by rk {}",
1672 proof.verified_at, proof.rk_version
1673 ));
1674 }
1675 RkError::check_failed(diagnostic)
1676}
1677
1678fn checkpoint(out: Output, ctx: Ctx) -> Result<(), RkError> {
1684 let mut engine = Engine::open(out, ctx, "setup checkpoint", false)?;
1685 let report = observe_all(&mut engine)?;
1686 let Some(proof) = proof::of(&engine.ctx, &report, crate::applog::now_utc()) else {
1687 let error = RkError::check_failed(
1688 Diagnostic::new(
1689 Reason::StateDrift,
1690 format!(
1691 "the observation is incomplete: {} not satisfied and {} could not be verified",
1692 step_count(report.unsatisfied()),
1693 report.unknown()
1694 ),
1695 )
1696 .expected("every applicable step observed and holding")
1697 .action(format!(
1698 "rk setup check --target {} names each gap; checkpoint again once none remains",
1699 engine.ctx.target
1700 ))
1701 .target_state(format!("{} was not written", proof::PROOF_PATH)),
1702 );
1703 return Err(fail(&mut engine, error));
1704 };
1705 if let Err(source) = proof::write(&engine.ctx.target, &proof) {
1706 let error = RkError::refusal(
1707 Diagnostic::new(
1708 Reason::Io,
1709 format!("{} cannot be written: {source}", proof::PROOF_PATH),
1710 )
1711 .expected("a writable .release-kit directory in the target")
1712 .target_state("the earlier proof, where one exists, is unchanged"),
1713 );
1714 return Err(fail(&mut engine, error));
1715 }
1716 engine.out.result_line(format!(
1717 "wrote {}: {} proven at {} by rk {}",
1718 proof::PROOF_PATH,
1719 step_count(proof.steps.len()),
1720 proof.verified_at,
1721 proof.rk_version
1722 ));
1723 engine.out.next(&[
1724 format!("commit {} with the setup it proves", proof::PROOF_PATH),
1725 format!(
1726 "rk setup status --target {} reads it offline",
1727 engine.ctx.target
1728 ),
1729 ]);
1730 engine.finish(0, None);
1731 Ok(())
1732}
1733
1734fn status(out: Output, ctx: &Ctx) -> Result<(), RkError> {
1740 let standing = proof::judge(&ctx.target, &proof::subject(ctx));
1741 out.emit(&proof::StatusDocument::of(&standing))?;
1742 match &standing {
1743 Standing::Absent => out.result_line(format!(
1744 "setup proof: absent — {} does not exist, so no complete observation is on record",
1745 proof::PROOF_PATH
1746 )),
1747 Standing::Compatible(proof) => out.result_line(format!(
1748 "setup proof: compatible — proven {} by rk {}, and this target's setup contract has not changed since",
1749 proof.verified_at, proof.rk_version
1750 )),
1751 Standing::Stale { proof, differences } => out.result_line(format!(
1752 "setup proof: stale — proven {} by rk {}, and the setup contract changed since: {}",
1753 proof.verified_at,
1754 proof.rk_version,
1755 differences.join(", ")
1756 )),
1757 Standing::Invalid { detail, .. } => {
1758 out.result_line(format!("setup proof: invalid — {detail}"));
1759 }
1760 }
1761 if let Standing::Compatible(proof) | Standing::Stale { proof, .. } = &standing {
1762 for step in &proof.steps {
1763 let limitation = step
1764 .limitation
1765 .as_ref()
1766 .map_or_else(String::new, |limit| format!(" (limitation: {limit})"));
1767 out.result_line(format!(" {} {}{limitation}", step.state, step.name));
1768 }
1769 }
1770 let next = match standing {
1771 Standing::Compatible(_) => format!(
1772 "rk setup check --target {} observes the forge now, where this runtime can",
1773 ctx.target
1774 ),
1775 _ => format!(
1776 "rk setup checkpoint --target {}, from a host that can observe every step, records a proof",
1777 ctx.target
1778 ),
1779 };
1780 out.next(&[next]);
1781 Ok(())
1782}
1783
1784fn restrict(path: &std::path::Path, mode: u32) {
1787 #[cfg(unix)]
1788 {
1789 use std::os::unix::fs::PermissionsExt as _;
1790 let _ = fs::set_permissions(path, fs::Permissions::from_mode(mode));
1791 }
1792 #[cfg(not(unix))]
1793 let _ = (path, mode);
1794}
1795
1796fn guard_sh() -> Result<(), RkError> {
1799 let ok = std::process::Command::new(crate::probes::sh_bin())
1800 .args(["-c", "exit 0"])
1801 .status()
1802 .is_ok_and(|status| status.success());
1803 if ok {
1804 Ok(())
1805 } else {
1806 Err(RkError::refusal(
1807 Diagnostic::new(Reason::PrerequisiteUnmet, "no POSIX sh runs on this host")
1808 .expected("a working sh on PATH; every step spawns through it")
1809 .action("install a POSIX shell, then rerun")
1810 .target_state("nothing was run and nothing changed"),
1811 ))
1812 }
1813}
1814
1815#[cfg(test)]
1816mod tests {
1817 #[test]
1820 fn a_step_count_carries_a_noun_that_agrees_with_it() {
1821 assert_eq!(super::step_count(0), "0 steps");
1822 assert_eq!(super::step_count(1), "1 step");
1823 assert_eq!(super::step_count(2), "2 steps");
1824 }
1825}