Skip to main content

release_kit/landing/
apply.rs

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