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