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 refusal(target: &Utf8Path, collisions: &[Collision]) -> RkError {
432 let listed: Vec<String> = collisions
433 .iter()
434 .map(|collision| format!("{} ({})", collision.path, collision.reason))
435 .collect();
436 RkError::refusal(
437 Diagnostic::new(
438 Reason::StateDrift,
439 format!(
440 "these destinations cannot be landed as they stand, and nothing was written: {}",
441 listed.join("; ")
442 ),
443 )
444 .expected(
445 "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",
446 )
447 .action(format!(
448 "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"
449 ))
450 .target_state("unchanged"),
451 )
452}
453
454#[derive(Debug, Clone, Copy, PartialEq, Eq)]
456pub enum Origin {
457 Init,
459 Upgrade,
462 Adopt,
465}
466
467#[derive(Debug)]
469pub struct Landed {
470 pub completed: Vec<String>,
472 pub config_written: bool,
474 pub receipt: Manifest,
476}
477
478pub fn land(
494 target: &Held,
495 recorded: Option<&Manifest>,
496 prepared: &Prepared,
497 origin: Origin,
498 _lock: &lock::TargetLock,
499) -> Result<Landed, RkError> {
500 if let Some(reason) = prepared.projection.licence_refusal.as_deref() {
501 return Err(licence_refusal(reason));
502 }
503 if !prepared.collisions.is_empty() {
504 return Err(refusal(target.display(), &prepared.collisions));
505 }
506 let root = &target.root;
507 held::pause(PAUSE_VAR, "validated", "proceed");
511 let unwritten = reverify(root, prepared, origin)?;
516 let mut writer = Writer {
517 root,
518 completed: Vec::new(),
519 stop: std::env::var_os(INTERRUPT_VAR).map(|value| value.to_string_lossy().into_owned()),
520 };
521 let config_current = read_relative(root, config::CONFIG_PATH)?;
523 let config_written = config_current.as_deref() != Some(prepared.config.content.as_bytes());
524 if config_written {
525 writer.write(config::CONFIG_PATH, prepared.config.content.as_bytes())?;
526 }
527 let mut files = Vec::new();
528 for candidate in &prepared.projection.candidates {
529 let sha256 = match unwritten.get(&candidate.destination) {
530 Some(digest) => digest.clone(),
531 None => match action_of(prepared, origin, &candidate.destination) {
532 Some(Action::Created | Action::Replaced) => {
533 writer.write(&candidate.destination, &candidate.bytes)?;
534 candidate_digest(candidate)
535 }
536 _ => continue,
537 },
538 };
539 files.push(FileRecord {
540 destination: candidate.destination.clone(),
541 kind: candidate.kind,
542 sha256,
543 placement: match candidate.placement {
544 Placement::Whole => manifest::Placement::Whole,
545 Placement::Region { .. } => manifest::Placement::Region,
546 },
547 });
548 }
549 let receipt = receipt(&prepared.params, recorded, origin, files);
550 writer.write(manifest::MANIFEST_PATH, &manifest::render(&receipt)?)?;
551 Ok(Landed {
552 completed: writer.completed,
553 config_written,
554 receipt,
555 })
556}
557
558fn action_of(prepared: &Prepared, origin: Origin, destination: &str) -> Option<Action> {
563 match origin {
564 Origin::Adopt => Some(Action::Preserved),
565 Origin::Init | Origin::Upgrade => prepared.decision(destination).map(|d| d.action),
566 }
567}
568
569fn current_form(root: &File, candidate: &Candidate) -> Result<Option<Vec<u8>>, RkError> {
573 let Some(current) = read_relative(root, &candidate.destination)? else {
574 return Ok(None);
575 };
576 Ok(match candidate.placement {
577 Placement::Whole => Some(current),
578 Placement::Region { begin, end } => {
579 let text = String::from_utf8_lossy(¤t);
580 super::extract_block(&text, begin, end).map(|block| block.as_bytes().to_vec())
581 }
582 })
583}
584
585fn reverify(
595 root: &File,
596 prepared: &Prepared,
597 origin: Origin,
598) -> Result<std::collections::BTreeMap<String, Digest>, RkError> {
599 let mut digests = std::collections::BTreeMap::new();
600 for candidate in &prepared.projection.candidates {
601 let action = action_of(prepared, origin, &candidate.destination);
602 let must_match = match (origin, action) {
603 (Origin::Adopt, _) => candidate.kind == Kind::Rendered,
604 (_, Some(Action::Matched)) => true,
605 (_, Some(Action::Preserved | Action::Drift)) => false,
606 _ => continue,
607 };
608 let Some(current) = current_form(root, candidate)? else {
609 return Err(moved(&candidate.destination, "is no longer present"));
610 };
611 let expected: &[u8] = candidate.region.as_deref().unwrap_or(&candidate.bytes);
612 if must_match && current != expected {
613 return Err(moved(
614 &candidate.destination,
615 "no longer holds the candidate's bytes",
616 ));
617 }
618 digests.insert(candidate.destination.clone(), Digest::of(¤t));
619 }
620 Ok(digests)
621}
622
623fn moved(destination: &str, what: &str) -> RkError {
626 RkError::refusal(
627 Diagnostic::new(
628 Reason::StateDrift,
629 format!(
630 "{destination} {what} since it was validated, and nothing was written; the previous receipt stands"
631 ),
632 )
633 .expected("every destination the landing leaves as it stands to stand still until the receipt is written")
634 .action("re-run once the target is at rest")
635 .target_state("unchanged"),
636 )
637}
638
639fn candidate_digest(candidate: &Candidate) -> Digest {
642 candidate
643 .region
644 .as_deref()
645 .map_or_else(|| Digest::of(&candidate.bytes), Digest::of)
646}
647
648fn receipt(
650 params: &Params,
651 recorded: Option<&Manifest>,
652 origin: Origin,
653 files: Vec<FileRecord>,
654) -> Manifest {
655 Manifest {
656 schema_version: manifest::SCHEMA_VERSION,
657 rk_version: env!("CARGO_PKG_VERSION").to_owned(),
658 origin: recorded.map_or_else(
659 || match origin {
660 Origin::Adopt => "adopt".to_owned(),
661 Origin::Init | Origin::Upgrade => "init".to_owned(),
662 },
663 |record| record.origin.clone(),
664 ),
665 tech: params.tech().to_owned(),
666 forge: params.forge().to_owned(),
667 landed_at: recorded.map_or_else(manifest::now, |record| record.landed_at.clone()),
668 parameters: Parameters {
669 repo: params.repo().to_owned(),
670 workflow: params.workflow(),
671 style: params.style(),
672 nix: params.nix(),
673 scorecard: params.scorecard(),
674 code_scanning: params.code_scanning(),
675 trunk: params.trunk().to_owned(),
676 line_prefix: params.line_prefix().to_owned(),
677 security_contact: params.security_contact().to_owned(),
678 security_response: params.security_response().to_owned(),
679 },
680 files,
681 pins: crate::registry::pins_for(params.tech())
682 .into_iter()
683 .map(|pin| (pin.name, pin.version))
684 .collect(),
685 }
686}
687
688fn read_relative(root: &File, relative: &str) -> std::io::Result<Option<Vec<u8>>> {
691 let path = Path::new(relative);
692 let (parent, name) = split(path)?;
693 let dir = match held::hold_dir_existing(root, parent) {
694 Ok(dir) => dir,
695 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
696 Err(error) => return Err(error),
697 };
698 held::read_file(&dir, name)
699}
700
701fn split(path: &Path) -> std::io::Result<(&Path, &OsStr)> {
703 let name = path
704 .file_name()
705 .ok_or_else(|| std::io::Error::other(format!("{} has no file name", path.display())))?;
706 let parent = path.parent().unwrap_or_else(|| Path::new(""));
707 Ok((parent, name))
708}
709
710struct Writer<'a> {
713 root: &'a File,
714 completed: Vec<String>,
715 stop: Option<String>,
716}
717
718impl Writer<'_> {
719 fn write(&mut self, destination: &str, bytes: &[u8]) -> Result<(), RkError> {
722 let path = Path::new(destination);
723 let (parent, name) = split(path)?;
724 let outcome = held::hold_dir(self.root, parent).and_then(|dir| {
725 if self.stop.as_deref() == Some(destination) {
726 return Err(std::io::Error::other(
727 "the rename was stopped here for the proof",
728 ));
729 }
730 held::write_file(&dir, name, bytes)
731 });
732 match outcome {
733 Ok(()) => {
734 self.completed.push(destination.to_owned());
735 Ok(())
736 }
737 Err(error) => Err(self.failure(destination, bytes, &error)),
738 }
739 }
740
741 fn failure(&self, destination: &str, bytes: &[u8], error: &std::io::Error) -> RkError {
747 let observed = match read_relative(self.root, destination) {
748 Ok(None) => "is absent".to_owned(),
749 Ok(Some(current)) if current == bytes => "holds the candidate bytes whole".to_owned(),
750 Ok(Some(_)) => "holds its previous bytes whole".to_owned(),
751 Err(again) => format!("could not be observed again: {again}"),
752 };
753 let completed = if self.completed.is_empty() {
754 "none".to_owned()
755 } else {
756 self.completed.join(", ")
757 };
758 RkError::Io(std::io::Error::new(
759 error.kind(),
760 format!(
761 "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"
762 ),
763 ))
764 }
765}
766
767#[cfg(test)]
768mod tests {
769 use super::{Action, Held, Origin, Prepared, decide, land, prepare};
770 use crate::landing::manifest::{self, Manifest};
771 use crate::landing::{Params, Style, lock};
772 use crate::projection::{Projection, ProjectionInput, TargetEvidence};
773
774 fn target() -> (tempfile::TempDir, camino::Utf8PathBuf) {
775 let dir = tempfile::tempdir().expect("a scratch target exists");
776 let path = camino::Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("utf-8");
777 (dir, path)
778 }
779
780 fn params() -> Params {
781 Params::for_test("acme/widget", Some(Style::Trunk))
782 }
783
784 fn prepared(target: &camino::Utf8Path, recorded: Option<&Manifest>) -> Prepared {
785 prepare(
786 &Held::open(target).expect("opens"),
787 recorded,
788 ¶ms(),
789 None,
790 )
791 .expect("prepares")
792 }
793
794 fn landed(
795 target: &camino::Utf8Path,
796 recorded: Option<&Manifest>,
797 origin: Origin,
798 ) -> super::Landed {
799 let locks = tempfile::tempdir().expect("a scratch locks directory exists");
800 let lock = lock::acquire_in(locks.path(), target).expect("the target is taken");
801 let held = Held::open(target).expect("opens");
802 land(&held, recorded, &prepared(target, recorded), origin, &lock).expect("lands")
803 }
804
805 #[test]
809 fn a_fresh_landing_creates_and_a_rerun_decides_by_the_receipt() {
810 let (_dir, target) = target();
811 let first = prepared(&target, None);
812 assert!(first.collisions.is_empty(), "{:?}", first.collisions);
813 assert!(
814 first
815 .decisions
816 .iter()
817 .all(|decision| decision.action == Action::Created)
818 );
819 let outcome = landed(&target, None, Origin::Init);
820 assert_eq!(
821 outcome.completed.last().map(String::as_str),
822 Some(manifest::MANIFEST_PATH)
823 );
824 assert_eq!(outcome.receipt.schema_version, 8);
825 let record = manifest::load(&target).expect("loads").expect("exists");
826 let again = prepared(&target, Some(&record));
827 for decision in &again.decisions {
828 let expected = match decision.kind {
829 crate::landing::Kind::Rendered => Action::Replaced,
830 crate::landing::Kind::Seeded | crate::landing::Kind::State => Action::Preserved,
831 };
832 assert_eq!(decision.action, expected, "{}", decision.destination);
833 }
834 }
835
836 #[test]
840 fn every_collision_is_collected_and_the_refusal_writes_nothing() {
841 let (_dir, target) = target();
842 std::fs::write(target.join("SECURITY.md"), "ours\n").expect("writes");
843 std::fs::create_dir_all(target.join("release-plz.toml")).expect("creates");
844 std::fs::write(
845 target.join("AGENTS.md"),
846 format!(
847 "{b}\n{e}\n{b}\n{e}\n",
848 b = crate::landing::BLOCK_BEGIN,
849 e = crate::landing::BLOCK_END
850 ),
851 )
852 .expect("writes");
853 let prepared = prepared(&target, None);
854 let paths: Vec<&str> = prepared
855 .collisions
856 .iter()
857 .map(|collision| collision.path.as_str())
858 .collect();
859 assert_eq!(paths, ["AGENTS.md", "SECURITY.md", "release-plz.toml"]);
860 let locks = tempfile::tempdir().expect("a scratch locks directory exists");
861 let lock = lock::acquire_in(locks.path(), &target).expect("the target is taken");
862 let held = Held::open(&target).expect("opens");
863 let refused =
864 land(&held, None, &prepared, Origin::Init, &lock).expect_err("the landing refuses");
865 assert_eq!(refused.exit_code(), 73);
866 assert!(!target.join(".release-kit").exists());
867 assert!(!target.join("dist-workspace.toml").exists());
868 }
869
870 #[test]
873 fn a_released_destination_stays_and_leaves_the_receipt() {
874 let (_dir, target) = target();
875 landed(&target, None, Origin::Init);
876 let mut record = manifest::load(&target).expect("loads").expect("exists");
877 std::fs::write(target.join("legacy.yml"), "old\n").expect("writes");
878 record.files.push(manifest::FileRecord {
879 destination: "legacy.yml".into(),
880 kind: crate::landing::Kind::Rendered,
881 sha256: crate::digest::Digest::of(b"old\n"),
882 placement: manifest::Placement::Whole,
883 });
884 let (decisions, _) = decide(
885 &Held::open(&target).expect("opens"),
886 Some(&record),
887 &Projection::compute(&ProjectionInput {
888 params: params(),
889 evidence: TargetEvidence::gather(&target, Some(&record)).expect("gathers"),
890 })
891 .expect("projects"),
892 )
893 .expect("decides");
894 let released = decisions
895 .iter()
896 .find(|decision| decision.destination == "legacy.yml")
897 .expect("the released destination is decided");
898 assert_eq!(released.action, Action::Released);
899 let outcome = landed(&target, Some(&record), Origin::Upgrade);
900 assert!(target.join("legacy.yml").is_file());
901 assert!(outcome.receipt.file("legacy.yml").is_none());
902 }
903}