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 Decision {
171 pub destination: String,
173 pub kind: Kind,
176 pub action: Action,
178}
179
180#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
187pub struct Collision {
188 pub path: String,
190 pub reason: String,
192 pub replaceable: bool,
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
201pub struct Replaced {
202 pub path: String,
204 pub saved_to: Option<String>,
207}
208
209const EDITED: &str = "was edited after the landing, and the record no longer vouches for its bytes";
211
212#[derive(Debug)]
220pub struct Held {
221 root: File,
222 base: camino::Utf8PathBuf,
223 display: camino::Utf8PathBuf,
224}
225
226impl Held {
227 pub fn open(target: &Utf8Path) -> Result<Self, RkError> {
233 let root = held::open_dir(target.as_std_path())?;
234 let base = camino::Utf8PathBuf::from_path_buf(held::proc_path(&root))
235 .map_err(|path| anyhow::anyhow!("the kernel's link {} is not UTF-8", path.display()))?;
236 Ok(Self {
237 root,
238 base,
239 display: target.to_owned(),
240 })
241 }
242
243 pub fn open_locked(target: &Utf8Path, lock: &lock::TargetLock) -> Result<Self, RkError> {
251 let held = Self::open(target)?;
252 let opened = held::Identity::of(&held.root.metadata()?);
253 if lock.identity() != opened {
254 return Err(RkError::refusal(
255 Diagnostic::new(
256 Reason::StateDrift,
257 format!(
258 "the directory at {target} was exchanged after the lock was taken, and nothing was written"
259 ),
260 )
261 .expected("one directory at the target path from the lock through the receipt write")
262 .action("re-run once the target is at rest")
263 .target_state("unchanged"),
264 ));
265 }
266 Ok(held)
267 }
268
269 #[must_use]
272 pub fn base(&self) -> &Utf8Path {
273 &self.base
274 }
275
276 #[must_use]
278 pub fn display(&self) -> &Utf8Path {
279 &self.display
280 }
281}
282
283#[derive(Debug)]
287pub struct Prepared {
288 pub params: Params,
290 pub projection: Projection,
292 pub decisions: Vec<Decision>,
294 pub collisions: Vec<Collision>,
296 pub config: config::Plan,
298 pub replacements: Vec<String>,
301}
302
303impl Prepared {
304 #[must_use]
306 pub fn decision(&self, destination: &str) -> Option<&Decision> {
307 self.decisions
308 .iter()
309 .find(|decision| decision.destination == destination)
310 }
311}
312
313pub fn prepare(
324 target: &Held,
325 recorded: Option<&Manifest>,
326 params: &Params,
327 existing_config: Option<&config::Config>,
328) -> Result<Prepared, RkError> {
329 let evidence = TargetEvidence::gather(target.base(), recorded)?;
330 let projection = Projection::compute(&ProjectionInput {
331 params: params.clone(),
332 evidence,
333 })?;
334 let (decisions, collisions) = decide(target, recorded, &projection)?;
335 let config = config::Plan::new(
336 target.base().as_std_path(),
337 params,
338 existing_config,
339 recorded,
340 )?;
341 Ok(Prepared {
342 params: params.clone(),
343 projection,
344 decisions,
345 collisions,
346 config,
347 replacements: Vec::new(),
348 })
349}
350
351pub fn name_replacements(prepared: &mut Prepared, replace: &[String]) -> Result<(), RkError> {
365 for path in replace {
366 if prepared.replacements.contains(path) {
367 continue;
368 }
369 let Some(index) = prepared
370 .collisions
371 .iter()
372 .position(|collision| collision.path == *path && collision.replaceable)
373 else {
374 return Err(RkError::Usage(format!(
375 "--replace {path} names no conflict this landing has; a conflict is an unattributed file or a recorded rendered file someone edited"
376 )));
377 };
378 prepared.collisions.remove(index);
379 let kind = prepared
380 .projection
381 .candidates
382 .iter()
383 .find(|candidate| candidate.destination == *path)
384 .map_or(Kind::Rendered, |candidate| candidate.kind);
385 prepared.decisions.push(Decision {
386 destination: path.clone(),
387 kind,
388 action: Action::Replaced,
389 });
390 prepared.replacements.push(path.clone());
391 }
392 let order = |destination: &str| {
393 prepared
394 .projection
395 .candidates
396 .iter()
397 .position(|candidate| candidate.destination == destination)
398 .unwrap_or(usize::MAX)
399 };
400 let mut decisions = std::mem::take(&mut prepared.decisions);
401 decisions.sort_by_key(|decision| order(&decision.destination));
402 prepared.decisions = decisions;
403 Ok(())
404}
405
406#[allow(
418 clippy::too_many_lines,
419 reason = "one pass decides every destination by its placement, its record, and its bytes"
420)]
421pub fn decide(
422 target: &Held,
423 recorded: Option<&Manifest>,
424 projection: &Projection,
425) -> Result<(Vec<Decision>, Vec<Collision>), RkError> {
426 let root = &target.root;
427 let mut decisions = Vec::new();
428 let mut collisions: Vec<Collision> = projection
429 .collisions
430 .iter()
431 .map(|collision| Collision {
432 path: collision.destination.clone(),
433 reason: collision.reason.clone(),
434 replaceable: false,
435 })
436 .collect();
437 for candidate in &projection.candidates {
438 let record = recorded.and_then(|record| record.file(&candidate.destination));
439 let located = match locate(root, &candidate.destination)? {
440 Located::Collision(reason) => {
441 collisions.push(Collision {
442 path: candidate.destination.clone(),
443 reason,
444 replaceable: false,
445 });
446 continue;
447 }
448 other => other,
449 };
450 let present = matches!(located, Located::Present { .. });
451 let current = || located.read();
452 let action = match (candidate.placement, present, record) {
453 (_, false, _) => Action::Created,
454 (Placement::Region { begin, end }, true, Some(record))
459 if record.kind == Kind::Rendered
460 && super::extract_block(&String::from_utf8_lossy(¤t()?), begin, end)
461 .is_some_and(|block| {
462 Digest::of(block.as_bytes()) != record.sha256
463 && candidate.region.as_deref() != Some(block.as_bytes())
464 }) =>
465 {
466 collisions.push(Collision {
467 path: candidate.destination.clone(),
468 reason: format!("its marked region {EDITED}"),
469 replaceable: true,
470 });
471 continue;
472 }
473 (Placement::Region { .. }, true, _) => Action::Replaced,
477 (Placement::Whole, true, None) => {
482 if current()? == candidate.bytes {
483 Action::Matched
484 } else {
485 collisions.push(Collision {
486 path: candidate.destination.clone(),
487 reason:
488 "exists with bytes differing from the candidate, and no receipt attributes it to release-kit"
489 .to_owned(),
490 replaceable: true,
491 });
492 continue;
493 }
494 }
495 (Placement::Whole, true, Some(record)) => match (candidate.kind, record.kind) {
496 (Kind::Rendered, Kind::Rendered) => {
500 let bytes = current()?;
501 if bytes == candidate.bytes {
506 Action::Matched
507 } else if Digest::of(&bytes) == record.sha256 {
508 Action::Replaced
509 } else {
510 collisions.push(Collision {
511 path: candidate.destination.clone(),
512 reason: EDITED.to_owned(),
513 replaceable: true,
514 });
515 continue;
516 }
517 }
518 (Kind::Rendered, Kind::Seeded | Kind::State) => {
519 collisions.push(Collision {
520 path: candidate.destination.clone(),
521 reason: format!(
522 "is recorded as {}, and this release renders it, so its bytes are the target's",
523 record.kind.as_str()
524 ),
525 replaceable: true,
526 });
527 continue;
528 }
529 (Kind::Seeded, _) => {
530 if Digest::of(¤t()?) == record.sha256 {
531 Action::Preserved
532 } else {
533 Action::Drift
534 }
535 }
536 (Kind::State, _) => Action::Preserved,
537 },
538 };
539 decisions.push(Decision {
540 destination: candidate.destination.clone(),
541 kind: candidate.kind,
542 action,
543 });
544 }
545 if let Some(record) = recorded {
546 for file in &record.files {
547 let produced = projection
548 .candidates
549 .iter()
550 .any(|candidate| candidate.destination == file.destination);
551 if !produced {
552 decisions.push(Decision {
553 destination: file.destination.clone(),
554 kind: file.kind,
555 action: Action::Released,
556 });
557 }
558 }
559 }
560 collisions.sort_by(|a, b| a.path.cmp(&b.path));
561 collisions.dedup_by(|a, b| a.path == b.path);
562 Ok((decisions, collisions))
563}
564
565enum Located {
567 Collision(String),
570 Absent,
572 Present { dir: File, name: std::ffi::OsString },
574}
575
576impl Located {
577 fn read(&self) -> std::io::Result<Vec<u8>> {
579 match self {
580 Self::Present { dir, name } => {
581 held::read_file(dir, name).map(Option::unwrap_or_default)
582 }
583 Self::Absent | Self::Collision(_) => Ok(Vec::new()),
584 }
585 }
586}
587
588fn locate(root: &File, destination: &str) -> std::io::Result<Located> {
592 let (parent, name) = split(Path::new(destination))?;
593 let dir = match held::hold_dir_existing(root, parent) {
594 Ok(dir) => dir,
595 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Located::Absent),
596 Err(error) => {
597 return Ok(Located::Collision(format!(
598 "a parent component cannot be held: {error}"
599 )));
600 }
601 };
602 match std::fs::symlink_metadata(held::proc_path(&dir).join(name)) {
603 Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => Ok(
604 Located::Collision("exists and is not a regular file".to_owned()),
605 ),
606 Ok(_) => Ok(Located::Present {
607 dir,
608 name: name.to_owned(),
609 }),
610 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Located::Absent),
611 Err(error) => Err(error),
612 }
613}
614
615#[must_use]
621pub fn licence_refusal(reason: &str) -> RkError {
622 RkError::refusal(
623 Diagnostic::new(
624 Reason::StateDrift,
625 format!("the code scanning provider's licence condition is not satisfied, and nothing was written: {reason}"),
626 )
627 .expected("a provider whose terms the target's declared licence permits")
628 .action("pass --code-scanning semgrep, which carries no licence condition, or --code-scanning off")
629 .target_state("unchanged"),
630 )
631}
632
633#[must_use]
639pub fn release_unavailable(reason: &str) -> RkError {
640 RkError::refusal(
641 Diagnostic::new(
642 Reason::StateDrift,
643 format!("the selected release automation is not available in this release, and nothing was written: {reason}"),
644 )
645 .expected("a release driver and forge this release ships automation for, or a release mode that selects none")
646 .action("pass --release-driver and --forge at an available tuple, or --release-mode external or none")
647 .target_state("unchanged"),
648 )
649}
650
651#[must_use]
657pub fn refusal(target: &Utf8Path, collisions: &[Collision]) -> RkError {
658 let listed: Vec<String> = collisions
659 .iter()
660 .map(|collision| format!("{} ({})", collision.path, collision.reason))
661 .collect();
662 RkError::refusal(
663 Diagnostic::new(
664 Reason::StateDrift,
665 format!(
666 "these destinations cannot be landed as they stand, and nothing was written: {}",
667 listed.join("; ")
668 ),
669 )
670 .expected(
671 "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",
672 )
673 .action(format!(
674 "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"
675 ))
676 .target_state("unchanged"),
677 )
678}
679
680#[derive(Debug, Clone, Copy, PartialEq, Eq)]
682pub enum Origin {
683 Init,
685 Upgrade,
688 Adopt,
691}
692
693#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
699pub struct SecretMove {
700 pub path: String,
702 pub name: String,
704 pub direction: String,
707 #[serde(default, skip_serializing_if = "Option::is_none")]
709 pub step: Option<String>,
710}
711
712impl SecretMove {
713 #[must_use]
715 pub fn line(&self) -> String {
716 if self.direction == "dropped" {
717 format!(
718 "secret {} read by the replaced {} is not read by the rendered file",
719 self.name, self.path
720 )
721 } else {
722 self.step.as_deref().map_or_else(
723 || {
724 format!(
725 "secret {} is new; no setup step stores it, so the operator does",
726 self.name
727 )
728 },
729 |step| format!("secret {} is new; {step} stores it", self.name),
730 )
731 }
732 }
733}
734
735#[must_use]
739pub fn secret_names(text: &str) -> std::collections::BTreeSet<String> {
740 let mut names = std::collections::BTreeSet::new();
741 let mut rest = text;
742 while let Some(start) = rest.find("${{") {
743 let Some(end) = rest[start..].find("}}") else {
744 break;
745 };
746 let expression = &rest[start + 3..start + end];
747 let mut inner = expression;
748 while let Some(at) = inner.find("secrets.") {
749 let name: String = inner[at + "secrets.".len()..]
750 .chars()
751 .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
752 .collect();
753 if !name.is_empty() && name != "GITHUB_TOKEN" {
754 names.insert(name);
755 }
756 inner = &inner[at + "secrets.".len()..];
757 }
758 rest = &rest[start + end + 2..];
759 }
760 names
761}
762
763#[must_use]
767pub fn secrets_moved(path: &str, old: &str, new: &str) -> Vec<SecretMove> {
768 let (before, after) = (secret_names(old), secret_names(new));
769 let dropped = before.difference(&after).map(|name| SecretMove {
770 path: path.to_owned(),
771 name: name.clone(),
772 direction: "dropped".to_owned(),
773 step: None,
774 });
775 let added = after.difference(&before).map(|name| SecretMove {
776 path: path.to_owned(),
777 name: name.clone(),
778 direction: "new".to_owned(),
779 step: crate::setup::steps::secret_step(name).map(str::to_owned),
780 });
781 dropped.chain(added).collect()
782}
783
784pub fn replaced_secrets(target: &Held, prepared: &Prepared) -> Result<Vec<SecretMove>, RkError> {
792 let mut moved = Vec::new();
793 for decision in &prepared.decisions {
794 if decision.action != Action::Replaced
795 || !decision.destination.starts_with(".github/workflows/")
796 {
797 continue;
798 }
799 let Some(candidate) = prepared
800 .projection
801 .candidates
802 .iter()
803 .find(|candidate| candidate.destination == decision.destination)
804 else {
805 continue;
806 };
807 let Some(current) = read_relative(&target.root, &decision.destination)? else {
808 continue;
809 };
810 moved.extend(secrets_moved(
811 &decision.destination,
812 &String::from_utf8_lossy(¤t),
813 &String::from_utf8_lossy(&candidate.bytes),
814 ));
815 }
816 Ok(moved)
817}
818
819#[derive(Debug)]
821pub struct Landed {
822 pub completed: Vec<String>,
824 pub config_written: bool,
826 pub receipt: Manifest,
828 pub replaced: Vec<Replaced>,
830}
831
832pub fn land(
848 target: &Held,
849 recorded: Option<&Manifest>,
850 prepared: &Prepared,
851 origin: Origin,
852 _lock: &lock::TargetLock,
853) -> Result<Landed, RkError> {
854 if let Some(reason) = prepared.projection.licence_refusal.as_deref() {
855 return Err(licence_refusal(reason));
856 }
857 if let Some(reason) = prepared.projection.release_unavailable() {
858 return Err(release_unavailable(reason));
859 }
860 if !prepared.collisions.is_empty() {
861 return Err(refusal(target.display(), &prepared.collisions));
862 }
863 let root = &target.root;
864 held::pause(PAUSE_VAR, "validated", "proceed");
868 let unwritten = reverify(root, prepared, origin)?;
873 let replaced = save_replacements(target, root, prepared)?;
876 let mut writer = Writer {
877 root,
878 completed: Vec::new(),
879 stop: std::env::var_os(INTERRUPT_VAR).map(|value| value.to_string_lossy().into_owned()),
880 };
881 let config_current = read_relative(root, config::CONFIG_PATH)?;
883 let config_written = config_current.as_deref() != Some(prepared.config.content.as_bytes());
884 if config_written {
885 writer.write(config::CONFIG_PATH, prepared.config.content.as_bytes())?;
886 }
887 let mut files = Vec::new();
888 for candidate in &prepared.projection.candidates {
889 let sha256 = match unwritten.get(&candidate.destination) {
890 Some(digest) => digest.clone(),
891 None => match action_of(prepared, origin, &candidate.destination) {
892 Some(Action::Created | Action::Replaced) => {
893 writer.write(&candidate.destination, &candidate.bytes)?;
894 candidate_digest(candidate)
895 }
896 _ => continue,
897 },
898 };
899 files.push(FileRecord {
900 destination: candidate.destination.clone(),
901 kind: candidate.kind,
902 sha256,
903 placement: match candidate.placement {
904 Placement::Whole => manifest::Placement::Whole,
905 Placement::Region { .. } => manifest::Placement::Region,
906 },
907 });
908 }
909 let receipt = receipt(
910 &prepared.params,
911 &prepared.projection,
912 recorded,
913 origin,
914 files,
915 );
916 writer.write(manifest::MANIFEST_PATH, &manifest::render(&receipt)?)?;
917 Ok(Landed {
918 completed: writer.completed,
919 config_written,
920 receipt,
921 replaced,
922 })
923}
924
925fn save_replacements(
927 target: &Held,
928 root: &File,
929 prepared: &Prepared,
930) -> Result<Vec<Replaced>, RkError> {
931 if prepared.replacements.is_empty() {
932 return Ok(Vec::new());
933 }
934 let canonical = std::fs::canonicalize(target.display().as_std_path())?;
938 let snapshot = crate::snapshot::Snapshot::begin(&canonical)?;
939 let mut replaced = Vec::new();
940 for path in &prepared.replacements {
941 let saved_to = match read_relative(root, path)? {
942 Some(bytes) => Some(snapshot.save_replaced(path, &bytes)?.display().to_string()),
943 None => None,
944 };
945 replaced.push(Replaced {
946 path: path.clone(),
947 saved_to,
948 });
949 }
950 Ok(replaced)
951}
952
953#[must_use]
956pub fn previewed_replacements(prepared: &Prepared) -> Vec<Replaced> {
957 prepared
958 .replacements
959 .iter()
960 .map(|path| Replaced {
961 path: path.clone(),
962 saved_to: None,
963 })
964 .collect()
965}
966
967fn action_of(prepared: &Prepared, origin: Origin, destination: &str) -> Option<Action> {
972 match origin {
973 Origin::Adopt if prepared.replacements.iter().any(|path| path == destination) => {
974 Some(Action::Replaced)
975 }
976 Origin::Adopt => Some(Action::Preserved),
977 Origin::Init | Origin::Upgrade => prepared.decision(destination).map(|d| d.action),
978 }
979}
980
981fn current_form(root: &File, candidate: &Candidate) -> Result<Option<Vec<u8>>, RkError> {
985 let Some(current) = read_relative(root, &candidate.destination)? else {
986 return Ok(None);
987 };
988 Ok(match candidate.placement {
989 Placement::Whole => Some(current),
990 Placement::Region { begin, end } => {
991 let text = String::from_utf8_lossy(¤t);
992 super::extract_block(&text, begin, end).map(|block| block.as_bytes().to_vec())
993 }
994 })
995}
996
997fn reverify(
1007 root: &File,
1008 prepared: &Prepared,
1009 origin: Origin,
1010) -> Result<std::collections::BTreeMap<String, Digest>, RkError> {
1011 let mut digests = std::collections::BTreeMap::new();
1012 for candidate in &prepared.projection.candidates {
1013 let action = action_of(prepared, origin, &candidate.destination);
1014 let must_match = match (origin, action) {
1015 (_, Some(Action::Replaced | Action::Created)) => continue,
1016 (Origin::Adopt, _) => candidate.kind == Kind::Rendered,
1017 (_, Some(Action::Matched)) => true,
1018 (_, Some(Action::Preserved | Action::Drift)) => false,
1019 _ => continue,
1020 };
1021 let Some(current) = current_form(root, candidate)? else {
1022 return Err(moved(&candidate.destination, "is no longer present"));
1023 };
1024 let expected: &[u8] = candidate.region.as_deref().unwrap_or(&candidate.bytes);
1025 if must_match && current != expected {
1026 return Err(moved(
1027 &candidate.destination,
1028 "no longer holds the candidate's bytes",
1029 ));
1030 }
1031 digests.insert(candidate.destination.clone(), Digest::of(¤t));
1032 }
1033 Ok(digests)
1034}
1035
1036fn moved(destination: &str, what: &str) -> RkError {
1039 RkError::refusal(
1040 Diagnostic::new(
1041 Reason::StateDrift,
1042 format!(
1043 "{destination} {what} since it was validated, and nothing was written; the previous receipt stands"
1044 ),
1045 )
1046 .expected("every destination the landing leaves as it stands to stand still until the receipt is written")
1047 .action("re-run once the target is at rest")
1048 .target_state("unchanged"),
1049 )
1050}
1051
1052fn candidate_digest(candidate: &Candidate) -> Digest {
1055 candidate
1056 .region
1057 .as_deref()
1058 .map_or_else(|| Digest::of(&candidate.bytes), Digest::of)
1059}
1060
1061fn receipt(
1063 params: &Params,
1064 projection: &Projection,
1065 recorded: Option<&Manifest>,
1066 origin: Origin,
1067 files: Vec<FileRecord>,
1068) -> Manifest {
1069 Manifest {
1070 schema_version: manifest::SCHEMA_VERSION,
1071 rk_version: env!("CARGO_PKG_VERSION").to_owned(),
1072 origin: recorded.map_or_else(
1073 || match origin {
1074 Origin::Adopt => "adopt".to_owned(),
1075 Origin::Init | Origin::Upgrade => "init".to_owned(),
1076 },
1077 |record| record.origin.clone(),
1078 ),
1079 landed_at: recorded.map_or_else(manifest::now, |record| record.landed_at.clone()),
1080 profile: params.profile().clone(),
1081 git: params.git().clone(),
1082 capabilities: params.capabilities().clone(),
1083 parameters: Parameters {
1084 repo: params.repo().to_owned(),
1085 security_contact: params.security_contact().to_owned(),
1086 security_response: params.security_response().to_owned(),
1087 security_scope: params.security_scope().to_owned(),
1088 required_check: params.required_check().to_owned(),
1089 required_workflow: params.required_workflow().to_owned(),
1090 dev_environment: params.dev_environment().cloned(),
1091 },
1092 files,
1093 pins: crate::registry::pins_for(&projection.capabilities)
1094 .into_iter()
1095 .map(|pin| (pin.name, pin.version))
1096 .collect(),
1097 }
1098}
1099
1100fn read_relative(root: &File, relative: &str) -> std::io::Result<Option<Vec<u8>>> {
1103 let path = Path::new(relative);
1104 let (parent, name) = split(path)?;
1105 let dir = match held::hold_dir_existing(root, parent) {
1106 Ok(dir) => dir,
1107 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1108 Err(error) => return Err(error),
1109 };
1110 held::read_file(&dir, name)
1111}
1112
1113fn split(path: &Path) -> std::io::Result<(&Path, &OsStr)> {
1115 let name = path
1116 .file_name()
1117 .ok_or_else(|| std::io::Error::other(format!("{} has no file name", path.display())))?;
1118 let parent = path.parent().unwrap_or_else(|| Path::new(""));
1119 Ok((parent, name))
1120}
1121
1122struct Writer<'a> {
1125 root: &'a File,
1126 completed: Vec<String>,
1127 stop: Option<String>,
1128}
1129
1130impl Writer<'_> {
1131 fn write(&mut self, destination: &str, bytes: &[u8]) -> Result<(), RkError> {
1134 let path = Path::new(destination);
1135 let (parent, name) = split(path)?;
1136 let outcome = held::hold_dir(self.root, parent).and_then(|dir| {
1137 if self.stop.as_deref() == Some(destination) {
1138 return Err(std::io::Error::other(
1139 "the rename was stopped here for the proof",
1140 ));
1141 }
1142 held::write_file(&dir, name, bytes)
1143 });
1144 match outcome {
1145 Ok(()) => {
1146 self.completed.push(destination.to_owned());
1147 Ok(())
1148 }
1149 Err(error) => Err(self.failure(destination, bytes, &error)),
1150 }
1151 }
1152
1153 fn failure(&self, destination: &str, bytes: &[u8], error: &std::io::Error) -> RkError {
1159 let observed = match read_relative(self.root, destination) {
1160 Ok(None) => "is absent".to_owned(),
1161 Ok(Some(current)) if current == bytes => "holds the candidate bytes whole".to_owned(),
1162 Ok(Some(_)) => "holds its previous bytes whole".to_owned(),
1163 Err(again) => format!("could not be observed again: {again}"),
1164 };
1165 let completed = if self.completed.is_empty() {
1166 "none".to_owned()
1167 } else {
1168 self.completed.join(", ")
1169 };
1170 RkError::Io(std::io::Error::new(
1171 error.kind(),
1172 format!(
1173 "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"
1174 ),
1175 ))
1176 }
1177}
1178
1179#[cfg(test)]
1180mod tests {
1181 use super::{Action, Held, Origin, Prepared, decide, land, prepare};
1182 use crate::landing::manifest::{self, Manifest};
1183 use crate::landing::{Params, Style, lock};
1184 use crate::projection::{Projection, ProjectionInput, TargetEvidence};
1185
1186 fn target() -> (tempfile::TempDir, camino::Utf8PathBuf) {
1187 let dir = tempfile::tempdir().expect("a scratch target exists");
1188 let path = camino::Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("utf-8");
1189 (dir, path)
1190 }
1191
1192 fn params() -> Params {
1193 Params::for_test("acme/widget", Some(Style::Trunk))
1194 }
1195
1196 fn prepared(target: &camino::Utf8Path, recorded: Option<&Manifest>) -> Prepared {
1197 prepare(
1198 &Held::open(target).expect("opens"),
1199 recorded,
1200 ¶ms(),
1201 None,
1202 )
1203 .expect("prepares")
1204 }
1205
1206 fn landed(
1207 target: &camino::Utf8Path,
1208 recorded: Option<&Manifest>,
1209 origin: Origin,
1210 ) -> super::Landed {
1211 let locks = tempfile::tempdir().expect("a scratch locks directory exists");
1212 let lock = lock::acquire_in(locks.path(), target).expect("the target is taken");
1213 let held = Held::open(target).expect("opens");
1214 land(&held, recorded, &prepared(target, recorded), origin, &lock).expect("lands")
1215 }
1216
1217 #[test]
1222 fn a_fresh_landing_creates_and_a_rerun_decides_by_the_receipt() {
1223 let (_dir, target) = target();
1224 let first = prepared(&target, None);
1225 assert!(first.collisions.is_empty(), "{:?}", first.collisions);
1226 assert!(
1227 first
1228 .decisions
1229 .iter()
1230 .all(|decision| decision.action == Action::Created)
1231 );
1232 let outcome = landed(&target, None, Origin::Init);
1233 assert_eq!(
1234 outcome.completed.last().map(String::as_str),
1235 Some(manifest::MANIFEST_PATH)
1236 );
1237 assert_eq!(outcome.receipt.schema_version, manifest::SCHEMA_VERSION);
1238 let record = manifest::load(&target).expect("loads").expect("exists");
1239 let again = prepared(&target, Some(&record));
1240 for decision in &again.decisions {
1241 let region = again.projection.candidates.iter().any(|candidate| {
1242 candidate.destination == decision.destination
1243 && matches!(
1244 candidate.placement,
1245 crate::projection::Placement::Region { .. }
1246 )
1247 });
1248 let expected = match decision.kind {
1249 crate::landing::Kind::Rendered if region => Action::Replaced,
1250 crate::landing::Kind::Rendered => Action::Matched,
1251 crate::landing::Kind::Seeded | crate::landing::Kind::State => Action::Preserved,
1252 };
1253 assert_eq!(decision.action, expected, "{}", decision.destination);
1254 }
1255 }
1256
1257 #[test]
1261 fn every_collision_is_collected_and_the_refusal_writes_nothing() {
1262 let (_dir, target) = target();
1263 std::fs::write(target.join("SECURITY.md"), "ours\n").expect("writes");
1264 std::fs::create_dir_all(target.join("release-plz.toml")).expect("creates");
1265 std::fs::write(
1266 target.join("AGENTS.md"),
1267 format!(
1268 "{b}\n{e}\n{b}\n{e}\n",
1269 b = crate::landing::BLOCK_BEGIN,
1270 e = crate::landing::BLOCK_END
1271 ),
1272 )
1273 .expect("writes");
1274 let prepared = prepared(&target, None);
1275 let paths: Vec<&str> = prepared
1276 .collisions
1277 .iter()
1278 .map(|collision| collision.path.as_str())
1279 .collect();
1280 assert_eq!(paths, ["AGENTS.md", "SECURITY.md", "release-plz.toml"]);
1281 let locks = tempfile::tempdir().expect("a scratch locks directory exists");
1282 let lock = lock::acquire_in(locks.path(), &target).expect("the target is taken");
1283 let held = Held::open(&target).expect("opens");
1284 let refused =
1285 land(&held, None, &prepared, Origin::Init, &lock).expect_err("the landing refuses");
1286 assert_eq!(refused.exit_code(), 73);
1287 assert!(!target.join(".release-kit").exists());
1288 assert!(!target.join("dist-workspace.toml").exists());
1289 }
1290
1291 #[test]
1294 fn a_released_destination_stays_and_leaves_the_receipt() {
1295 let (_dir, target) = target();
1296 landed(&target, None, Origin::Init);
1297 let mut record = manifest::load(&target).expect("loads").expect("exists");
1298 std::fs::write(target.join("legacy.yml"), "old\n").expect("writes");
1299 record.files.push(manifest::FileRecord {
1300 destination: "legacy.yml".into(),
1301 kind: crate::landing::Kind::Rendered,
1302 sha256: crate::digest::Digest::of(b"old\n"),
1303 placement: manifest::Placement::Whole,
1304 });
1305 let (decisions, _) = decide(
1306 &Held::open(&target).expect("opens"),
1307 Some(&record),
1308 &Projection::compute(&ProjectionInput {
1309 params: params(),
1310 evidence: TargetEvidence::gather(&target, Some(&record)).expect("gathers"),
1311 })
1312 .expect("projects"),
1313 )
1314 .expect("decides");
1315 let released = decisions
1316 .iter()
1317 .find(|decision| decision.destination == "legacy.yml")
1318 .expect("the released destination is decided");
1319 assert_eq!(released.action, Action::Released);
1320 let outcome = landed(&target, Some(&record), Origin::Upgrade);
1321 assert!(target.join("legacy.yml").is_file());
1322 assert!(outcome.receipt.file("legacy.yml").is_none());
1323 }
1324}