Skip to main content

release_kit/landing/
apply.rs

1//! The direct landing writer: from a computed [`Projection`] and the
2//! receipt as it stands to the files on disk and the receipt written
3//! last.
4//!
5//! Every landing renders afresh from this binary and the target at
6//! invocation. The writer decides each destination by its recorded kind
7//! alone, validates every destination before the first write, holds one
8//! target lock through the receipt write, opens the target directory once
9//! and writes every file relative to that held directory so a component
10//! swapped for a link after validation redirects nothing, and replaces
11//! each destination through a same-directory temporary file and a
12//! rename. The set is not transactional: a failure names every completed
13//! path, leaves the previous receipt, and the rerun lands the rest.
14//!
15//! SATISFIES landing:ownership-is-elementary
16//! SATISFIES landing:a-partial-landing-is-visible-and-rerunnable
17//! SATISFIES landing:a-landing-leaves-a-record
18
19use 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
35/// The environment variable the interruption proof sets to the relative
36/// destination whose rename is to fail on purpose.
37///
38/// A rename cannot be made to fail from outside without a read failing
39/// first, and the proof is about what the tree holds after a landing that
40/// stopped part way.
41pub const INTERRUPT_VAR: &str = "RK_APPLY_INTERRUPT_AT";
42
43/// The environment variable naming a directory the proof pauses through
44/// once validation is over and the target is held: `validated` appears
45/// there, and the landing waits for `proceed`.
46pub const PAUSE_VAR: &str = "RK_APPLY_PAUSE_DIR";
47
48/// What the landing does with one destination.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
50#[serde(rename_all = "lowercase")]
51pub enum Action {
52    /// The destination is absent and the candidate is written.
53    Created,
54    /// A recorded marked region, or a recorded rendered whole file whose
55    /// bytes the record vouches for and the candidate changes, is
56    /// rewritten from the candidate.
57    Replaced,
58    /// A whole-file destination already holds the candidate's bytes:
59    /// nothing is written, and the receipt records it. A recorded rendered
60    /// file the landing leaves byte for byte is matched, not replaced, and
61    /// an unrecorded one is attributed by its content, because a run
62    /// stopped after creating it must be rerunnable.
63    Matched,
64    /// A recorded seeded or state file stays as it is, its current digest
65    /// entering the receipt.
66    Preserved,
67    /// A recorded seeded file stays and its bytes differ from the receipt:
68    /// the target tuned it, which is what a seeded file is for.
69    Drift,
70    /// A recorded whole rendered file this binary no longer produces,
71    /// whose bytes the record still vouches for: removed, and out of the
72    /// new receipt.
73    Retired,
74    /// A recorded destination this binary no longer produces that the
75    /// target owns: a seeded or state file, a marked region, a rendered
76    /// file someone edited, or one the target's own state withholds. Left
77    /// on disk, target-owned from this landing, out of the new receipt.
78    Dropped,
79}
80
81impl Action {
82    /// The report form.
83    #[must_use]
84    pub const fn as_str(self) -> &'static str {
85        match self {
86            Self::Created => "created",
87            Self::Replaced => "replaced",
88            Self::Matched => "matched",
89            Self::Preserved => "preserved",
90            Self::Drift => "drift",
91            Self::Retired => "retired",
92            Self::Dropped => "dropped",
93        }
94    }
95}
96
97/// What an adoption finds at one candidate's destination.
98///
99/// It is judged on the bytes alone. `rk adopt` decides from it, and
100/// `rk stage compare` takes the word of a target the adoption lands from
101/// it, so the comparison is the adoption's own decision.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum Adoption {
104    /// The destination holds the candidate's bytes.
105    Matches,
106    /// A seeded file the target tuned: it stays, target-owned.
107    Tuned,
108    /// A state file: it stays as the target left it.
109    State,
110    /// A rendered destination whose bytes differ from the candidate.
111    Differs,
112    /// The destination is absent, or carries no release-kit block where
113    /// `exists`.
114    Missing {
115        /// Whether a file stands at the destination.
116        exists: bool,
117    },
118    /// Something other than a regular file stands at the destination.
119    Irregular,
120}
121
122impl Adoption {
123    /// The comparison word, in the landing's vocabulary and with the
124    /// meaning the method gives it: a kept seeded or state file is
125    /// `preserved`, and whatever the adoption refuses is a `collision`.
126    #[must_use]
127    pub const fn word(self) -> &'static str {
128        match self {
129            Self::Matches => "matched",
130            Self::Tuned | Self::State => "preserved",
131            Self::Differs | Self::Missing { .. } | Self::Irregular => "collision",
132        }
133    }
134
135    /// Whether the adoption accepts `--replace` for a destination of
136    /// `kind` judged so: a rendered destination that differs or is
137    /// missing. Every other collision refuses whatever is named.
138    #[must_use]
139    pub fn replaceable(self, kind: Kind) -> bool {
140        matches!(self, Self::Differs)
141            || (matches!(self, Self::Missing { .. }) && kind == Kind::Rendered)
142    }
143}
144
145/// Judge one candidate's destination as an adoption does.
146///
147/// # Errors
148///
149/// [`RkError::Io`] for a read that fails for a reason other than
150/// absence.
151pub fn judge_adoption(target: &Held, candidate: &Candidate) -> Result<Adoption, RkError> {
152    // The landing's own walk: a linked or non-directory parent, a link at
153    // the destination, dangling or not, and any non-regular entry are
154    // irregular, because the write would refuse to follow them.
155    match locate(&target.root, &candidate.destination)? {
156        Located::Collision(_) => return Ok(Adoption::Irregular),
157        Located::Absent => return Ok(Adoption::Missing { exists: false }),
158        Located::Present { .. } => {}
159    }
160    let Some(current) = super::read_recorded(target.base(), &candidate.destination)? else {
161        return Ok(Adoption::Missing { exists: true });
162    };
163    let expected: &[u8] = match candidate.placement {
164        Placement::Whole => &candidate.bytes,
165        Placement::Region { .. } => candidate.region.as_deref().unwrap_or(&candidate.bytes),
166    };
167    Ok(match candidate.kind {
168        Kind::Rendered | Kind::Seeded if current == expected => Adoption::Matches,
169        Kind::Rendered => Adoption::Differs,
170        Kind::Seeded => Adoption::Tuned,
171        Kind::State => Adoption::State,
172    })
173}
174
175/// A rendered file the landing preserves under an accepted-drift entry.
176#[derive(Debug, Clone, PartialEq, Eq)]
177pub struct Accepted {
178    /// Why the project deviates, from its entry.
179    pub reason: String,
180    /// Whether the candidate for the destination changed since the
181    /// previous receipt, which tells the project its deviation may be
182    /// droppable.
183    pub candidate_moved: bool,
184    /// The digest of the accepted bytes, which the landing checks again
185    /// through the held directory before its first write.
186    pub sha256: String,
187}
188
189/// One decided destination.
190#[derive(Debug, Clone, PartialEq, Eq)]
191pub struct Decision {
192    /// The destination, relative to the target.
193    pub destination: String,
194    /// The kind the candidate declares, or the recorded kind for a
195    /// destination this binary no longer produces.
196    pub kind: Kind,
197    /// What happens to it.
198    pub action: Action,
199    /// The accepted drift a preserved rendered file stays under.
200    pub accepted: Option<Accepted>,
201}
202
203/// One destination the landing refuses before any write.
204///
205/// It is a whole-file destination present on disk with no receipt entry
206/// attributing it, a recorded rendered file or region whose bytes the
207/// record no longer vouches for, or a document whose markers offer the
208/// block no place.
209#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
210pub struct Collision {
211    /// The destination.
212    pub path: String,
213    /// Why it refuses.
214    pub reason: String,
215    /// Whether it is a conflict `--replace` may name: a regular file whose
216    /// bytes a landing could save and overwrite. A defective marker or a
217    /// linked parent is no conflict, and only a fix to the target clears
218    /// it.
219    pub replaceable: bool,
220}
221
222/// One conflict the operator named, replaced after its bytes were saved.
223#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
224pub struct Replaced {
225    /// The destination.
226    pub path: String,
227    /// Where its bytes were saved; absent in a preview, which saves
228    /// nothing.
229    pub saved_to: Option<String>,
230}
231
232/// The reason a recorded rendered file reads as a conflict.
233const EDITED: &str = "was edited after the landing, and the record no longer vouches for its bytes";
234
235/// One target directory held open for a whole landing verb.
236///
237/// Opened once, right after the lock under an apply and before any
238/// evidence is gathered, and carried through every decision read and
239/// every write, so a target root exchanged under the pathname after
240/// validation receives nothing: the descriptor names the directory that
241/// was validated, whatever its path has since become.
242#[derive(Debug)]
243pub struct Held {
244    root: File,
245    base: camino::Utf8PathBuf,
246    display: camino::Utf8PathBuf,
247}
248
249impl Held {
250    /// Hold `target`, following no link at its final component.
251    ///
252    /// # Errors
253    ///
254    /// The open failure, and a kernel link that is not UTF-8.
255    pub fn open(target: &Utf8Path) -> Result<Self, RkError> {
256        let root = held::open_dir(target.as_std_path())?;
257        let base = camino::Utf8PathBuf::from_path_buf(held::proc_path(&root))
258            .map_err(|path| anyhow::anyhow!("the kernel's link {} is not UTF-8", path.display()))?;
259        Ok(Self {
260            root,
261            base,
262            display: target.to_owned(),
263        })
264    }
265
266    /// Hold `target` under `lock`: the directory opened must be the one
267    /// the lock key was derived from, or the target was exchanged between
268    /// the two steps and the landing refuses before it reads anything.
269    ///
270    /// # Errors
271    ///
272    /// As [`Self::open`], and a `state-drift` refusal naming the exchange.
273    pub fn open_locked(target: &Utf8Path, lock: &lock::TargetLock) -> Result<Self, RkError> {
274        let held = Self::open(target)?;
275        let opened = held::Identity::of(&held.root.metadata()?);
276        if lock.identity() != opened {
277            return Err(RkError::refusal(
278                Diagnostic::new(
279                    Reason::StateDrift,
280                    format!(
281                        "the directory at {target} was exchanged after the lock was taken, and nothing was written"
282                    ),
283                )
284                .expected("one directory at the target path from the lock through the receipt write")
285                .action("re-run once the target is at rest")
286                .target_state("unchanged"),
287            ));
288        }
289        Ok(held)
290    }
291
292    /// The path every read of this target goes through: the kernel's
293    /// link to the held directory.
294    #[must_use]
295    pub fn base(&self) -> &Utf8Path {
296        &self.base
297    }
298
299    /// The path the operator named, for reports.
300    #[must_use]
301    pub fn display(&self) -> &Utf8Path {
302        &self.display
303    }
304}
305
306/// The landing decided and ready: the projection, every decision in
307/// projection order with the dropped and retired destinations after them, every
308/// collision, and the configuration the landing writes first.
309#[derive(Debug)]
310pub struct Prepared {
311    /// The resolved parameters.
312    pub params: Params,
313    /// The candidate tree.
314    pub projection: Projection,
315    /// Every decision.
316    pub decisions: Vec<Decision>,
317    /// Every collision, in destination order.
318    pub collisions: Vec<Collision>,
319    /// The configuration the landing writes before the files.
320    pub config: config::Plan,
321    /// The conflicts the operator named, which the landing saves and then
322    /// replaces.
323    pub replacements: Vec<String>,
324}
325
326impl Prepared {
327    /// The decision for one destination.
328    #[must_use]
329    pub fn decision(&self, destination: &str) -> Option<&Decision> {
330        self.decisions
331            .iter()
332            .find(|decision| decision.destination == destination)
333    }
334}
335
336/// Gather the target's evidence once, compute the projection, and decide
337/// every destination, writing nothing.
338///
339/// Under an apply this runs inside the target lock, so the evidence the
340/// landing writes from is the evidence it gathered.
341///
342/// # Errors
343///
344/// The evidence read's failures, the projection's own defects, and an
345/// invalid committed configuration.
346pub fn prepare(
347    target: &Held,
348    recorded: Option<&Manifest>,
349    params: &Params,
350    existing_config: Option<&config::Config>,
351) -> Result<Prepared, RkError> {
352    let evidence = TargetEvidence::gather(target.base(), recorded)?;
353    let projection = Projection::compute(&ProjectionInput {
354        params: params.clone(),
355        evidence,
356    })?;
357    super::accepted_drift_faults(&projection, recorded, existing_config)?;
358    let accepted_drift = existing_config
359        .map(|config| config.landing.accepted_drift.clone())
360        .unwrap_or_default();
361    let (decisions, collisions) = decide(target, recorded, &projection, &accepted_drift)?;
362    let config = config::Plan::new(
363        target.base().as_std_path(),
364        params,
365        existing_config,
366        recorded,
367    )?;
368    Ok(Prepared {
369        params: params.clone(),
370        projection,
371        decisions,
372        collisions,
373        config,
374        replacements: Vec::new(),
375    })
376}
377
378/// Turn every conflict `replace` names into a replacement, and refuse a
379/// name that is no conflict.
380///
381/// A conflict is an unattributed collision or a recorded rendered file
382/// whose bytes the record no longer vouches for. Every conflict the
383/// operator did not name stays a collision, and the landing still
384/// refuses it before the first write; a name that matches no conflict is
385/// a usage error, because a replace the operator believes happened and
386/// did not is worse than a refusal.
387///
388/// # Errors
389///
390/// [`RkError::Usage`] naming the first path that is no conflict.
391pub fn name_replacements(prepared: &mut Prepared, replace: &[String]) -> Result<(), RkError> {
392    for path in replace {
393        if prepared.replacements.contains(path) {
394            continue;
395        }
396        let Some(index) = prepared
397            .collisions
398            .iter()
399            .position(|collision| collision.path == *path && collision.replaceable)
400        else {
401            return Err(RkError::Usage(format!(
402                "--replace {path} names no conflict this landing has; a conflict is an unattributed file or a recorded rendered file someone edited"
403            )));
404        };
405        prepared.collisions.remove(index);
406        let kind = prepared
407            .projection
408            .candidates
409            .iter()
410            .find(|candidate| candidate.destination == *path)
411            .map_or(Kind::Rendered, |candidate| candidate.kind);
412        prepared.decisions.push(Decision {
413            destination: path.clone(),
414            kind,
415            action: Action::Replaced,
416            accepted: None,
417        });
418        prepared.replacements.push(path.clone());
419    }
420    let order = |destination: &str| {
421        prepared
422            .projection
423            .candidates
424            .iter()
425            .position(|candidate| candidate.destination == destination)
426            .unwrap_or(usize::MAX)
427    };
428    let mut decisions = std::mem::take(&mut prepared.decisions);
429    decisions.sort_by_key(|decision| order(&decision.destination));
430    prepared.decisions = decisions;
431    Ok(())
432}
433
434/// Decide every destination from the receipt and the disk, collecting
435/// every collision rather than stopping at the first.
436///
437/// Every existing parent component of a candidate is walked relative to
438/// the held target directory with no link followed, so a linked or
439/// non-directory component is a collision here, before any write, and
440/// not a failure after the configuration landed.
441///
442/// # Errors
443///
444/// A read failure other than absence.
445#[allow(
446    clippy::too_many_lines,
447    reason = "one pass decides every destination by its placement, its record, and its bytes"
448)]
449pub fn decide(
450    target: &Held,
451    recorded: Option<&Manifest>,
452    projection: &Projection,
453    accepted_drift: &std::collections::BTreeMap<String, config::AcceptedDrift>,
454) -> Result<(Vec<Decision>, Vec<Collision>), RkError> {
455    let root = &target.root;
456    let mut decisions = Vec::new();
457    let mut collisions: Vec<Collision> = projection
458        .collisions
459        .iter()
460        .map(|collision| Collision {
461            path: collision.destination.clone(),
462            reason: collision.reason.clone(),
463            replaceable: false,
464        })
465        .collect();
466    for candidate in &projection.candidates {
467        let record = recorded.and_then(|record| record.file(&candidate.destination));
468        let located = match locate(root, &candidate.destination)? {
469            Located::Collision(reason) => {
470                collisions.push(Collision {
471                    path: candidate.destination.clone(),
472                    reason,
473                    replaceable: false,
474                });
475                continue;
476            }
477            other => other,
478        };
479        let present = matches!(located, Located::Present { .. });
480        let current = || located.read();
481        let mut accepted = None;
482        let action = match (candidate.placement, present, record) {
483            (_, false, _) => Action::Created,
484            // A recorded region someone edited is a conflict like an
485            // edited rendered file: the record no longer vouches for it,
486            // and it does not already hold the candidate's region, which
487            // is what a landing stopped part way leaves behind.
488            (Placement::Region { begin, end }, true, Some(record))
489                if record.kind == Kind::Rendered
490                    && super::extract_block(&String::from_utf8_lossy(&current()?), begin, end)
491                        .is_some_and(|block| {
492                            Digest::of(block.as_bytes()) != record.sha256
493                                && candidate.region.as_deref() != Some(block.as_bytes())
494                        }) =>
495            {
496                collisions.push(Collision {
497                    path: candidate.destination.clone(),
498                    reason: format!("its marked region {EDITED}"),
499                    replaceable: true,
500                });
501                continue;
502            }
503            // A marked region lands into the target's document whether
504            // the receipt names it or not: the bytes outside the markers
505            // stay the target's, so nothing is taken from it.
506            (Placement::Region { .. }, true, _) => Action::Replaced,
507            // An unrecorded whole file holding the candidate's bytes is
508            // attributed by its content: a run stopped after creating it
509            // leaves exactly this, and replacing identical bytes changes
510            // nothing. Differing bytes are the target's, and refuse.
511            (Placement::Whole, true, None) => {
512                if current()? == candidate.bytes {
513                    Action::Matched
514                } else {
515                    collisions.push(Collision {
516                        path: candidate.destination.clone(),
517                        reason:
518                            "exists with bytes differing from the candidate, and no receipt attributes it to release-kit"
519                                .to_owned(),
520                        replaceable: true,
521                    });
522                    continue;
523                }
524            }
525            (Placement::Whole, true, Some(record)) => match (candidate.kind, record.kind) {
526                // The record vouches for the bytes on disk, so they are
527                // release-kit's own; bytes it no longer vouches for are an
528                // edit, which only a named replace overwrites.
529                (Kind::Rendered, Kind::Rendered) => {
530                    let bytes = current()?;
531                    // Bytes equal to the candidate need no write, whether
532                    // the record vouches for them or a landing stopped part
533                    // way wrote them: the file is matched, so the report
534                    // tells an untouched file from a rewritten one.
535                    let entry = accepted_drift
536                        .get(&candidate.destination)
537                        .filter(|entry| Digest::of(&bytes).to_string() == entry.sha256);
538                    if bytes == candidate.bytes {
539                        Action::Matched
540                    } else if Digest::of(&bytes) == record.sha256 {
541                        Action::Replaced
542                    } else if let Some(entry) = entry {
543                        // An accepted deviation stays on disk: the receipt
544                        // keeps the candidate's digest, so it still says
545                        // what the tool would write.
546                        accepted = Some(Accepted {
547                            reason: entry.reason.clone(),
548                            candidate_moved: Digest::of(&candidate.bytes) != record.sha256,
549                            sha256: entry.sha256.clone(),
550                        });
551                        Action::Preserved
552                    } else {
553                        collisions.push(Collision {
554                            path: candidate.destination.clone(),
555                            reason: EDITED.to_owned(),
556                            replaceable: true,
557                        });
558                        continue;
559                    }
560                }
561                (Kind::Rendered, Kind::Seeded | Kind::State) => {
562                    collisions.push(Collision {
563                        path: candidate.destination.clone(),
564                        reason: format!(
565                            "is recorded as {}, and this release renders it, so its bytes are the target's",
566                            record.kind.as_str()
567                        ),
568                        replaceable: true,
569                    });
570                    continue;
571                }
572                (Kind::Seeded, _) => {
573                    if Digest::of(&current()?) == record.sha256 {
574                        Action::Preserved
575                    } else {
576                        Action::Drift
577                    }
578                }
579                (Kind::State, _) => Action::Preserved,
580            },
581        };
582        decisions.push(Decision {
583            destination: candidate.destination.clone(),
584            kind: candidate.kind,
585            action,
586            accepted,
587        });
588    }
589    if let Some(record) = recorded {
590        decisions.extend(dropped(target, record, projection)?);
591    }
592    collisions.sort_by(|a, b| a.path.cmp(&b.path));
593    collisions.dedup_by(|a, b| a.path == b.path);
594    Ok((decisions, collisions))
595}
596
597/// Decide every recorded destination `projection` no longer produces.
598///
599/// A whole rendered file whose bytes the record still vouches for is
600/// `retired`, and a landing removes it: release-kit wrote those bytes and
601/// nobody changed them, and a workflow left behind keeps running beside
602/// its successor. One already absent is retired too, because nothing of
603/// it remains. Every other such destination is `dropped` and stays on
604/// disk as the target's own: a seeded or state file, whose bytes are the
605/// target's by kind, a marked region inside the target's document, a
606/// rendered file someone edited, an entry that is no regular file, and a
607/// destination the target's own state withholds. Each leaves the receipt.
608///
609/// `rk stage`, `rk stage compare`, the upgrade preview, and the apply all
610/// decide through this one function.
611///
612/// SATISFIES landing:a-dropped-file-stays
613///
614/// # Errors
615///
616/// A read of the held target that fails for a reason other than absence.
617pub fn dropped(
618    target: &Held,
619    recorded: &Manifest,
620    projection: &Projection,
621) -> Result<Vec<Decision>, RkError> {
622    let mut decisions = Vec::new();
623    for file in &recorded.files {
624        let produced = projection
625            .candidates
626            .iter()
627            .any(|candidate| candidate.destination == file.destination);
628        if produced {
629            continue;
630        }
631        let withheld = projection
632            .omissions
633            .iter()
634            .any(|omission| omission.destination == file.destination);
635        let retirable = !withheld
636            && file.kind == Kind::Rendered
637            && file.placement == manifest::Placement::Whole;
638        let action = if retirable && unedited(&target.root, file)? {
639            Action::Retired
640        } else {
641            Action::Dropped
642        };
643        decisions.push(Decision {
644            destination: file.destination.clone(),
645            kind: file.kind,
646            action,
647            accepted: None,
648        });
649    }
650    Ok(decisions)
651}
652
653/// Whether a recorded whole file is absent, or a regular file holding the
654/// bytes the record vouches for.
655fn unedited(root: &File, file: &FileRecord) -> std::io::Result<bool> {
656    Ok(match locate(root, &file.destination)? {
657        Located::Absent => true,
658        Located::Collision(_) => false,
659        present @ Located::Present { .. } => Digest::of(&present.read()?) == file.sha256,
660    })
661}
662
663/// What stands at a destination inside the held target.
664enum Located {
665    /// The parent chain or the final component cannot be landed through:
666    /// a linked or non-directory parent, or a non-regular entry.
667    Collision(String),
668    /// Nothing stands there.
669    Absent,
670    /// A regular file stands there, inside its held parent.
671    Present { dir: File, name: std::ffi::OsString },
672}
673
674impl Located {
675    /// The bytes present, empty where nothing stands.
676    fn read(&self) -> std::io::Result<Vec<u8>> {
677        match self {
678            Self::Present { dir, name } => {
679                held::read_file(dir, name).map(Option::unwrap_or_default)
680            }
681            Self::Absent | Self::Collision(_) => Ok(Vec::new()),
682        }
683    }
684}
685
686/// Locate `destination` inside the held `root`: the parent chain is held
687/// first, following no link, and the final component is then examined
688/// inside the held parent.
689fn locate(root: &File, destination: &str) -> std::io::Result<Located> {
690    let (parent, name) = split(Path::new(destination))?;
691    let dir = match held::hold_dir_existing(root, parent) {
692        Ok(dir) => dir,
693        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Located::Absent),
694        Err(error) => {
695            return Ok(Located::Collision(format!(
696                "a parent component cannot be held: {error}"
697            )));
698        }
699    };
700    match std::fs::symlink_metadata(held::proc_path(&dir).join(name)) {
701        Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => Ok(
702            Located::Collision("exists and is not a regular file".to_owned()),
703        ),
704        Ok(_) => Ok(Located::Present {
705            dir,
706            name: name.to_owned(),
707        }),
708        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Located::Absent),
709        Err(error) => Err(error),
710    }
711}
712
713/// The refusal a licence condition answers, before any write.
714///
715/// A landing that guessed past it would write a workflow whose provider's
716/// terms the target's own licence does not permit, which is a licence
717/// violation this convention does not commit on a target's behalf.
718#[must_use]
719pub fn licence_refusal(reason: &str) -> RkError {
720    RkError::refusal(
721        Diagnostic::new(
722            Reason::StateDrift,
723            format!("the code scanning provider's licence condition is not satisfied, and nothing was written: {reason}"),
724        )
725        .expected("a provider whose terms the target's declared licence permits")
726        .action("pass --code-scanning semgrep, which carries no licence condition, or --code-scanning off")
727        .target_state("unchanged"),
728    )
729}
730
731/// The refusal a project the selected release driver cannot release
732/// answers, before any write: a python project with a dynamic version or
733/// no `uv.lock` would land a release that fails at its first bump.
734///
735/// SATISFIES project-profile:release-intent-has-three-modes
736#[must_use]
737pub fn driver_refusal(reason: &str) -> RkError {
738    RkError::refusal(
739        Diagnostic::new(
740            Reason::PrerequisiteUnmet,
741            format!("the project cannot take the selected release driver, and nothing was written: {reason}"),
742        )
743        .expected("a uv project with a static [project] version and a committed uv.lock")
744        .action("uv lock, or a static version in [project], then land again")
745        .target_state("unchanged"),
746    )
747}
748
749/// The refusal a selected release automation this release cannot land
750/// answers, before any write: silently omitting the declared release would
751/// write a record that claims an automation nothing landed.
752///
753/// SATISFIES project-profile:an-operation-refuses-only-what-it-requires
754#[must_use]
755pub fn release_unavailable(reason: &str) -> RkError {
756    RkError::refusal(
757        Diagnostic::new(
758            Reason::StateDrift,
759            format!("the selected release automation is not available in this release, and nothing was written: {reason}"),
760        )
761        .expected("a release driver and forge this release ships automation for, or a release mode that selects none")
762        .action("pass --release-driver and --forge at an available tuple, or --release-mode external or none")
763        .target_state("unchanged"),
764    )
765}
766
767/// The one refusal for every collision, before any write.
768///
769/// No flag forces past a collision. Only a named `--replace`, which saves
770/// a copy first, passes a conflict, and a defective marker or a linked
771/// parent only a fix to the target clears.
772#[must_use]
773pub fn refusal(target: &Utf8Path, collisions: &[Collision]) -> RkError {
774    let listed: Vec<String> = collisions
775        .iter()
776        .map(|collision| format!("{} ({})", collision.path, collision.reason))
777        .collect();
778    RkError::refusal(
779        Diagnostic::new(
780            Reason::StateDrift,
781            format!(
782                "these destinations cannot be landed as they stand, and nothing was written: {}",
783                listed.join("; ")
784            ),
785        )
786        .expected(
787            "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",
788        )
789        .action(format!(
790            "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"
791        ))
792        .target_state("unchanged"),
793    )
794}
795
796/// How a landing came to write its receipt.
797#[derive(Debug, Clone, Copy, PartialEq, Eq)]
798pub enum Origin {
799    /// A first landing: files and receipt.
800    Init,
801    /// A landing over a receipt: files and receipt, the first landing's
802    /// instant and origin preserved.
803    Upgrade,
804    /// A record of a target already at the projection: config and
805    /// receipt only.
806    Adopt,
807}
808
809/// One stored secret whose reading moved when a landing replaced a GitHub
810/// workflow: the replaced file read it and the rendered file does not, or
811/// the reverse.
812///
813/// SATISFIES landing:ownership-is-elementary
814#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
815pub struct SecretMove {
816    /// The workflow, relative to the target.
817    pub path: String,
818    /// The secret's name.
819    pub name: String,
820    /// `dropped` where only the replaced file read it, `new` where only
821    /// the rendered file reads it.
822    pub direction: String,
823    /// The setup step that stores a new secret, where one does.
824    #[serde(default, skip_serializing_if = "Option::is_none")]
825    pub step: Option<String>,
826}
827
828impl SecretMove {
829    /// The human line.
830    #[must_use]
831    pub fn line(&self) -> String {
832        if self.direction == "dropped" {
833            format!(
834                "secret {} read by the replaced {} is not read by the rendered file",
835                self.name, self.path
836            )
837        } else {
838            self.step.as_deref().map_or_else(
839                || {
840                    format!(
841                        "secret {} is new; no setup step stores it, so the operator does",
842                        self.name
843                    )
844                },
845                |step| format!("secret {} is new; {step} stores it", self.name),
846            )
847        }
848    }
849}
850
851/// The stored secrets a workflow text reads: every `secrets.NAME` inside a
852/// `${{ }}` expression, with or without spaces. `GITHUB_TOKEN` is the
853/// forge's own and no stored secret.
854#[must_use]
855pub fn secret_names(text: &str) -> std::collections::BTreeSet<String> {
856    let mut names = std::collections::BTreeSet::new();
857    let mut rest = text;
858    while let Some(start) = rest.find("${{") {
859        let Some(end) = rest[start..].find("}}") else {
860            break;
861        };
862        let expression = &rest[start + 3..start + end];
863        let mut inner = expression;
864        while let Some(at) = inner.find("secrets.") {
865            let name: String = inner[at + "secrets.".len()..]
866                .chars()
867                .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
868                .collect();
869            if !name.is_empty() && name != "GITHUB_TOKEN" {
870                names.insert(name);
871            }
872            inner = &inner[at + "secrets.".len()..];
873        }
874        rest = &rest[start + end + 2..];
875    }
876    names
877}
878
879/// The secrets that move when the workflow at `path` goes from `old` to
880/// `new`: each name `old` reads and `new` does not, then each name `new`
881/// reads and `old` did not, with the step that stores it.
882#[must_use]
883pub fn secrets_moved(path: &str, old: &str, new: &str) -> Vec<SecretMove> {
884    let (before, after) = (secret_names(old), secret_names(new));
885    let dropped = before.difference(&after).map(|name| SecretMove {
886        path: path.to_owned(),
887        name: name.clone(),
888        direction: "dropped".to_owned(),
889        step: None,
890    });
891    let added = after.difference(&before).map(|name| SecretMove {
892        path: path.to_owned(),
893        name: name.clone(),
894        direction: "new".to_owned(),
895        step: crate::setup::steps::secret_step(name).map(str::to_owned),
896    });
897    dropped.chain(added).collect()
898}
899
900/// The secrets every workflow this landing replaces would move: each
901/// destination under `.github/workflows/` the landing replaces, read
902/// before the write and set beside its candidate.
903///
904/// # Errors
905///
906/// A read of the held target that fails for a reason other than absence.
907pub fn replaced_secrets(target: &Held, prepared: &Prepared) -> Result<Vec<SecretMove>, RkError> {
908    let mut moved = Vec::new();
909    for decision in &prepared.decisions {
910        if decision.action != Action::Replaced
911            || !decision.destination.starts_with(".github/workflows/")
912        {
913            continue;
914        }
915        let Some(candidate) = prepared
916            .projection
917            .candidates
918            .iter()
919            .find(|candidate| candidate.destination == decision.destination)
920        else {
921            continue;
922        };
923        let Some(current) = read_relative(&target.root, &decision.destination)? else {
924            continue;
925        };
926        moved.extend(secrets_moved(
927            &decision.destination,
928            &String::from_utf8_lossy(&current),
929            &String::from_utf8_lossy(&candidate.bytes),
930        ));
931    }
932    Ok(moved)
933}
934
935/// What a landing completed.
936#[derive(Debug)]
937pub struct Landed {
938    /// Every path written, in order, the config and the receipt included.
939    pub completed: Vec<String>,
940    /// Whether the configuration was written, or already held its bytes.
941    pub config_written: bool,
942    /// The receipt as written.
943    pub receipt: Manifest,
944    /// Every conflict replaced, with where its bytes were saved.
945    pub replaced: Vec<Replaced>,
946}
947
948/// Land `prepared` into `target`.
949///
950/// Refuse every collision first, open the target once under the lock the
951/// caller holds, write the configuration where its bytes changed, then
952/// each candidate by its decision, then the receipt.
953///
954/// The caller gathers under the same lock where it wants the evidence
955/// and the writes to agree; [`prepare`] itself takes none, so a preview
956/// holds nothing.
957///
958/// # Errors
959///
960/// The collision refusal at exit 73 with nothing written; the lock's
961/// refusals; and [`RkError::Io`] for a write that fails, naming every
962/// path completed before it, with the previous receipt left in place.
963pub fn land(
964    target: &Held,
965    recorded: Option<&Manifest>,
966    prepared: &Prepared,
967    origin: Origin,
968    _lock: &lock::TargetLock,
969) -> Result<Landed, RkError> {
970    if let Some(reason) = prepared.projection.licence_refusal.as_deref() {
971        return Err(licence_refusal(reason));
972    }
973    if let Some(reason) = prepared.projection.release_unavailable() {
974        return Err(release_unavailable(reason));
975    }
976    if let Some(reason) = prepared.projection.driver_refusal.as_deref() {
977        return Err(driver_refusal(reason));
978    }
979    if !prepared.collisions.is_empty() {
980        return Err(refusal(target.display(), &prepared.collisions));
981    }
982    let root = &target.root;
983    // The proof's pause: validation is over and the target is held, so a
984    // link or a directory swapped in under the pathname from here on
985    // meets the held descriptor, not the path.
986    held::pause(PAUSE_VAR, "validated", "proceed");
987    // Every destination the landing does not write is read again through
988    // the held directory before the first write: a preserved or matched
989    // file must still stand, and an adopted value must still equal the
990    // candidate, or the landing refuses with the old receipt intact.
991    let unwritten = reverify(root, prepared, origin)?;
992    // Every file the landing retires must still hold the bytes the record
993    // vouches for, or it became the target's after the decision.
994    let retiring = retiring(root, recorded, prepared)?;
995    // Every named conflict is saved before the first write, so a failed
996    // save leaves the target exactly as it stood.
997    let replaced = save_replacements(target, root, prepared)?;
998    let mut writer = Writer {
999        root,
1000        completed: Vec::new(),
1001        stop: std::env::var_os(INTERRUPT_VAR).map(|value| value.to_string_lossy().into_owned()),
1002    };
1003    // The configuration first, where the resolved answers changed.
1004    let config_current = read_relative(root, config::CONFIG_PATH)?;
1005    let config_written = config_current.as_deref() != Some(prepared.config.content.as_bytes());
1006    if config_written {
1007        writer.write(config::CONFIG_PATH, prepared.config.content.as_bytes())?;
1008    }
1009    let mut files = Vec::new();
1010    for candidate in &prepared.projection.candidates {
1011        let accepted = prepared
1012            .decision(&candidate.destination)
1013            .is_some_and(|decision| decision.accepted.is_some());
1014        let sha256 = match unwritten.get(&candidate.destination) {
1015            // The receipt of an accepted deviation keeps the candidate's
1016            // digest, not the bytes on disk, so it goes on meaning what the
1017            // tool would write and the next landing preserves it again.
1018            Some(_) if accepted => candidate_digest(candidate),
1019            Some(digest) => digest.clone(),
1020            None => match action_of(prepared, origin, &candidate.destination) {
1021                Some(Action::Created | Action::Replaced) => {
1022                    writer.write(&candidate.destination, &candidate.bytes)?;
1023                    candidate_digest(candidate)
1024                }
1025                _ => continue,
1026            },
1027        };
1028        files.push(FileRecord {
1029            destination: candidate.destination.clone(),
1030            kind: candidate.kind,
1031            sha256,
1032            placement: match candidate.placement {
1033                Placement::Whole => manifest::Placement::Whole,
1034                Placement::Region { .. } => manifest::Placement::Region,
1035            },
1036        });
1037    }
1038    // The retired files go after every write and before the receipt, so
1039    // a stop between leaves the old receipt naming each one, and the
1040    // rerun finds it absent and retires it again.
1041    for destination in &retiring {
1042        writer.remove(destination)?;
1043    }
1044    let receipt = receipt(
1045        &prepared.params,
1046        &prepared.projection,
1047        recorded,
1048        origin,
1049        files,
1050    );
1051    writer.write(manifest::MANIFEST_PATH, &manifest::render(&receipt)?)?;
1052    Ok(Landed {
1053        completed: writer.completed,
1054        config_written,
1055        receipt,
1056        replaced,
1057    })
1058}
1059
1060/// Save the bytes of every named conflict that stands on disk.
1061fn save_replacements(
1062    target: &Held,
1063    root: &File,
1064    prepared: &Prepared,
1065) -> Result<Vec<Replaced>, RkError> {
1066    if prepared.replacements.is_empty() {
1067        return Ok(Vec::new());
1068    }
1069    // Every run claims its own state-root snapshot, an open migration's
1070    // included: nothing reads a saved copy back, and a fresh claim never
1071    // collides with an earlier run's copy of the same path.
1072    let canonical = std::fs::canonicalize(target.display().as_std_path())?;
1073    let snapshot = crate::snapshot::Snapshot::begin(&canonical)?;
1074    let mut replaced = Vec::new();
1075    for path in &prepared.replacements {
1076        let saved_to = match read_relative(root, path)? {
1077            Some(bytes) => Some(snapshot.save_replaced(path, &bytes)?.display().to_string()),
1078            None => None,
1079        };
1080        replaced.push(Replaced {
1081            path: path.clone(),
1082            saved_to,
1083        });
1084    }
1085    Ok(replaced)
1086}
1087
1088/// What a preview reports for the named conflicts: each path, with no
1089/// saved copy yet.
1090#[must_use]
1091pub fn previewed_replacements(prepared: &Prepared) -> Vec<Replaced> {
1092    prepared
1093        .replacements
1094        .iter()
1095        .map(|path| Replaced {
1096            path: path.clone(),
1097            saved_to: None,
1098        })
1099        .collect()
1100}
1101
1102/// What the landing does with one destination under `origin`: an
1103/// adoption preserves everything it verified; a landing follows its
1104/// decision, and a candidate without one was a collision the refusal
1105/// already named.
1106fn action_of(prepared: &Prepared, origin: Origin, destination: &str) -> Option<Action> {
1107    match origin {
1108        Origin::Adopt if prepared.replacements.iter().any(|path| path == destination) => {
1109            Some(Action::Replaced)
1110        }
1111        Origin::Adopt => Some(Action::Preserved),
1112        Origin::Init | Origin::Upgrade => prepared.decision(destination).map(|d| d.action),
1113    }
1114}
1115
1116/// The recorded form of what a destination holds now, read through the
1117/// held directory: the whole file, or the marked region alone; `None`
1118/// where the file, or the region, is absent.
1119fn current_form(root: &File, candidate: &Candidate) -> Result<Option<Vec<u8>>, RkError> {
1120    let Some(current) = read_relative(root, &candidate.destination)? else {
1121        return Ok(None);
1122    };
1123    Ok(match candidate.placement {
1124        Placement::Whole => Some(current),
1125        Placement::Region { begin, end } => {
1126            let text = String::from_utf8_lossy(&current);
1127            super::extract_block(&text, begin, end).map(|block| block.as_bytes().to_vec())
1128        }
1129    })
1130}
1131
1132/// Read every destination the landing leaves unwritten again, through
1133/// the held directory, and digest it for the receipt: a preserved,
1134/// drifted, or matched file must still be present, and a matched or
1135/// adopted rendered value must still equal the candidate.
1136///
1137/// # Errors
1138///
1139/// A `state-drift` refusal naming the destination that moved since the
1140/// decision, with nothing written; and any read failure.
1141fn reverify(
1142    root: &File,
1143    prepared: &Prepared,
1144    origin: Origin,
1145) -> Result<std::collections::BTreeMap<String, Digest>, RkError> {
1146    let mut digests = std::collections::BTreeMap::new();
1147    for candidate in &prepared.projection.candidates {
1148        let action = action_of(prepared, origin, &candidate.destination);
1149        let must_match = match (origin, action) {
1150            (_, Some(Action::Replaced | Action::Created)) => continue,
1151            (Origin::Adopt, _) => candidate.kind == Kind::Rendered,
1152            (_, Some(Action::Matched)) => true,
1153            (_, Some(Action::Preserved | Action::Drift)) => false,
1154            _ => continue,
1155        };
1156        let Some(current) = current_form(root, candidate)? else {
1157            return Err(moved(&candidate.destination, "is no longer present"));
1158        };
1159        let expected: &[u8] = candidate.region.as_deref().unwrap_or(&candidate.bytes);
1160        if must_match && current != expected {
1161            return Err(moved(
1162                &candidate.destination,
1163                "no longer holds the candidate's bytes",
1164            ));
1165        }
1166        // An accepted deviation is preserved only while the bytes on disk
1167        // are the ones its entry accepted.
1168        let accepted = prepared
1169            .decision(&candidate.destination)
1170            .and_then(|decision| decision.accepted.as_ref());
1171        if let Some(accepted) = accepted
1172            && Digest::of(&current).to_string() != accepted.sha256
1173        {
1174            return Err(moved(
1175                &candidate.destination,
1176                "no longer holds the bytes its accepted drift entry names",
1177            ));
1178        }
1179        digests.insert(candidate.destination.clone(), Digest::of(&current));
1180    }
1181    Ok(digests)
1182}
1183
1184/// Every destination the landing retires, each read again through the
1185/// held directory: it must still be absent or hold the bytes the record
1186/// vouches for.
1187///
1188/// # Errors
1189///
1190/// A `state-drift` refusal naming the destination someone edited since the
1191/// decision, with nothing written; and any read failure.
1192fn retiring(
1193    root: &File,
1194    recorded: Option<&Manifest>,
1195    prepared: &Prepared,
1196) -> Result<Vec<String>, RkError> {
1197    let mut out = Vec::new();
1198    for decision in &prepared.decisions {
1199        if decision.action != Action::Retired {
1200            continue;
1201        }
1202        let Some(file) = recorded.and_then(|record| record.file(&decision.destination)) else {
1203            continue;
1204        };
1205        if !unedited(root, file)? {
1206            return Err(moved(
1207                &decision.destination,
1208                "no longer holds the bytes the record vouches for",
1209            ));
1210        }
1211        out.push(decision.destination.clone());
1212    }
1213    Ok(out)
1214}
1215
1216/// The refusal for a destination that changed between the decision and
1217/// the first write.
1218fn moved(destination: &str, what: &str) -> RkError {
1219    RkError::refusal(
1220        Diagnostic::new(
1221            Reason::StateDrift,
1222            format!(
1223                "{destination} {what} since it was validated, and nothing was written; the previous receipt stands"
1224            ),
1225        )
1226        .expected("every destination the landing leaves as it stands to stand still until the receipt is written")
1227        .action("re-run once the target is at rest")
1228        .target_state("unchanged"),
1229    )
1230}
1231
1232/// The digest the receipt carries for a written candidate: the whole
1233/// file, or the marked region alone.
1234fn candidate_digest(candidate: &Candidate) -> Digest {
1235    candidate
1236        .region
1237        .as_deref()
1238        .map_or_else(|| Digest::of(&candidate.bytes), Digest::of)
1239}
1240
1241/// The receipt for this landing.
1242fn receipt(
1243    params: &Params,
1244    projection: &Projection,
1245    recorded: Option<&Manifest>,
1246    origin: Origin,
1247    files: Vec<FileRecord>,
1248) -> Manifest {
1249    Manifest {
1250        schema_version: manifest::SCHEMA_VERSION,
1251        rk_version: env!("CARGO_PKG_VERSION").to_owned(),
1252        origin: recorded.map_or_else(
1253            || match origin {
1254                Origin::Adopt => "adopt".to_owned(),
1255                Origin::Init | Origin::Upgrade => "init".to_owned(),
1256            },
1257            |record| record.origin.clone(),
1258        ),
1259        landed_at: recorded.map_or_else(manifest::now, |record| record.landed_at.clone()),
1260        profile: params.profile().clone(),
1261        git: params.git().clone(),
1262        capabilities: params.capabilities().clone(),
1263        parameters: Parameters {
1264            repo: params.repo().to_owned(),
1265            security_contact: params.security_contact().to_owned(),
1266            security_response: params.security_response().to_owned(),
1267            security_scope: params.security_scope().to_owned(),
1268            required_check: params.required_check().to_owned(),
1269            required_workflow: params.required_workflow().to_owned(),
1270            dev_environment: params.dev_environment().cloned(),
1271        },
1272        files,
1273        pins: crate::registry::pins_for(&projection.capabilities)
1274            .into_iter()
1275            .map(|pin| (pin.name, pin.version))
1276            .collect(),
1277    }
1278}
1279
1280/// The bytes at a relative path below the held root, read through the
1281/// held directory chain, or `None` where nothing stands there.
1282fn read_relative(root: &File, relative: &str) -> std::io::Result<Option<Vec<u8>>> {
1283    let path = Path::new(relative);
1284    let (parent, name) = split(path)?;
1285    let dir = match held::hold_dir_existing(root, parent) {
1286        Ok(dir) => dir,
1287        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1288        Err(error) => return Err(error),
1289    };
1290    held::read_file(&dir, name)
1291}
1292
1293/// A relative path split into its parent and its file name.
1294fn split(path: &Path) -> std::io::Result<(&Path, &OsStr)> {
1295    let name = path
1296        .file_name()
1297        .ok_or_else(|| std::io::Error::other(format!("{} has no file name", path.display())))?;
1298    let parent = path.parent().unwrap_or_else(|| Path::new(""));
1299    Ok((parent, name))
1300}
1301
1302/// The writes of one landing, each through the held root, with the
1303/// completed paths kept for the failure report.
1304struct Writer<'a> {
1305    root: &'a File,
1306    completed: Vec<String>,
1307    stop: Option<String>,
1308}
1309
1310impl Writer<'_> {
1311    /// Write `bytes` at the relative `destination` through the held
1312    /// directory chain and a same-directory temporary file and rename.
1313    fn write(&mut self, destination: &str, bytes: &[u8]) -> Result<(), RkError> {
1314        let path = Path::new(destination);
1315        let (parent, name) = split(path)?;
1316        let outcome = held::hold_dir(self.root, parent).and_then(|dir| {
1317            if self.stop.as_deref() == Some(destination) {
1318                return Err(std::io::Error::other(
1319                    "the rename was stopped here for the proof",
1320                ));
1321            }
1322            held::write_file(&dir, name, bytes)
1323        });
1324        match outcome {
1325            Ok(()) => {
1326                self.completed.push(destination.to_owned());
1327                Ok(())
1328            }
1329            Err(error) => Err(self.failure(destination, bytes, &error)),
1330        }
1331    }
1332
1333    /// Remove the retired file at the relative `destination` through the
1334    /// held directory chain, following no link; an absent file is already
1335    /// retired.
1336    fn remove(&mut self, destination: &str) -> Result<(), RkError> {
1337        let path = Path::new(destination);
1338        let (parent, name) = split(path)?;
1339        let dir = match held::hold_dir_existing(self.root, parent) {
1340            Ok(dir) => dir,
1341            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
1342            Err(error) => return Err(self.failure(destination, &[], &error)),
1343        };
1344        match std::fs::remove_file(held::proc_path(&dir).join(name)) {
1345            Ok(()) => {
1346                self.completed.push(destination.to_owned());
1347                Ok(())
1348            }
1349            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1350            Err(error) => Err(self.failure(destination, &[], &error)),
1351        }
1352    }
1353
1354    /// The failure of a write that stopped the landing: the destination is
1355    /// observed again, because a remote filesystem may have completed a
1356    /// rename it reported as failed, and every completed path is named.
1357    /// The previous receipt stands; Git holds the diff; a rerun lands the
1358    /// rest.
1359    fn failure(&self, destination: &str, bytes: &[u8], error: &std::io::Error) -> RkError {
1360        let observed = match read_relative(self.root, destination) {
1361            Ok(None) => "is absent".to_owned(),
1362            Ok(Some(current)) if current == bytes => "holds the candidate bytes whole".to_owned(),
1363            Ok(Some(_)) => "holds its previous bytes whole".to_owned(),
1364            Err(again) => format!("could not be observed again: {again}"),
1365        };
1366        let completed = if self.completed.is_empty() {
1367            "none".to_owned()
1368        } else {
1369            self.completed.join(", ")
1370        };
1371        RkError::Io(std::io::Error::new(
1372            error.kind(),
1373            format!(
1374                "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"
1375            ),
1376        ))
1377    }
1378}
1379
1380#[cfg(test)]
1381mod tests {
1382    use super::{Action, Held, Origin, Prepared, decide, land, prepare};
1383    use crate::landing::manifest::{self, Manifest};
1384    use crate::landing::{Params, Style, lock};
1385    use crate::projection::{Projection, ProjectionInput, TargetEvidence};
1386
1387    fn target() -> (tempfile::TempDir, camino::Utf8PathBuf) {
1388        let dir = tempfile::tempdir().expect("a scratch target exists");
1389        let path = camino::Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("utf-8");
1390        (dir, path)
1391    }
1392
1393    fn params() -> Params {
1394        Params::for_test("acme/widget", Some(Style::Trunk))
1395    }
1396
1397    fn prepared(target: &camino::Utf8Path, recorded: Option<&Manifest>) -> Prepared {
1398        prepare(
1399            &Held::open(target).expect("opens"),
1400            recorded,
1401            &params(),
1402            None,
1403        )
1404        .expect("prepares")
1405    }
1406
1407    fn landed(
1408        target: &camino::Utf8Path,
1409        recorded: Option<&Manifest>,
1410        origin: Origin,
1411    ) -> super::Landed {
1412        let locks = tempfile::tempdir().expect("a scratch locks directory exists");
1413        let lock = lock::acquire_in(locks.path(), target).expect("the target is taken");
1414        let held = Held::open(target).expect("opens");
1415        land(&held, recorded, &prepared(target, recorded), origin, &lock).expect("lands")
1416    }
1417
1418    /// A fresh target: every candidate is created, the receipt is written
1419    /// last at schema 7, and a rerun matches the unchanged rendered whole
1420    /// files, replaces the marked regions, and preserves the seeded ones
1421    /// from the receipt alone.
1422    #[test]
1423    fn a_fresh_landing_creates_and_a_rerun_decides_by_the_receipt() {
1424        let (_dir, target) = target();
1425        let first = prepared(&target, None);
1426        assert!(first.collisions.is_empty(), "{:?}", first.collisions);
1427        assert!(
1428            first
1429                .decisions
1430                .iter()
1431                .all(|decision| decision.action == Action::Created)
1432        );
1433        let outcome = landed(&target, None, Origin::Init);
1434        assert_eq!(
1435            outcome.completed.last().map(String::as_str),
1436            Some(manifest::MANIFEST_PATH)
1437        );
1438        assert_eq!(outcome.receipt.schema_version, manifest::SCHEMA_VERSION);
1439        let record = manifest::load(&target).expect("loads").expect("exists");
1440        let again = prepared(&target, Some(&record));
1441        for decision in &again.decisions {
1442            let region = again.projection.candidates.iter().any(|candidate| {
1443                candidate.destination == decision.destination
1444                    && matches!(
1445                        candidate.placement,
1446                        crate::projection::Placement::Region { .. }
1447                    )
1448            });
1449            let expected = match decision.kind {
1450                crate::landing::Kind::Rendered if region => Action::Replaced,
1451                crate::landing::Kind::Rendered => Action::Matched,
1452                crate::landing::Kind::Seeded | crate::landing::Kind::State => Action::Preserved,
1453            };
1454            assert_eq!(decision.action, expected, "{}", decision.destination);
1455        }
1456    }
1457
1458    /// A whole file the receipt does not name, a non-regular entry, and a
1459    /// document with a doubled marker are all collected in one pass, and
1460    /// the refusal writes nothing.
1461    #[test]
1462    fn every_collision_is_collected_and_the_refusal_writes_nothing() {
1463        let (_dir, target) = target();
1464        std::fs::write(target.join("SECURITY.md"), "ours\n").expect("writes");
1465        std::fs::create_dir_all(target.join("release-plz.toml")).expect("creates");
1466        std::fs::write(
1467            target.join("AGENTS.md"),
1468            format!(
1469                "{b}\n{e}\n{b}\n{e}\n",
1470                b = crate::landing::BLOCK_BEGIN,
1471                e = crate::landing::BLOCK_END
1472            ),
1473        )
1474        .expect("writes");
1475        let prepared = prepared(&target, None);
1476        let paths: Vec<&str> = prepared
1477            .collisions
1478            .iter()
1479            .map(|collision| collision.path.as_str())
1480            .collect();
1481        assert_eq!(paths, ["AGENTS.md", "SECURITY.md", "release-plz.toml"]);
1482        let locks = tempfile::tempdir().expect("a scratch locks directory exists");
1483        let lock = lock::acquire_in(locks.path(), &target).expect("the target is taken");
1484        let held = Held::open(&target).expect("opens");
1485        let refused =
1486            land(&held, None, &prepared, Origin::Init, &lock).expect_err("the landing refuses");
1487        assert_eq!(refused.exit_code(), 73);
1488        assert!(!target.join(".release-kit").exists());
1489        assert!(!target.join("dist-workspace.toml").exists());
1490    }
1491
1492    /// A recorded destination the projection stops producing leaves the
1493    /// receipt: removed where its bytes are the ones the record vouches
1494    /// for, and kept, named dropped, where someone edited them.
1495    #[test]
1496    fn a_dropped_destination_is_retired_unless_the_target_edited_it() {
1497        for (bytes, action, stays) in [
1498            (&b"old\n"[..], Action::Retired, false),
1499            (&b"edited\n"[..], Action::Dropped, true),
1500        ] {
1501            let (_dir, target) = target();
1502            landed(&target, None, Origin::Init);
1503            let mut record = manifest::load(&target).expect("loads").expect("exists");
1504            std::fs::write(target.join("legacy.yml"), bytes).expect("writes");
1505            record.files.push(manifest::FileRecord {
1506                destination: "legacy.yml".into(),
1507                kind: crate::landing::Kind::Rendered,
1508                sha256: crate::digest::Digest::of(b"old\n"),
1509                placement: manifest::Placement::Whole,
1510            });
1511            let (decisions, _) = decide(
1512                &Held::open(&target).expect("opens"),
1513                Some(&record),
1514                &Projection::compute(&ProjectionInput {
1515                    params: params(),
1516                    evidence: TargetEvidence::gather(&target, Some(&record)).expect("gathers"),
1517                })
1518                .expect("projects"),
1519                &std::collections::BTreeMap::new(),
1520            )
1521            .expect("decides");
1522            let decided = decisions
1523                .iter()
1524                .find(|decision| decision.destination == "legacy.yml")
1525                .expect("the dropped destination is decided");
1526            assert_eq!(decided.action, action);
1527            let outcome = landed(&target, Some(&record), Origin::Upgrade);
1528            assert_eq!(target.join("legacy.yml").is_file(), stays);
1529            assert!(outcome.receipt.file("legacy.yml").is_none());
1530        }
1531    }
1532}