Skip to main content

release_kit/landing/
apply.rs

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