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,
57 Matched,
62 Preserved,
65 Drift,
68 Released,
71}
72
73impl Action {
74 #[must_use]
76 pub const fn as_str(self) -> &'static str {
77 match self {
78 Self::Created => "created",
79 Self::Replaced => "replaced",
80 Self::Matched => "matched",
81 Self::Preserved => "preserved",
82 Self::Drift => "drift",
83 Self::Released => "released",
84 }
85 }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum Adoption {
95 Matches,
97 Tuned,
99 State,
101 Differs,
103 Missing {
106 exists: bool,
108 },
109 Irregular,
111}
112
113impl Adoption {
114 #[must_use]
118 pub const fn word(self) -> &'static str {
119 match self {
120 Self::Matches => "matched",
121 Self::Tuned | Self::State => "preserved",
122 Self::Differs | Self::Missing { .. } | Self::Irregular => "collision",
123 }
124 }
125
126 #[must_use]
130 pub fn replaceable(self, kind: Kind) -> bool {
131 matches!(self, Self::Differs)
132 || (matches!(self, Self::Missing { .. }) && kind == Kind::Rendered)
133 }
134}
135
136pub fn judge_adoption(target: &Held, candidate: &Candidate) -> Result<Adoption, RkError> {
143 match locate(&target.root, &candidate.destination)? {
147 Located::Collision(_) => return Ok(Adoption::Irregular),
148 Located::Absent => return Ok(Adoption::Missing { exists: false }),
149 Located::Present { .. } => {}
150 }
151 let Some(current) = super::read_recorded(target.base(), &candidate.destination)? else {
152 return Ok(Adoption::Missing { exists: true });
153 };
154 let expected: &[u8] = match candidate.placement {
155 Placement::Whole => &candidate.bytes,
156 Placement::Region { .. } => candidate.region.as_deref().unwrap_or(&candidate.bytes),
157 };
158 Ok(match candidate.kind {
159 Kind::Rendered | Kind::Seeded if current == expected => Adoption::Matches,
160 Kind::Rendered => Adoption::Differs,
161 Kind::Seeded => Adoption::Tuned,
162 Kind::State => Adoption::State,
163 })
164}
165
166#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct Decision {
169 pub destination: String,
171 pub kind: Kind,
174 pub action: Action,
176}
177
178#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
185pub struct Collision {
186 pub path: String,
188 pub reason: String,
190 pub replaceable: bool,
195}
196
197#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
199pub struct Replaced {
200 pub path: String,
202 pub saved_to: Option<String>,
205}
206
207const EDITED: &str = "was edited after the landing, and the record no longer vouches for its bytes";
209
210#[derive(Debug)]
218pub struct Held {
219 root: File,
220 base: camino::Utf8PathBuf,
221 display: camino::Utf8PathBuf,
222}
223
224impl Held {
225 pub fn open(target: &Utf8Path) -> Result<Self, RkError> {
231 let root = held::open_dir(target.as_std_path())?;
232 let base = camino::Utf8PathBuf::from_path_buf(held::proc_path(&root))
233 .map_err(|path| anyhow::anyhow!("the kernel's link {} is not UTF-8", path.display()))?;
234 Ok(Self {
235 root,
236 base,
237 display: target.to_owned(),
238 })
239 }
240
241 pub fn open_locked(target: &Utf8Path, lock: &lock::TargetLock) -> Result<Self, RkError> {
249 let held = Self::open(target)?;
250 let opened = held::Identity::of(&held.root.metadata()?);
251 if lock.identity() != opened {
252 return Err(RkError::refusal(
253 Diagnostic::new(
254 Reason::StateDrift,
255 format!(
256 "the directory at {target} was exchanged after the lock was taken, and nothing was written"
257 ),
258 )
259 .expected("one directory at the target path from the lock through the receipt write")
260 .action("re-run once the target is at rest")
261 .target_state("unchanged"),
262 ));
263 }
264 Ok(held)
265 }
266
267 #[must_use]
270 pub fn base(&self) -> &Utf8Path {
271 &self.base
272 }
273
274 #[must_use]
276 pub fn display(&self) -> &Utf8Path {
277 &self.display
278 }
279}
280
281#[derive(Debug)]
285pub struct Prepared {
286 pub params: Params,
288 pub projection: Projection,
290 pub decisions: Vec<Decision>,
292 pub collisions: Vec<Collision>,
294 pub config: config::Plan,
296 pub replacements: Vec<String>,
299}
300
301impl Prepared {
302 #[must_use]
304 pub fn decision(&self, destination: &str) -> Option<&Decision> {
305 self.decisions
306 .iter()
307 .find(|decision| decision.destination == destination)
308 }
309}
310
311pub fn prepare(
322 target: &Held,
323 recorded: Option<&Manifest>,
324 params: &Params,
325 existing_config: Option<&config::Config>,
326) -> Result<Prepared, RkError> {
327 let evidence = TargetEvidence::gather(target.base(), recorded)?;
328 let projection = Projection::compute(&ProjectionInput {
329 params: params.clone(),
330 evidence,
331 })?;
332 let (decisions, collisions) = decide(target, recorded, &projection)?;
333 let config = config::Plan::new(
334 target.base().as_std_path(),
335 params,
336 existing_config,
337 recorded,
338 )?;
339 Ok(Prepared {
340 params: params.clone(),
341 projection,
342 decisions,
343 collisions,
344 config,
345 replacements: Vec::new(),
346 })
347}
348
349pub fn name_replacements(prepared: &mut Prepared, replace: &[String]) -> Result<(), RkError> {
363 for path in replace {
364 if prepared.replacements.contains(path) {
365 continue;
366 }
367 let Some(index) = prepared
368 .collisions
369 .iter()
370 .position(|collision| collision.path == *path && collision.replaceable)
371 else {
372 return Err(RkError::Usage(format!(
373 "--replace {path} names no conflict this landing has; a conflict is an unattributed file or a recorded rendered file someone edited"
374 )));
375 };
376 prepared.collisions.remove(index);
377 let kind = prepared
378 .projection
379 .candidates
380 .iter()
381 .find(|candidate| candidate.destination == *path)
382 .map_or(Kind::Rendered, |candidate| candidate.kind);
383 prepared.decisions.push(Decision {
384 destination: path.clone(),
385 kind,
386 action: Action::Replaced,
387 });
388 prepared.replacements.push(path.clone());
389 }
390 let order = |destination: &str| {
391 prepared
392 .projection
393 .candidates
394 .iter()
395 .position(|candidate| candidate.destination == destination)
396 .unwrap_or(usize::MAX)
397 };
398 let mut decisions = std::mem::take(&mut prepared.decisions);
399 decisions.sort_by_key(|decision| order(&decision.destination));
400 prepared.decisions = decisions;
401 Ok(())
402}
403
404#[allow(
416 clippy::too_many_lines,
417 reason = "one pass decides every destination by its placement, its record, and its bytes"
418)]
419pub fn decide(
420 target: &Held,
421 recorded: Option<&Manifest>,
422 projection: &Projection,
423) -> Result<(Vec<Decision>, Vec<Collision>), RkError> {
424 let root = &target.root;
425 let mut decisions = Vec::new();
426 let mut collisions: Vec<Collision> = projection
427 .collisions
428 .iter()
429 .map(|collision| Collision {
430 path: collision.destination.clone(),
431 reason: collision.reason.clone(),
432 replaceable: false,
433 })
434 .collect();
435 for candidate in &projection.candidates {
436 let record = recorded.and_then(|record| record.file(&candidate.destination));
437 let located = match locate(root, &candidate.destination)? {
438 Located::Collision(reason) => {
439 collisions.push(Collision {
440 path: candidate.destination.clone(),
441 reason,
442 replaceable: false,
443 });
444 continue;
445 }
446 other => other,
447 };
448 let present = matches!(located, Located::Present { .. });
449 let current = || located.read();
450 let action = match (candidate.placement, present, record) {
451 (_, false, _) => Action::Created,
452 (Placement::Region { begin, end }, true, Some(record))
457 if record.kind == Kind::Rendered
458 && super::extract_block(&String::from_utf8_lossy(¤t()?), begin, end)
459 .is_some_and(|block| {
460 Digest::of(block.as_bytes()) != record.sha256
461 && candidate.region.as_deref() != Some(block.as_bytes())
462 }) =>
463 {
464 collisions.push(Collision {
465 path: candidate.destination.clone(),
466 reason: format!("its marked region {EDITED}"),
467 replaceable: true,
468 });
469 continue;
470 }
471 (Placement::Region { .. }, true, _) => Action::Replaced,
475 (Placement::Whole, true, None) => {
480 if current()? == candidate.bytes {
481 Action::Matched
482 } else {
483 collisions.push(Collision {
484 path: candidate.destination.clone(),
485 reason:
486 "exists with bytes differing from the candidate, and no receipt attributes it to release-kit"
487 .to_owned(),
488 replaceable: true,
489 });
490 continue;
491 }
492 }
493 (Placement::Whole, true, Some(record)) => match (candidate.kind, record.kind) {
494 (Kind::Rendered, Kind::Rendered) => {
498 let bytes = current()?;
499 if Digest::of(&bytes) == record.sha256 || bytes == candidate.bytes {
503 Action::Replaced
504 } else {
505 collisions.push(Collision {
506 path: candidate.destination.clone(),
507 reason: EDITED.to_owned(),
508 replaceable: true,
509 });
510 continue;
511 }
512 }
513 (Kind::Rendered, Kind::Seeded | Kind::State) => {
514 collisions.push(Collision {
515 path: candidate.destination.clone(),
516 reason: format!(
517 "is recorded as {}, and this release renders it, so its bytes are the target's",
518 record.kind.as_str()
519 ),
520 replaceable: true,
521 });
522 continue;
523 }
524 (Kind::Seeded, _) => {
525 if Digest::of(¤t()?) == record.sha256 {
526 Action::Preserved
527 } else {
528 Action::Drift
529 }
530 }
531 (Kind::State, _) => Action::Preserved,
532 },
533 };
534 decisions.push(Decision {
535 destination: candidate.destination.clone(),
536 kind: candidate.kind,
537 action,
538 });
539 }
540 if let Some(record) = recorded {
541 for file in &record.files {
542 let produced = projection
543 .candidates
544 .iter()
545 .any(|candidate| candidate.destination == file.destination);
546 if !produced {
547 decisions.push(Decision {
548 destination: file.destination.clone(),
549 kind: file.kind,
550 action: Action::Released,
551 });
552 }
553 }
554 }
555 collisions.sort_by(|a, b| a.path.cmp(&b.path));
556 collisions.dedup_by(|a, b| a.path == b.path);
557 Ok((decisions, collisions))
558}
559
560enum Located {
562 Collision(String),
565 Absent,
567 Present { dir: File, name: std::ffi::OsString },
569}
570
571impl Located {
572 fn read(&self) -> std::io::Result<Vec<u8>> {
574 match self {
575 Self::Present { dir, name } => {
576 held::read_file(dir, name).map(Option::unwrap_or_default)
577 }
578 Self::Absent | Self::Collision(_) => Ok(Vec::new()),
579 }
580 }
581}
582
583fn locate(root: &File, destination: &str) -> std::io::Result<Located> {
587 let (parent, name) = split(Path::new(destination))?;
588 let dir = match held::hold_dir_existing(root, parent) {
589 Ok(dir) => dir,
590 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Located::Absent),
591 Err(error) => {
592 return Ok(Located::Collision(format!(
593 "a parent component cannot be held: {error}"
594 )));
595 }
596 };
597 match std::fs::symlink_metadata(held::proc_path(&dir).join(name)) {
598 Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => Ok(
599 Located::Collision("exists and is not a regular file".to_owned()),
600 ),
601 Ok(_) => Ok(Located::Present {
602 dir,
603 name: name.to_owned(),
604 }),
605 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Located::Absent),
606 Err(error) => Err(error),
607 }
608}
609
610#[must_use]
616pub fn licence_refusal(reason: &str) -> RkError {
617 RkError::refusal(
618 Diagnostic::new(
619 Reason::StateDrift,
620 format!("the code scanning provider's licence condition is not satisfied, and nothing was written: {reason}"),
621 )
622 .expected("a provider whose terms the target's declared licence permits")
623 .action("pass --code-scanning semgrep, which carries no licence condition, or --code-scanning off")
624 .target_state("unchanged"),
625 )
626}
627
628#[must_use]
634pub fn release_unavailable(reason: &str) -> RkError {
635 RkError::refusal(
636 Diagnostic::new(
637 Reason::StateDrift,
638 format!("the selected release automation is not available in this release, and nothing was written: {reason}"),
639 )
640 .expected("a release driver and forge this release ships automation for, or a release mode that selects none")
641 .action("pass --release-driver and --forge at an available tuple, or --release-mode external or none")
642 .target_state("unchanged"),
643 )
644}
645
646#[must_use]
652pub fn refusal(target: &Utf8Path, collisions: &[Collision]) -> RkError {
653 let listed: Vec<String> = collisions
654 .iter()
655 .map(|collision| format!("{} ({})", collision.path, collision.reason))
656 .collect();
657 RkError::refusal(
658 Diagnostic::new(
659 Reason::StateDrift,
660 format!(
661 "these destinations cannot be landed as they stand, and nothing was written: {}",
662 listed.join("; ")
663 ),
664 )
665 .expected(
666 "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",
667 )
668 .action(format!(
669 "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"
670 ))
671 .target_state("unchanged"),
672 )
673}
674
675#[derive(Debug, Clone, Copy, PartialEq, Eq)]
677pub enum Origin {
678 Init,
680 Upgrade,
683 Adopt,
686}
687
688#[derive(Debug)]
690pub struct Landed {
691 pub completed: Vec<String>,
693 pub config_written: bool,
695 pub receipt: Manifest,
697 pub replaced: Vec<Replaced>,
699}
700
701pub fn land(
717 target: &Held,
718 recorded: Option<&Manifest>,
719 prepared: &Prepared,
720 origin: Origin,
721 _lock: &lock::TargetLock,
722) -> Result<Landed, RkError> {
723 if let Some(reason) = prepared.projection.licence_refusal.as_deref() {
724 return Err(licence_refusal(reason));
725 }
726 if let Some(reason) = prepared.projection.release_unavailable() {
727 return Err(release_unavailable(reason));
728 }
729 if !prepared.collisions.is_empty() {
730 return Err(refusal(target.display(), &prepared.collisions));
731 }
732 let root = &target.root;
733 held::pause(PAUSE_VAR, "validated", "proceed");
737 let unwritten = reverify(root, prepared, origin)?;
742 let replaced = save_replacements(target, root, prepared)?;
745 let mut writer = Writer {
746 root,
747 completed: Vec::new(),
748 stop: std::env::var_os(INTERRUPT_VAR).map(|value| value.to_string_lossy().into_owned()),
749 };
750 let config_current = read_relative(root, config::CONFIG_PATH)?;
752 let config_written = config_current.as_deref() != Some(prepared.config.content.as_bytes());
753 if config_written {
754 writer.write(config::CONFIG_PATH, prepared.config.content.as_bytes())?;
755 }
756 let mut files = Vec::new();
757 for candidate in &prepared.projection.candidates {
758 let sha256 = match unwritten.get(&candidate.destination) {
759 Some(digest) => digest.clone(),
760 None => match action_of(prepared, origin, &candidate.destination) {
761 Some(Action::Created | Action::Replaced) => {
762 writer.write(&candidate.destination, &candidate.bytes)?;
763 candidate_digest(candidate)
764 }
765 _ => continue,
766 },
767 };
768 files.push(FileRecord {
769 destination: candidate.destination.clone(),
770 kind: candidate.kind,
771 sha256,
772 placement: match candidate.placement {
773 Placement::Whole => manifest::Placement::Whole,
774 Placement::Region { .. } => manifest::Placement::Region,
775 },
776 });
777 }
778 let receipt = receipt(
779 &prepared.params,
780 &prepared.projection,
781 recorded,
782 origin,
783 files,
784 );
785 writer.write(manifest::MANIFEST_PATH, &manifest::render(&receipt)?)?;
786 Ok(Landed {
787 completed: writer.completed,
788 config_written,
789 receipt,
790 replaced,
791 })
792}
793
794fn save_replacements(
796 target: &Held,
797 root: &File,
798 prepared: &Prepared,
799) -> Result<Vec<Replaced>, RkError> {
800 if prepared.replacements.is_empty() {
801 return Ok(Vec::new());
802 }
803 let canonical = std::fs::canonicalize(target.display().as_std_path())?;
807 let snapshot = crate::snapshot::Snapshot::begin(&canonical)?;
808 let mut replaced = Vec::new();
809 for path in &prepared.replacements {
810 let saved_to = match read_relative(root, path)? {
811 Some(bytes) => Some(snapshot.save_replaced(path, &bytes)?.display().to_string()),
812 None => None,
813 };
814 replaced.push(Replaced {
815 path: path.clone(),
816 saved_to,
817 });
818 }
819 Ok(replaced)
820}
821
822#[must_use]
825pub fn previewed_replacements(prepared: &Prepared) -> Vec<Replaced> {
826 prepared
827 .replacements
828 .iter()
829 .map(|path| Replaced {
830 path: path.clone(),
831 saved_to: None,
832 })
833 .collect()
834}
835
836fn action_of(prepared: &Prepared, origin: Origin, destination: &str) -> Option<Action> {
841 match origin {
842 Origin::Adopt if prepared.replacements.iter().any(|path| path == destination) => {
843 Some(Action::Replaced)
844 }
845 Origin::Adopt => Some(Action::Preserved),
846 Origin::Init | Origin::Upgrade => prepared.decision(destination).map(|d| d.action),
847 }
848}
849
850fn current_form(root: &File, candidate: &Candidate) -> Result<Option<Vec<u8>>, RkError> {
854 let Some(current) = read_relative(root, &candidate.destination)? else {
855 return Ok(None);
856 };
857 Ok(match candidate.placement {
858 Placement::Whole => Some(current),
859 Placement::Region { begin, end } => {
860 let text = String::from_utf8_lossy(¤t);
861 super::extract_block(&text, begin, end).map(|block| block.as_bytes().to_vec())
862 }
863 })
864}
865
866fn reverify(
876 root: &File,
877 prepared: &Prepared,
878 origin: Origin,
879) -> Result<std::collections::BTreeMap<String, Digest>, RkError> {
880 let mut digests = std::collections::BTreeMap::new();
881 for candidate in &prepared.projection.candidates {
882 let action = action_of(prepared, origin, &candidate.destination);
883 let must_match = match (origin, action) {
884 (_, Some(Action::Replaced | Action::Created)) => continue,
885 (Origin::Adopt, _) => candidate.kind == Kind::Rendered,
886 (_, Some(Action::Matched)) => true,
887 (_, Some(Action::Preserved | Action::Drift)) => false,
888 _ => continue,
889 };
890 let Some(current) = current_form(root, candidate)? else {
891 return Err(moved(&candidate.destination, "is no longer present"));
892 };
893 let expected: &[u8] = candidate.region.as_deref().unwrap_or(&candidate.bytes);
894 if must_match && current != expected {
895 return Err(moved(
896 &candidate.destination,
897 "no longer holds the candidate's bytes",
898 ));
899 }
900 digests.insert(candidate.destination.clone(), Digest::of(¤t));
901 }
902 Ok(digests)
903}
904
905fn moved(destination: &str, what: &str) -> RkError {
908 RkError::refusal(
909 Diagnostic::new(
910 Reason::StateDrift,
911 format!(
912 "{destination} {what} since it was validated, and nothing was written; the previous receipt stands"
913 ),
914 )
915 .expected("every destination the landing leaves as it stands to stand still until the receipt is written")
916 .action("re-run once the target is at rest")
917 .target_state("unchanged"),
918 )
919}
920
921fn candidate_digest(candidate: &Candidate) -> Digest {
924 candidate
925 .region
926 .as_deref()
927 .map_or_else(|| Digest::of(&candidate.bytes), Digest::of)
928}
929
930fn receipt(
932 params: &Params,
933 projection: &Projection,
934 recorded: Option<&Manifest>,
935 origin: Origin,
936 files: Vec<FileRecord>,
937) -> Manifest {
938 Manifest {
939 schema_version: manifest::SCHEMA_VERSION,
940 rk_version: env!("CARGO_PKG_VERSION").to_owned(),
941 origin: recorded.map_or_else(
942 || match origin {
943 Origin::Adopt => "adopt".to_owned(),
944 Origin::Init | Origin::Upgrade => "init".to_owned(),
945 },
946 |record| record.origin.clone(),
947 ),
948 landed_at: recorded.map_or_else(manifest::now, |record| record.landed_at.clone()),
949 profile: params.profile().clone(),
950 git: params.git().clone(),
951 capabilities: params.capabilities().clone(),
952 parameters: Parameters {
953 repo: params.repo().to_owned(),
954 security_contact: params.security_contact().to_owned(),
955 security_response: params.security_response().to_owned(),
956 required_check: params.required_check().to_owned(),
957 required_workflow: params.required_workflow().to_owned(),
958 },
959 files,
960 pins: crate::registry::pins_for(&projection.capabilities)
961 .into_iter()
962 .map(|pin| (pin.name, pin.version))
963 .collect(),
964 }
965}
966
967fn read_relative(root: &File, relative: &str) -> std::io::Result<Option<Vec<u8>>> {
970 let path = Path::new(relative);
971 let (parent, name) = split(path)?;
972 let dir = match held::hold_dir_existing(root, parent) {
973 Ok(dir) => dir,
974 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
975 Err(error) => return Err(error),
976 };
977 held::read_file(&dir, name)
978}
979
980fn split(path: &Path) -> std::io::Result<(&Path, &OsStr)> {
982 let name = path
983 .file_name()
984 .ok_or_else(|| std::io::Error::other(format!("{} has no file name", path.display())))?;
985 let parent = path.parent().unwrap_or_else(|| Path::new(""));
986 Ok((parent, name))
987}
988
989struct Writer<'a> {
992 root: &'a File,
993 completed: Vec<String>,
994 stop: Option<String>,
995}
996
997impl Writer<'_> {
998 fn write(&mut self, destination: &str, bytes: &[u8]) -> Result<(), RkError> {
1001 let path = Path::new(destination);
1002 let (parent, name) = split(path)?;
1003 let outcome = held::hold_dir(self.root, parent).and_then(|dir| {
1004 if self.stop.as_deref() == Some(destination) {
1005 return Err(std::io::Error::other(
1006 "the rename was stopped here for the proof",
1007 ));
1008 }
1009 held::write_file(&dir, name, bytes)
1010 });
1011 match outcome {
1012 Ok(()) => {
1013 self.completed.push(destination.to_owned());
1014 Ok(())
1015 }
1016 Err(error) => Err(self.failure(destination, bytes, &error)),
1017 }
1018 }
1019
1020 fn failure(&self, destination: &str, bytes: &[u8], error: &std::io::Error) -> RkError {
1026 let observed = match read_relative(self.root, destination) {
1027 Ok(None) => "is absent".to_owned(),
1028 Ok(Some(current)) if current == bytes => "holds the candidate bytes whole".to_owned(),
1029 Ok(Some(_)) => "holds its previous bytes whole".to_owned(),
1030 Err(again) => format!("could not be observed again: {again}"),
1031 };
1032 let completed = if self.completed.is_empty() {
1033 "none".to_owned()
1034 } else {
1035 self.completed.join(", ")
1036 };
1037 RkError::Io(std::io::Error::new(
1038 error.kind(),
1039 format!(
1040 "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"
1041 ),
1042 ))
1043 }
1044}
1045
1046#[cfg(test)]
1047mod tests {
1048 use super::{Action, Held, Origin, Prepared, decide, land, prepare};
1049 use crate::landing::manifest::{self, Manifest};
1050 use crate::landing::{Params, Style, lock};
1051 use crate::projection::{Projection, ProjectionInput, TargetEvidence};
1052
1053 fn target() -> (tempfile::TempDir, camino::Utf8PathBuf) {
1054 let dir = tempfile::tempdir().expect("a scratch target exists");
1055 let path = camino::Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("utf-8");
1056 (dir, path)
1057 }
1058
1059 fn params() -> Params {
1060 Params::for_test("acme/widget", Some(Style::Trunk))
1061 }
1062
1063 fn prepared(target: &camino::Utf8Path, recorded: Option<&Manifest>) -> Prepared {
1064 prepare(
1065 &Held::open(target).expect("opens"),
1066 recorded,
1067 ¶ms(),
1068 None,
1069 )
1070 .expect("prepares")
1071 }
1072
1073 fn landed(
1074 target: &camino::Utf8Path,
1075 recorded: Option<&Manifest>,
1076 origin: Origin,
1077 ) -> super::Landed {
1078 let locks = tempfile::tempdir().expect("a scratch locks directory exists");
1079 let lock = lock::acquire_in(locks.path(), target).expect("the target is taken");
1080 let held = Held::open(target).expect("opens");
1081 land(&held, recorded, &prepared(target, recorded), origin, &lock).expect("lands")
1082 }
1083
1084 #[test]
1088 fn a_fresh_landing_creates_and_a_rerun_decides_by_the_receipt() {
1089 let (_dir, target) = target();
1090 let first = prepared(&target, None);
1091 assert!(first.collisions.is_empty(), "{:?}", first.collisions);
1092 assert!(
1093 first
1094 .decisions
1095 .iter()
1096 .all(|decision| decision.action == Action::Created)
1097 );
1098 let outcome = landed(&target, None, Origin::Init);
1099 assert_eq!(
1100 outcome.completed.last().map(String::as_str),
1101 Some(manifest::MANIFEST_PATH)
1102 );
1103 assert_eq!(outcome.receipt.schema_version, manifest::SCHEMA_VERSION);
1104 let record = manifest::load(&target).expect("loads").expect("exists");
1105 let again = prepared(&target, Some(&record));
1106 for decision in &again.decisions {
1107 let expected = match decision.kind {
1108 crate::landing::Kind::Rendered => Action::Replaced,
1109 crate::landing::Kind::Seeded | crate::landing::Kind::State => Action::Preserved,
1110 };
1111 assert_eq!(decision.action, expected, "{}", decision.destination);
1112 }
1113 }
1114
1115 #[test]
1119 fn every_collision_is_collected_and_the_refusal_writes_nothing() {
1120 let (_dir, target) = target();
1121 std::fs::write(target.join("SECURITY.md"), "ours\n").expect("writes");
1122 std::fs::create_dir_all(target.join("release-plz.toml")).expect("creates");
1123 std::fs::write(
1124 target.join("AGENTS.md"),
1125 format!(
1126 "{b}\n{e}\n{b}\n{e}\n",
1127 b = crate::landing::BLOCK_BEGIN,
1128 e = crate::landing::BLOCK_END
1129 ),
1130 )
1131 .expect("writes");
1132 let prepared = prepared(&target, None);
1133 let paths: Vec<&str> = prepared
1134 .collisions
1135 .iter()
1136 .map(|collision| collision.path.as_str())
1137 .collect();
1138 assert_eq!(paths, ["AGENTS.md", "SECURITY.md", "release-plz.toml"]);
1139 let locks = tempfile::tempdir().expect("a scratch locks directory exists");
1140 let lock = lock::acquire_in(locks.path(), &target).expect("the target is taken");
1141 let held = Held::open(&target).expect("opens");
1142 let refused =
1143 land(&held, None, &prepared, Origin::Init, &lock).expect_err("the landing refuses");
1144 assert_eq!(refused.exit_code(), 73);
1145 assert!(!target.join(".release-kit").exists());
1146 assert!(!target.join("dist-workspace.toml").exists());
1147 }
1148
1149 #[test]
1152 fn a_released_destination_stays_and_leaves_the_receipt() {
1153 let (_dir, target) = target();
1154 landed(&target, None, Origin::Init);
1155 let mut record = manifest::load(&target).expect("loads").expect("exists");
1156 std::fs::write(target.join("legacy.yml"), "old\n").expect("writes");
1157 record.files.push(manifest::FileRecord {
1158 destination: "legacy.yml".into(),
1159 kind: crate::landing::Kind::Rendered,
1160 sha256: crate::digest::Digest::of(b"old\n"),
1161 placement: manifest::Placement::Whole,
1162 });
1163 let (decisions, _) = decide(
1164 &Held::open(&target).expect("opens"),
1165 Some(&record),
1166 &Projection::compute(&ProjectionInput {
1167 params: params(),
1168 evidence: TargetEvidence::gather(&target, Some(&record)).expect("gathers"),
1169 })
1170 .expect("projects"),
1171 )
1172 .expect("decides");
1173 let released = decisions
1174 .iter()
1175 .find(|decision| decision.destination == "legacy.yml")
1176 .expect("the released destination is decided");
1177 assert_eq!(released.action, Action::Released);
1178 let outcome = landed(&target, Some(&record), Origin::Upgrade);
1179 assert!(target.join("legacy.yml").is_file());
1180 assert!(outcome.receipt.file("legacy.yml").is_none());
1181 }
1182}