1use std::ffi::OsStr;
20use std::fs::File;
21use std::path::Path;
22
23use camino::Utf8Path;
24use serde::Serialize;
25
26use super::manifest::{self, FileRecord, Manifest, Parameters};
27use super::{Kind, Params, lock};
28use crate::config;
29use crate::diagnostic::{Diagnostic, Reason};
30use crate::digest::Digest;
31use crate::error::RkError;
32use crate::held;
33use crate::projection::{
34 Candidate, Placement, Projection, ProjectionInput, TargetEvidence, is_skill_folder,
35 skill_folder,
36};
37
38pub const INTERRUPT_VAR: &str = "RK_APPLY_INTERRUPT_AT";
45
46pub const PAUSE_VAR: &str = "RK_APPLY_PAUSE_DIR";
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
53#[serde(rename_all = "lowercase")]
54pub enum Action {
55 Created,
57 Replaced,
61 Matched,
67 Preserved,
70 Drift,
73 Retired,
77 Dropped,
82}
83
84impl Action {
85 #[must_use]
87 pub const fn as_str(self) -> &'static str {
88 match self {
89 Self::Created => "created",
90 Self::Replaced => "replaced",
91 Self::Matched => "matched",
92 Self::Preserved => "preserved",
93 Self::Drift => "drift",
94 Self::Retired => "retired",
95 Self::Dropped => "dropped",
96 }
97 }
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum Adoption {
107 Matches,
109 Tuned,
111 State,
113 Differs,
115 Missing {
118 exists: bool,
120 },
121 Irregular,
123}
124
125impl Adoption {
126 #[must_use]
130 pub const fn word(self) -> &'static str {
131 match self {
132 Self::Matches => "matched",
133 Self::Tuned | Self::State => "preserved",
134 Self::Differs | Self::Missing { .. } | Self::Irregular => "collision",
135 }
136 }
137
138 #[must_use]
142 pub fn replaceable(self, kind: Kind) -> bool {
143 matches!(self, Self::Differs)
144 || (matches!(self, Self::Missing { .. }) && kind == Kind::Rendered)
145 }
146}
147
148pub fn judge_adoption(target: &Held, candidate: &Candidate) -> Result<Adoption, RkError> {
155 match locate(&target.root, &candidate.destination)? {
159 Located::Collision(_) => return Ok(Adoption::Irregular),
160 Located::Absent => return Ok(Adoption::Missing { exists: false }),
161 Located::Present { .. } => {}
162 }
163 let Some(current) = super::read_recorded(target.base(), &candidate.destination)? else {
164 return Ok(Adoption::Missing { exists: true });
165 };
166 let expected: &[u8] = match candidate.placement {
167 Placement::Whole => &candidate.bytes,
168 Placement::Region { .. } => candidate.region.as_deref().unwrap_or(&candidate.bytes),
169 };
170 Ok(match candidate.kind {
171 Kind::Rendered | Kind::Seeded if current == expected => Adoption::Matches,
172 Kind::Rendered => Adoption::Differs,
173 Kind::Seeded => Adoption::Tuned,
174 Kind::State => Adoption::State,
175 })
176}
177
178#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct Accepted {
181 pub reason: String,
183 pub candidate_moved: bool,
187 pub sha256: String,
190}
191
192#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct Decision {
195 pub destination: String,
197 pub kind: Kind,
200 pub action: Action,
202 pub accepted: Option<Accepted>,
204}
205
206#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
213pub struct Collision {
214 pub path: String,
216 pub reason: String,
218 pub replaceable: bool,
223}
224
225#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
227pub struct Replaced {
228 pub path: String,
230 pub saved_to: Option<String>,
233}
234
235const EDITED: &str = "was edited after the landing, and the record no longer vouches for its bytes";
237
238#[derive(Debug)]
246pub struct Held {
247 root: File,
248 base: camino::Utf8PathBuf,
249 display: camino::Utf8PathBuf,
250}
251
252impl Held {
253 pub fn open(target: &Utf8Path) -> Result<Self, RkError> {
259 let root = held::open_dir(target.as_std_path())?;
260 let base = camino::Utf8PathBuf::from_path_buf(held::proc_path(&root))
261 .map_err(|path| anyhow::anyhow!("the kernel's link {} is not UTF-8", path.display()))?;
262 Ok(Self {
263 root,
264 base,
265 display: target.to_owned(),
266 })
267 }
268
269 pub fn open_locked(target: &Utf8Path, lock: &lock::TargetLock) -> Result<Self, RkError> {
277 let held = Self::open(target)?;
278 let opened = held::Identity::of(&held.root.metadata()?);
279 if lock.identity() != opened {
280 return Err(RkError::refusal(
281 Diagnostic::new(
282 Reason::StateDrift,
283 format!(
284 "the directory at {target} was exchanged after the lock was taken, and nothing was written"
285 ),
286 )
287 .expected("one directory at the target path from the lock through the receipt write")
288 .action("re-run once the target is at rest")
289 .target_state("unchanged"),
290 ));
291 }
292 Ok(held)
293 }
294
295 #[must_use]
298 pub fn base(&self) -> &Utf8Path {
299 &self.base
300 }
301
302 #[must_use]
304 pub fn display(&self) -> &Utf8Path {
305 &self.display
306 }
307}
308
309#[derive(Debug)]
313pub struct Prepared {
314 pub params: Params,
316 pub projection: Projection,
318 pub decisions: Vec<Decision>,
320 pub collisions: Vec<Collision>,
322 pub config: config::Plan,
324 pub replacements: Vec<String>,
327}
328
329impl Prepared {
330 #[must_use]
332 pub fn decision(&self, destination: &str) -> Option<&Decision> {
333 self.decisions
334 .iter()
335 .find(|decision| decision.destination == destination)
336 }
337}
338
339pub fn prepare(
350 target: &Held,
351 recorded: Option<&Manifest>,
352 params: &Params,
353 existing_config: Option<&config::Config>,
354) -> Result<Prepared, RkError> {
355 let evidence = TargetEvidence::gather(target.base(), recorded)?;
356 let projection = Projection::compute(&ProjectionInput {
357 params: params.clone(),
358 evidence,
359 })?;
360 super::accepted_drift_faults(&projection, recorded, existing_config)?;
361 let accepted_drift = existing_config
362 .map(|config| config.landing.accepted_drift.clone())
363 .unwrap_or_default();
364 let (decisions, collisions) = decide(target, recorded, &projection, &accepted_drift)?;
365 let config = config::Plan::new(
366 target.base().as_std_path(),
367 params,
368 existing_config,
369 recorded,
370 )?;
371 Ok(Prepared {
372 params: params.clone(),
373 projection,
374 decisions,
375 collisions,
376 config,
377 replacements: Vec::new(),
378 })
379}
380
381pub fn name_replacements(prepared: &mut Prepared, replace: &[String]) -> Result<(), RkError> {
395 for path in replace {
396 if prepared.replacements.contains(path) {
397 continue;
398 }
399 if refused_skill_folder(&prepared.collisions, path) {
402 continue;
403 }
404 let Some(index) = prepared
405 .collisions
406 .iter()
407 .position(|collision| collision.path == *path && collision.replaceable)
408 else {
409 return Err(RkError::Usage(format!(
410 "--replace {path} names no conflict this landing has; a conflict is an unattributed file or a recorded rendered file someone edited"
411 )));
412 };
413 prepared.collisions.remove(index);
414 let kind = prepared
415 .projection
416 .candidates
417 .iter()
418 .find(|candidate| candidate.destination == *path)
419 .map_or(Kind::Rendered, |candidate| candidate.kind);
420 prepared.decisions.push(Decision {
421 destination: path.clone(),
422 kind,
423 action: Action::Replaced,
424 accepted: None,
425 });
426 prepared.replacements.push(path.clone());
427 }
428 let order = |destination: &str| {
429 prepared
430 .projection
431 .candidates
432 .iter()
433 .position(|candidate| candidate.destination == destination)
434 .unwrap_or(usize::MAX)
435 };
436 let mut decisions = std::mem::take(&mut prepared.decisions);
437 decisions.sort_by_key(|decision| order(&decision.destination));
438 prepared.decisions = decisions;
439 Ok(())
440}
441
442#[allow(
454 clippy::too_many_lines,
455 reason = "one pass decides every destination by its placement, its record, and its bytes"
456)]
457pub fn decide(
458 target: &Held,
459 recorded: Option<&Manifest>,
460 projection: &Projection,
461 accepted_drift: &std::collections::BTreeMap<String, config::AcceptedDrift>,
462) -> Result<(Vec<Decision>, Vec<Collision>), RkError> {
463 let root = &target.root;
464 let mut decisions = Vec::new();
465 let mut collisions: Vec<Collision> = projection
466 .collisions
467 .iter()
468 .map(|collision| Collision {
469 path: collision.destination.clone(),
470 reason: collision.reason.clone(),
471 replaceable: false,
472 })
473 .collect();
474 let refused = skill_folder_collisions(root, recorded, projection)?;
475 collisions.extend(refused.iter().cloned());
476 for candidate in &projection.candidates {
477 if skill_folder(&candidate.destination)
478 .is_some_and(|folder| refused.iter().any(|collision| collision.path == folder))
479 {
480 continue;
481 }
482 let record = recorded.and_then(|record| record.file(&candidate.destination));
483 let located = match locate(root, &candidate.destination)? {
484 Located::Collision(reason) => {
485 collisions.push(Collision {
486 path: candidate.destination.clone(),
487 reason,
488 replaceable: false,
489 });
490 continue;
491 }
492 other => other,
493 };
494 let present = matches!(located, Located::Present { .. });
495 let current = || located.read();
496 let mut accepted = None;
497 let action = match (candidate.placement, present, record) {
498 (_, false, _) => Action::Created,
499 (Placement::Region { begin, end }, true, Some(record))
504 if record.kind == Kind::Rendered
505 && super::extract_block(&String::from_utf8_lossy(¤t()?), begin, end)
506 .is_some_and(|block| {
507 Digest::of(block.as_bytes()) != record.sha256
508 && candidate.region.as_deref() != Some(block.as_bytes())
509 }) =>
510 {
511 collisions.push(Collision {
512 path: candidate.destination.clone(),
513 reason: format!("its marked region {EDITED}"),
514 replaceable: true,
515 });
516 continue;
517 }
518 (Placement::Region { .. }, true, _) => Action::Replaced,
522 (Placement::Whole, true, None) => {
527 if current()? == candidate.bytes {
528 Action::Matched
529 } else {
530 collisions.push(Collision {
531 path: candidate.destination.clone(),
532 reason:
533 "exists with bytes differing from the candidate, and no receipt attributes it to release-kit"
534 .to_owned(),
535 replaceable: true,
536 });
537 continue;
538 }
539 }
540 (Placement::Whole, true, Some(record)) => match (candidate.kind, record.kind) {
541 (Kind::Rendered, Kind::Rendered) => {
545 let bytes = current()?;
546 let entry = accepted_drift
551 .get(&candidate.destination)
552 .filter(|entry| Digest::of(&bytes).to_string() == entry.sha256);
553 if bytes == candidate.bytes {
554 Action::Matched
555 } else if Digest::of(&bytes) == record.sha256 {
556 Action::Replaced
557 } else if let Some(entry) = entry {
558 accepted = Some(Accepted {
562 reason: entry.reason.clone(),
563 candidate_moved: Digest::of(&candidate.bytes) != record.sha256,
564 sha256: entry.sha256.clone(),
565 });
566 Action::Preserved
567 } else {
568 collisions.push(Collision {
569 path: candidate.destination.clone(),
570 reason: EDITED.to_owned(),
571 replaceable: true,
572 });
573 continue;
574 }
575 }
576 (Kind::Rendered, Kind::Seeded | Kind::State) => {
577 collisions.push(Collision {
578 path: candidate.destination.clone(),
579 reason: format!(
580 "is recorded as {}, and this release renders it, so its bytes are the target's",
581 record.kind.as_str()
582 ),
583 replaceable: true,
584 });
585 continue;
586 }
587 (Kind::Seeded, _) => {
588 if Digest::of(¤t()?) == record.sha256 {
589 Action::Preserved
590 } else {
591 Action::Drift
592 }
593 }
594 (Kind::State, _) => Action::Preserved,
595 },
596 };
597 decisions.push(Decision {
598 destination: candidate.destination.clone(),
599 kind: candidate.kind,
600 action,
601 accepted,
602 });
603 }
604 if let Some(record) = recorded {
605 decisions.extend(dropped(target, record, projection)?);
606 }
607 collisions.sort_by(|a, b| a.path.cmp(&b.path));
608 collisions.dedup_by(|a, b| a.path == b.path);
609 Ok((decisions, collisions))
610}
611
612pub fn dropped(
633 target: &Held,
634 recorded: &Manifest,
635 projection: &Projection,
636) -> Result<Vec<Decision>, RkError> {
637 let mut decisions = Vec::new();
638 for file in &recorded.files {
639 let produced = projection
640 .candidates
641 .iter()
642 .any(|candidate| candidate.destination == file.destination);
643 if produced {
644 continue;
645 }
646 let withheld = projection
647 .omissions
648 .iter()
649 .any(|omission| omission.destination == file.destination);
650 let retirable = !withheld
651 && file.kind == Kind::Rendered
652 && file.placement == manifest::Placement::Whole;
653 let action = if retirable && unedited(&target.root, file)? {
654 Action::Retired
655 } else {
656 Action::Dropped
657 };
658 decisions.push(Decision {
659 destination: file.destination.clone(),
660 kind: file.kind,
661 action,
662 accepted: None,
663 });
664 }
665 Ok(decisions)
666}
667
668fn unedited(root: &File, file: &FileRecord) -> std::io::Result<bool> {
671 Ok(match locate(root, &file.destination)? {
672 Located::Absent => true,
673 Located::Collision(_) => false,
674 present @ Located::Present { .. } => Digest::of(&present.read()?) == file.sha256,
675 })
676}
677
678enum Located {
680 Collision(String),
683 Absent,
685 Present { dir: File, name: std::ffi::OsString },
687}
688
689impl Located {
690 fn read(&self) -> std::io::Result<Vec<u8>> {
692 match self {
693 Self::Present { dir, name } => {
694 held::read_file(dir, name).map(Option::unwrap_or_default)
695 }
696 Self::Absent | Self::Collision(_) => Ok(Vec::new()),
697 }
698 }
699}
700
701#[must_use]
704pub fn adoption_creates(prepared: &Prepared, destination: &str) -> bool {
705 skill_folder(destination).is_some()
706 && prepared
707 .decision(destination)
708 .is_some_and(|decision| decision.action == Action::Created)
709}
710
711#[must_use]
714pub fn refused_skill_folder(collisions: &[Collision], path: &str) -> bool {
715 collisions.iter().any(|collision| {
716 !collision.replaceable
717 && is_skill_folder(&collision.path)
718 && (path == collision.path || path.starts_with(&format!("{}/", collision.path)))
719 })
720}
721
722fn skill_folder_collisions(
732 root: &File,
733 recorded: Option<&Manifest>,
734 projection: &Projection,
735) -> Result<Vec<Collision>, RkError> {
736 let mut folders: Vec<String> = projection
737 .candidates
738 .iter()
739 .filter_map(|candidate| skill_folder(&candidate.destination))
740 .collect();
741 folders.dedup();
742 let mut out = Vec::new();
743 for folder in folders {
744 let prefix = format!("{folder}/");
745 let attributed = recorded.is_some_and(|record| {
746 record
747 .files
748 .iter()
749 .any(|file| file.destination.starts_with(&prefix))
750 });
751 if attributed {
752 continue;
753 }
754 if let Some(reason) = skill_folder_refusal(root, &folder, projection)? {
755 out.push(Collision {
756 path: folder,
757 reason,
758 replaceable: false,
759 });
760 }
761 }
762 Ok(out)
763}
764
765fn skill_folder_refusal(
768 root: &File,
769 folder: &str,
770 projection: &Projection,
771) -> Result<Option<String>, RkError> {
772 const UNATTRIBUTED: &str = "is a skill folder that no receipt attributes to release-kit";
773 let dir = match held::hold_dir_existing(root, Path::new(folder)) {
774 Ok(dir) => dir,
775 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
776 Err(error) => {
777 return Ok(Some(format!(
778 "{UNATTRIBUTED}, and it cannot be held: {error}"
779 )));
780 }
781 };
782 let mut pending = vec![(held::proc_path(&dir), String::new())];
783 while let Some((path, relative)) = pending.pop() {
784 let listing = match std::fs::read_dir(&path) {
785 Ok(listing) => listing,
786 Err(error) => {
787 return Ok(Some(format!(
788 "{UNATTRIBUTED}, and it cannot be read: {error}"
789 )));
790 }
791 };
792 for entry in listing {
793 let entry = entry?;
794 let name = entry.file_name().to_string_lossy().into_owned();
795 let inner = if relative.is_empty() {
796 name
797 } else {
798 format!("{relative}/{name}")
799 };
800 let metadata = std::fs::symlink_metadata(entry.path())?;
801 if metadata.is_dir() {
802 pending.push((entry.path(), inner));
803 continue;
804 }
805 let destination = format!("{folder}/{inner}");
806 let Some(candidate) = projection
807 .candidates
808 .iter()
809 .find(|candidate| candidate.destination == destination)
810 else {
811 return Ok(Some(format!(
812 "{UNATTRIBUTED}, and it holds {inner}, which the package does not carry"
813 )));
814 };
815 if !metadata.is_file() {
816 return Ok(Some(format!(
817 "{UNATTRIBUTED}, and its {inner} is not a regular file"
818 )));
819 }
820 match std::fs::read(entry.path()) {
821 Ok(bytes) if bytes == candidate.bytes => {}
822 Ok(_) => {
823 return Ok(Some(format!(
824 "{UNATTRIBUTED}, and its {inner} differs from the package"
825 )));
826 }
827 Err(error) => {
828 return Ok(Some(format!(
829 "{UNATTRIBUTED}, and its {inner} cannot be read: {error}"
830 )));
831 }
832 }
833 }
834 }
835 Ok(None)
836}
837
838fn locate(root: &File, destination: &str) -> std::io::Result<Located> {
842 let (parent, name) = split(Path::new(destination))?;
843 let dir = match held::hold_dir_existing(root, parent) {
844 Ok(dir) => dir,
845 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Located::Absent),
846 Err(error) => {
847 return Ok(Located::Collision(format!(
848 "a parent component cannot be held: {error}"
849 )));
850 }
851 };
852 match std::fs::symlink_metadata(held::proc_path(&dir).join(name)) {
853 Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => Ok(
854 Located::Collision("exists and is not a regular file".to_owned()),
855 ),
856 Ok(_) => Ok(Located::Present {
857 dir,
858 name: name.to_owned(),
859 }),
860 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Located::Absent),
861 Err(error) => Err(error),
862 }
863}
864
865#[must_use]
871pub fn licence_refusal(reason: &str) -> RkError {
872 RkError::refusal(
873 Diagnostic::new(
874 Reason::StateDrift,
875 format!("the code scanning provider's licence condition is not satisfied, and nothing was written: {reason}"),
876 )
877 .expected("a provider whose terms the target's declared licence permits")
878 .action("pass --code-scanning semgrep, which carries no licence condition, or --code-scanning off")
879 .target_state("unchanged"),
880 )
881}
882
883#[must_use]
889pub fn driver_refusal(reason: &str) -> RkError {
890 RkError::refusal(
891 Diagnostic::new(
892 Reason::PrerequisiteUnmet,
893 format!("the project cannot take the selected release driver, and nothing was written: {reason}"),
894 )
895 .expected("a uv project with a static [project] version and a committed uv.lock")
896 .action("uv lock, or a static version in [project], then land again")
897 .target_state("unchanged"),
898 )
899}
900
901#[must_use]
907pub fn release_unavailable(reason: &str) -> RkError {
908 RkError::refusal(
909 Diagnostic::new(
910 Reason::StateDrift,
911 format!("the selected release automation is not available in this release, and nothing was written: {reason}"),
912 )
913 .expected("a release driver and forge this release ships automation for, or a release mode that selects none")
914 .action("pass --release-driver and --forge at an available tuple, or --release-mode external or none")
915 .target_state("unchanged"),
916 )
917}
918
919#[must_use]
925pub fn refusal(target: &Utf8Path, collisions: &[Collision]) -> RkError {
926 let listed: Vec<String> = collisions
927 .iter()
928 .map(|collision| format!("{} ({})", collision.path, collision.reason))
929 .collect();
930 RkError::refusal(
931 Diagnostic::new(
932 Reason::StateDrift,
933 format!(
934 "these destinations cannot be landed as they stand, and nothing was written: {}",
935 listed.join("; ")
936 ),
937 )
938 .expected(
939 "every whole-file destination absent, named by the receipt, or already holding the candidate's bytes, every parent component a directory reached through no link, and every marked document offering its block one place",
940 )
941 .action(format!(
942 "rk stage --target {target}, then rk stage compare <stage> names each conflict, and the rk-setup skill asks the operator about each one; --replace <path> saves a copy of a named conflict and replaces it, then re-run"
943 ))
944 .target_state("unchanged"),
945 )
946}
947
948#[derive(Debug, Clone, Copy, PartialEq, Eq)]
950pub enum Origin {
951 Init,
953 Upgrade,
956 Adopt,
959}
960
961#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
967pub struct SecretMove {
968 pub path: String,
970 pub name: String,
972 pub direction: String,
975 #[serde(default, skip_serializing_if = "Option::is_none")]
977 pub step: Option<String>,
978}
979
980impl SecretMove {
981 #[must_use]
983 pub fn line(&self) -> String {
984 if self.direction == "dropped" {
985 format!(
986 "secret {} read by the replaced {} is not read by the rendered file",
987 self.name, self.path
988 )
989 } else {
990 self.step.as_deref().map_or_else(
991 || {
992 format!(
993 "secret {} is new; no setup step stores it, so the operator does",
994 self.name
995 )
996 },
997 |step| format!("secret {} is new; {step} stores it", self.name),
998 )
999 }
1000 }
1001}
1002
1003#[must_use]
1007pub fn secret_names(text: &str) -> std::collections::BTreeSet<String> {
1008 let mut names = std::collections::BTreeSet::new();
1009 let mut rest = text;
1010 while let Some(start) = rest.find("${{") {
1011 let Some(end) = rest[start..].find("}}") else {
1012 break;
1013 };
1014 let expression = &rest[start + 3..start + end];
1015 let mut inner = expression;
1016 while let Some(at) = inner.find("secrets.") {
1017 let name: String = inner[at + "secrets.".len()..]
1018 .chars()
1019 .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
1020 .collect();
1021 if !name.is_empty() && name != "GITHUB_TOKEN" {
1022 names.insert(name);
1023 }
1024 inner = &inner[at + "secrets.".len()..];
1025 }
1026 rest = &rest[start + end + 2..];
1027 }
1028 names
1029}
1030
1031#[must_use]
1035pub fn secrets_moved(path: &str, old: &str, new: &str) -> Vec<SecretMove> {
1036 let (before, after) = (secret_names(old), secret_names(new));
1037 let dropped = before.difference(&after).map(|name| SecretMove {
1038 path: path.to_owned(),
1039 name: name.clone(),
1040 direction: "dropped".to_owned(),
1041 step: None,
1042 });
1043 let added = after.difference(&before).map(|name| SecretMove {
1044 path: path.to_owned(),
1045 name: name.clone(),
1046 direction: "new".to_owned(),
1047 step: crate::setup::steps::secret_step(name).map(str::to_owned),
1048 });
1049 dropped.chain(added).collect()
1050}
1051
1052pub fn replaced_secrets(target: &Held, prepared: &Prepared) -> Result<Vec<SecretMove>, RkError> {
1060 let mut moved = Vec::new();
1061 for decision in &prepared.decisions {
1062 if decision.action != Action::Replaced
1063 || !decision.destination.starts_with(".github/workflows/")
1064 {
1065 continue;
1066 }
1067 let Some(candidate) = prepared
1068 .projection
1069 .candidates
1070 .iter()
1071 .find(|candidate| candidate.destination == decision.destination)
1072 else {
1073 continue;
1074 };
1075 let Some(current) = read_relative(&target.root, &decision.destination)? else {
1076 continue;
1077 };
1078 moved.extend(secrets_moved(
1079 &decision.destination,
1080 &String::from_utf8_lossy(¤t),
1081 &String::from_utf8_lossy(&candidate.bytes),
1082 ));
1083 }
1084 Ok(moved)
1085}
1086
1087#[derive(Debug)]
1089pub struct Landed {
1090 pub completed: Vec<String>,
1092 pub config_written: bool,
1094 pub receipt: Manifest,
1096 pub replaced: Vec<Replaced>,
1098}
1099
1100pub fn land(
1116 target: &Held,
1117 recorded: Option<&Manifest>,
1118 prepared: &Prepared,
1119 origin: Origin,
1120 _lock: &lock::TargetLock,
1121) -> Result<Landed, RkError> {
1122 if let Some(reason) = prepared.projection.licence_refusal.as_deref() {
1123 return Err(licence_refusal(reason));
1124 }
1125 if let Some(reason) = prepared.projection.release_unavailable() {
1126 return Err(release_unavailable(reason));
1127 }
1128 if let Some(reason) = prepared.projection.driver_refusal.as_deref() {
1129 return Err(driver_refusal(reason));
1130 }
1131 if !prepared.collisions.is_empty() {
1132 return Err(refusal(target.display(), &prepared.collisions));
1133 }
1134 let root = &target.root;
1135 held::pause(PAUSE_VAR, "validated", "proceed");
1139 let unwritten = reverify(root, prepared, origin)?;
1144 let retiring = retiring(root, recorded, prepared)?;
1147 let replaced = save_replacements(target, root, prepared)?;
1150 let mut writer = Writer {
1151 root,
1152 completed: Vec::new(),
1153 stop: std::env::var_os(INTERRUPT_VAR).map(|value| value.to_string_lossy().into_owned()),
1154 };
1155 let config_current = read_relative(root, config::CONFIG_PATH)?;
1157 let config_written = config_current.as_deref() != Some(prepared.config.content.as_bytes());
1158 if config_written {
1159 writer.write(config::CONFIG_PATH, prepared.config.content.as_bytes())?;
1160 }
1161 let mut files = Vec::new();
1162 for candidate in &prepared.projection.candidates {
1163 let accepted = prepared
1164 .decision(&candidate.destination)
1165 .is_some_and(|decision| decision.accepted.is_some());
1166 let sha256 = match unwritten.get(&candidate.destination) {
1167 Some(_) if accepted => candidate_digest(candidate),
1171 Some(digest) => digest.clone(),
1172 None => match action_of(prepared, origin, &candidate.destination) {
1173 Some(Action::Created | Action::Replaced) => {
1174 writer.write(&candidate.destination, &candidate.bytes)?;
1175 candidate_digest(candidate)
1176 }
1177 _ => continue,
1178 },
1179 };
1180 files.push(FileRecord {
1181 destination: candidate.destination.clone(),
1182 kind: candidate.kind,
1183 sha256,
1184 placement: match candidate.placement {
1185 Placement::Whole => manifest::Placement::Whole,
1186 Placement::Region { .. } => manifest::Placement::Region,
1187 },
1188 });
1189 }
1190 for destination in &retiring {
1194 writer.remove(destination)?;
1195 }
1196 let receipt = receipt(
1197 &prepared.params,
1198 &prepared.projection,
1199 recorded,
1200 origin,
1201 files,
1202 );
1203 writer.write(manifest::MANIFEST_PATH, &manifest::render(&receipt)?)?;
1204 Ok(Landed {
1205 completed: writer.completed,
1206 config_written,
1207 receipt,
1208 replaced,
1209 })
1210}
1211
1212fn save_replacements(
1214 target: &Held,
1215 root: &File,
1216 prepared: &Prepared,
1217) -> Result<Vec<Replaced>, RkError> {
1218 if prepared.replacements.is_empty() {
1219 return Ok(Vec::new());
1220 }
1221 let canonical = std::fs::canonicalize(target.display().as_std_path())?;
1225 let snapshot = crate::snapshot::Snapshot::begin(&canonical)?;
1226 let mut replaced = Vec::new();
1227 for path in &prepared.replacements {
1228 let saved_to = match read_relative(root, path)? {
1229 Some(bytes) => Some(snapshot.save_replaced(path, &bytes)?.display().to_string()),
1230 None => None,
1231 };
1232 replaced.push(Replaced {
1233 path: path.clone(),
1234 saved_to,
1235 });
1236 }
1237 Ok(replaced)
1238}
1239
1240#[must_use]
1243pub fn previewed_replacements(prepared: &Prepared) -> Vec<Replaced> {
1244 prepared
1245 .replacements
1246 .iter()
1247 .map(|path| Replaced {
1248 path: path.clone(),
1249 saved_to: None,
1250 })
1251 .collect()
1252}
1253
1254fn action_of(prepared: &Prepared, origin: Origin, destination: &str) -> Option<Action> {
1259 match origin {
1260 Origin::Adopt if prepared.replacements.iter().any(|path| path == destination) => {
1261 Some(Action::Replaced)
1262 }
1263 Origin::Adopt if adoption_creates(prepared, destination) => Some(Action::Created),
1267 Origin::Adopt => Some(Action::Preserved),
1268 Origin::Init | Origin::Upgrade => prepared.decision(destination).map(|d| d.action),
1269 }
1270}
1271
1272fn current_form(root: &File, candidate: &Candidate) -> Result<Option<Vec<u8>>, RkError> {
1276 let Some(current) = read_relative(root, &candidate.destination)? else {
1277 return Ok(None);
1278 };
1279 Ok(match candidate.placement {
1280 Placement::Whole => Some(current),
1281 Placement::Region { begin, end } => {
1282 let text = String::from_utf8_lossy(¤t);
1283 super::extract_block(&text, begin, end).map(|block| block.as_bytes().to_vec())
1284 }
1285 })
1286}
1287
1288fn reverify(
1298 root: &File,
1299 prepared: &Prepared,
1300 origin: Origin,
1301) -> Result<std::collections::BTreeMap<String, Digest>, RkError> {
1302 let mut digests = std::collections::BTreeMap::new();
1303 for candidate in &prepared.projection.candidates {
1304 let action = action_of(prepared, origin, &candidate.destination);
1305 let must_match = match (origin, action) {
1306 (_, Some(Action::Replaced | Action::Created)) => continue,
1307 (Origin::Adopt, _) => candidate.kind == Kind::Rendered,
1308 (_, Some(Action::Matched)) => true,
1309 (_, Some(Action::Preserved | Action::Drift)) => false,
1310 _ => continue,
1311 };
1312 let Some(current) = current_form(root, candidate)? else {
1313 return Err(moved(&candidate.destination, "is no longer present"));
1314 };
1315 let expected: &[u8] = candidate.region.as_deref().unwrap_or(&candidate.bytes);
1316 if must_match && current != expected {
1317 return Err(moved(
1318 &candidate.destination,
1319 "no longer holds the candidate's bytes",
1320 ));
1321 }
1322 let accepted = prepared
1325 .decision(&candidate.destination)
1326 .and_then(|decision| decision.accepted.as_ref());
1327 if let Some(accepted) = accepted
1328 && Digest::of(¤t).to_string() != accepted.sha256
1329 {
1330 return Err(moved(
1331 &candidate.destination,
1332 "no longer holds the bytes its accepted drift entry names",
1333 ));
1334 }
1335 digests.insert(candidate.destination.clone(), Digest::of(¤t));
1336 }
1337 Ok(digests)
1338}
1339
1340fn retiring(
1349 root: &File,
1350 recorded: Option<&Manifest>,
1351 prepared: &Prepared,
1352) -> Result<Vec<String>, RkError> {
1353 let mut out = Vec::new();
1354 for decision in &prepared.decisions {
1355 if decision.action != Action::Retired {
1356 continue;
1357 }
1358 let Some(file) = recorded.and_then(|record| record.file(&decision.destination)) else {
1359 continue;
1360 };
1361 if !unedited(root, file)? {
1362 return Err(moved(
1363 &decision.destination,
1364 "no longer holds the bytes the record vouches for",
1365 ));
1366 }
1367 out.push(decision.destination.clone());
1368 }
1369 Ok(out)
1370}
1371
1372fn moved(destination: &str, what: &str) -> RkError {
1375 RkError::refusal(
1376 Diagnostic::new(
1377 Reason::StateDrift,
1378 format!(
1379 "{destination} {what} since it was validated, and nothing was written; the previous receipt stands"
1380 ),
1381 )
1382 .expected("every destination the landing leaves as it stands to stand still until the receipt is written")
1383 .action("re-run once the target is at rest")
1384 .target_state("unchanged"),
1385 )
1386}
1387
1388fn candidate_digest(candidate: &Candidate) -> Digest {
1391 candidate
1392 .region
1393 .as_deref()
1394 .map_or_else(|| Digest::of(&candidate.bytes), Digest::of)
1395}
1396
1397fn receipt(
1399 params: &Params,
1400 projection: &Projection,
1401 recorded: Option<&Manifest>,
1402 origin: Origin,
1403 files: Vec<FileRecord>,
1404) -> Manifest {
1405 Manifest {
1406 schema_version: manifest::SCHEMA_VERSION,
1407 rk_version: env!("CARGO_PKG_VERSION").to_owned(),
1408 origin: recorded.map_or_else(
1409 || match origin {
1410 Origin::Adopt => "adopt".to_owned(),
1411 Origin::Init | Origin::Upgrade => "init".to_owned(),
1412 },
1413 |record| record.origin.clone(),
1414 ),
1415 landed_at: recorded.map_or_else(manifest::now, |record| record.landed_at.clone()),
1416 profile: params.profile().clone(),
1417 git: params.git().clone(),
1418 capabilities: params.capabilities().clone(),
1419 parameters: Parameters {
1420 repo: params.repo().to_owned(),
1421 security_contact: params.security_contact().to_owned(),
1422 security_response: params.security_response().to_owned(),
1423 security_scope: params.security_scope().to_owned(),
1424 required_check: params.required_check().to_owned(),
1425 dev_environment: params.dev_environment().cloned(),
1426 },
1427 files,
1428 pins: crate::registry::pins_for(&projection.capabilities)
1429 .into_iter()
1430 .map(|pin| (pin.name, pin.version))
1431 .collect(),
1432 }
1433}
1434
1435fn read_relative(root: &File, relative: &str) -> std::io::Result<Option<Vec<u8>>> {
1438 let path = Path::new(relative);
1439 let (parent, name) = split(path)?;
1440 let dir = match held::hold_dir_existing(root, parent) {
1441 Ok(dir) => dir,
1442 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1443 Err(error) => return Err(error),
1444 };
1445 held::read_file(&dir, name)
1446}
1447
1448fn split(path: &Path) -> std::io::Result<(&Path, &OsStr)> {
1450 let name = path
1451 .file_name()
1452 .ok_or_else(|| std::io::Error::other(format!("{} has no file name", path.display())))?;
1453 let parent = path.parent().unwrap_or_else(|| Path::new(""));
1454 Ok((parent, name))
1455}
1456
1457struct Writer<'a> {
1460 root: &'a File,
1461 completed: Vec<String>,
1462 stop: Option<String>,
1463}
1464
1465impl Writer<'_> {
1466 fn write(&mut self, destination: &str, bytes: &[u8]) -> Result<(), RkError> {
1469 let path = Path::new(destination);
1470 let (parent, name) = split(path)?;
1471 let outcome = held::hold_dir(self.root, parent).and_then(|dir| {
1472 if self.stop.as_deref() == Some(destination) {
1473 return Err(std::io::Error::other(
1474 "the rename was stopped here for the proof",
1475 ));
1476 }
1477 held::write_file(&dir, name, bytes)
1478 });
1479 match outcome {
1480 Ok(()) => {
1481 self.completed.push(destination.to_owned());
1482 Ok(())
1483 }
1484 Err(error) => Err(self.failure(destination, bytes, &error)),
1485 }
1486 }
1487
1488 fn remove(&mut self, destination: &str) -> Result<(), RkError> {
1492 let path = Path::new(destination);
1493 let (parent, name) = split(path)?;
1494 let dir = match held::hold_dir_existing(self.root, parent) {
1495 Ok(dir) => dir,
1496 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
1497 Err(error) => return Err(self.failure(destination, &[], &error)),
1498 };
1499 match std::fs::remove_file(held::proc_path(&dir).join(name)) {
1500 Ok(()) => {
1501 self.completed.push(destination.to_owned());
1502 Ok(())
1503 }
1504 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1505 Err(error) => Err(self.failure(destination, &[], &error)),
1506 }
1507 }
1508
1509 fn failure(&self, destination: &str, bytes: &[u8], error: &std::io::Error) -> RkError {
1515 let observed = match read_relative(self.root, destination) {
1516 Ok(None) => "is absent".to_owned(),
1517 Ok(Some(current)) if current == bytes => "holds the candidate bytes whole".to_owned(),
1518 Ok(Some(_)) => "holds its previous bytes whole".to_owned(),
1519 Err(again) => format!("could not be observed again: {again}"),
1520 };
1521 let completed = if self.completed.is_empty() {
1522 "none".to_owned()
1523 } else {
1524 self.completed.join(", ")
1525 };
1526 RkError::Io(std::io::Error::new(
1527 error.kind(),
1528 format!(
1529 "the landing stopped at {destination}: {error}; observed again, {destination} {observed}; the previous receipt stands; these landed before it: {completed}; re-run to land the rest"
1530 ),
1531 ))
1532 }
1533}
1534
1535#[cfg(test)]
1536mod tests {
1537 use super::{Action, Held, Origin, Prepared, decide, land, prepare};
1538 use crate::landing::manifest::{self, Manifest};
1539 use crate::landing::{Params, Style, lock};
1540 use crate::projection::{Projection, ProjectionInput, TargetEvidence};
1541
1542 fn target() -> (tempfile::TempDir, camino::Utf8PathBuf) {
1543 let dir = tempfile::tempdir().expect("a scratch target exists");
1544 let path = camino::Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("utf-8");
1545 (dir, path)
1546 }
1547
1548 fn params() -> Params {
1549 Params::for_test("acme/widget", Some(Style::Trunk))
1550 }
1551
1552 fn prepared(target: &camino::Utf8Path, recorded: Option<&Manifest>) -> Prepared {
1553 prepare(
1554 &Held::open(target).expect("opens"),
1555 recorded,
1556 ¶ms(),
1557 None,
1558 )
1559 .expect("prepares")
1560 }
1561
1562 fn landed(
1563 target: &camino::Utf8Path,
1564 recorded: Option<&Manifest>,
1565 origin: Origin,
1566 ) -> super::Landed {
1567 let locks = tempfile::tempdir().expect("a scratch locks directory exists");
1568 let lock = lock::acquire_in(locks.path(), target).expect("the target is taken");
1569 let held = Held::open(target).expect("opens");
1570 land(&held, recorded, &prepared(target, recorded), origin, &lock).expect("lands")
1571 }
1572
1573 #[test]
1578 fn a_fresh_landing_creates_and_a_rerun_decides_by_the_receipt() {
1579 let (_dir, target) = target();
1580 let first = prepared(&target, None);
1581 assert!(first.collisions.is_empty(), "{:?}", first.collisions);
1582 assert!(
1583 first
1584 .decisions
1585 .iter()
1586 .all(|decision| decision.action == Action::Created)
1587 );
1588 let outcome = landed(&target, None, Origin::Init);
1589 assert_eq!(
1590 outcome.completed.last().map(String::as_str),
1591 Some(manifest::MANIFEST_PATH)
1592 );
1593 assert_eq!(outcome.receipt.schema_version, manifest::SCHEMA_VERSION);
1594 let record = manifest::load(&target).expect("loads").expect("exists");
1595 let again = prepared(&target, Some(&record));
1596 for decision in &again.decisions {
1597 let region = again.projection.candidates.iter().any(|candidate| {
1598 candidate.destination == decision.destination
1599 && matches!(
1600 candidate.placement,
1601 crate::projection::Placement::Region { .. }
1602 )
1603 });
1604 let expected = match decision.kind {
1605 crate::landing::Kind::Rendered if region => Action::Replaced,
1606 crate::landing::Kind::Rendered => Action::Matched,
1607 crate::landing::Kind::Seeded | crate::landing::Kind::State => Action::Preserved,
1608 };
1609 assert_eq!(decision.action, expected, "{}", decision.destination);
1610 }
1611 }
1612
1613 #[test]
1617 fn every_collision_is_collected_and_the_refusal_writes_nothing() {
1618 let (_dir, target) = target();
1619 std::fs::write(target.join("SECURITY.md"), "ours\n").expect("writes");
1620 std::fs::create_dir_all(target.join("release-plz.toml")).expect("creates");
1621 std::fs::write(
1622 target.join("AGENTS.md"),
1623 format!(
1624 "{b}\n{e}\n{b}\n{e}\n",
1625 b = crate::landing::BLOCK_BEGIN,
1626 e = crate::landing::BLOCK_END
1627 ),
1628 )
1629 .expect("writes");
1630 let prepared = prepared(&target, None);
1631 let paths: Vec<&str> = prepared
1632 .collisions
1633 .iter()
1634 .map(|collision| collision.path.as_str())
1635 .collect();
1636 assert_eq!(paths, ["AGENTS.md", "SECURITY.md", "release-plz.toml"]);
1637 let locks = tempfile::tempdir().expect("a scratch locks directory exists");
1638 let lock = lock::acquire_in(locks.path(), &target).expect("the target is taken");
1639 let held = Held::open(&target).expect("opens");
1640 let refused =
1641 land(&held, None, &prepared, Origin::Init, &lock).expect_err("the landing refuses");
1642 assert_eq!(refused.exit_code(), 73);
1643 assert!(!target.join(".release-kit").exists());
1644 assert!(!target.join("dist-workspace.toml").exists());
1645 }
1646
1647 #[test]
1651 fn a_dropped_destination_is_retired_unless_the_target_edited_it() {
1652 for (bytes, action, stays) in [
1653 (&b"old\n"[..], Action::Retired, false),
1654 (&b"edited\n"[..], Action::Dropped, true),
1655 ] {
1656 let (_dir, target) = target();
1657 landed(&target, None, Origin::Init);
1658 let mut record = manifest::load(&target).expect("loads").expect("exists");
1659 std::fs::write(target.join("legacy.yml"), bytes).expect("writes");
1660 record.files.push(manifest::FileRecord {
1661 destination: "legacy.yml".into(),
1662 kind: crate::landing::Kind::Rendered,
1663 sha256: crate::digest::Digest::of(b"old\n"),
1664 placement: manifest::Placement::Whole,
1665 });
1666 let (decisions, _) = decide(
1667 &Held::open(&target).expect("opens"),
1668 Some(&record),
1669 &Projection::compute(&ProjectionInput {
1670 params: params(),
1671 evidence: TargetEvidence::gather(&target, Some(&record)).expect("gathers"),
1672 })
1673 .expect("projects"),
1674 &std::collections::BTreeMap::new(),
1675 )
1676 .expect("decides");
1677 let decided = decisions
1678 .iter()
1679 .find(|decision| decision.destination == "legacy.yml")
1680 .expect("the dropped destination is decided");
1681 assert_eq!(decided.action, action);
1682 let outcome = landed(&target, Some(&record), Origin::Upgrade);
1683 assert_eq!(target.join("legacy.yml").is_file(), stays);
1684 assert!(outcome.receipt.file("legacy.yml").is_none());
1685 }
1686 }
1687}