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, PartialEq, Eq)]
90pub struct Decision {
91 pub destination: String,
93 pub kind: Kind,
96 pub action: Action,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
104pub struct Collision {
105 pub path: String,
107 pub reason: String,
109}
110
111#[derive(Debug)]
119pub struct Held {
120 root: File,
121 base: camino::Utf8PathBuf,
122 display: camino::Utf8PathBuf,
123}
124
125impl Held {
126 pub fn open(target: &Utf8Path) -> Result<Self, RkError> {
132 let root = held::open_dir(target.as_std_path())?;
133 let base = camino::Utf8PathBuf::from_path_buf(held::proc_path(&root))
134 .map_err(|path| anyhow::anyhow!("the kernel's link {} is not UTF-8", path.display()))?;
135 Ok(Self {
136 root,
137 base,
138 display: target.to_owned(),
139 })
140 }
141
142 pub fn open_locked(target: &Utf8Path, lock: &lock::TargetLock) -> Result<Self, RkError> {
150 let held = Self::open(target)?;
151 let opened = held::Identity::of(&held.root.metadata()?);
152 if lock.identity() != opened {
153 return Err(RkError::refusal(
154 Diagnostic::new(
155 Reason::StateDrift,
156 format!(
157 "the directory at {target} was exchanged after the lock was taken, and nothing was written"
158 ),
159 )
160 .expected("one directory at the target path from the lock through the receipt write")
161 .action("re-run once the target is at rest")
162 .target_state("unchanged"),
163 ));
164 }
165 Ok(held)
166 }
167
168 #[must_use]
171 pub fn base(&self) -> &Utf8Path {
172 &self.base
173 }
174
175 #[must_use]
177 pub fn display(&self) -> &Utf8Path {
178 &self.display
179 }
180}
181
182#[derive(Debug)]
186pub struct Prepared {
187 pub params: Params,
189 pub projection: Projection,
191 pub decisions: Vec<Decision>,
193 pub collisions: Vec<Collision>,
195 pub config: config::Plan,
197}
198
199impl Prepared {
200 #[must_use]
202 pub fn decision(&self, destination: &str) -> Option<&Decision> {
203 self.decisions
204 .iter()
205 .find(|decision| decision.destination == destination)
206 }
207}
208
209pub fn prepare(
220 target: &Held,
221 recorded: Option<&Manifest>,
222 params: &Params,
223 existing_config: Option<&config::Config>,
224) -> Result<Prepared, RkError> {
225 let evidence = TargetEvidence::gather(target.base(), recorded)?;
226 let projection = Projection::compute(&ProjectionInput {
227 params: params.clone(),
228 evidence,
229 })?;
230 let (decisions, collisions) = decide(target, recorded, &projection)?;
231 let config = config::Plan::new(
232 target.base().as_std_path(),
233 params,
234 existing_config,
235 recorded,
236 )?;
237 Ok(Prepared {
238 params: params.clone(),
239 projection,
240 decisions,
241 collisions,
242 config,
243 })
244}
245
246pub fn decide(
258 target: &Held,
259 recorded: Option<&Manifest>,
260 projection: &Projection,
261) -> Result<(Vec<Decision>, Vec<Collision>), RkError> {
262 let root = &target.root;
263 let mut decisions = Vec::new();
264 let mut collisions: Vec<Collision> = projection
265 .collisions
266 .iter()
267 .map(|collision| Collision {
268 path: collision.destination.clone(),
269 reason: collision.reason.clone(),
270 })
271 .collect();
272 for candidate in &projection.candidates {
273 let record = recorded.and_then(|record| record.file(&candidate.destination));
274 let located = match locate(root, &candidate.destination)? {
275 Located::Collision(reason) => {
276 collisions.push(Collision {
277 path: candidate.destination.clone(),
278 reason,
279 });
280 continue;
281 }
282 other => other,
283 };
284 let present = matches!(located, Located::Present { .. });
285 let current = || located.read();
286 let action = match (candidate.placement, present, record) {
287 (_, false, _) => Action::Created,
288 (Placement::Region { .. }, true, _) => Action::Replaced,
292 (Placement::Whole, true, None) => {
297 if current()? == candidate.bytes {
298 Action::Matched
299 } else {
300 collisions.push(Collision {
301 path: candidate.destination.clone(),
302 reason:
303 "exists with bytes differing from the candidate, and no receipt attributes it to release-kit"
304 .to_owned(),
305 });
306 continue;
307 }
308 }
309 (Placement::Whole, true, Some(record)) => match (candidate.kind, record.kind) {
310 (Kind::Rendered, Kind::Rendered) => Action::Replaced,
311 (Kind::Rendered, Kind::Seeded | Kind::State) => {
312 collisions.push(Collision {
313 path: candidate.destination.clone(),
314 reason: format!(
315 "is recorded as {}, and this release renders it, so its bytes are the target's",
316 record.kind.as_str()
317 ),
318 });
319 continue;
320 }
321 (Kind::Seeded, _) => {
322 if Digest::of(¤t()?) == record.sha256 {
323 Action::Preserved
324 } else {
325 Action::Drift
326 }
327 }
328 (Kind::State, _) => Action::Preserved,
329 },
330 };
331 decisions.push(Decision {
332 destination: candidate.destination.clone(),
333 kind: candidate.kind,
334 action,
335 });
336 }
337 if let Some(record) = recorded {
338 for file in &record.files {
339 let produced = projection
340 .candidates
341 .iter()
342 .any(|candidate| candidate.destination == file.destination);
343 if !produced {
344 decisions.push(Decision {
345 destination: file.destination.clone(),
346 kind: file.kind,
347 action: Action::Released,
348 });
349 }
350 }
351 }
352 collisions.sort_by(|a, b| a.path.cmp(&b.path));
353 collisions.dedup_by(|a, b| a.path == b.path);
354 Ok((decisions, collisions))
355}
356
357enum Located {
359 Collision(String),
362 Absent,
364 Present { dir: File, name: std::ffi::OsString },
366}
367
368impl Located {
369 fn read(&self) -> std::io::Result<Vec<u8>> {
371 match self {
372 Self::Present { dir, name } => {
373 held::read_file(dir, name).map(Option::unwrap_or_default)
374 }
375 Self::Absent | Self::Collision(_) => Ok(Vec::new()),
376 }
377 }
378}
379
380fn locate(root: &File, destination: &str) -> std::io::Result<Located> {
384 let (parent, name) = split(Path::new(destination))?;
385 let dir = match held::hold_dir_existing(root, parent) {
386 Ok(dir) => dir,
387 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Located::Absent),
388 Err(error) => {
389 return Ok(Located::Collision(format!(
390 "a parent component cannot be held: {error}"
391 )));
392 }
393 };
394 match std::fs::symlink_metadata(held::proc_path(&dir).join(name)) {
395 Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => Ok(
396 Located::Collision("exists and is not a regular file".to_owned()),
397 ),
398 Ok(_) => Ok(Located::Present {
399 dir,
400 name: name.to_owned(),
401 }),
402 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Located::Absent),
403 Err(error) => Err(error),
404 }
405}
406
407#[must_use]
413pub fn licence_refusal(reason: &str) -> RkError {
414 RkError::refusal(
415 Diagnostic::new(
416 Reason::StateDrift,
417 format!("the code scanning provider's licence condition is not satisfied, and nothing was written: {reason}"),
418 )
419 .expected("a provider whose terms the target's declared licence permits")
420 .action("pass --code-scanning semgrep, which carries no licence condition, or --code-scanning off")
421 .target_state("unchanged"),
422 )
423}
424
425#[must_use]
431pub fn release_unavailable(reason: &str) -> RkError {
432 RkError::refusal(
433 Diagnostic::new(
434 Reason::StateDrift,
435 format!("the selected release automation is not available in this release, and nothing was written: {reason}"),
436 )
437 .expected("a release driver and forge this release ships automation for, or a release mode that selects none")
438 .action("pass --release-driver and --forge at an available tuple, or --release-mode external or none")
439 .target_state("unchanged"),
440 )
441}
442
443#[must_use]
449pub fn refusal(target: &Utf8Path, collisions: &[Collision]) -> RkError {
450 let listed: Vec<String> = collisions
451 .iter()
452 .map(|collision| format!("{} ({})", collision.path, collision.reason))
453 .collect();
454 RkError::refusal(
455 Diagnostic::new(
456 Reason::StateDrift,
457 format!(
458 "these destinations cannot be landed as they stand, and nothing was written: {}",
459 listed.join("; ")
460 ),
461 )
462 .expected(
463 "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",
464 )
465 .action(format!(
466 "rk stage --target {target} stages this binary's candidate for a byte comparison; the rk-setup skill carries the migration that brings each file to the candidate or records it, then re-run"
467 ))
468 .target_state("unchanged"),
469 )
470}
471
472#[derive(Debug, Clone, Copy, PartialEq, Eq)]
474pub enum Origin {
475 Init,
477 Upgrade,
480 Adopt,
483}
484
485#[derive(Debug)]
487pub struct Landed {
488 pub completed: Vec<String>,
490 pub config_written: bool,
492 pub receipt: Manifest,
494}
495
496pub fn land(
512 target: &Held,
513 recorded: Option<&Manifest>,
514 prepared: &Prepared,
515 origin: Origin,
516 _lock: &lock::TargetLock,
517) -> Result<Landed, RkError> {
518 if let Some(reason) = prepared.projection.licence_refusal.as_deref() {
519 return Err(licence_refusal(reason));
520 }
521 if let Some(reason) = prepared.projection.release_unavailable() {
522 return Err(release_unavailable(reason));
523 }
524 if !prepared.collisions.is_empty() {
525 return Err(refusal(target.display(), &prepared.collisions));
526 }
527 let root = &target.root;
528 held::pause(PAUSE_VAR, "validated", "proceed");
532 let unwritten = reverify(root, prepared, origin)?;
537 let mut writer = Writer {
538 root,
539 completed: Vec::new(),
540 stop: std::env::var_os(INTERRUPT_VAR).map(|value| value.to_string_lossy().into_owned()),
541 };
542 let config_current = read_relative(root, config::CONFIG_PATH)?;
544 let config_written = config_current.as_deref() != Some(prepared.config.content.as_bytes());
545 if config_written {
546 writer.write(config::CONFIG_PATH, prepared.config.content.as_bytes())?;
547 }
548 let mut files = Vec::new();
549 for candidate in &prepared.projection.candidates {
550 let sha256 = match unwritten.get(&candidate.destination) {
551 Some(digest) => digest.clone(),
552 None => match action_of(prepared, origin, &candidate.destination) {
553 Some(Action::Created | Action::Replaced) => {
554 writer.write(&candidate.destination, &candidate.bytes)?;
555 candidate_digest(candidate)
556 }
557 _ => continue,
558 },
559 };
560 files.push(FileRecord {
561 destination: candidate.destination.clone(),
562 kind: candidate.kind,
563 sha256,
564 placement: match candidate.placement {
565 Placement::Whole => manifest::Placement::Whole,
566 Placement::Region { .. } => manifest::Placement::Region,
567 },
568 });
569 }
570 let receipt = receipt(
571 &prepared.params,
572 &prepared.projection,
573 recorded,
574 origin,
575 files,
576 );
577 writer.write(manifest::MANIFEST_PATH, &manifest::render(&receipt)?)?;
578 Ok(Landed {
579 completed: writer.completed,
580 config_written,
581 receipt,
582 })
583}
584
585fn action_of(prepared: &Prepared, origin: Origin, destination: &str) -> Option<Action> {
590 match origin {
591 Origin::Adopt => Some(Action::Preserved),
592 Origin::Init | Origin::Upgrade => prepared.decision(destination).map(|d| d.action),
593 }
594}
595
596fn current_form(root: &File, candidate: &Candidate) -> Result<Option<Vec<u8>>, RkError> {
600 let Some(current) = read_relative(root, &candidate.destination)? else {
601 return Ok(None);
602 };
603 Ok(match candidate.placement {
604 Placement::Whole => Some(current),
605 Placement::Region { begin, end } => {
606 let text = String::from_utf8_lossy(¤t);
607 super::extract_block(&text, begin, end).map(|block| block.as_bytes().to_vec())
608 }
609 })
610}
611
612fn reverify(
622 root: &File,
623 prepared: &Prepared,
624 origin: Origin,
625) -> Result<std::collections::BTreeMap<String, Digest>, RkError> {
626 let mut digests = std::collections::BTreeMap::new();
627 for candidate in &prepared.projection.candidates {
628 let action = action_of(prepared, origin, &candidate.destination);
629 let must_match = match (origin, action) {
630 (Origin::Adopt, _) => candidate.kind == Kind::Rendered,
631 (_, Some(Action::Matched)) => true,
632 (_, Some(Action::Preserved | Action::Drift)) => false,
633 _ => continue,
634 };
635 let Some(current) = current_form(root, candidate)? else {
636 return Err(moved(&candidate.destination, "is no longer present"));
637 };
638 let expected: &[u8] = candidate.region.as_deref().unwrap_or(&candidate.bytes);
639 if must_match && current != expected {
640 return Err(moved(
641 &candidate.destination,
642 "no longer holds the candidate's bytes",
643 ));
644 }
645 digests.insert(candidate.destination.clone(), Digest::of(¤t));
646 }
647 Ok(digests)
648}
649
650fn moved(destination: &str, what: &str) -> RkError {
653 RkError::refusal(
654 Diagnostic::new(
655 Reason::StateDrift,
656 format!(
657 "{destination} {what} since it was validated, and nothing was written; the previous receipt stands"
658 ),
659 )
660 .expected("every destination the landing leaves as it stands to stand still until the receipt is written")
661 .action("re-run once the target is at rest")
662 .target_state("unchanged"),
663 )
664}
665
666fn candidate_digest(candidate: &Candidate) -> Digest {
669 candidate
670 .region
671 .as_deref()
672 .map_or_else(|| Digest::of(&candidate.bytes), Digest::of)
673}
674
675fn receipt(
677 params: &Params,
678 projection: &Projection,
679 recorded: Option<&Manifest>,
680 origin: Origin,
681 files: Vec<FileRecord>,
682) -> Manifest {
683 Manifest {
684 schema_version: manifest::SCHEMA_VERSION,
685 rk_version: env!("CARGO_PKG_VERSION").to_owned(),
686 origin: recorded.map_or_else(
687 || match origin {
688 Origin::Adopt => "adopt".to_owned(),
689 Origin::Init | Origin::Upgrade => "init".to_owned(),
690 },
691 |record| record.origin.clone(),
692 ),
693 landed_at: recorded.map_or_else(manifest::now, |record| record.landed_at.clone()),
694 profile: params.profile().clone(),
695 git: params.git().clone(),
696 capabilities: params.capabilities().clone(),
697 parameters: Parameters {
698 repo: params.repo().to_owned(),
699 security_contact: params.security_contact().to_owned(),
700 security_response: params.security_response().to_owned(),
701 },
702 files,
703 pins: crate::registry::pins_for(&projection.capabilities)
704 .into_iter()
705 .map(|pin| (pin.name, pin.version))
706 .collect(),
707 }
708}
709
710fn read_relative(root: &File, relative: &str) -> std::io::Result<Option<Vec<u8>>> {
713 let path = Path::new(relative);
714 let (parent, name) = split(path)?;
715 let dir = match held::hold_dir_existing(root, parent) {
716 Ok(dir) => dir,
717 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
718 Err(error) => return Err(error),
719 };
720 held::read_file(&dir, name)
721}
722
723fn split(path: &Path) -> std::io::Result<(&Path, &OsStr)> {
725 let name = path
726 .file_name()
727 .ok_or_else(|| std::io::Error::other(format!("{} has no file name", path.display())))?;
728 let parent = path.parent().unwrap_or_else(|| Path::new(""));
729 Ok((parent, name))
730}
731
732struct Writer<'a> {
735 root: &'a File,
736 completed: Vec<String>,
737 stop: Option<String>,
738}
739
740impl Writer<'_> {
741 fn write(&mut self, destination: &str, bytes: &[u8]) -> Result<(), RkError> {
744 let path = Path::new(destination);
745 let (parent, name) = split(path)?;
746 let outcome = held::hold_dir(self.root, parent).and_then(|dir| {
747 if self.stop.as_deref() == Some(destination) {
748 return Err(std::io::Error::other(
749 "the rename was stopped here for the proof",
750 ));
751 }
752 held::write_file(&dir, name, bytes)
753 });
754 match outcome {
755 Ok(()) => {
756 self.completed.push(destination.to_owned());
757 Ok(())
758 }
759 Err(error) => Err(self.failure(destination, bytes, &error)),
760 }
761 }
762
763 fn failure(&self, destination: &str, bytes: &[u8], error: &std::io::Error) -> RkError {
769 let observed = match read_relative(self.root, destination) {
770 Ok(None) => "is absent".to_owned(),
771 Ok(Some(current)) if current == bytes => "holds the candidate bytes whole".to_owned(),
772 Ok(Some(_)) => "holds its previous bytes whole".to_owned(),
773 Err(again) => format!("could not be observed again: {again}"),
774 };
775 let completed = if self.completed.is_empty() {
776 "none".to_owned()
777 } else {
778 self.completed.join(", ")
779 };
780 RkError::Io(std::io::Error::new(
781 error.kind(),
782 format!(
783 "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"
784 ),
785 ))
786 }
787}
788
789#[cfg(test)]
790mod tests {
791 use super::{Action, Held, Origin, Prepared, decide, land, prepare};
792 use crate::landing::manifest::{self, Manifest};
793 use crate::landing::{Params, Style, lock};
794 use crate::projection::{Projection, ProjectionInput, TargetEvidence};
795
796 fn target() -> (tempfile::TempDir, camino::Utf8PathBuf) {
797 let dir = tempfile::tempdir().expect("a scratch target exists");
798 let path = camino::Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("utf-8");
799 (dir, path)
800 }
801
802 fn params() -> Params {
803 Params::for_test("acme/widget", Some(Style::Trunk))
804 }
805
806 fn prepared(target: &camino::Utf8Path, recorded: Option<&Manifest>) -> Prepared {
807 prepare(
808 &Held::open(target).expect("opens"),
809 recorded,
810 ¶ms(),
811 None,
812 )
813 .expect("prepares")
814 }
815
816 fn landed(
817 target: &camino::Utf8Path,
818 recorded: Option<&Manifest>,
819 origin: Origin,
820 ) -> super::Landed {
821 let locks = tempfile::tempdir().expect("a scratch locks directory exists");
822 let lock = lock::acquire_in(locks.path(), target).expect("the target is taken");
823 let held = Held::open(target).expect("opens");
824 land(&held, recorded, &prepared(target, recorded), origin, &lock).expect("lands")
825 }
826
827 #[test]
831 fn a_fresh_landing_creates_and_a_rerun_decides_by_the_receipt() {
832 let (_dir, target) = target();
833 let first = prepared(&target, None);
834 assert!(first.collisions.is_empty(), "{:?}", first.collisions);
835 assert!(
836 first
837 .decisions
838 .iter()
839 .all(|decision| decision.action == Action::Created)
840 );
841 let outcome = landed(&target, None, Origin::Init);
842 assert_eq!(
843 outcome.completed.last().map(String::as_str),
844 Some(manifest::MANIFEST_PATH)
845 );
846 assert_eq!(outcome.receipt.schema_version, manifest::SCHEMA_VERSION);
847 let record = manifest::load(&target).expect("loads").expect("exists");
848 let again = prepared(&target, Some(&record));
849 for decision in &again.decisions {
850 let expected = match decision.kind {
851 crate::landing::Kind::Rendered => Action::Replaced,
852 crate::landing::Kind::Seeded | crate::landing::Kind::State => Action::Preserved,
853 };
854 assert_eq!(decision.action, expected, "{}", decision.destination);
855 }
856 }
857
858 #[test]
862 fn every_collision_is_collected_and_the_refusal_writes_nothing() {
863 let (_dir, target) = target();
864 std::fs::write(target.join("SECURITY.md"), "ours\n").expect("writes");
865 std::fs::create_dir_all(target.join("release-plz.toml")).expect("creates");
866 std::fs::write(
867 target.join("AGENTS.md"),
868 format!(
869 "{b}\n{e}\n{b}\n{e}\n",
870 b = crate::landing::BLOCK_BEGIN,
871 e = crate::landing::BLOCK_END
872 ),
873 )
874 .expect("writes");
875 let prepared = prepared(&target, None);
876 let paths: Vec<&str> = prepared
877 .collisions
878 .iter()
879 .map(|collision| collision.path.as_str())
880 .collect();
881 assert_eq!(paths, ["AGENTS.md", "SECURITY.md", "release-plz.toml"]);
882 let locks = tempfile::tempdir().expect("a scratch locks directory exists");
883 let lock = lock::acquire_in(locks.path(), &target).expect("the target is taken");
884 let held = Held::open(&target).expect("opens");
885 let refused =
886 land(&held, None, &prepared, Origin::Init, &lock).expect_err("the landing refuses");
887 assert_eq!(refused.exit_code(), 73);
888 assert!(!target.join(".release-kit").exists());
889 assert!(!target.join("dist-workspace.toml").exists());
890 }
891
892 #[test]
895 fn a_released_destination_stays_and_leaves_the_receipt() {
896 let (_dir, target) = target();
897 landed(&target, None, Origin::Init);
898 let mut record = manifest::load(&target).expect("loads").expect("exists");
899 std::fs::write(target.join("legacy.yml"), "old\n").expect("writes");
900 record.files.push(manifest::FileRecord {
901 destination: "legacy.yml".into(),
902 kind: crate::landing::Kind::Rendered,
903 sha256: crate::digest::Digest::of(b"old\n"),
904 placement: manifest::Placement::Whole,
905 });
906 let (decisions, _) = decide(
907 &Held::open(&target).expect("opens"),
908 Some(&record),
909 &Projection::compute(&ProjectionInput {
910 params: params(),
911 evidence: TargetEvidence::gather(&target, Some(&record)).expect("gathers"),
912 })
913 .expect("projects"),
914 )
915 .expect("decides");
916 let released = decisions
917 .iter()
918 .find(|decision| decision.destination == "legacy.yml")
919 .expect("the released destination is decided");
920 assert_eq!(released.action, Action::Released);
921 let outcome = landed(&target, Some(&record), Origin::Upgrade);
922 assert!(target.join("legacy.yml").is_file());
923 assert!(outcome.receipt.file("legacy.yml").is_none());
924 }
925}