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