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 Released,
73}
74
75impl Action {
76 #[must_use]
78 pub const fn as_str(self) -> &'static str {
79 match self {
80 Self::Created => "created",
81 Self::Replaced => "replaced",
82 Self::Matched => "matched",
83 Self::Preserved => "preserved",
84 Self::Drift => "drift",
85 Self::Released => "released",
86 }
87 }
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum Adoption {
97 Matches,
99 Tuned,
101 State,
103 Differs,
105 Missing {
108 exists: bool,
110 },
111 Irregular,
113}
114
115impl Adoption {
116 #[must_use]
120 pub const fn word(self) -> &'static str {
121 match self {
122 Self::Matches => "matched",
123 Self::Tuned | Self::State => "preserved",
124 Self::Differs | Self::Missing { .. } | Self::Irregular => "collision",
125 }
126 }
127
128 #[must_use]
132 pub fn replaceable(self, kind: Kind) -> bool {
133 matches!(self, Self::Differs)
134 || (matches!(self, Self::Missing { .. }) && kind == Kind::Rendered)
135 }
136}
137
138pub fn judge_adoption(target: &Held, candidate: &Candidate) -> Result<Adoption, RkError> {
145 match locate(&target.root, &candidate.destination)? {
149 Located::Collision(_) => return Ok(Adoption::Irregular),
150 Located::Absent => return Ok(Adoption::Missing { exists: false }),
151 Located::Present { .. } => {}
152 }
153 let Some(current) = super::read_recorded(target.base(), &candidate.destination)? else {
154 return Ok(Adoption::Missing { exists: true });
155 };
156 let expected: &[u8] = match candidate.placement {
157 Placement::Whole => &candidate.bytes,
158 Placement::Region { .. } => candidate.region.as_deref().unwrap_or(&candidate.bytes),
159 };
160 Ok(match candidate.kind {
161 Kind::Rendered | Kind::Seeded if current == expected => Adoption::Matches,
162 Kind::Rendered => Adoption::Differs,
163 Kind::Seeded => Adoption::Tuned,
164 Kind::State => Adoption::State,
165 })
166}
167
168#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct Accepted {
171 pub reason: String,
173 pub candidate_moved: bool,
177 pub sha256: String,
180}
181
182#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct Decision {
185 pub destination: String,
187 pub kind: Kind,
190 pub action: Action,
192 pub accepted: Option<Accepted>,
194}
195
196#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
203pub struct Collision {
204 pub path: String,
206 pub reason: String,
208 pub replaceable: bool,
213}
214
215#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
217pub struct Replaced {
218 pub path: String,
220 pub saved_to: Option<String>,
223}
224
225const EDITED: &str = "was edited after the landing, and the record no longer vouches for its bytes";
227
228#[derive(Debug)]
236pub struct Held {
237 root: File,
238 base: camino::Utf8PathBuf,
239 display: camino::Utf8PathBuf,
240}
241
242impl Held {
243 pub fn open(target: &Utf8Path) -> Result<Self, RkError> {
249 let root = held::open_dir(target.as_std_path())?;
250 let base = camino::Utf8PathBuf::from_path_buf(held::proc_path(&root))
251 .map_err(|path| anyhow::anyhow!("the kernel's link {} is not UTF-8", path.display()))?;
252 Ok(Self {
253 root,
254 base,
255 display: target.to_owned(),
256 })
257 }
258
259 pub fn open_locked(target: &Utf8Path, lock: &lock::TargetLock) -> Result<Self, RkError> {
267 let held = Self::open(target)?;
268 let opened = held::Identity::of(&held.root.metadata()?);
269 if lock.identity() != opened {
270 return Err(RkError::refusal(
271 Diagnostic::new(
272 Reason::StateDrift,
273 format!(
274 "the directory at {target} was exchanged after the lock was taken, and nothing was written"
275 ),
276 )
277 .expected("one directory at the target path from the lock through the receipt write")
278 .action("re-run once the target is at rest")
279 .target_state("unchanged"),
280 ));
281 }
282 Ok(held)
283 }
284
285 #[must_use]
288 pub fn base(&self) -> &Utf8Path {
289 &self.base
290 }
291
292 #[must_use]
294 pub fn display(&self) -> &Utf8Path {
295 &self.display
296 }
297}
298
299#[derive(Debug)]
303pub struct Prepared {
304 pub params: Params,
306 pub projection: Projection,
308 pub decisions: Vec<Decision>,
310 pub collisions: Vec<Collision>,
312 pub config: config::Plan,
314 pub replacements: Vec<String>,
317}
318
319impl Prepared {
320 #[must_use]
322 pub fn decision(&self, destination: &str) -> Option<&Decision> {
323 self.decisions
324 .iter()
325 .find(|decision| decision.destination == destination)
326 }
327}
328
329pub fn prepare(
340 target: &Held,
341 recorded: Option<&Manifest>,
342 params: &Params,
343 existing_config: Option<&config::Config>,
344) -> Result<Prepared, RkError> {
345 let evidence = TargetEvidence::gather(target.base(), recorded)?;
346 let projection = Projection::compute(&ProjectionInput {
347 params: params.clone(),
348 evidence,
349 })?;
350 super::accepted_drift_faults(&projection, recorded, existing_config)?;
351 let accepted_drift = existing_config
352 .map(|config| config.landing.accepted_drift.clone())
353 .unwrap_or_default();
354 let (decisions, collisions) = decide(target, recorded, &projection, &accepted_drift)?;
355 let config = config::Plan::new(
356 target.base().as_std_path(),
357 params,
358 existing_config,
359 recorded,
360 )?;
361 Ok(Prepared {
362 params: params.clone(),
363 projection,
364 decisions,
365 collisions,
366 config,
367 replacements: Vec::new(),
368 })
369}
370
371pub fn name_replacements(prepared: &mut Prepared, replace: &[String]) -> Result<(), RkError> {
385 for path in replace {
386 if prepared.replacements.contains(path) {
387 continue;
388 }
389 let Some(index) = prepared
390 .collisions
391 .iter()
392 .position(|collision| collision.path == *path && collision.replaceable)
393 else {
394 return Err(RkError::Usage(format!(
395 "--replace {path} names no conflict this landing has; a conflict is an unattributed file or a recorded rendered file someone edited"
396 )));
397 };
398 prepared.collisions.remove(index);
399 let kind = prepared
400 .projection
401 .candidates
402 .iter()
403 .find(|candidate| candidate.destination == *path)
404 .map_or(Kind::Rendered, |candidate| candidate.kind);
405 prepared.decisions.push(Decision {
406 destination: path.clone(),
407 kind,
408 action: Action::Replaced,
409 accepted: None,
410 });
411 prepared.replacements.push(path.clone());
412 }
413 let order = |destination: &str| {
414 prepared
415 .projection
416 .candidates
417 .iter()
418 .position(|candidate| candidate.destination == destination)
419 .unwrap_or(usize::MAX)
420 };
421 let mut decisions = std::mem::take(&mut prepared.decisions);
422 decisions.sort_by_key(|decision| order(&decision.destination));
423 prepared.decisions = decisions;
424 Ok(())
425}
426
427#[allow(
439 clippy::too_many_lines,
440 reason = "one pass decides every destination by its placement, its record, and its bytes"
441)]
442pub fn decide(
443 target: &Held,
444 recorded: Option<&Manifest>,
445 projection: &Projection,
446 accepted_drift: &std::collections::BTreeMap<String, config::AcceptedDrift>,
447) -> Result<(Vec<Decision>, Vec<Collision>), RkError> {
448 let root = &target.root;
449 let mut decisions = Vec::new();
450 let mut collisions: Vec<Collision> = projection
451 .collisions
452 .iter()
453 .map(|collision| Collision {
454 path: collision.destination.clone(),
455 reason: collision.reason.clone(),
456 replaceable: false,
457 })
458 .collect();
459 for candidate in &projection.candidates {
460 let record = recorded.and_then(|record| record.file(&candidate.destination));
461 let located = match locate(root, &candidate.destination)? {
462 Located::Collision(reason) => {
463 collisions.push(Collision {
464 path: candidate.destination.clone(),
465 reason,
466 replaceable: false,
467 });
468 continue;
469 }
470 other => other,
471 };
472 let present = matches!(located, Located::Present { .. });
473 let current = || located.read();
474 let mut accepted = None;
475 let action = match (candidate.placement, present, record) {
476 (_, false, _) => Action::Created,
477 (Placement::Region { begin, end }, true, Some(record))
482 if record.kind == Kind::Rendered
483 && super::extract_block(&String::from_utf8_lossy(¤t()?), begin, end)
484 .is_some_and(|block| {
485 Digest::of(block.as_bytes()) != record.sha256
486 && candidate.region.as_deref() != Some(block.as_bytes())
487 }) =>
488 {
489 collisions.push(Collision {
490 path: candidate.destination.clone(),
491 reason: format!("its marked region {EDITED}"),
492 replaceable: true,
493 });
494 continue;
495 }
496 (Placement::Region { .. }, true, _) => Action::Replaced,
500 (Placement::Whole, true, None) => {
505 if current()? == candidate.bytes {
506 Action::Matched
507 } else {
508 collisions.push(Collision {
509 path: candidate.destination.clone(),
510 reason:
511 "exists with bytes differing from the candidate, and no receipt attributes it to release-kit"
512 .to_owned(),
513 replaceable: true,
514 });
515 continue;
516 }
517 }
518 (Placement::Whole, true, Some(record)) => match (candidate.kind, record.kind) {
519 (Kind::Rendered, Kind::Rendered) => {
523 let bytes = current()?;
524 let entry = accepted_drift
529 .get(&candidate.destination)
530 .filter(|entry| Digest::of(&bytes).to_string() == entry.sha256);
531 if bytes == candidate.bytes {
532 Action::Matched
533 } else if Digest::of(&bytes) == record.sha256 {
534 Action::Replaced
535 } else if let Some(entry) = entry {
536 accepted = Some(Accepted {
540 reason: entry.reason.clone(),
541 candidate_moved: Digest::of(&candidate.bytes) != record.sha256,
542 sha256: entry.sha256.clone(),
543 });
544 Action::Preserved
545 } else {
546 collisions.push(Collision {
547 path: candidate.destination.clone(),
548 reason: EDITED.to_owned(),
549 replaceable: true,
550 });
551 continue;
552 }
553 }
554 (Kind::Rendered, Kind::Seeded | Kind::State) => {
555 collisions.push(Collision {
556 path: candidate.destination.clone(),
557 reason: format!(
558 "is recorded as {}, and this release renders it, so its bytes are the target's",
559 record.kind.as_str()
560 ),
561 replaceable: true,
562 });
563 continue;
564 }
565 (Kind::Seeded, _) => {
566 if Digest::of(¤t()?) == record.sha256 {
567 Action::Preserved
568 } else {
569 Action::Drift
570 }
571 }
572 (Kind::State, _) => Action::Preserved,
573 },
574 };
575 decisions.push(Decision {
576 destination: candidate.destination.clone(),
577 kind: candidate.kind,
578 action,
579 accepted,
580 });
581 }
582 if let Some(record) = recorded {
583 for file in &record.files {
584 let produced = projection
585 .candidates
586 .iter()
587 .any(|candidate| candidate.destination == file.destination);
588 if !produced {
589 decisions.push(Decision {
590 destination: file.destination.clone(),
591 kind: file.kind,
592 action: Action::Released,
593 accepted: None,
594 });
595 }
596 }
597 }
598 collisions.sort_by(|a, b| a.path.cmp(&b.path));
599 collisions.dedup_by(|a, b| a.path == b.path);
600 Ok((decisions, collisions))
601}
602
603enum Located {
605 Collision(String),
608 Absent,
610 Present { dir: File, name: std::ffi::OsString },
612}
613
614impl Located {
615 fn read(&self) -> std::io::Result<Vec<u8>> {
617 match self {
618 Self::Present { dir, name } => {
619 held::read_file(dir, name).map(Option::unwrap_or_default)
620 }
621 Self::Absent | Self::Collision(_) => Ok(Vec::new()),
622 }
623 }
624}
625
626fn locate(root: &File, destination: &str) -> std::io::Result<Located> {
630 let (parent, name) = split(Path::new(destination))?;
631 let dir = match held::hold_dir_existing(root, parent) {
632 Ok(dir) => dir,
633 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Located::Absent),
634 Err(error) => {
635 return Ok(Located::Collision(format!(
636 "a parent component cannot be held: {error}"
637 )));
638 }
639 };
640 match std::fs::symlink_metadata(held::proc_path(&dir).join(name)) {
641 Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => Ok(
642 Located::Collision("exists and is not a regular file".to_owned()),
643 ),
644 Ok(_) => Ok(Located::Present {
645 dir,
646 name: name.to_owned(),
647 }),
648 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Located::Absent),
649 Err(error) => Err(error),
650 }
651}
652
653#[must_use]
659pub fn licence_refusal(reason: &str) -> RkError {
660 RkError::refusal(
661 Diagnostic::new(
662 Reason::StateDrift,
663 format!("the code scanning provider's licence condition is not satisfied, and nothing was written: {reason}"),
664 )
665 .expected("a provider whose terms the target's declared licence permits")
666 .action("pass --code-scanning semgrep, which carries no licence condition, or --code-scanning off")
667 .target_state("unchanged"),
668 )
669}
670
671#[must_use]
677pub fn release_unavailable(reason: &str) -> RkError {
678 RkError::refusal(
679 Diagnostic::new(
680 Reason::StateDrift,
681 format!("the selected release automation is not available in this release, and nothing was written: {reason}"),
682 )
683 .expected("a release driver and forge this release ships automation for, or a release mode that selects none")
684 .action("pass --release-driver and --forge at an available tuple, or --release-mode external or none")
685 .target_state("unchanged"),
686 )
687}
688
689#[must_use]
695pub fn refusal(target: &Utf8Path, collisions: &[Collision]) -> RkError {
696 let listed: Vec<String> = collisions
697 .iter()
698 .map(|collision| format!("{} ({})", collision.path, collision.reason))
699 .collect();
700 RkError::refusal(
701 Diagnostic::new(
702 Reason::StateDrift,
703 format!(
704 "these destinations cannot be landed as they stand, and nothing was written: {}",
705 listed.join("; ")
706 ),
707 )
708 .expected(
709 "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",
710 )
711 .action(format!(
712 "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"
713 ))
714 .target_state("unchanged"),
715 )
716}
717
718#[derive(Debug, Clone, Copy, PartialEq, Eq)]
720pub enum Origin {
721 Init,
723 Upgrade,
726 Adopt,
729}
730
731#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
737pub struct SecretMove {
738 pub path: String,
740 pub name: String,
742 pub direction: String,
745 #[serde(default, skip_serializing_if = "Option::is_none")]
747 pub step: Option<String>,
748}
749
750impl SecretMove {
751 #[must_use]
753 pub fn line(&self) -> String {
754 if self.direction == "dropped" {
755 format!(
756 "secret {} read by the replaced {} is not read by the rendered file",
757 self.name, self.path
758 )
759 } else {
760 self.step.as_deref().map_or_else(
761 || {
762 format!(
763 "secret {} is new; no setup step stores it, so the operator does",
764 self.name
765 )
766 },
767 |step| format!("secret {} is new; {step} stores it", self.name),
768 )
769 }
770 }
771}
772
773#[must_use]
777pub fn secret_names(text: &str) -> std::collections::BTreeSet<String> {
778 let mut names = std::collections::BTreeSet::new();
779 let mut rest = text;
780 while let Some(start) = rest.find("${{") {
781 let Some(end) = rest[start..].find("}}") else {
782 break;
783 };
784 let expression = &rest[start + 3..start + end];
785 let mut inner = expression;
786 while let Some(at) = inner.find("secrets.") {
787 let name: String = inner[at + "secrets.".len()..]
788 .chars()
789 .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
790 .collect();
791 if !name.is_empty() && name != "GITHUB_TOKEN" {
792 names.insert(name);
793 }
794 inner = &inner[at + "secrets.".len()..];
795 }
796 rest = &rest[start + end + 2..];
797 }
798 names
799}
800
801#[must_use]
805pub fn secrets_moved(path: &str, old: &str, new: &str) -> Vec<SecretMove> {
806 let (before, after) = (secret_names(old), secret_names(new));
807 let dropped = before.difference(&after).map(|name| SecretMove {
808 path: path.to_owned(),
809 name: name.clone(),
810 direction: "dropped".to_owned(),
811 step: None,
812 });
813 let added = after.difference(&before).map(|name| SecretMove {
814 path: path.to_owned(),
815 name: name.clone(),
816 direction: "new".to_owned(),
817 step: crate::setup::steps::secret_step(name).map(str::to_owned),
818 });
819 dropped.chain(added).collect()
820}
821
822pub fn replaced_secrets(target: &Held, prepared: &Prepared) -> Result<Vec<SecretMove>, RkError> {
830 let mut moved = Vec::new();
831 for decision in &prepared.decisions {
832 if decision.action != Action::Replaced
833 || !decision.destination.starts_with(".github/workflows/")
834 {
835 continue;
836 }
837 let Some(candidate) = prepared
838 .projection
839 .candidates
840 .iter()
841 .find(|candidate| candidate.destination == decision.destination)
842 else {
843 continue;
844 };
845 let Some(current) = read_relative(&target.root, &decision.destination)? else {
846 continue;
847 };
848 moved.extend(secrets_moved(
849 &decision.destination,
850 &String::from_utf8_lossy(¤t),
851 &String::from_utf8_lossy(&candidate.bytes),
852 ));
853 }
854 Ok(moved)
855}
856
857#[derive(Debug)]
859pub struct Landed {
860 pub completed: Vec<String>,
862 pub config_written: bool,
864 pub receipt: Manifest,
866 pub replaced: Vec<Replaced>,
868}
869
870pub fn land(
886 target: &Held,
887 recorded: Option<&Manifest>,
888 prepared: &Prepared,
889 origin: Origin,
890 _lock: &lock::TargetLock,
891) -> Result<Landed, RkError> {
892 if let Some(reason) = prepared.projection.licence_refusal.as_deref() {
893 return Err(licence_refusal(reason));
894 }
895 if let Some(reason) = prepared.projection.release_unavailable() {
896 return Err(release_unavailable(reason));
897 }
898 if !prepared.collisions.is_empty() {
899 return Err(refusal(target.display(), &prepared.collisions));
900 }
901 let root = &target.root;
902 held::pause(PAUSE_VAR, "validated", "proceed");
906 let unwritten = reverify(root, prepared, origin)?;
911 let replaced = save_replacements(target, root, prepared)?;
914 let mut writer = Writer {
915 root,
916 completed: Vec::new(),
917 stop: std::env::var_os(INTERRUPT_VAR).map(|value| value.to_string_lossy().into_owned()),
918 };
919 let config_current = read_relative(root, config::CONFIG_PATH)?;
921 let config_written = config_current.as_deref() != Some(prepared.config.content.as_bytes());
922 if config_written {
923 writer.write(config::CONFIG_PATH, prepared.config.content.as_bytes())?;
924 }
925 let mut files = Vec::new();
926 for candidate in &prepared.projection.candidates {
927 let accepted = prepared
928 .decision(&candidate.destination)
929 .is_some_and(|decision| decision.accepted.is_some());
930 let sha256 = match unwritten.get(&candidate.destination) {
931 Some(_) if accepted => candidate_digest(candidate),
935 Some(digest) => digest.clone(),
936 None => match action_of(prepared, origin, &candidate.destination) {
937 Some(Action::Created | Action::Replaced) => {
938 writer.write(&candidate.destination, &candidate.bytes)?;
939 candidate_digest(candidate)
940 }
941 _ => continue,
942 },
943 };
944 files.push(FileRecord {
945 destination: candidate.destination.clone(),
946 kind: candidate.kind,
947 sha256,
948 placement: match candidate.placement {
949 Placement::Whole => manifest::Placement::Whole,
950 Placement::Region { .. } => manifest::Placement::Region,
951 },
952 });
953 }
954 let receipt = receipt(
955 &prepared.params,
956 &prepared.projection,
957 recorded,
958 origin,
959 files,
960 );
961 writer.write(manifest::MANIFEST_PATH, &manifest::render(&receipt)?)?;
962 Ok(Landed {
963 completed: writer.completed,
964 config_written,
965 receipt,
966 replaced,
967 })
968}
969
970fn save_replacements(
972 target: &Held,
973 root: &File,
974 prepared: &Prepared,
975) -> Result<Vec<Replaced>, RkError> {
976 if prepared.replacements.is_empty() {
977 return Ok(Vec::new());
978 }
979 let canonical = std::fs::canonicalize(target.display().as_std_path())?;
983 let snapshot = crate::snapshot::Snapshot::begin(&canonical)?;
984 let mut replaced = Vec::new();
985 for path in &prepared.replacements {
986 let saved_to = match read_relative(root, path)? {
987 Some(bytes) => Some(snapshot.save_replaced(path, &bytes)?.display().to_string()),
988 None => None,
989 };
990 replaced.push(Replaced {
991 path: path.clone(),
992 saved_to,
993 });
994 }
995 Ok(replaced)
996}
997
998#[must_use]
1001pub fn previewed_replacements(prepared: &Prepared) -> Vec<Replaced> {
1002 prepared
1003 .replacements
1004 .iter()
1005 .map(|path| Replaced {
1006 path: path.clone(),
1007 saved_to: None,
1008 })
1009 .collect()
1010}
1011
1012fn action_of(prepared: &Prepared, origin: Origin, destination: &str) -> Option<Action> {
1017 match origin {
1018 Origin::Adopt if prepared.replacements.iter().any(|path| path == destination) => {
1019 Some(Action::Replaced)
1020 }
1021 Origin::Adopt => Some(Action::Preserved),
1022 Origin::Init | Origin::Upgrade => prepared.decision(destination).map(|d| d.action),
1023 }
1024}
1025
1026fn current_form(root: &File, candidate: &Candidate) -> Result<Option<Vec<u8>>, RkError> {
1030 let Some(current) = read_relative(root, &candidate.destination)? else {
1031 return Ok(None);
1032 };
1033 Ok(match candidate.placement {
1034 Placement::Whole => Some(current),
1035 Placement::Region { begin, end } => {
1036 let text = String::from_utf8_lossy(¤t);
1037 super::extract_block(&text, begin, end).map(|block| block.as_bytes().to_vec())
1038 }
1039 })
1040}
1041
1042fn reverify(
1052 root: &File,
1053 prepared: &Prepared,
1054 origin: Origin,
1055) -> Result<std::collections::BTreeMap<String, Digest>, RkError> {
1056 let mut digests = std::collections::BTreeMap::new();
1057 for candidate in &prepared.projection.candidates {
1058 let action = action_of(prepared, origin, &candidate.destination);
1059 let must_match = match (origin, action) {
1060 (_, Some(Action::Replaced | Action::Created)) => continue,
1061 (Origin::Adopt, _) => candidate.kind == Kind::Rendered,
1062 (_, Some(Action::Matched)) => true,
1063 (_, Some(Action::Preserved | Action::Drift)) => false,
1064 _ => continue,
1065 };
1066 let Some(current) = current_form(root, candidate)? else {
1067 return Err(moved(&candidate.destination, "is no longer present"));
1068 };
1069 let expected: &[u8] = candidate.region.as_deref().unwrap_or(&candidate.bytes);
1070 if must_match && current != expected {
1071 return Err(moved(
1072 &candidate.destination,
1073 "no longer holds the candidate's bytes",
1074 ));
1075 }
1076 let accepted = prepared
1079 .decision(&candidate.destination)
1080 .and_then(|decision| decision.accepted.as_ref());
1081 if let Some(accepted) = accepted
1082 && Digest::of(¤t).to_string() != accepted.sha256
1083 {
1084 return Err(moved(
1085 &candidate.destination,
1086 "no longer holds the bytes its accepted drift entry names",
1087 ));
1088 }
1089 digests.insert(candidate.destination.clone(), Digest::of(¤t));
1090 }
1091 Ok(digests)
1092}
1093
1094fn moved(destination: &str, what: &str) -> RkError {
1097 RkError::refusal(
1098 Diagnostic::new(
1099 Reason::StateDrift,
1100 format!(
1101 "{destination} {what} since it was validated, and nothing was written; the previous receipt stands"
1102 ),
1103 )
1104 .expected("every destination the landing leaves as it stands to stand still until the receipt is written")
1105 .action("re-run once the target is at rest")
1106 .target_state("unchanged"),
1107 )
1108}
1109
1110fn candidate_digest(candidate: &Candidate) -> Digest {
1113 candidate
1114 .region
1115 .as_deref()
1116 .map_or_else(|| Digest::of(&candidate.bytes), Digest::of)
1117}
1118
1119fn receipt(
1121 params: &Params,
1122 projection: &Projection,
1123 recorded: Option<&Manifest>,
1124 origin: Origin,
1125 files: Vec<FileRecord>,
1126) -> Manifest {
1127 Manifest {
1128 schema_version: manifest::SCHEMA_VERSION,
1129 rk_version: env!("CARGO_PKG_VERSION").to_owned(),
1130 origin: recorded.map_or_else(
1131 || match origin {
1132 Origin::Adopt => "adopt".to_owned(),
1133 Origin::Init | Origin::Upgrade => "init".to_owned(),
1134 },
1135 |record| record.origin.clone(),
1136 ),
1137 landed_at: recorded.map_or_else(manifest::now, |record| record.landed_at.clone()),
1138 profile: params.profile().clone(),
1139 git: params.git().clone(),
1140 capabilities: params.capabilities().clone(),
1141 parameters: Parameters {
1142 repo: params.repo().to_owned(),
1143 security_contact: params.security_contact().to_owned(),
1144 security_response: params.security_response().to_owned(),
1145 security_scope: params.security_scope().to_owned(),
1146 required_check: params.required_check().to_owned(),
1147 required_workflow: params.required_workflow().to_owned(),
1148 dev_environment: params.dev_environment().cloned(),
1149 },
1150 files,
1151 pins: crate::registry::pins_for(&projection.capabilities)
1152 .into_iter()
1153 .map(|pin| (pin.name, pin.version))
1154 .collect(),
1155 }
1156}
1157
1158fn read_relative(root: &File, relative: &str) -> std::io::Result<Option<Vec<u8>>> {
1161 let path = Path::new(relative);
1162 let (parent, name) = split(path)?;
1163 let dir = match held::hold_dir_existing(root, parent) {
1164 Ok(dir) => dir,
1165 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1166 Err(error) => return Err(error),
1167 };
1168 held::read_file(&dir, name)
1169}
1170
1171fn split(path: &Path) -> std::io::Result<(&Path, &OsStr)> {
1173 let name = path
1174 .file_name()
1175 .ok_or_else(|| std::io::Error::other(format!("{} has no file name", path.display())))?;
1176 let parent = path.parent().unwrap_or_else(|| Path::new(""));
1177 Ok((parent, name))
1178}
1179
1180struct Writer<'a> {
1183 root: &'a File,
1184 completed: Vec<String>,
1185 stop: Option<String>,
1186}
1187
1188impl Writer<'_> {
1189 fn write(&mut self, destination: &str, bytes: &[u8]) -> Result<(), RkError> {
1192 let path = Path::new(destination);
1193 let (parent, name) = split(path)?;
1194 let outcome = held::hold_dir(self.root, parent).and_then(|dir| {
1195 if self.stop.as_deref() == Some(destination) {
1196 return Err(std::io::Error::other(
1197 "the rename was stopped here for the proof",
1198 ));
1199 }
1200 held::write_file(&dir, name, bytes)
1201 });
1202 match outcome {
1203 Ok(()) => {
1204 self.completed.push(destination.to_owned());
1205 Ok(())
1206 }
1207 Err(error) => Err(self.failure(destination, bytes, &error)),
1208 }
1209 }
1210
1211 fn failure(&self, destination: &str, bytes: &[u8], error: &std::io::Error) -> RkError {
1217 let observed = match read_relative(self.root, destination) {
1218 Ok(None) => "is absent".to_owned(),
1219 Ok(Some(current)) if current == bytes => "holds the candidate bytes whole".to_owned(),
1220 Ok(Some(_)) => "holds its previous bytes whole".to_owned(),
1221 Err(again) => format!("could not be observed again: {again}"),
1222 };
1223 let completed = if self.completed.is_empty() {
1224 "none".to_owned()
1225 } else {
1226 self.completed.join(", ")
1227 };
1228 RkError::Io(std::io::Error::new(
1229 error.kind(),
1230 format!(
1231 "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"
1232 ),
1233 ))
1234 }
1235}
1236
1237#[cfg(test)]
1238mod tests {
1239 use super::{Action, Held, Origin, Prepared, decide, land, prepare};
1240 use crate::landing::manifest::{self, Manifest};
1241 use crate::landing::{Params, Style, lock};
1242 use crate::projection::{Projection, ProjectionInput, TargetEvidence};
1243
1244 fn target() -> (tempfile::TempDir, camino::Utf8PathBuf) {
1245 let dir = tempfile::tempdir().expect("a scratch target exists");
1246 let path = camino::Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("utf-8");
1247 (dir, path)
1248 }
1249
1250 fn params() -> Params {
1251 Params::for_test("acme/widget", Some(Style::Trunk))
1252 }
1253
1254 fn prepared(target: &camino::Utf8Path, recorded: Option<&Manifest>) -> Prepared {
1255 prepare(
1256 &Held::open(target).expect("opens"),
1257 recorded,
1258 ¶ms(),
1259 None,
1260 )
1261 .expect("prepares")
1262 }
1263
1264 fn landed(
1265 target: &camino::Utf8Path,
1266 recorded: Option<&Manifest>,
1267 origin: Origin,
1268 ) -> super::Landed {
1269 let locks = tempfile::tempdir().expect("a scratch locks directory exists");
1270 let lock = lock::acquire_in(locks.path(), target).expect("the target is taken");
1271 let held = Held::open(target).expect("opens");
1272 land(&held, recorded, &prepared(target, recorded), origin, &lock).expect("lands")
1273 }
1274
1275 #[test]
1280 fn a_fresh_landing_creates_and_a_rerun_decides_by_the_receipt() {
1281 let (_dir, target) = target();
1282 let first = prepared(&target, None);
1283 assert!(first.collisions.is_empty(), "{:?}", first.collisions);
1284 assert!(
1285 first
1286 .decisions
1287 .iter()
1288 .all(|decision| decision.action == Action::Created)
1289 );
1290 let outcome = landed(&target, None, Origin::Init);
1291 assert_eq!(
1292 outcome.completed.last().map(String::as_str),
1293 Some(manifest::MANIFEST_PATH)
1294 );
1295 assert_eq!(outcome.receipt.schema_version, manifest::SCHEMA_VERSION);
1296 let record = manifest::load(&target).expect("loads").expect("exists");
1297 let again = prepared(&target, Some(&record));
1298 for decision in &again.decisions {
1299 let region = again.projection.candidates.iter().any(|candidate| {
1300 candidate.destination == decision.destination
1301 && matches!(
1302 candidate.placement,
1303 crate::projection::Placement::Region { .. }
1304 )
1305 });
1306 let expected = match decision.kind {
1307 crate::landing::Kind::Rendered if region => Action::Replaced,
1308 crate::landing::Kind::Rendered => Action::Matched,
1309 crate::landing::Kind::Seeded | crate::landing::Kind::State => Action::Preserved,
1310 };
1311 assert_eq!(decision.action, expected, "{}", decision.destination);
1312 }
1313 }
1314
1315 #[test]
1319 fn every_collision_is_collected_and_the_refusal_writes_nothing() {
1320 let (_dir, target) = target();
1321 std::fs::write(target.join("SECURITY.md"), "ours\n").expect("writes");
1322 std::fs::create_dir_all(target.join("release-plz.toml")).expect("creates");
1323 std::fs::write(
1324 target.join("AGENTS.md"),
1325 format!(
1326 "{b}\n{e}\n{b}\n{e}\n",
1327 b = crate::landing::BLOCK_BEGIN,
1328 e = crate::landing::BLOCK_END
1329 ),
1330 )
1331 .expect("writes");
1332 let prepared = prepared(&target, None);
1333 let paths: Vec<&str> = prepared
1334 .collisions
1335 .iter()
1336 .map(|collision| collision.path.as_str())
1337 .collect();
1338 assert_eq!(paths, ["AGENTS.md", "SECURITY.md", "release-plz.toml"]);
1339 let locks = tempfile::tempdir().expect("a scratch locks directory exists");
1340 let lock = lock::acquire_in(locks.path(), &target).expect("the target is taken");
1341 let held = Held::open(&target).expect("opens");
1342 let refused =
1343 land(&held, None, &prepared, Origin::Init, &lock).expect_err("the landing refuses");
1344 assert_eq!(refused.exit_code(), 73);
1345 assert!(!target.join(".release-kit").exists());
1346 assert!(!target.join("dist-workspace.toml").exists());
1347 }
1348
1349 #[test]
1352 fn a_released_destination_stays_and_leaves_the_receipt() {
1353 let (_dir, target) = target();
1354 landed(&target, None, Origin::Init);
1355 let mut record = manifest::load(&target).expect("loads").expect("exists");
1356 std::fs::write(target.join("legacy.yml"), "old\n").expect("writes");
1357 record.files.push(manifest::FileRecord {
1358 destination: "legacy.yml".into(),
1359 kind: crate::landing::Kind::Rendered,
1360 sha256: crate::digest::Digest::of(b"old\n"),
1361 placement: manifest::Placement::Whole,
1362 });
1363 let (decisions, _) = decide(
1364 &Held::open(&target).expect("opens"),
1365 Some(&record),
1366 &Projection::compute(&ProjectionInput {
1367 params: params(),
1368 evidence: TargetEvidence::gather(&target, Some(&record)).expect("gathers"),
1369 })
1370 .expect("projects"),
1371 &std::collections::BTreeMap::new(),
1372 )
1373 .expect("decides");
1374 let released = decisions
1375 .iter()
1376 .find(|decision| decision.destination == "legacy.yml")
1377 .expect("the released destination is decided");
1378 assert_eq!(released.action, Action::Released);
1379 let outcome = landed(&target, Some(&record), Origin::Upgrade);
1380 assert!(target.join("legacy.yml").is_file());
1381 assert!(outcome.receipt.file("legacy.yml").is_none());
1382 }
1383}