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::{Candidate, Placement, Projection, ProjectionInput, TargetEvidence};
34
35pub const INTERRUPT_VAR: &str = "RK_APPLY_INTERRUPT_AT";
42
43pub const PAUSE_VAR: &str = "RK_APPLY_PAUSE_DIR";
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
50#[serde(rename_all = "lowercase")]
51pub enum Action {
52 Created,
54 Replaced,
58 Matched,
64 Preserved,
67 Drift,
70 Retired,
74 Dropped,
79}
80
81impl Action {
82 #[must_use]
84 pub const fn as_str(self) -> &'static str {
85 match self {
86 Self::Created => "created",
87 Self::Replaced => "replaced",
88 Self::Matched => "matched",
89 Self::Preserved => "preserved",
90 Self::Drift => "drift",
91 Self::Retired => "retired",
92 Self::Dropped => "dropped",
93 }
94 }
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum Adoption {
104 Matches,
106 Tuned,
108 State,
110 Differs,
112 Missing {
115 exists: bool,
117 },
118 Irregular,
120}
121
122impl Adoption {
123 #[must_use]
127 pub const fn word(self) -> &'static str {
128 match self {
129 Self::Matches => "matched",
130 Self::Tuned | Self::State => "preserved",
131 Self::Differs | Self::Missing { .. } | Self::Irregular => "collision",
132 }
133 }
134
135 #[must_use]
139 pub fn replaceable(self, kind: Kind) -> bool {
140 matches!(self, Self::Differs)
141 || (matches!(self, Self::Missing { .. }) && kind == Kind::Rendered)
142 }
143}
144
145pub fn judge_adoption(target: &Held, candidate: &Candidate) -> Result<Adoption, RkError> {
152 match locate(&target.root, &candidate.destination)? {
156 Located::Collision(_) => return Ok(Adoption::Irregular),
157 Located::Absent => return Ok(Adoption::Missing { exists: false }),
158 Located::Present { .. } => {}
159 }
160 let Some(current) = super::read_recorded(target.base(), &candidate.destination)? else {
161 return Ok(Adoption::Missing { exists: true });
162 };
163 let expected: &[u8] = match candidate.placement {
164 Placement::Whole => &candidate.bytes,
165 Placement::Region { .. } => candidate.region.as_deref().unwrap_or(&candidate.bytes),
166 };
167 Ok(match candidate.kind {
168 Kind::Rendered | Kind::Seeded if current == expected => Adoption::Matches,
169 Kind::Rendered => Adoption::Differs,
170 Kind::Seeded => Adoption::Tuned,
171 Kind::State => Adoption::State,
172 })
173}
174
175#[derive(Debug, Clone, PartialEq, Eq)]
177pub struct Accepted {
178 pub reason: String,
180 pub candidate_moved: bool,
184 pub sha256: String,
187}
188
189#[derive(Debug, Clone, PartialEq, Eq)]
191pub struct Decision {
192 pub destination: String,
194 pub kind: Kind,
197 pub action: Action,
199 pub accepted: Option<Accepted>,
201}
202
203#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
210pub struct Collision {
211 pub path: String,
213 pub reason: String,
215 pub replaceable: bool,
220}
221
222#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
224pub struct Replaced {
225 pub path: String,
227 pub saved_to: Option<String>,
230}
231
232const EDITED: &str = "was edited after the landing, and the record no longer vouches for its bytes";
234
235#[derive(Debug)]
243pub struct Held {
244 root: File,
245 base: camino::Utf8PathBuf,
246 display: camino::Utf8PathBuf,
247}
248
249impl Held {
250 pub fn open(target: &Utf8Path) -> Result<Self, RkError> {
256 let root = held::open_dir(target.as_std_path())?;
257 let base = camino::Utf8PathBuf::from_path_buf(held::proc_path(&root))
258 .map_err(|path| anyhow::anyhow!("the kernel's link {} is not UTF-8", path.display()))?;
259 Ok(Self {
260 root,
261 base,
262 display: target.to_owned(),
263 })
264 }
265
266 pub fn open_locked(target: &Utf8Path, lock: &lock::TargetLock) -> Result<Self, RkError> {
274 let held = Self::open(target)?;
275 let opened = held::Identity::of(&held.root.metadata()?);
276 if lock.identity() != opened {
277 return Err(RkError::refusal(
278 Diagnostic::new(
279 Reason::StateDrift,
280 format!(
281 "the directory at {target} was exchanged after the lock was taken, and nothing was written"
282 ),
283 )
284 .expected("one directory at the target path from the lock through the receipt write")
285 .action("re-run once the target is at rest")
286 .target_state("unchanged"),
287 ));
288 }
289 Ok(held)
290 }
291
292 #[must_use]
295 pub fn base(&self) -> &Utf8Path {
296 &self.base
297 }
298
299 #[must_use]
301 pub fn display(&self) -> &Utf8Path {
302 &self.display
303 }
304}
305
306#[derive(Debug)]
310pub struct Prepared {
311 pub params: Params,
313 pub projection: Projection,
315 pub decisions: Vec<Decision>,
317 pub collisions: Vec<Collision>,
319 pub config: config::Plan,
321 pub replacements: Vec<String>,
324}
325
326impl Prepared {
327 #[must_use]
329 pub fn decision(&self, destination: &str) -> Option<&Decision> {
330 self.decisions
331 .iter()
332 .find(|decision| decision.destination == destination)
333 }
334}
335
336pub fn prepare(
347 target: &Held,
348 recorded: Option<&Manifest>,
349 params: &Params,
350 existing_config: Option<&config::Config>,
351) -> Result<Prepared, RkError> {
352 let evidence = TargetEvidence::gather(target.base(), recorded)?;
353 let projection = Projection::compute(&ProjectionInput {
354 params: params.clone(),
355 evidence,
356 })?;
357 super::accepted_drift_faults(&projection, recorded, existing_config)?;
358 let accepted_drift = existing_config
359 .map(|config| config.landing.accepted_drift.clone())
360 .unwrap_or_default();
361 let (decisions, collisions) = decide(target, recorded, &projection, &accepted_drift)?;
362 let config = config::Plan::new(
363 target.base().as_std_path(),
364 params,
365 existing_config,
366 recorded,
367 )?;
368 Ok(Prepared {
369 params: params.clone(),
370 projection,
371 decisions,
372 collisions,
373 config,
374 replacements: Vec::new(),
375 })
376}
377
378pub fn name_replacements(prepared: &mut Prepared, replace: &[String]) -> Result<(), RkError> {
392 for path in replace {
393 if prepared.replacements.contains(path) {
394 continue;
395 }
396 let Some(index) = prepared
397 .collisions
398 .iter()
399 .position(|collision| collision.path == *path && collision.replaceable)
400 else {
401 return Err(RkError::Usage(format!(
402 "--replace {path} names no conflict this landing has; a conflict is an unattributed file or a recorded rendered file someone edited"
403 )));
404 };
405 prepared.collisions.remove(index);
406 let kind = prepared
407 .projection
408 .candidates
409 .iter()
410 .find(|candidate| candidate.destination == *path)
411 .map_or(Kind::Rendered, |candidate| candidate.kind);
412 prepared.decisions.push(Decision {
413 destination: path.clone(),
414 kind,
415 action: Action::Replaced,
416 accepted: None,
417 });
418 prepared.replacements.push(path.clone());
419 }
420 let order = |destination: &str| {
421 prepared
422 .projection
423 .candidates
424 .iter()
425 .position(|candidate| candidate.destination == destination)
426 .unwrap_or(usize::MAX)
427 };
428 let mut decisions = std::mem::take(&mut prepared.decisions);
429 decisions.sort_by_key(|decision| order(&decision.destination));
430 prepared.decisions = decisions;
431 Ok(())
432}
433
434#[allow(
446 clippy::too_many_lines,
447 reason = "one pass decides every destination by its placement, its record, and its bytes"
448)]
449pub fn decide(
450 target: &Held,
451 recorded: Option<&Manifest>,
452 projection: &Projection,
453 accepted_drift: &std::collections::BTreeMap<String, config::AcceptedDrift>,
454) -> Result<(Vec<Decision>, Vec<Collision>), RkError> {
455 let root = &target.root;
456 let mut decisions = Vec::new();
457 let mut collisions: Vec<Collision> = projection
458 .collisions
459 .iter()
460 .map(|collision| Collision {
461 path: collision.destination.clone(),
462 reason: collision.reason.clone(),
463 replaceable: false,
464 })
465 .collect();
466 for candidate in &projection.candidates {
467 let record = recorded.and_then(|record| record.file(&candidate.destination));
468 let located = match locate(root, &candidate.destination)? {
469 Located::Collision(reason) => {
470 collisions.push(Collision {
471 path: candidate.destination.clone(),
472 reason,
473 replaceable: false,
474 });
475 continue;
476 }
477 other => other,
478 };
479 let present = matches!(located, Located::Present { .. });
480 let current = || located.read();
481 let mut accepted = None;
482 let action = match (candidate.placement, present, record) {
483 (_, false, _) => Action::Created,
484 (Placement::Region { begin, end }, true, Some(record))
489 if record.kind == Kind::Rendered
490 && super::extract_block(&String::from_utf8_lossy(¤t()?), begin, end)
491 .is_some_and(|block| {
492 Digest::of(block.as_bytes()) != record.sha256
493 && candidate.region.as_deref() != Some(block.as_bytes())
494 }) =>
495 {
496 collisions.push(Collision {
497 path: candidate.destination.clone(),
498 reason: format!("its marked region {EDITED}"),
499 replaceable: true,
500 });
501 continue;
502 }
503 (Placement::Region { .. }, true, _) => Action::Replaced,
507 (Placement::Whole, true, None) => {
512 if current()? == candidate.bytes {
513 Action::Matched
514 } else {
515 collisions.push(Collision {
516 path: candidate.destination.clone(),
517 reason:
518 "exists with bytes differing from the candidate, and no receipt attributes it to release-kit"
519 .to_owned(),
520 replaceable: true,
521 });
522 continue;
523 }
524 }
525 (Placement::Whole, true, Some(record)) => match (candidate.kind, record.kind) {
526 (Kind::Rendered, Kind::Rendered) => {
530 let bytes = current()?;
531 let entry = accepted_drift
536 .get(&candidate.destination)
537 .filter(|entry| Digest::of(&bytes).to_string() == entry.sha256);
538 if bytes == candidate.bytes {
539 Action::Matched
540 } else if Digest::of(&bytes) == record.sha256 {
541 Action::Replaced
542 } else if let Some(entry) = entry {
543 accepted = Some(Accepted {
547 reason: entry.reason.clone(),
548 candidate_moved: Digest::of(&candidate.bytes) != record.sha256,
549 sha256: entry.sha256.clone(),
550 });
551 Action::Preserved
552 } else {
553 collisions.push(Collision {
554 path: candidate.destination.clone(),
555 reason: EDITED.to_owned(),
556 replaceable: true,
557 });
558 continue;
559 }
560 }
561 (Kind::Rendered, Kind::Seeded | Kind::State) => {
562 collisions.push(Collision {
563 path: candidate.destination.clone(),
564 reason: format!(
565 "is recorded as {}, and this release renders it, so its bytes are the target's",
566 record.kind.as_str()
567 ),
568 replaceable: true,
569 });
570 continue;
571 }
572 (Kind::Seeded, _) => {
573 if Digest::of(¤t()?) == record.sha256 {
574 Action::Preserved
575 } else {
576 Action::Drift
577 }
578 }
579 (Kind::State, _) => Action::Preserved,
580 },
581 };
582 decisions.push(Decision {
583 destination: candidate.destination.clone(),
584 kind: candidate.kind,
585 action,
586 accepted,
587 });
588 }
589 if let Some(record) = recorded {
590 decisions.extend(dropped(target, record, projection)?);
591 }
592 collisions.sort_by(|a, b| a.path.cmp(&b.path));
593 collisions.dedup_by(|a, b| a.path == b.path);
594 Ok((decisions, collisions))
595}
596
597pub fn dropped(
618 target: &Held,
619 recorded: &Manifest,
620 projection: &Projection,
621) -> Result<Vec<Decision>, RkError> {
622 let mut decisions = Vec::new();
623 for file in &recorded.files {
624 let produced = projection
625 .candidates
626 .iter()
627 .any(|candidate| candidate.destination == file.destination);
628 if produced {
629 continue;
630 }
631 let withheld = projection
632 .omissions
633 .iter()
634 .any(|omission| omission.destination == file.destination);
635 let retirable = !withheld
636 && file.kind == Kind::Rendered
637 && file.placement == manifest::Placement::Whole;
638 let action = if retirable && unedited(&target.root, file)? {
639 Action::Retired
640 } else {
641 Action::Dropped
642 };
643 decisions.push(Decision {
644 destination: file.destination.clone(),
645 kind: file.kind,
646 action,
647 accepted: None,
648 });
649 }
650 Ok(decisions)
651}
652
653fn unedited(root: &File, file: &FileRecord) -> std::io::Result<bool> {
656 Ok(match locate(root, &file.destination)? {
657 Located::Absent => true,
658 Located::Collision(_) => false,
659 present @ Located::Present { .. } => Digest::of(&present.read()?) == file.sha256,
660 })
661}
662
663enum Located {
665 Collision(String),
668 Absent,
670 Present { dir: File, name: std::ffi::OsString },
672}
673
674impl Located {
675 fn read(&self) -> std::io::Result<Vec<u8>> {
677 match self {
678 Self::Present { dir, name } => {
679 held::read_file(dir, name).map(Option::unwrap_or_default)
680 }
681 Self::Absent | Self::Collision(_) => Ok(Vec::new()),
682 }
683 }
684}
685
686fn locate(root: &File, destination: &str) -> std::io::Result<Located> {
690 let (parent, name) = split(Path::new(destination))?;
691 let dir = match held::hold_dir_existing(root, parent) {
692 Ok(dir) => dir,
693 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Located::Absent),
694 Err(error) => {
695 return Ok(Located::Collision(format!(
696 "a parent component cannot be held: {error}"
697 )));
698 }
699 };
700 match std::fs::symlink_metadata(held::proc_path(&dir).join(name)) {
701 Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => Ok(
702 Located::Collision("exists and is not a regular file".to_owned()),
703 ),
704 Ok(_) => Ok(Located::Present {
705 dir,
706 name: name.to_owned(),
707 }),
708 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Located::Absent),
709 Err(error) => Err(error),
710 }
711}
712
713#[must_use]
719pub fn licence_refusal(reason: &str) -> RkError {
720 RkError::refusal(
721 Diagnostic::new(
722 Reason::StateDrift,
723 format!("the code scanning provider's licence condition is not satisfied, and nothing was written: {reason}"),
724 )
725 .expected("a provider whose terms the target's declared licence permits")
726 .action("pass --code-scanning semgrep, which carries no licence condition, or --code-scanning off")
727 .target_state("unchanged"),
728 )
729}
730
731#[must_use]
737pub fn driver_refusal(reason: &str) -> RkError {
738 RkError::refusal(
739 Diagnostic::new(
740 Reason::PrerequisiteUnmet,
741 format!("the project cannot take the selected release driver, and nothing was written: {reason}"),
742 )
743 .expected("a uv project with a static [project] version and a committed uv.lock")
744 .action("uv lock, or a static version in [project], then land again")
745 .target_state("unchanged"),
746 )
747}
748
749#[must_use]
755pub fn release_unavailable(reason: &str) -> RkError {
756 RkError::refusal(
757 Diagnostic::new(
758 Reason::StateDrift,
759 format!("the selected release automation is not available in this release, and nothing was written: {reason}"),
760 )
761 .expected("a release driver and forge this release ships automation for, or a release mode that selects none")
762 .action("pass --release-driver and --forge at an available tuple, or --release-mode external or none")
763 .target_state("unchanged"),
764 )
765}
766
767#[must_use]
773pub fn refusal(target: &Utf8Path, collisions: &[Collision]) -> RkError {
774 let listed: Vec<String> = collisions
775 .iter()
776 .map(|collision| format!("{} ({})", collision.path, collision.reason))
777 .collect();
778 RkError::refusal(
779 Diagnostic::new(
780 Reason::StateDrift,
781 format!(
782 "these destinations cannot be landed as they stand, and nothing was written: {}",
783 listed.join("; ")
784 ),
785 )
786 .expected(
787 "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",
788 )
789 .action(format!(
790 "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"
791 ))
792 .target_state("unchanged"),
793 )
794}
795
796#[derive(Debug, Clone, Copy, PartialEq, Eq)]
798pub enum Origin {
799 Init,
801 Upgrade,
804 Adopt,
807}
808
809#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
815pub struct SecretMove {
816 pub path: String,
818 pub name: String,
820 pub direction: String,
823 #[serde(default, skip_serializing_if = "Option::is_none")]
825 pub step: Option<String>,
826}
827
828impl SecretMove {
829 #[must_use]
831 pub fn line(&self) -> String {
832 if self.direction == "dropped" {
833 format!(
834 "secret {} read by the replaced {} is not read by the rendered file",
835 self.name, self.path
836 )
837 } else {
838 self.step.as_deref().map_or_else(
839 || {
840 format!(
841 "secret {} is new; no setup step stores it, so the operator does",
842 self.name
843 )
844 },
845 |step| format!("secret {} is new; {step} stores it", self.name),
846 )
847 }
848 }
849}
850
851#[must_use]
855pub fn secret_names(text: &str) -> std::collections::BTreeSet<String> {
856 let mut names = std::collections::BTreeSet::new();
857 let mut rest = text;
858 while let Some(start) = rest.find("${{") {
859 let Some(end) = rest[start..].find("}}") else {
860 break;
861 };
862 let expression = &rest[start + 3..start + end];
863 let mut inner = expression;
864 while let Some(at) = inner.find("secrets.") {
865 let name: String = inner[at + "secrets.".len()..]
866 .chars()
867 .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
868 .collect();
869 if !name.is_empty() && name != "GITHUB_TOKEN" {
870 names.insert(name);
871 }
872 inner = &inner[at + "secrets.".len()..];
873 }
874 rest = &rest[start + end + 2..];
875 }
876 names
877}
878
879#[must_use]
883pub fn secrets_moved(path: &str, old: &str, new: &str) -> Vec<SecretMove> {
884 let (before, after) = (secret_names(old), secret_names(new));
885 let dropped = before.difference(&after).map(|name| SecretMove {
886 path: path.to_owned(),
887 name: name.clone(),
888 direction: "dropped".to_owned(),
889 step: None,
890 });
891 let added = after.difference(&before).map(|name| SecretMove {
892 path: path.to_owned(),
893 name: name.clone(),
894 direction: "new".to_owned(),
895 step: crate::setup::steps::secret_step(name).map(str::to_owned),
896 });
897 dropped.chain(added).collect()
898}
899
900pub fn replaced_secrets(target: &Held, prepared: &Prepared) -> Result<Vec<SecretMove>, RkError> {
908 let mut moved = Vec::new();
909 for decision in &prepared.decisions {
910 if decision.action != Action::Replaced
911 || !decision.destination.starts_with(".github/workflows/")
912 {
913 continue;
914 }
915 let Some(candidate) = prepared
916 .projection
917 .candidates
918 .iter()
919 .find(|candidate| candidate.destination == decision.destination)
920 else {
921 continue;
922 };
923 let Some(current) = read_relative(&target.root, &decision.destination)? else {
924 continue;
925 };
926 moved.extend(secrets_moved(
927 &decision.destination,
928 &String::from_utf8_lossy(¤t),
929 &String::from_utf8_lossy(&candidate.bytes),
930 ));
931 }
932 Ok(moved)
933}
934
935#[derive(Debug)]
937pub struct Landed {
938 pub completed: Vec<String>,
940 pub config_written: bool,
942 pub receipt: Manifest,
944 pub replaced: Vec<Replaced>,
946}
947
948pub fn land(
964 target: &Held,
965 recorded: Option<&Manifest>,
966 prepared: &Prepared,
967 origin: Origin,
968 _lock: &lock::TargetLock,
969) -> Result<Landed, RkError> {
970 if let Some(reason) = prepared.projection.licence_refusal.as_deref() {
971 return Err(licence_refusal(reason));
972 }
973 if let Some(reason) = prepared.projection.release_unavailable() {
974 return Err(release_unavailable(reason));
975 }
976 if let Some(reason) = prepared.projection.driver_refusal.as_deref() {
977 return Err(driver_refusal(reason));
978 }
979 if !prepared.collisions.is_empty() {
980 return Err(refusal(target.display(), &prepared.collisions));
981 }
982 let root = &target.root;
983 held::pause(PAUSE_VAR, "validated", "proceed");
987 let unwritten = reverify(root, prepared, origin)?;
992 let retiring = retiring(root, recorded, prepared)?;
995 let replaced = save_replacements(target, root, prepared)?;
998 let mut writer = Writer {
999 root,
1000 completed: Vec::new(),
1001 stop: std::env::var_os(INTERRUPT_VAR).map(|value| value.to_string_lossy().into_owned()),
1002 };
1003 let config_current = read_relative(root, config::CONFIG_PATH)?;
1005 let config_written = config_current.as_deref() != Some(prepared.config.content.as_bytes());
1006 if config_written {
1007 writer.write(config::CONFIG_PATH, prepared.config.content.as_bytes())?;
1008 }
1009 let mut files = Vec::new();
1010 for candidate in &prepared.projection.candidates {
1011 let accepted = prepared
1012 .decision(&candidate.destination)
1013 .is_some_and(|decision| decision.accepted.is_some());
1014 let sha256 = match unwritten.get(&candidate.destination) {
1015 Some(_) if accepted => candidate_digest(candidate),
1019 Some(digest) => digest.clone(),
1020 None => match action_of(prepared, origin, &candidate.destination) {
1021 Some(Action::Created | Action::Replaced) => {
1022 writer.write(&candidate.destination, &candidate.bytes)?;
1023 candidate_digest(candidate)
1024 }
1025 _ => continue,
1026 },
1027 };
1028 files.push(FileRecord {
1029 destination: candidate.destination.clone(),
1030 kind: candidate.kind,
1031 sha256,
1032 placement: match candidate.placement {
1033 Placement::Whole => manifest::Placement::Whole,
1034 Placement::Region { .. } => manifest::Placement::Region,
1035 },
1036 });
1037 }
1038 for destination in &retiring {
1042 writer.remove(destination)?;
1043 }
1044 let receipt = receipt(
1045 &prepared.params,
1046 &prepared.projection,
1047 recorded,
1048 origin,
1049 files,
1050 );
1051 writer.write(manifest::MANIFEST_PATH, &manifest::render(&receipt)?)?;
1052 Ok(Landed {
1053 completed: writer.completed,
1054 config_written,
1055 receipt,
1056 replaced,
1057 })
1058}
1059
1060fn save_replacements(
1062 target: &Held,
1063 root: &File,
1064 prepared: &Prepared,
1065) -> Result<Vec<Replaced>, RkError> {
1066 if prepared.replacements.is_empty() {
1067 return Ok(Vec::new());
1068 }
1069 let canonical = std::fs::canonicalize(target.display().as_std_path())?;
1073 let snapshot = crate::snapshot::Snapshot::begin(&canonical)?;
1074 let mut replaced = Vec::new();
1075 for path in &prepared.replacements {
1076 let saved_to = match read_relative(root, path)? {
1077 Some(bytes) => Some(snapshot.save_replaced(path, &bytes)?.display().to_string()),
1078 None => None,
1079 };
1080 replaced.push(Replaced {
1081 path: path.clone(),
1082 saved_to,
1083 });
1084 }
1085 Ok(replaced)
1086}
1087
1088#[must_use]
1091pub fn previewed_replacements(prepared: &Prepared) -> Vec<Replaced> {
1092 prepared
1093 .replacements
1094 .iter()
1095 .map(|path| Replaced {
1096 path: path.clone(),
1097 saved_to: None,
1098 })
1099 .collect()
1100}
1101
1102fn action_of(prepared: &Prepared, origin: Origin, destination: &str) -> Option<Action> {
1107 match origin {
1108 Origin::Adopt if prepared.replacements.iter().any(|path| path == destination) => {
1109 Some(Action::Replaced)
1110 }
1111 Origin::Adopt => Some(Action::Preserved),
1112 Origin::Init | Origin::Upgrade => prepared.decision(destination).map(|d| d.action),
1113 }
1114}
1115
1116fn current_form(root: &File, candidate: &Candidate) -> Result<Option<Vec<u8>>, RkError> {
1120 let Some(current) = read_relative(root, &candidate.destination)? else {
1121 return Ok(None);
1122 };
1123 Ok(match candidate.placement {
1124 Placement::Whole => Some(current),
1125 Placement::Region { begin, end } => {
1126 let text = String::from_utf8_lossy(¤t);
1127 super::extract_block(&text, begin, end).map(|block| block.as_bytes().to_vec())
1128 }
1129 })
1130}
1131
1132fn reverify(
1142 root: &File,
1143 prepared: &Prepared,
1144 origin: Origin,
1145) -> Result<std::collections::BTreeMap<String, Digest>, RkError> {
1146 let mut digests = std::collections::BTreeMap::new();
1147 for candidate in &prepared.projection.candidates {
1148 let action = action_of(prepared, origin, &candidate.destination);
1149 let must_match = match (origin, action) {
1150 (_, Some(Action::Replaced | Action::Created)) => continue,
1151 (Origin::Adopt, _) => candidate.kind == Kind::Rendered,
1152 (_, Some(Action::Matched)) => true,
1153 (_, Some(Action::Preserved | Action::Drift)) => false,
1154 _ => continue,
1155 };
1156 let Some(current) = current_form(root, candidate)? else {
1157 return Err(moved(&candidate.destination, "is no longer present"));
1158 };
1159 let expected: &[u8] = candidate.region.as_deref().unwrap_or(&candidate.bytes);
1160 if must_match && current != expected {
1161 return Err(moved(
1162 &candidate.destination,
1163 "no longer holds the candidate's bytes",
1164 ));
1165 }
1166 let accepted = prepared
1169 .decision(&candidate.destination)
1170 .and_then(|decision| decision.accepted.as_ref());
1171 if let Some(accepted) = accepted
1172 && Digest::of(¤t).to_string() != accepted.sha256
1173 {
1174 return Err(moved(
1175 &candidate.destination,
1176 "no longer holds the bytes its accepted drift entry names",
1177 ));
1178 }
1179 digests.insert(candidate.destination.clone(), Digest::of(¤t));
1180 }
1181 Ok(digests)
1182}
1183
1184fn retiring(
1193 root: &File,
1194 recorded: Option<&Manifest>,
1195 prepared: &Prepared,
1196) -> Result<Vec<String>, RkError> {
1197 let mut out = Vec::new();
1198 for decision in &prepared.decisions {
1199 if decision.action != Action::Retired {
1200 continue;
1201 }
1202 let Some(file) = recorded.and_then(|record| record.file(&decision.destination)) else {
1203 continue;
1204 };
1205 if !unedited(root, file)? {
1206 return Err(moved(
1207 &decision.destination,
1208 "no longer holds the bytes the record vouches for",
1209 ));
1210 }
1211 out.push(decision.destination.clone());
1212 }
1213 Ok(out)
1214}
1215
1216fn moved(destination: &str, what: &str) -> RkError {
1219 RkError::refusal(
1220 Diagnostic::new(
1221 Reason::StateDrift,
1222 format!(
1223 "{destination} {what} since it was validated, and nothing was written; the previous receipt stands"
1224 ),
1225 )
1226 .expected("every destination the landing leaves as it stands to stand still until the receipt is written")
1227 .action("re-run once the target is at rest")
1228 .target_state("unchanged"),
1229 )
1230}
1231
1232fn candidate_digest(candidate: &Candidate) -> Digest {
1235 candidate
1236 .region
1237 .as_deref()
1238 .map_or_else(|| Digest::of(&candidate.bytes), Digest::of)
1239}
1240
1241fn receipt(
1243 params: &Params,
1244 projection: &Projection,
1245 recorded: Option<&Manifest>,
1246 origin: Origin,
1247 files: Vec<FileRecord>,
1248) -> Manifest {
1249 Manifest {
1250 schema_version: manifest::SCHEMA_VERSION,
1251 rk_version: env!("CARGO_PKG_VERSION").to_owned(),
1252 origin: recorded.map_or_else(
1253 || match origin {
1254 Origin::Adopt => "adopt".to_owned(),
1255 Origin::Init | Origin::Upgrade => "init".to_owned(),
1256 },
1257 |record| record.origin.clone(),
1258 ),
1259 landed_at: recorded.map_or_else(manifest::now, |record| record.landed_at.clone()),
1260 profile: params.profile().clone(),
1261 git: params.git().clone(),
1262 capabilities: params.capabilities().clone(),
1263 parameters: Parameters {
1264 repo: params.repo().to_owned(),
1265 security_contact: params.security_contact().to_owned(),
1266 security_response: params.security_response().to_owned(),
1267 security_scope: params.security_scope().to_owned(),
1268 required_check: params.required_check().to_owned(),
1269 required_workflow: params.required_workflow().to_owned(),
1270 dev_environment: params.dev_environment().cloned(),
1271 },
1272 files,
1273 pins: crate::registry::pins_for(&projection.capabilities)
1274 .into_iter()
1275 .map(|pin| (pin.name, pin.version))
1276 .collect(),
1277 }
1278}
1279
1280fn read_relative(root: &File, relative: &str) -> std::io::Result<Option<Vec<u8>>> {
1283 let path = Path::new(relative);
1284 let (parent, name) = split(path)?;
1285 let dir = match held::hold_dir_existing(root, parent) {
1286 Ok(dir) => dir,
1287 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1288 Err(error) => return Err(error),
1289 };
1290 held::read_file(&dir, name)
1291}
1292
1293fn split(path: &Path) -> std::io::Result<(&Path, &OsStr)> {
1295 let name = path
1296 .file_name()
1297 .ok_or_else(|| std::io::Error::other(format!("{} has no file name", path.display())))?;
1298 let parent = path.parent().unwrap_or_else(|| Path::new(""));
1299 Ok((parent, name))
1300}
1301
1302struct Writer<'a> {
1305 root: &'a File,
1306 completed: Vec<String>,
1307 stop: Option<String>,
1308}
1309
1310impl Writer<'_> {
1311 fn write(&mut self, destination: &str, bytes: &[u8]) -> Result<(), RkError> {
1314 let path = Path::new(destination);
1315 let (parent, name) = split(path)?;
1316 let outcome = held::hold_dir(self.root, parent).and_then(|dir| {
1317 if self.stop.as_deref() == Some(destination) {
1318 return Err(std::io::Error::other(
1319 "the rename was stopped here for the proof",
1320 ));
1321 }
1322 held::write_file(&dir, name, bytes)
1323 });
1324 match outcome {
1325 Ok(()) => {
1326 self.completed.push(destination.to_owned());
1327 Ok(())
1328 }
1329 Err(error) => Err(self.failure(destination, bytes, &error)),
1330 }
1331 }
1332
1333 fn remove(&mut self, destination: &str) -> Result<(), RkError> {
1337 let path = Path::new(destination);
1338 let (parent, name) = split(path)?;
1339 let dir = match held::hold_dir_existing(self.root, parent) {
1340 Ok(dir) => dir,
1341 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
1342 Err(error) => return Err(self.failure(destination, &[], &error)),
1343 };
1344 match std::fs::remove_file(held::proc_path(&dir).join(name)) {
1345 Ok(()) => {
1346 self.completed.push(destination.to_owned());
1347 Ok(())
1348 }
1349 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1350 Err(error) => Err(self.failure(destination, &[], &error)),
1351 }
1352 }
1353
1354 fn failure(&self, destination: &str, bytes: &[u8], error: &std::io::Error) -> RkError {
1360 let observed = match read_relative(self.root, destination) {
1361 Ok(None) => "is absent".to_owned(),
1362 Ok(Some(current)) if current == bytes => "holds the candidate bytes whole".to_owned(),
1363 Ok(Some(_)) => "holds its previous bytes whole".to_owned(),
1364 Err(again) => format!("could not be observed again: {again}"),
1365 };
1366 let completed = if self.completed.is_empty() {
1367 "none".to_owned()
1368 } else {
1369 self.completed.join(", ")
1370 };
1371 RkError::Io(std::io::Error::new(
1372 error.kind(),
1373 format!(
1374 "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"
1375 ),
1376 ))
1377 }
1378}
1379
1380#[cfg(test)]
1381mod tests {
1382 use super::{Action, Held, Origin, Prepared, decide, land, prepare};
1383 use crate::landing::manifest::{self, Manifest};
1384 use crate::landing::{Params, Style, lock};
1385 use crate::projection::{Projection, ProjectionInput, TargetEvidence};
1386
1387 fn target() -> (tempfile::TempDir, camino::Utf8PathBuf) {
1388 let dir = tempfile::tempdir().expect("a scratch target exists");
1389 let path = camino::Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("utf-8");
1390 (dir, path)
1391 }
1392
1393 fn params() -> Params {
1394 Params::for_test("acme/widget", Some(Style::Trunk))
1395 }
1396
1397 fn prepared(target: &camino::Utf8Path, recorded: Option<&Manifest>) -> Prepared {
1398 prepare(
1399 &Held::open(target).expect("opens"),
1400 recorded,
1401 ¶ms(),
1402 None,
1403 )
1404 .expect("prepares")
1405 }
1406
1407 fn landed(
1408 target: &camino::Utf8Path,
1409 recorded: Option<&Manifest>,
1410 origin: Origin,
1411 ) -> super::Landed {
1412 let locks = tempfile::tempdir().expect("a scratch locks directory exists");
1413 let lock = lock::acquire_in(locks.path(), target).expect("the target is taken");
1414 let held = Held::open(target).expect("opens");
1415 land(&held, recorded, &prepared(target, recorded), origin, &lock).expect("lands")
1416 }
1417
1418 #[test]
1423 fn a_fresh_landing_creates_and_a_rerun_decides_by_the_receipt() {
1424 let (_dir, target) = target();
1425 let first = prepared(&target, None);
1426 assert!(first.collisions.is_empty(), "{:?}", first.collisions);
1427 assert!(
1428 first
1429 .decisions
1430 .iter()
1431 .all(|decision| decision.action == Action::Created)
1432 );
1433 let outcome = landed(&target, None, Origin::Init);
1434 assert_eq!(
1435 outcome.completed.last().map(String::as_str),
1436 Some(manifest::MANIFEST_PATH)
1437 );
1438 assert_eq!(outcome.receipt.schema_version, manifest::SCHEMA_VERSION);
1439 let record = manifest::load(&target).expect("loads").expect("exists");
1440 let again = prepared(&target, Some(&record));
1441 for decision in &again.decisions {
1442 let region = again.projection.candidates.iter().any(|candidate| {
1443 candidate.destination == decision.destination
1444 && matches!(
1445 candidate.placement,
1446 crate::projection::Placement::Region { .. }
1447 )
1448 });
1449 let expected = match decision.kind {
1450 crate::landing::Kind::Rendered if region => Action::Replaced,
1451 crate::landing::Kind::Rendered => Action::Matched,
1452 crate::landing::Kind::Seeded | crate::landing::Kind::State => Action::Preserved,
1453 };
1454 assert_eq!(decision.action, expected, "{}", decision.destination);
1455 }
1456 }
1457
1458 #[test]
1462 fn every_collision_is_collected_and_the_refusal_writes_nothing() {
1463 let (_dir, target) = target();
1464 std::fs::write(target.join("SECURITY.md"), "ours\n").expect("writes");
1465 std::fs::create_dir_all(target.join("release-plz.toml")).expect("creates");
1466 std::fs::write(
1467 target.join("AGENTS.md"),
1468 format!(
1469 "{b}\n{e}\n{b}\n{e}\n",
1470 b = crate::landing::BLOCK_BEGIN,
1471 e = crate::landing::BLOCK_END
1472 ),
1473 )
1474 .expect("writes");
1475 let prepared = prepared(&target, None);
1476 let paths: Vec<&str> = prepared
1477 .collisions
1478 .iter()
1479 .map(|collision| collision.path.as_str())
1480 .collect();
1481 assert_eq!(paths, ["AGENTS.md", "SECURITY.md", "release-plz.toml"]);
1482 let locks = tempfile::tempdir().expect("a scratch locks directory exists");
1483 let lock = lock::acquire_in(locks.path(), &target).expect("the target is taken");
1484 let held = Held::open(&target).expect("opens");
1485 let refused =
1486 land(&held, None, &prepared, Origin::Init, &lock).expect_err("the landing refuses");
1487 assert_eq!(refused.exit_code(), 73);
1488 assert!(!target.join(".release-kit").exists());
1489 assert!(!target.join("dist-workspace.toml").exists());
1490 }
1491
1492 #[test]
1496 fn a_dropped_destination_is_retired_unless_the_target_edited_it() {
1497 for (bytes, action, stays) in [
1498 (&b"old\n"[..], Action::Retired, false),
1499 (&b"edited\n"[..], Action::Dropped, true),
1500 ] {
1501 let (_dir, target) = target();
1502 landed(&target, None, Origin::Init);
1503 let mut record = manifest::load(&target).expect("loads").expect("exists");
1504 std::fs::write(target.join("legacy.yml"), bytes).expect("writes");
1505 record.files.push(manifest::FileRecord {
1506 destination: "legacy.yml".into(),
1507 kind: crate::landing::Kind::Rendered,
1508 sha256: crate::digest::Digest::of(b"old\n"),
1509 placement: manifest::Placement::Whole,
1510 });
1511 let (decisions, _) = decide(
1512 &Held::open(&target).expect("opens"),
1513 Some(&record),
1514 &Projection::compute(&ProjectionInput {
1515 params: params(),
1516 evidence: TargetEvidence::gather(&target, Some(&record)).expect("gathers"),
1517 })
1518 .expect("projects"),
1519 &std::collections::BTreeMap::new(),
1520 )
1521 .expect("decides");
1522 let decided = decisions
1523 .iter()
1524 .find(|decision| decision.destination == "legacy.yml")
1525 .expect("the dropped destination is decided");
1526 assert_eq!(decided.action, action);
1527 let outcome = landed(&target, Some(&record), Origin::Upgrade);
1528 assert_eq!(target.join("legacy.yml").is_file(), stays);
1529 assert!(outcome.receipt.file("legacy.yml").is_none());
1530 }
1531 }
1532}