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, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
694pub struct SecretMove {
695 pub path: String,
697 pub name: String,
699 pub direction: String,
702 #[serde(default, skip_serializing_if = "Option::is_none")]
704 pub step: Option<String>,
705}
706
707impl SecretMove {
708 #[must_use]
710 pub fn line(&self) -> String {
711 if self.direction == "dropped" {
712 format!(
713 "secret {} read by the replaced {} is not read by the rendered file",
714 self.name, self.path
715 )
716 } else {
717 self.step.as_deref().map_or_else(
718 || {
719 format!(
720 "secret {} is new; no setup step stores it, so the operator does",
721 self.name
722 )
723 },
724 |step| format!("secret {} is new; {step} stores it", self.name),
725 )
726 }
727 }
728}
729
730#[must_use]
734pub fn secret_names(text: &str) -> std::collections::BTreeSet<String> {
735 let mut names = std::collections::BTreeSet::new();
736 let mut rest = text;
737 while let Some(start) = rest.find("${{") {
738 let Some(end) = rest[start..].find("}}") else {
739 break;
740 };
741 let expression = &rest[start + 3..start + end];
742 let mut inner = expression;
743 while let Some(at) = inner.find("secrets.") {
744 let name: String = inner[at + "secrets.".len()..]
745 .chars()
746 .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
747 .collect();
748 if !name.is_empty() && name != "GITHUB_TOKEN" {
749 names.insert(name);
750 }
751 inner = &inner[at + "secrets.".len()..];
752 }
753 rest = &rest[start + end + 2..];
754 }
755 names
756}
757
758#[must_use]
762pub fn secrets_moved(path: &str, old: &str, new: &str) -> Vec<SecretMove> {
763 let (before, after) = (secret_names(old), secret_names(new));
764 let dropped = before.difference(&after).map(|name| SecretMove {
765 path: path.to_owned(),
766 name: name.clone(),
767 direction: "dropped".to_owned(),
768 step: None,
769 });
770 let added = after.difference(&before).map(|name| SecretMove {
771 path: path.to_owned(),
772 name: name.clone(),
773 direction: "new".to_owned(),
774 step: crate::setup::steps::secret_step(name).map(str::to_owned),
775 });
776 dropped.chain(added).collect()
777}
778
779pub fn replaced_secrets(target: &Held, prepared: &Prepared) -> Result<Vec<SecretMove>, RkError> {
787 let mut moved = Vec::new();
788 for decision in &prepared.decisions {
789 if decision.action != Action::Replaced
790 || !decision.destination.starts_with(".github/workflows/")
791 {
792 continue;
793 }
794 let Some(candidate) = prepared
795 .projection
796 .candidates
797 .iter()
798 .find(|candidate| candidate.destination == decision.destination)
799 else {
800 continue;
801 };
802 let Some(current) = read_relative(&target.root, &decision.destination)? else {
803 continue;
804 };
805 moved.extend(secrets_moved(
806 &decision.destination,
807 &String::from_utf8_lossy(¤t),
808 &String::from_utf8_lossy(&candidate.bytes),
809 ));
810 }
811 Ok(moved)
812}
813
814#[derive(Debug)]
816pub struct Landed {
817 pub completed: Vec<String>,
819 pub config_written: bool,
821 pub receipt: Manifest,
823 pub replaced: Vec<Replaced>,
825}
826
827pub fn land(
843 target: &Held,
844 recorded: Option<&Manifest>,
845 prepared: &Prepared,
846 origin: Origin,
847 _lock: &lock::TargetLock,
848) -> Result<Landed, RkError> {
849 if let Some(reason) = prepared.projection.licence_refusal.as_deref() {
850 return Err(licence_refusal(reason));
851 }
852 if let Some(reason) = prepared.projection.release_unavailable() {
853 return Err(release_unavailable(reason));
854 }
855 if !prepared.collisions.is_empty() {
856 return Err(refusal(target.display(), &prepared.collisions));
857 }
858 let root = &target.root;
859 held::pause(PAUSE_VAR, "validated", "proceed");
863 let unwritten = reverify(root, prepared, origin)?;
868 let replaced = save_replacements(target, root, prepared)?;
871 let mut writer = Writer {
872 root,
873 completed: Vec::new(),
874 stop: std::env::var_os(INTERRUPT_VAR).map(|value| value.to_string_lossy().into_owned()),
875 };
876 let config_current = read_relative(root, config::CONFIG_PATH)?;
878 let config_written = config_current.as_deref() != Some(prepared.config.content.as_bytes());
879 if config_written {
880 writer.write(config::CONFIG_PATH, prepared.config.content.as_bytes())?;
881 }
882 let mut files = Vec::new();
883 for candidate in &prepared.projection.candidates {
884 let sha256 = match unwritten.get(&candidate.destination) {
885 Some(digest) => digest.clone(),
886 None => match action_of(prepared, origin, &candidate.destination) {
887 Some(Action::Created | Action::Replaced) => {
888 writer.write(&candidate.destination, &candidate.bytes)?;
889 candidate_digest(candidate)
890 }
891 _ => continue,
892 },
893 };
894 files.push(FileRecord {
895 destination: candidate.destination.clone(),
896 kind: candidate.kind,
897 sha256,
898 placement: match candidate.placement {
899 Placement::Whole => manifest::Placement::Whole,
900 Placement::Region { .. } => manifest::Placement::Region,
901 },
902 });
903 }
904 let receipt = receipt(
905 &prepared.params,
906 &prepared.projection,
907 recorded,
908 origin,
909 files,
910 );
911 writer.write(manifest::MANIFEST_PATH, &manifest::render(&receipt)?)?;
912 Ok(Landed {
913 completed: writer.completed,
914 config_written,
915 receipt,
916 replaced,
917 })
918}
919
920fn save_replacements(
922 target: &Held,
923 root: &File,
924 prepared: &Prepared,
925) -> Result<Vec<Replaced>, RkError> {
926 if prepared.replacements.is_empty() {
927 return Ok(Vec::new());
928 }
929 let canonical = std::fs::canonicalize(target.display().as_std_path())?;
933 let snapshot = crate::snapshot::Snapshot::begin(&canonical)?;
934 let mut replaced = Vec::new();
935 for path in &prepared.replacements {
936 let saved_to = match read_relative(root, path)? {
937 Some(bytes) => Some(snapshot.save_replaced(path, &bytes)?.display().to_string()),
938 None => None,
939 };
940 replaced.push(Replaced {
941 path: path.clone(),
942 saved_to,
943 });
944 }
945 Ok(replaced)
946}
947
948#[must_use]
951pub fn previewed_replacements(prepared: &Prepared) -> Vec<Replaced> {
952 prepared
953 .replacements
954 .iter()
955 .map(|path| Replaced {
956 path: path.clone(),
957 saved_to: None,
958 })
959 .collect()
960}
961
962fn action_of(prepared: &Prepared, origin: Origin, destination: &str) -> Option<Action> {
967 match origin {
968 Origin::Adopt if prepared.replacements.iter().any(|path| path == destination) => {
969 Some(Action::Replaced)
970 }
971 Origin::Adopt => Some(Action::Preserved),
972 Origin::Init | Origin::Upgrade => prepared.decision(destination).map(|d| d.action),
973 }
974}
975
976fn current_form(root: &File, candidate: &Candidate) -> Result<Option<Vec<u8>>, RkError> {
980 let Some(current) = read_relative(root, &candidate.destination)? else {
981 return Ok(None);
982 };
983 Ok(match candidate.placement {
984 Placement::Whole => Some(current),
985 Placement::Region { begin, end } => {
986 let text = String::from_utf8_lossy(¤t);
987 super::extract_block(&text, begin, end).map(|block| block.as_bytes().to_vec())
988 }
989 })
990}
991
992fn reverify(
1002 root: &File,
1003 prepared: &Prepared,
1004 origin: Origin,
1005) -> Result<std::collections::BTreeMap<String, Digest>, RkError> {
1006 let mut digests = std::collections::BTreeMap::new();
1007 for candidate in &prepared.projection.candidates {
1008 let action = action_of(prepared, origin, &candidate.destination);
1009 let must_match = match (origin, action) {
1010 (_, Some(Action::Replaced | Action::Created)) => continue,
1011 (Origin::Adopt, _) => candidate.kind == Kind::Rendered,
1012 (_, Some(Action::Matched)) => true,
1013 (_, Some(Action::Preserved | Action::Drift)) => false,
1014 _ => continue,
1015 };
1016 let Some(current) = current_form(root, candidate)? else {
1017 return Err(moved(&candidate.destination, "is no longer present"));
1018 };
1019 let expected: &[u8] = candidate.region.as_deref().unwrap_or(&candidate.bytes);
1020 if must_match && current != expected {
1021 return Err(moved(
1022 &candidate.destination,
1023 "no longer holds the candidate's bytes",
1024 ));
1025 }
1026 digests.insert(candidate.destination.clone(), Digest::of(¤t));
1027 }
1028 Ok(digests)
1029}
1030
1031fn moved(destination: &str, what: &str) -> RkError {
1034 RkError::refusal(
1035 Diagnostic::new(
1036 Reason::StateDrift,
1037 format!(
1038 "{destination} {what} since it was validated, and nothing was written; the previous receipt stands"
1039 ),
1040 )
1041 .expected("every destination the landing leaves as it stands to stand still until the receipt is written")
1042 .action("re-run once the target is at rest")
1043 .target_state("unchanged"),
1044 )
1045}
1046
1047fn candidate_digest(candidate: &Candidate) -> Digest {
1050 candidate
1051 .region
1052 .as_deref()
1053 .map_or_else(|| Digest::of(&candidate.bytes), Digest::of)
1054}
1055
1056fn receipt(
1058 params: &Params,
1059 projection: &Projection,
1060 recorded: Option<&Manifest>,
1061 origin: Origin,
1062 files: Vec<FileRecord>,
1063) -> Manifest {
1064 Manifest {
1065 schema_version: manifest::SCHEMA_VERSION,
1066 rk_version: env!("CARGO_PKG_VERSION").to_owned(),
1067 origin: recorded.map_or_else(
1068 || match origin {
1069 Origin::Adopt => "adopt".to_owned(),
1070 Origin::Init | Origin::Upgrade => "init".to_owned(),
1071 },
1072 |record| record.origin.clone(),
1073 ),
1074 landed_at: recorded.map_or_else(manifest::now, |record| record.landed_at.clone()),
1075 profile: params.profile().clone(),
1076 git: params.git().clone(),
1077 capabilities: params.capabilities().clone(),
1078 parameters: Parameters {
1079 repo: params.repo().to_owned(),
1080 security_contact: params.security_contact().to_owned(),
1081 security_response: params.security_response().to_owned(),
1082 required_check: params.required_check().to_owned(),
1083 required_workflow: params.required_workflow().to_owned(),
1084 dev_environment: params.dev_environment().cloned(),
1085 },
1086 files,
1087 pins: crate::registry::pins_for(&projection.capabilities)
1088 .into_iter()
1089 .map(|pin| (pin.name, pin.version))
1090 .collect(),
1091 }
1092}
1093
1094fn read_relative(root: &File, relative: &str) -> std::io::Result<Option<Vec<u8>>> {
1097 let path = Path::new(relative);
1098 let (parent, name) = split(path)?;
1099 let dir = match held::hold_dir_existing(root, parent) {
1100 Ok(dir) => dir,
1101 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1102 Err(error) => return Err(error),
1103 };
1104 held::read_file(&dir, name)
1105}
1106
1107fn split(path: &Path) -> std::io::Result<(&Path, &OsStr)> {
1109 let name = path
1110 .file_name()
1111 .ok_or_else(|| std::io::Error::other(format!("{} has no file name", path.display())))?;
1112 let parent = path.parent().unwrap_or_else(|| Path::new(""));
1113 Ok((parent, name))
1114}
1115
1116struct Writer<'a> {
1119 root: &'a File,
1120 completed: Vec<String>,
1121 stop: Option<String>,
1122}
1123
1124impl Writer<'_> {
1125 fn write(&mut self, destination: &str, bytes: &[u8]) -> Result<(), RkError> {
1128 let path = Path::new(destination);
1129 let (parent, name) = split(path)?;
1130 let outcome = held::hold_dir(self.root, parent).and_then(|dir| {
1131 if self.stop.as_deref() == Some(destination) {
1132 return Err(std::io::Error::other(
1133 "the rename was stopped here for the proof",
1134 ));
1135 }
1136 held::write_file(&dir, name, bytes)
1137 });
1138 match outcome {
1139 Ok(()) => {
1140 self.completed.push(destination.to_owned());
1141 Ok(())
1142 }
1143 Err(error) => Err(self.failure(destination, bytes, &error)),
1144 }
1145 }
1146
1147 fn failure(&self, destination: &str, bytes: &[u8], error: &std::io::Error) -> RkError {
1153 let observed = match read_relative(self.root, destination) {
1154 Ok(None) => "is absent".to_owned(),
1155 Ok(Some(current)) if current == bytes => "holds the candidate bytes whole".to_owned(),
1156 Ok(Some(_)) => "holds its previous bytes whole".to_owned(),
1157 Err(again) => format!("could not be observed again: {again}"),
1158 };
1159 let completed = if self.completed.is_empty() {
1160 "none".to_owned()
1161 } else {
1162 self.completed.join(", ")
1163 };
1164 RkError::Io(std::io::Error::new(
1165 error.kind(),
1166 format!(
1167 "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"
1168 ),
1169 ))
1170 }
1171}
1172
1173#[cfg(test)]
1174mod tests {
1175 use super::{Action, Held, Origin, Prepared, decide, land, prepare};
1176 use crate::landing::manifest::{self, Manifest};
1177 use crate::landing::{Params, Style, lock};
1178 use crate::projection::{Projection, ProjectionInput, TargetEvidence};
1179
1180 fn target() -> (tempfile::TempDir, camino::Utf8PathBuf) {
1181 let dir = tempfile::tempdir().expect("a scratch target exists");
1182 let path = camino::Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("utf-8");
1183 (dir, path)
1184 }
1185
1186 fn params() -> Params {
1187 Params::for_test("acme/widget", Some(Style::Trunk))
1188 }
1189
1190 fn prepared(target: &camino::Utf8Path, recorded: Option<&Manifest>) -> Prepared {
1191 prepare(
1192 &Held::open(target).expect("opens"),
1193 recorded,
1194 ¶ms(),
1195 None,
1196 )
1197 .expect("prepares")
1198 }
1199
1200 fn landed(
1201 target: &camino::Utf8Path,
1202 recorded: Option<&Manifest>,
1203 origin: Origin,
1204 ) -> super::Landed {
1205 let locks = tempfile::tempdir().expect("a scratch locks directory exists");
1206 let lock = lock::acquire_in(locks.path(), target).expect("the target is taken");
1207 let held = Held::open(target).expect("opens");
1208 land(&held, recorded, &prepared(target, recorded), origin, &lock).expect("lands")
1209 }
1210
1211 #[test]
1215 fn a_fresh_landing_creates_and_a_rerun_decides_by_the_receipt() {
1216 let (_dir, target) = target();
1217 let first = prepared(&target, None);
1218 assert!(first.collisions.is_empty(), "{:?}", first.collisions);
1219 assert!(
1220 first
1221 .decisions
1222 .iter()
1223 .all(|decision| decision.action == Action::Created)
1224 );
1225 let outcome = landed(&target, None, Origin::Init);
1226 assert_eq!(
1227 outcome.completed.last().map(String::as_str),
1228 Some(manifest::MANIFEST_PATH)
1229 );
1230 assert_eq!(outcome.receipt.schema_version, manifest::SCHEMA_VERSION);
1231 let record = manifest::load(&target).expect("loads").expect("exists");
1232 let again = prepared(&target, Some(&record));
1233 for decision in &again.decisions {
1234 let expected = match decision.kind {
1235 crate::landing::Kind::Rendered => Action::Replaced,
1236 crate::landing::Kind::Seeded | crate::landing::Kind::State => Action::Preserved,
1237 };
1238 assert_eq!(decision.action, expected, "{}", decision.destination);
1239 }
1240 }
1241
1242 #[test]
1246 fn every_collision_is_collected_and_the_refusal_writes_nothing() {
1247 let (_dir, target) = target();
1248 std::fs::write(target.join("SECURITY.md"), "ours\n").expect("writes");
1249 std::fs::create_dir_all(target.join("release-plz.toml")).expect("creates");
1250 std::fs::write(
1251 target.join("AGENTS.md"),
1252 format!(
1253 "{b}\n{e}\n{b}\n{e}\n",
1254 b = crate::landing::BLOCK_BEGIN,
1255 e = crate::landing::BLOCK_END
1256 ),
1257 )
1258 .expect("writes");
1259 let prepared = prepared(&target, None);
1260 let paths: Vec<&str> = prepared
1261 .collisions
1262 .iter()
1263 .map(|collision| collision.path.as_str())
1264 .collect();
1265 assert_eq!(paths, ["AGENTS.md", "SECURITY.md", "release-plz.toml"]);
1266 let locks = tempfile::tempdir().expect("a scratch locks directory exists");
1267 let lock = lock::acquire_in(locks.path(), &target).expect("the target is taken");
1268 let held = Held::open(&target).expect("opens");
1269 let refused =
1270 land(&held, None, &prepared, Origin::Init, &lock).expect_err("the landing refuses");
1271 assert_eq!(refused.exit_code(), 73);
1272 assert!(!target.join(".release-kit").exists());
1273 assert!(!target.join("dist-workspace.toml").exists());
1274 }
1275
1276 #[test]
1279 fn a_released_destination_stays_and_leaves_the_receipt() {
1280 let (_dir, target) = target();
1281 landed(&target, None, Origin::Init);
1282 let mut record = manifest::load(&target).expect("loads").expect("exists");
1283 std::fs::write(target.join("legacy.yml"), "old\n").expect("writes");
1284 record.files.push(manifest::FileRecord {
1285 destination: "legacy.yml".into(),
1286 kind: crate::landing::Kind::Rendered,
1287 sha256: crate::digest::Digest::of(b"old\n"),
1288 placement: manifest::Placement::Whole,
1289 });
1290 let (decisions, _) = decide(
1291 &Held::open(&target).expect("opens"),
1292 Some(&record),
1293 &Projection::compute(&ProjectionInput {
1294 params: params(),
1295 evidence: TargetEvidence::gather(&target, Some(&record)).expect("gathers"),
1296 })
1297 .expect("projects"),
1298 )
1299 .expect("decides");
1300 let released = decisions
1301 .iter()
1302 .find(|decision| decision.destination == "legacy.yml")
1303 .expect("the released destination is decided");
1304 assert_eq!(released.action, Action::Released);
1305 let outcome = landed(&target, Some(&record), Origin::Upgrade);
1306 assert!(target.join("legacy.yml").is_file());
1307 assert!(outcome.receipt.file("legacy.yml").is_none());
1308 }
1309}