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