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 generated whole file or marked region is rewritten from
55    /// the candidate, whatever its bytes were.
56    Replaced,
57    /// A whole-file destination the receipt does not name already holds
58    /// the candidate's bytes: nothing is written, and the receipt records
59    /// it, because replacing identical bytes changes nothing and a run
60    /// stopped after creating it must be rerunnable.
61    Matched,
62    /// A recorded seeded or state file stays as it is, its current digest
63    /// entering the receipt.
64    Preserved,
65    /// A recorded seeded file stays and its bytes differ from the receipt:
66    /// the target tuned it, which is what a seeded file is for.
67    Drift,
68    /// A recorded destination this binary no longer produces: left on
69    /// disk, target-owned from this landing, out of the new receipt.
70    Released,
71}
72
73impl Action {
74    /// The report form.
75    #[must_use]
76    pub const fn as_str(self) -> &'static str {
77        match self {
78            Self::Created => "created",
79            Self::Replaced => "replaced",
80            Self::Matched => "matched",
81            Self::Preserved => "preserved",
82            Self::Drift => "drift",
83            Self::Released => "released",
84        }
85    }
86}
87
88/// What an adoption finds at one candidate's destination.
89///
90/// It is judged on the bytes alone. `rk adopt` decides from it, and
91/// `rk stage compare` takes the word of a target the adoption lands from
92/// it, so the comparison is the adoption's own decision.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum Adoption {
95    /// The destination holds the candidate's bytes.
96    Matches,
97    /// A seeded file the target tuned: it stays, target-owned.
98    Tuned,
99    /// A state file: it stays as the target left it.
100    State,
101    /// A rendered destination whose bytes differ from the candidate.
102    Differs,
103    /// The destination is absent, or carries no release-kit block where
104    /// `exists`.
105    Missing {
106        /// Whether a file stands at the destination.
107        exists: bool,
108    },
109    /// Something other than a regular file stands at the destination.
110    Irregular,
111}
112
113impl Adoption {
114    /// The comparison word, in the landing's vocabulary and with the
115    /// meaning the method gives it: a kept seeded or state file is
116    /// `preserved`, and whatever the adoption refuses is a `collision`.
117    #[must_use]
118    pub const fn word(self) -> &'static str {
119        match self {
120            Self::Matches => "matched",
121            Self::Tuned | Self::State => "preserved",
122            Self::Differs | Self::Missing { .. } | Self::Irregular => "collision",
123        }
124    }
125
126    /// Whether the adoption accepts `--replace` for a destination of
127    /// `kind` judged so: a rendered destination that differs or is
128    /// missing. Every other collision refuses whatever is named.
129    #[must_use]
130    pub fn replaceable(self, kind: Kind) -> bool {
131        matches!(self, Self::Differs)
132            || (matches!(self, Self::Missing { .. }) && kind == Kind::Rendered)
133    }
134}
135
136/// Judge one candidate's destination as an adoption does.
137///
138/// # Errors
139///
140/// [`RkError::Io`] for a read that fails for a reason other than
141/// absence.
142pub fn judge_adoption(target: &Held, candidate: &Candidate) -> Result<Adoption, RkError> {
143    // The landing's own walk: a linked or non-directory parent, a link at
144    // the destination, dangling or not, and any non-regular entry are
145    // irregular, because the write would refuse to follow them.
146    match locate(&target.root, &candidate.destination)? {
147        Located::Collision(_) => return Ok(Adoption::Irregular),
148        Located::Absent => return Ok(Adoption::Missing { exists: false }),
149        Located::Present { .. } => {}
150    }
151    let Some(current) = super::read_recorded(target.base(), &candidate.destination)? else {
152        return Ok(Adoption::Missing { exists: true });
153    };
154    let expected: &[u8] = match candidate.placement {
155        Placement::Whole => &candidate.bytes,
156        Placement::Region { .. } => candidate.region.as_deref().unwrap_or(&candidate.bytes),
157    };
158    Ok(match candidate.kind {
159        Kind::Rendered | Kind::Seeded if current == expected => Adoption::Matches,
160        Kind::Rendered => Adoption::Differs,
161        Kind::Seeded => Adoption::Tuned,
162        Kind::State => Adoption::State,
163    })
164}
165
166/// One decided destination.
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct Decision {
169    /// The destination, relative to the target.
170    pub destination: String,
171    /// The kind the candidate declares, or the recorded kind for a
172    /// released destination.
173    pub kind: Kind,
174    /// What happens to it.
175    pub action: Action,
176}
177
178/// One destination the landing refuses before any write.
179///
180/// It is a whole-file destination present on disk with no receipt entry
181/// attributing it, a recorded rendered file or region whose bytes the
182/// record no longer vouches for, or a document whose markers offer the
183/// block no place.
184#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
185pub struct Collision {
186    /// The destination.
187    pub path: String,
188    /// Why it refuses.
189    pub reason: String,
190    /// Whether it is a conflict `--replace` may name: a regular file whose
191    /// bytes a landing could save and overwrite. A defective marker or a
192    /// linked parent is no conflict, and only a fix to the target clears
193    /// it.
194    pub replaceable: bool,
195}
196
197/// One conflict the operator named, replaced after its bytes were saved.
198#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
199pub struct Replaced {
200    /// The destination.
201    pub path: String,
202    /// Where its bytes were saved; absent in a preview, which saves
203    /// nothing.
204    pub saved_to: Option<String>,
205}
206
207/// The reason a recorded rendered file reads as a conflict.
208const EDITED: &str = "was edited after the landing, and the record no longer vouches for its bytes";
209
210/// One target directory held open for a whole landing verb.
211///
212/// Opened once, right after the lock under an apply and before any
213/// evidence is gathered, and carried through every decision read and
214/// every write, so a target root exchanged under the pathname after
215/// validation receives nothing: the descriptor names the directory that
216/// was validated, whatever its path has since become.
217#[derive(Debug)]
218pub struct Held {
219    root: File,
220    base: camino::Utf8PathBuf,
221    display: camino::Utf8PathBuf,
222}
223
224impl Held {
225    /// Hold `target`, following no link at its final component.
226    ///
227    /// # Errors
228    ///
229    /// The open failure, and a kernel link that is not UTF-8.
230    pub fn open(target: &Utf8Path) -> Result<Self, RkError> {
231        let root = held::open_dir(target.as_std_path())?;
232        let base = camino::Utf8PathBuf::from_path_buf(held::proc_path(&root))
233            .map_err(|path| anyhow::anyhow!("the kernel's link {} is not UTF-8", path.display()))?;
234        Ok(Self {
235            root,
236            base,
237            display: target.to_owned(),
238        })
239    }
240
241    /// Hold `target` under `lock`: the directory opened must be the one
242    /// the lock key was derived from, or the target was exchanged between
243    /// the two steps and the landing refuses before it reads anything.
244    ///
245    /// # Errors
246    ///
247    /// As [`Self::open`], and a `state-drift` refusal naming the exchange.
248    pub fn open_locked(target: &Utf8Path, lock: &lock::TargetLock) -> Result<Self, RkError> {
249        let held = Self::open(target)?;
250        let opened = held::Identity::of(&held.root.metadata()?);
251        if lock.identity() != opened {
252            return Err(RkError::refusal(
253                Diagnostic::new(
254                    Reason::StateDrift,
255                    format!(
256                        "the directory at {target} was exchanged after the lock was taken, and nothing was written"
257                    ),
258                )
259                .expected("one directory at the target path from the lock through the receipt write")
260                .action("re-run once the target is at rest")
261                .target_state("unchanged"),
262            ));
263        }
264        Ok(held)
265    }
266
267    /// The path every read of this target goes through: the kernel's
268    /// link to the held directory.
269    #[must_use]
270    pub fn base(&self) -> &Utf8Path {
271        &self.base
272    }
273
274    /// The path the operator named, for reports.
275    #[must_use]
276    pub fn display(&self) -> &Utf8Path {
277        &self.display
278    }
279}
280
281/// The landing decided and ready: the projection, every decision in
282/// projection order with the released destinations after them, every
283/// collision, and the configuration the landing writes first.
284#[derive(Debug)]
285pub struct Prepared {
286    /// The resolved parameters.
287    pub params: Params,
288    /// The candidate tree.
289    pub projection: Projection,
290    /// Every decision.
291    pub decisions: Vec<Decision>,
292    /// Every collision, in destination order.
293    pub collisions: Vec<Collision>,
294    /// The configuration the landing writes before the files.
295    pub config: config::Plan,
296    /// The conflicts the operator named, which the landing saves and then
297    /// replaces.
298    pub replacements: Vec<String>,
299}
300
301impl Prepared {
302    /// The decision for one destination.
303    #[must_use]
304    pub fn decision(&self, destination: &str) -> Option<&Decision> {
305        self.decisions
306            .iter()
307            .find(|decision| decision.destination == destination)
308    }
309}
310
311/// Gather the target's evidence once, compute the projection, and decide
312/// every destination, writing nothing.
313///
314/// Under an apply this runs inside the target lock, so the evidence the
315/// landing writes from is the evidence it gathered.
316///
317/// # Errors
318///
319/// The evidence read's failures, the projection's own defects, and an
320/// invalid committed configuration.
321pub fn prepare(
322    target: &Held,
323    recorded: Option<&Manifest>,
324    params: &Params,
325    existing_config: Option<&config::Config>,
326) -> Result<Prepared, RkError> {
327    let evidence = TargetEvidence::gather(target.base(), recorded)?;
328    let projection = Projection::compute(&ProjectionInput {
329        params: params.clone(),
330        evidence,
331    })?;
332    let (decisions, collisions) = decide(target, recorded, &projection)?;
333    let config = config::Plan::new(
334        target.base().as_std_path(),
335        params,
336        existing_config,
337        recorded,
338    )?;
339    Ok(Prepared {
340        params: params.clone(),
341        projection,
342        decisions,
343        collisions,
344        config,
345        replacements: Vec::new(),
346    })
347}
348
349/// Turn every conflict `replace` names into a replacement, and refuse a
350/// name that is no conflict.
351///
352/// A conflict is an unattributed collision or a recorded rendered file
353/// whose bytes the record no longer vouches for. Every conflict the
354/// operator did not name stays a collision, and the landing still
355/// refuses it before the first write; a name that matches no conflict is
356/// a usage error, because a replace the operator believes happened and
357/// did not is worse than a refusal.
358///
359/// # Errors
360///
361/// [`RkError::Usage`] naming the first path that is no conflict.
362pub fn name_replacements(prepared: &mut Prepared, replace: &[String]) -> Result<(), RkError> {
363    for path in replace {
364        if prepared.replacements.contains(path) {
365            continue;
366        }
367        let Some(index) = prepared
368            .collisions
369            .iter()
370            .position(|collision| collision.path == *path && collision.replaceable)
371        else {
372            return Err(RkError::Usage(format!(
373                "--replace {path} names no conflict this landing has; a conflict is an unattributed file or a recorded rendered file someone edited"
374            )));
375        };
376        prepared.collisions.remove(index);
377        let kind = prepared
378            .projection
379            .candidates
380            .iter()
381            .find(|candidate| candidate.destination == *path)
382            .map_or(Kind::Rendered, |candidate| candidate.kind);
383        prepared.decisions.push(Decision {
384            destination: path.clone(),
385            kind,
386            action: Action::Replaced,
387        });
388        prepared.replacements.push(path.clone());
389    }
390    let order = |destination: &str| {
391        prepared
392            .projection
393            .candidates
394            .iter()
395            .position(|candidate| candidate.destination == destination)
396            .unwrap_or(usize::MAX)
397    };
398    let mut decisions = std::mem::take(&mut prepared.decisions);
399    decisions.sort_by_key(|decision| order(&decision.destination));
400    prepared.decisions = decisions;
401    Ok(())
402}
403
404/// Decide every destination from the receipt and the disk, collecting
405/// every collision rather than stopping at the first.
406///
407/// Every existing parent component of a candidate is walked relative to
408/// the held target directory with no link followed, so a linked or
409/// non-directory component is a collision here, before any write, and
410/// not a failure after the configuration landed.
411///
412/// # Errors
413///
414/// A read failure other than absence.
415#[allow(
416    clippy::too_many_lines,
417    reason = "one pass decides every destination by its placement, its record, and its bytes"
418)]
419pub fn decide(
420    target: &Held,
421    recorded: Option<&Manifest>,
422    projection: &Projection,
423) -> Result<(Vec<Decision>, Vec<Collision>), RkError> {
424    let root = &target.root;
425    let mut decisions = Vec::new();
426    let mut collisions: Vec<Collision> = projection
427        .collisions
428        .iter()
429        .map(|collision| Collision {
430            path: collision.destination.clone(),
431            reason: collision.reason.clone(),
432            replaceable: false,
433        })
434        .collect();
435    for candidate in &projection.candidates {
436        let record = recorded.and_then(|record| record.file(&candidate.destination));
437        let located = match locate(root, &candidate.destination)? {
438            Located::Collision(reason) => {
439                collisions.push(Collision {
440                    path: candidate.destination.clone(),
441                    reason,
442                    replaceable: false,
443                });
444                continue;
445            }
446            other => other,
447        };
448        let present = matches!(located, Located::Present { .. });
449        let current = || located.read();
450        let action = match (candidate.placement, present, record) {
451            (_, false, _) => Action::Created,
452            // A recorded region someone edited is a conflict like an
453            // edited rendered file: the record no longer vouches for it,
454            // and it does not already hold the candidate's region, which
455            // is what a landing stopped part way leaves behind.
456            (Placement::Region { begin, end }, true, Some(record))
457                if record.kind == Kind::Rendered
458                    && super::extract_block(&String::from_utf8_lossy(&current()?), begin, end)
459                        .is_some_and(|block| {
460                            Digest::of(block.as_bytes()) != record.sha256
461                                && candidate.region.as_deref() != Some(block.as_bytes())
462                        }) =>
463            {
464                collisions.push(Collision {
465                    path: candidate.destination.clone(),
466                    reason: format!("its marked region {EDITED}"),
467                    replaceable: true,
468                });
469                continue;
470            }
471            // A marked region lands into the target's document whether
472            // the receipt names it or not: the bytes outside the markers
473            // stay the target's, so nothing is taken from it.
474            (Placement::Region { .. }, true, _) => Action::Replaced,
475            // An unrecorded whole file holding the candidate's bytes is
476            // attributed by its content: a run stopped after creating it
477            // leaves exactly this, and replacing identical bytes changes
478            // nothing. Differing bytes are the target's, and refuse.
479            (Placement::Whole, true, None) => {
480                if current()? == candidate.bytes {
481                    Action::Matched
482                } else {
483                    collisions.push(Collision {
484                        path: candidate.destination.clone(),
485                        reason:
486                            "exists with bytes differing from the candidate, and no receipt attributes it to release-kit"
487                                .to_owned(),
488                        replaceable: true,
489                    });
490                    continue;
491                }
492            }
493            (Placement::Whole, true, Some(record)) => match (candidate.kind, record.kind) {
494                // The record vouches for the bytes on disk, so they are
495                // release-kit's own; bytes it no longer vouches for are an
496                // edit, which only a named replace overwrites.
497                (Kind::Rendered, Kind::Rendered) => {
498                    let bytes = current()?;
499                    // Bytes equal to the candidate are what a landing
500                    // stopped part way wrote: attributed by content, as a
501                    // matched file is, so the rerun lands the rest.
502                    if Digest::of(&bytes) == record.sha256 || bytes == candidate.bytes {
503                        Action::Replaced
504                    } else {
505                        collisions.push(Collision {
506                            path: candidate.destination.clone(),
507                            reason: EDITED.to_owned(),
508                            replaceable: true,
509                        });
510                        continue;
511                    }
512                }
513                (Kind::Rendered, Kind::Seeded | Kind::State) => {
514                    collisions.push(Collision {
515                        path: candidate.destination.clone(),
516                        reason: format!(
517                            "is recorded as {}, and this release renders it, so its bytes are the target's",
518                            record.kind.as_str()
519                        ),
520                        replaceable: true,
521                    });
522                    continue;
523                }
524                (Kind::Seeded, _) => {
525                    if Digest::of(&current()?) == record.sha256 {
526                        Action::Preserved
527                    } else {
528                        Action::Drift
529                    }
530                }
531                (Kind::State, _) => Action::Preserved,
532            },
533        };
534        decisions.push(Decision {
535            destination: candidate.destination.clone(),
536            kind: candidate.kind,
537            action,
538        });
539    }
540    if let Some(record) = recorded {
541        for file in &record.files {
542            let produced = projection
543                .candidates
544                .iter()
545                .any(|candidate| candidate.destination == file.destination);
546            if !produced {
547                decisions.push(Decision {
548                    destination: file.destination.clone(),
549                    kind: file.kind,
550                    action: Action::Released,
551                });
552            }
553        }
554    }
555    collisions.sort_by(|a, b| a.path.cmp(&b.path));
556    collisions.dedup_by(|a, b| a.path == b.path);
557    Ok((decisions, collisions))
558}
559
560/// What stands at a destination inside the held target.
561enum Located {
562    /// The parent chain or the final component cannot be landed through:
563    /// a linked or non-directory parent, or a non-regular entry.
564    Collision(String),
565    /// Nothing stands there.
566    Absent,
567    /// A regular file stands there, inside its held parent.
568    Present { dir: File, name: std::ffi::OsString },
569}
570
571impl Located {
572    /// The bytes present, empty where nothing stands.
573    fn read(&self) -> std::io::Result<Vec<u8>> {
574        match self {
575            Self::Present { dir, name } => {
576                held::read_file(dir, name).map(Option::unwrap_or_default)
577            }
578            Self::Absent | Self::Collision(_) => Ok(Vec::new()),
579        }
580    }
581}
582
583/// Locate `destination` inside the held `root`: the parent chain is held
584/// first, following no link, and the final component is then examined
585/// inside the held parent.
586fn locate(root: &File, destination: &str) -> std::io::Result<Located> {
587    let (parent, name) = split(Path::new(destination))?;
588    let dir = match held::hold_dir_existing(root, parent) {
589        Ok(dir) => dir,
590        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Located::Absent),
591        Err(error) => {
592            return Ok(Located::Collision(format!(
593                "a parent component cannot be held: {error}"
594            )));
595        }
596    };
597    match std::fs::symlink_metadata(held::proc_path(&dir).join(name)) {
598        Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => Ok(
599            Located::Collision("exists and is not a regular file".to_owned()),
600        ),
601        Ok(_) => Ok(Located::Present {
602            dir,
603            name: name.to_owned(),
604        }),
605        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Located::Absent),
606        Err(error) => Err(error),
607    }
608}
609
610/// The refusal a licence condition answers, before any write.
611///
612/// A landing that guessed past it would write a workflow whose provider's
613/// terms the target's own licence does not permit, which is a licence
614/// violation this convention does not commit on a target's behalf.
615#[must_use]
616pub fn licence_refusal(reason: &str) -> RkError {
617    RkError::refusal(
618        Diagnostic::new(
619            Reason::StateDrift,
620            format!("the code scanning provider's licence condition is not satisfied, and nothing was written: {reason}"),
621        )
622        .expected("a provider whose terms the target's declared licence permits")
623        .action("pass --code-scanning semgrep, which carries no licence condition, or --code-scanning off")
624        .target_state("unchanged"),
625    )
626}
627
628/// The refusal a selected release automation this release cannot land
629/// answers, before any write: silently omitting the declared release would
630/// write a record that claims an automation nothing landed.
631///
632/// SATISFIES project-profile:an-operation-refuses-only-what-it-requires
633#[must_use]
634pub fn release_unavailable(reason: &str) -> RkError {
635    RkError::refusal(
636        Diagnostic::new(
637            Reason::StateDrift,
638            format!("the selected release automation is not available in this release, and nothing was written: {reason}"),
639        )
640        .expected("a release driver and forge this release ships automation for, or a release mode that selects none")
641        .action("pass --release-driver and --forge at an available tuple, or --release-mode external or none")
642        .target_state("unchanged"),
643    )
644}
645
646/// The one refusal for every collision, before any write.
647///
648/// No flag forces past a collision. Only a named `--replace`, which saves
649/// a copy first, passes a conflict, and a defective marker or a linked
650/// parent only a fix to the target clears.
651#[must_use]
652pub fn refusal(target: &Utf8Path, collisions: &[Collision]) -> RkError {
653    let listed: Vec<String> = collisions
654        .iter()
655        .map(|collision| format!("{} ({})", collision.path, collision.reason))
656        .collect();
657    RkError::refusal(
658        Diagnostic::new(
659            Reason::StateDrift,
660            format!(
661                "these destinations cannot be landed as they stand, and nothing was written: {}",
662                listed.join("; ")
663            ),
664        )
665        .expected(
666            "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",
667        )
668        .action(format!(
669            "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"
670        ))
671        .target_state("unchanged"),
672    )
673}
674
675/// How a landing came to write its receipt.
676#[derive(Debug, Clone, Copy, PartialEq, Eq)]
677pub enum Origin {
678    /// A first landing: files and receipt.
679    Init,
680    /// A landing over a receipt: files and receipt, the first landing's
681    /// instant and origin preserved.
682    Upgrade,
683    /// A record of a target already at the projection: config and
684    /// receipt only.
685    Adopt,
686}
687
688/// What a landing completed.
689#[derive(Debug)]
690pub struct Landed {
691    /// Every path written, in order, the config and the receipt included.
692    pub completed: Vec<String>,
693    /// Whether the configuration was written, or already held its bytes.
694    pub config_written: bool,
695    /// The receipt as written.
696    pub receipt: Manifest,
697    /// Every conflict replaced, with where its bytes were saved.
698    pub replaced: Vec<Replaced>,
699}
700
701/// Land `prepared` into `target`.
702///
703/// Refuse every collision first, open the target once under the lock the
704/// caller holds, write the configuration where its bytes changed, then
705/// each candidate by its decision, then the receipt.
706///
707/// The caller gathers under the same lock where it wants the evidence
708/// and the writes to agree; [`prepare`] itself takes none, so a preview
709/// holds nothing.
710///
711/// # Errors
712///
713/// The collision refusal at exit 73 with nothing written; the lock's
714/// refusals; and [`RkError::Io`] for a write that fails, naming every
715/// path completed before it, with the previous receipt left in place.
716pub fn land(
717    target: &Held,
718    recorded: Option<&Manifest>,
719    prepared: &Prepared,
720    origin: Origin,
721    _lock: &lock::TargetLock,
722) -> Result<Landed, RkError> {
723    if let Some(reason) = prepared.projection.licence_refusal.as_deref() {
724        return Err(licence_refusal(reason));
725    }
726    if let Some(reason) = prepared.projection.release_unavailable() {
727        return Err(release_unavailable(reason));
728    }
729    if !prepared.collisions.is_empty() {
730        return Err(refusal(target.display(), &prepared.collisions));
731    }
732    let root = &target.root;
733    // The proof's pause: validation is over and the target is held, so a
734    // link or a directory swapped in under the pathname from here on
735    // meets the held descriptor, not the path.
736    held::pause(PAUSE_VAR, "validated", "proceed");
737    // Every destination the landing does not write is read again through
738    // the held directory before the first write: a preserved or matched
739    // file must still stand, and an adopted value must still equal the
740    // candidate, or the landing refuses with the old receipt intact.
741    let unwritten = reverify(root, prepared, origin)?;
742    // Every named conflict is saved before the first write, so a failed
743    // save leaves the target exactly as it stood.
744    let replaced = save_replacements(target, root, prepared)?;
745    let mut writer = Writer {
746        root,
747        completed: Vec::new(),
748        stop: std::env::var_os(INTERRUPT_VAR).map(|value| value.to_string_lossy().into_owned()),
749    };
750    // The configuration first, where the resolved answers changed.
751    let config_current = read_relative(root, config::CONFIG_PATH)?;
752    let config_written = config_current.as_deref() != Some(prepared.config.content.as_bytes());
753    if config_written {
754        writer.write(config::CONFIG_PATH, prepared.config.content.as_bytes())?;
755    }
756    let mut files = Vec::new();
757    for candidate in &prepared.projection.candidates {
758        let sha256 = match unwritten.get(&candidate.destination) {
759            Some(digest) => digest.clone(),
760            None => match action_of(prepared, origin, &candidate.destination) {
761                Some(Action::Created | Action::Replaced) => {
762                    writer.write(&candidate.destination, &candidate.bytes)?;
763                    candidate_digest(candidate)
764                }
765                _ => continue,
766            },
767        };
768        files.push(FileRecord {
769            destination: candidate.destination.clone(),
770            kind: candidate.kind,
771            sha256,
772            placement: match candidate.placement {
773                Placement::Whole => manifest::Placement::Whole,
774                Placement::Region { .. } => manifest::Placement::Region,
775            },
776        });
777    }
778    let receipt = receipt(
779        &prepared.params,
780        &prepared.projection,
781        recorded,
782        origin,
783        files,
784    );
785    writer.write(manifest::MANIFEST_PATH, &manifest::render(&receipt)?)?;
786    Ok(Landed {
787        completed: writer.completed,
788        config_written,
789        receipt,
790        replaced,
791    })
792}
793
794/// Save the bytes of every named conflict that stands on disk.
795fn save_replacements(
796    target: &Held,
797    root: &File,
798    prepared: &Prepared,
799) -> Result<Vec<Replaced>, RkError> {
800    if prepared.replacements.is_empty() {
801        return Ok(Vec::new());
802    }
803    // Every run claims its own state-root snapshot, an open migration's
804    // included: nothing reads a saved copy back, and a fresh claim never
805    // collides with an earlier run's copy of the same path.
806    let canonical = std::fs::canonicalize(target.display().as_std_path())?;
807    let snapshot = crate::snapshot::Snapshot::begin(&canonical)?;
808    let mut replaced = Vec::new();
809    for path in &prepared.replacements {
810        let saved_to = match read_relative(root, path)? {
811            Some(bytes) => Some(snapshot.save_replaced(path, &bytes)?.display().to_string()),
812            None => None,
813        };
814        replaced.push(Replaced {
815            path: path.clone(),
816            saved_to,
817        });
818    }
819    Ok(replaced)
820}
821
822/// What a preview reports for the named conflicts: each path, with no
823/// saved copy yet.
824#[must_use]
825pub fn previewed_replacements(prepared: &Prepared) -> Vec<Replaced> {
826    prepared
827        .replacements
828        .iter()
829        .map(|path| Replaced {
830            path: path.clone(),
831            saved_to: None,
832        })
833        .collect()
834}
835
836/// What the landing does with one destination under `origin`: an
837/// adoption preserves everything it verified; a landing follows its
838/// decision, and a candidate without one was a collision the refusal
839/// already named.
840fn action_of(prepared: &Prepared, origin: Origin, destination: &str) -> Option<Action> {
841    match origin {
842        Origin::Adopt if prepared.replacements.iter().any(|path| path == destination) => {
843            Some(Action::Replaced)
844        }
845        Origin::Adopt => Some(Action::Preserved),
846        Origin::Init | Origin::Upgrade => prepared.decision(destination).map(|d| d.action),
847    }
848}
849
850/// The recorded form of what a destination holds now, read through the
851/// held directory: the whole file, or the marked region alone; `None`
852/// where the file, or the region, is absent.
853fn current_form(root: &File, candidate: &Candidate) -> Result<Option<Vec<u8>>, RkError> {
854    let Some(current) = read_relative(root, &candidate.destination)? else {
855        return Ok(None);
856    };
857    Ok(match candidate.placement {
858        Placement::Whole => Some(current),
859        Placement::Region { begin, end } => {
860            let text = String::from_utf8_lossy(&current);
861            super::extract_block(&text, begin, end).map(|block| block.as_bytes().to_vec())
862        }
863    })
864}
865
866/// Read every destination the landing leaves unwritten again, through
867/// the held directory, and digest it for the receipt: a preserved,
868/// drifted, or matched file must still be present, and a matched or
869/// adopted rendered value must still equal the candidate.
870///
871/// # Errors
872///
873/// A `state-drift` refusal naming the destination that moved since the
874/// decision, with nothing written; and any read failure.
875fn reverify(
876    root: &File,
877    prepared: &Prepared,
878    origin: Origin,
879) -> Result<std::collections::BTreeMap<String, Digest>, RkError> {
880    let mut digests = std::collections::BTreeMap::new();
881    for candidate in &prepared.projection.candidates {
882        let action = action_of(prepared, origin, &candidate.destination);
883        let must_match = match (origin, action) {
884            (_, Some(Action::Replaced | Action::Created)) => continue,
885            (Origin::Adopt, _) => candidate.kind == Kind::Rendered,
886            (_, Some(Action::Matched)) => true,
887            (_, Some(Action::Preserved | Action::Drift)) => false,
888            _ => continue,
889        };
890        let Some(current) = current_form(root, candidate)? else {
891            return Err(moved(&candidate.destination, "is no longer present"));
892        };
893        let expected: &[u8] = candidate.region.as_deref().unwrap_or(&candidate.bytes);
894        if must_match && current != expected {
895            return Err(moved(
896                &candidate.destination,
897                "no longer holds the candidate's bytes",
898            ));
899        }
900        digests.insert(candidate.destination.clone(), Digest::of(&current));
901    }
902    Ok(digests)
903}
904
905/// The refusal for a destination that changed between the decision and
906/// the first write.
907fn moved(destination: &str, what: &str) -> RkError {
908    RkError::refusal(
909        Diagnostic::new(
910            Reason::StateDrift,
911            format!(
912                "{destination} {what} since it was validated, and nothing was written; the previous receipt stands"
913            ),
914        )
915        .expected("every destination the landing leaves as it stands to stand still until the receipt is written")
916        .action("re-run once the target is at rest")
917        .target_state("unchanged"),
918    )
919}
920
921/// The digest the receipt carries for a written candidate: the whole
922/// file, or the marked region alone.
923fn candidate_digest(candidate: &Candidate) -> Digest {
924    candidate
925        .region
926        .as_deref()
927        .map_or_else(|| Digest::of(&candidate.bytes), Digest::of)
928}
929
930/// The receipt for this landing.
931fn receipt(
932    params: &Params,
933    projection: &Projection,
934    recorded: Option<&Manifest>,
935    origin: Origin,
936    files: Vec<FileRecord>,
937) -> Manifest {
938    Manifest {
939        schema_version: manifest::SCHEMA_VERSION,
940        rk_version: env!("CARGO_PKG_VERSION").to_owned(),
941        origin: recorded.map_or_else(
942            || match origin {
943                Origin::Adopt => "adopt".to_owned(),
944                Origin::Init | Origin::Upgrade => "init".to_owned(),
945            },
946            |record| record.origin.clone(),
947        ),
948        landed_at: recorded.map_or_else(manifest::now, |record| record.landed_at.clone()),
949        profile: params.profile().clone(),
950        git: params.git().clone(),
951        capabilities: params.capabilities().clone(),
952        parameters: Parameters {
953            repo: params.repo().to_owned(),
954            security_contact: params.security_contact().to_owned(),
955            security_response: params.security_response().to_owned(),
956            required_check: params.required_check().to_owned(),
957            required_workflow: params.required_workflow().to_owned(),
958            dev_environment: params.dev_environment().cloned(),
959        },
960        files,
961        pins: crate::registry::pins_for(&projection.capabilities)
962            .into_iter()
963            .map(|pin| (pin.name, pin.version))
964            .collect(),
965    }
966}
967
968/// The bytes at a relative path below the held root, read through the
969/// held directory chain, or `None` where nothing stands there.
970fn read_relative(root: &File, relative: &str) -> std::io::Result<Option<Vec<u8>>> {
971    let path = Path::new(relative);
972    let (parent, name) = split(path)?;
973    let dir = match held::hold_dir_existing(root, parent) {
974        Ok(dir) => dir,
975        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
976        Err(error) => return Err(error),
977    };
978    held::read_file(&dir, name)
979}
980
981/// A relative path split into its parent and its file name.
982fn split(path: &Path) -> std::io::Result<(&Path, &OsStr)> {
983    let name = path
984        .file_name()
985        .ok_or_else(|| std::io::Error::other(format!("{} has no file name", path.display())))?;
986    let parent = path.parent().unwrap_or_else(|| Path::new(""));
987    Ok((parent, name))
988}
989
990/// The writes of one landing, each through the held root, with the
991/// completed paths kept for the failure report.
992struct Writer<'a> {
993    root: &'a File,
994    completed: Vec<String>,
995    stop: Option<String>,
996}
997
998impl Writer<'_> {
999    /// Write `bytes` at the relative `destination` through the held
1000    /// directory chain and a same-directory temporary file and rename.
1001    fn write(&mut self, destination: &str, bytes: &[u8]) -> Result<(), RkError> {
1002        let path = Path::new(destination);
1003        let (parent, name) = split(path)?;
1004        let outcome = held::hold_dir(self.root, parent).and_then(|dir| {
1005            if self.stop.as_deref() == Some(destination) {
1006                return Err(std::io::Error::other(
1007                    "the rename was stopped here for the proof",
1008                ));
1009            }
1010            held::write_file(&dir, name, bytes)
1011        });
1012        match outcome {
1013            Ok(()) => {
1014                self.completed.push(destination.to_owned());
1015                Ok(())
1016            }
1017            Err(error) => Err(self.failure(destination, bytes, &error)),
1018        }
1019    }
1020
1021    /// The failure of a write that stopped the landing: the destination is
1022    /// observed again, because a remote filesystem may have completed a
1023    /// rename it reported as failed, and every completed path is named.
1024    /// The previous receipt stands; Git holds the diff; a rerun lands the
1025    /// rest.
1026    fn failure(&self, destination: &str, bytes: &[u8], error: &std::io::Error) -> RkError {
1027        let observed = match read_relative(self.root, destination) {
1028            Ok(None) => "is absent".to_owned(),
1029            Ok(Some(current)) if current == bytes => "holds the candidate bytes whole".to_owned(),
1030            Ok(Some(_)) => "holds its previous bytes whole".to_owned(),
1031            Err(again) => format!("could not be observed again: {again}"),
1032        };
1033        let completed = if self.completed.is_empty() {
1034            "none".to_owned()
1035        } else {
1036            self.completed.join(", ")
1037        };
1038        RkError::Io(std::io::Error::new(
1039            error.kind(),
1040            format!(
1041                "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"
1042            ),
1043        ))
1044    }
1045}
1046
1047#[cfg(test)]
1048mod tests {
1049    use super::{Action, Held, Origin, Prepared, decide, land, prepare};
1050    use crate::landing::manifest::{self, Manifest};
1051    use crate::landing::{Params, Style, lock};
1052    use crate::projection::{Projection, ProjectionInput, TargetEvidence};
1053
1054    fn target() -> (tempfile::TempDir, camino::Utf8PathBuf) {
1055        let dir = tempfile::tempdir().expect("a scratch target exists");
1056        let path = camino::Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("utf-8");
1057        (dir, path)
1058    }
1059
1060    fn params() -> Params {
1061        Params::for_test("acme/widget", Some(Style::Trunk))
1062    }
1063
1064    fn prepared(target: &camino::Utf8Path, recorded: Option<&Manifest>) -> Prepared {
1065        prepare(
1066            &Held::open(target).expect("opens"),
1067            recorded,
1068            &params(),
1069            None,
1070        )
1071        .expect("prepares")
1072    }
1073
1074    fn landed(
1075        target: &camino::Utf8Path,
1076        recorded: Option<&Manifest>,
1077        origin: Origin,
1078    ) -> super::Landed {
1079        let locks = tempfile::tempdir().expect("a scratch locks directory exists");
1080        let lock = lock::acquire_in(locks.path(), target).expect("the target is taken");
1081        let held = Held::open(target).expect("opens");
1082        land(&held, recorded, &prepared(target, recorded), origin, &lock).expect("lands")
1083    }
1084
1085    /// A fresh target: every candidate is created, the receipt is written
1086    /// last at schema 7, and a rerun replaces the rendered files and
1087    /// preserves the seeded ones from the receipt alone.
1088    #[test]
1089    fn a_fresh_landing_creates_and_a_rerun_decides_by_the_receipt() {
1090        let (_dir, target) = target();
1091        let first = prepared(&target, None);
1092        assert!(first.collisions.is_empty(), "{:?}", first.collisions);
1093        assert!(
1094            first
1095                .decisions
1096                .iter()
1097                .all(|decision| decision.action == Action::Created)
1098        );
1099        let outcome = landed(&target, None, Origin::Init);
1100        assert_eq!(
1101            outcome.completed.last().map(String::as_str),
1102            Some(manifest::MANIFEST_PATH)
1103        );
1104        assert_eq!(outcome.receipt.schema_version, manifest::SCHEMA_VERSION);
1105        let record = manifest::load(&target).expect("loads").expect("exists");
1106        let again = prepared(&target, Some(&record));
1107        for decision in &again.decisions {
1108            let expected = match decision.kind {
1109                crate::landing::Kind::Rendered => Action::Replaced,
1110                crate::landing::Kind::Seeded | crate::landing::Kind::State => Action::Preserved,
1111            };
1112            assert_eq!(decision.action, expected, "{}", decision.destination);
1113        }
1114    }
1115
1116    /// A whole file the receipt does not name, a non-regular entry, and a
1117    /// document with a doubled marker are all collected in one pass, and
1118    /// the refusal writes nothing.
1119    #[test]
1120    fn every_collision_is_collected_and_the_refusal_writes_nothing() {
1121        let (_dir, target) = target();
1122        std::fs::write(target.join("SECURITY.md"), "ours\n").expect("writes");
1123        std::fs::create_dir_all(target.join("release-plz.toml")).expect("creates");
1124        std::fs::write(
1125            target.join("AGENTS.md"),
1126            format!(
1127                "{b}\n{e}\n{b}\n{e}\n",
1128                b = crate::landing::BLOCK_BEGIN,
1129                e = crate::landing::BLOCK_END
1130            ),
1131        )
1132        .expect("writes");
1133        let prepared = prepared(&target, None);
1134        let paths: Vec<&str> = prepared
1135            .collisions
1136            .iter()
1137            .map(|collision| collision.path.as_str())
1138            .collect();
1139        assert_eq!(paths, ["AGENTS.md", "SECURITY.md", "release-plz.toml"]);
1140        let locks = tempfile::tempdir().expect("a scratch locks directory exists");
1141        let lock = lock::acquire_in(locks.path(), &target).expect("the target is taken");
1142        let held = Held::open(&target).expect("opens");
1143        let refused =
1144            land(&held, None, &prepared, Origin::Init, &lock).expect_err("the landing refuses");
1145        assert_eq!(refused.exit_code(), 73);
1146        assert!(!target.join(".release-kit").exists());
1147        assert!(!target.join("dist-workspace.toml").exists());
1148    }
1149
1150    /// A recorded destination the projection stops producing is released:
1151    /// on disk, named, and out of the receipt.
1152    #[test]
1153    fn a_released_destination_stays_and_leaves_the_receipt() {
1154        let (_dir, target) = target();
1155        landed(&target, None, Origin::Init);
1156        let mut record = manifest::load(&target).expect("loads").expect("exists");
1157        std::fs::write(target.join("legacy.yml"), "old\n").expect("writes");
1158        record.files.push(manifest::FileRecord {
1159            destination: "legacy.yml".into(),
1160            kind: crate::landing::Kind::Rendered,
1161            sha256: crate::digest::Digest::of(b"old\n"),
1162            placement: manifest::Placement::Whole,
1163        });
1164        let (decisions, _) = decide(
1165            &Held::open(&target).expect("opens"),
1166            Some(&record),
1167            &Projection::compute(&ProjectionInput {
1168                params: params(),
1169                evidence: TargetEvidence::gather(&target, Some(&record)).expect("gathers"),
1170            })
1171            .expect("projects"),
1172        )
1173        .expect("decides");
1174        let released = decisions
1175            .iter()
1176            .find(|decision| decision.destination == "legacy.yml")
1177            .expect("the released destination is decided");
1178        assert_eq!(released.action, Action::Released);
1179        let outcome = landed(&target, Some(&record), Origin::Upgrade);
1180        assert!(target.join("legacy.yml").is_file());
1181        assert!(outcome.receipt.file("legacy.yml").is_none());
1182    }
1183}