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/// One decided destination.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct Decision {
91    /// The destination, relative to the target.
92    pub destination: String,
93    /// The kind the candidate declares, or the recorded kind for a
94    /// released destination.
95    pub kind: Kind,
96    /// What happens to it.
97    pub action: Action,
98}
99
100/// One destination the landing refuses before any write: a whole-file
101/// destination present on disk with no receipt entry attributing it, or a
102/// document whose markers offer the block no place.
103#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
104pub struct Collision {
105    /// The destination.
106    pub path: String,
107    /// Why it refuses.
108    pub reason: String,
109}
110
111/// One target directory held open for a whole landing verb.
112///
113/// Opened once, right after the lock under an apply and before any
114/// evidence is gathered, and carried through every decision read and
115/// every write, so a target root exchanged under the pathname after
116/// validation receives nothing: the descriptor names the directory that
117/// was validated, whatever its path has since become.
118#[derive(Debug)]
119pub struct Held {
120    root: File,
121    base: camino::Utf8PathBuf,
122    display: camino::Utf8PathBuf,
123}
124
125impl Held {
126    /// Hold `target`, following no link at its final component.
127    ///
128    /// # Errors
129    ///
130    /// The open failure, and a kernel link that is not UTF-8.
131    pub fn open(target: &Utf8Path) -> Result<Self, RkError> {
132        let root = held::open_dir(target.as_std_path())?;
133        let base = camino::Utf8PathBuf::from_path_buf(held::proc_path(&root))
134            .map_err(|path| anyhow::anyhow!("the kernel's link {} is not UTF-8", path.display()))?;
135        Ok(Self {
136            root,
137            base,
138            display: target.to_owned(),
139        })
140    }
141
142    /// Hold `target` under `lock`: the directory opened must be the one
143    /// the lock key was derived from, or the target was exchanged between
144    /// the two steps and the landing refuses before it reads anything.
145    ///
146    /// # Errors
147    ///
148    /// As [`Self::open`], and a `state-drift` refusal naming the exchange.
149    pub fn open_locked(target: &Utf8Path, lock: &lock::TargetLock) -> Result<Self, RkError> {
150        let held = Self::open(target)?;
151        let opened = held::Identity::of(&held.root.metadata()?);
152        if lock.identity() != opened {
153            return Err(RkError::refusal(
154                Diagnostic::new(
155                    Reason::StateDrift,
156                    format!(
157                        "the directory at {target} was exchanged after the lock was taken, and nothing was written"
158                    ),
159                )
160                .expected("one directory at the target path from the lock through the receipt write")
161                .action("re-run once the target is at rest")
162                .target_state("unchanged"),
163            ));
164        }
165        Ok(held)
166    }
167
168    /// The path every read of this target goes through: the kernel's
169    /// link to the held directory.
170    #[must_use]
171    pub fn base(&self) -> &Utf8Path {
172        &self.base
173    }
174
175    /// The path the operator named, for reports.
176    #[must_use]
177    pub fn display(&self) -> &Utf8Path {
178        &self.display
179    }
180}
181
182/// The landing decided and ready: the projection, every decision in
183/// projection order with the released destinations after them, every
184/// collision, and the configuration the landing writes first.
185#[derive(Debug)]
186pub struct Prepared {
187    /// The resolved parameters.
188    pub params: Params,
189    /// The candidate tree.
190    pub projection: Projection,
191    /// Every decision.
192    pub decisions: Vec<Decision>,
193    /// Every collision, in destination order.
194    pub collisions: Vec<Collision>,
195    /// The configuration the landing writes before the files.
196    pub config: config::Plan,
197}
198
199impl Prepared {
200    /// The decision for one destination.
201    #[must_use]
202    pub fn decision(&self, destination: &str) -> Option<&Decision> {
203        self.decisions
204            .iter()
205            .find(|decision| decision.destination == destination)
206    }
207}
208
209/// Gather the target's evidence once, compute the projection, and decide
210/// every destination, writing nothing.
211///
212/// Under an apply this runs inside the target lock, so the evidence the
213/// landing writes from is the evidence it gathered.
214///
215/// # Errors
216///
217/// The evidence read's failures, the projection's own defects, and an
218/// invalid committed configuration.
219pub fn prepare(
220    target: &Held,
221    recorded: Option<&Manifest>,
222    params: &Params,
223    existing_config: Option<&config::Config>,
224) -> Result<Prepared, RkError> {
225    let evidence = TargetEvidence::gather(target.base(), recorded)?;
226    let projection = Projection::compute(&ProjectionInput {
227        params: params.clone(),
228        evidence,
229    })?;
230    let (decisions, collisions) = decide(target, recorded, &projection)?;
231    let config = config::Plan::new(
232        target.base().as_std_path(),
233        params,
234        existing_config,
235        recorded,
236    )?;
237    Ok(Prepared {
238        params: params.clone(),
239        projection,
240        decisions,
241        collisions,
242        config,
243    })
244}
245
246/// Decide every destination from the receipt and the disk, collecting
247/// every collision rather than stopping at the first.
248///
249/// Every existing parent component of a candidate is walked relative to
250/// the held target directory with no link followed, so a linked or
251/// non-directory component is a collision here, before any write, and
252/// not a failure after the configuration landed.
253///
254/// # Errors
255///
256/// A read failure other than absence.
257pub fn decide(
258    target: &Held,
259    recorded: Option<&Manifest>,
260    projection: &Projection,
261) -> Result<(Vec<Decision>, Vec<Collision>), RkError> {
262    let root = &target.root;
263    let mut decisions = Vec::new();
264    let mut collisions: Vec<Collision> = projection
265        .collisions
266        .iter()
267        .map(|collision| Collision {
268            path: collision.destination.clone(),
269            reason: collision.reason.clone(),
270        })
271        .collect();
272    for candidate in &projection.candidates {
273        let record = recorded.and_then(|record| record.file(&candidate.destination));
274        let located = match locate(root, &candidate.destination)? {
275            Located::Collision(reason) => {
276                collisions.push(Collision {
277                    path: candidate.destination.clone(),
278                    reason,
279                });
280                continue;
281            }
282            other => other,
283        };
284        let present = matches!(located, Located::Present { .. });
285        let current = || located.read();
286        let action = match (candidate.placement, present, record) {
287            (_, false, _) => Action::Created,
288            // A marked region lands into the target's document whether
289            // the receipt names it or not: the bytes outside the markers
290            // stay the target's, so nothing is taken from it.
291            (Placement::Region { .. }, true, _) => Action::Replaced,
292            // An unrecorded whole file holding the candidate's bytes is
293            // attributed by its content: a run stopped after creating it
294            // leaves exactly this, and replacing identical bytes changes
295            // nothing. Differing bytes are the target's, and refuse.
296            (Placement::Whole, true, None) => {
297                if current()? == candidate.bytes {
298                    Action::Matched
299                } else {
300                    collisions.push(Collision {
301                        path: candidate.destination.clone(),
302                        reason:
303                            "exists with bytes differing from the candidate, and no receipt attributes it to release-kit"
304                                .to_owned(),
305                    });
306                    continue;
307                }
308            }
309            (Placement::Whole, true, Some(record)) => match (candidate.kind, record.kind) {
310                (Kind::Rendered, Kind::Rendered) => Action::Replaced,
311                (Kind::Rendered, Kind::Seeded | Kind::State) => {
312                    collisions.push(Collision {
313                        path: candidate.destination.clone(),
314                        reason: format!(
315                            "is recorded as {}, and this release renders it, so its bytes are the target's",
316                            record.kind.as_str()
317                        ),
318                    });
319                    continue;
320                }
321                (Kind::Seeded, _) => {
322                    if Digest::of(&current()?) == record.sha256 {
323                        Action::Preserved
324                    } else {
325                        Action::Drift
326                    }
327                }
328                (Kind::State, _) => Action::Preserved,
329            },
330        };
331        decisions.push(Decision {
332            destination: candidate.destination.clone(),
333            kind: candidate.kind,
334            action,
335        });
336    }
337    if let Some(record) = recorded {
338        for file in &record.files {
339            let produced = projection
340                .candidates
341                .iter()
342                .any(|candidate| candidate.destination == file.destination);
343            if !produced {
344                decisions.push(Decision {
345                    destination: file.destination.clone(),
346                    kind: file.kind,
347                    action: Action::Released,
348                });
349            }
350        }
351    }
352    collisions.sort_by(|a, b| a.path.cmp(&b.path));
353    collisions.dedup_by(|a, b| a.path == b.path);
354    Ok((decisions, collisions))
355}
356
357/// What stands at a destination inside the held target.
358enum Located {
359    /// The parent chain or the final component cannot be landed through:
360    /// a linked or non-directory parent, or a non-regular entry.
361    Collision(String),
362    /// Nothing stands there.
363    Absent,
364    /// A regular file stands there, inside its held parent.
365    Present { dir: File, name: std::ffi::OsString },
366}
367
368impl Located {
369    /// The bytes present, empty where nothing stands.
370    fn read(&self) -> std::io::Result<Vec<u8>> {
371        match self {
372            Self::Present { dir, name } => {
373                held::read_file(dir, name).map(Option::unwrap_or_default)
374            }
375            Self::Absent | Self::Collision(_) => Ok(Vec::new()),
376        }
377    }
378}
379
380/// Locate `destination` inside the held `root`: the parent chain is held
381/// first, following no link, and the final component is then examined
382/// inside the held parent.
383fn locate(root: &File, destination: &str) -> std::io::Result<Located> {
384    let (parent, name) = split(Path::new(destination))?;
385    let dir = match held::hold_dir_existing(root, parent) {
386        Ok(dir) => dir,
387        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Located::Absent),
388        Err(error) => {
389            return Ok(Located::Collision(format!(
390                "a parent component cannot be held: {error}"
391            )));
392        }
393    };
394    match std::fs::symlink_metadata(held::proc_path(&dir).join(name)) {
395        Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => Ok(
396            Located::Collision("exists and is not a regular file".to_owned()),
397        ),
398        Ok(_) => Ok(Located::Present {
399            dir,
400            name: name.to_owned(),
401        }),
402        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Located::Absent),
403        Err(error) => Err(error),
404    }
405}
406
407/// The one refusal for every collision, before any write.
408///
409/// The verbs offer no force flag: an unattributed file becomes landable
410/// through the agent's migration alone, which brings the target to the
411/// projection or records it through `rk adopt`.
412#[must_use]
413pub fn refusal(target: &Utf8Path, collisions: &[Collision]) -> RkError {
414    let listed: Vec<String> = collisions
415        .iter()
416        .map(|collision| format!("{} ({})", collision.path, collision.reason))
417        .collect();
418    RkError::refusal(
419        Diagnostic::new(
420            Reason::StateDrift,
421            format!(
422                "these destinations cannot be landed as they stand, and nothing was written: {}",
423                listed.join("; ")
424            ),
425        )
426        .expected(
427            "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",
428        )
429        .action(format!(
430            "rk stage --target {target} stages this binary's candidate for a byte comparison; the rk-setup skill carries the migration that brings each file to the candidate or records it, then re-run"
431        ))
432        .target_state("unchanged"),
433    )
434}
435
436/// How a landing came to write its receipt.
437#[derive(Debug, Clone, Copy, PartialEq, Eq)]
438pub enum Origin {
439    /// A first landing: files and receipt.
440    Init,
441    /// A landing over a receipt: files and receipt, the first landing's
442    /// instant and origin preserved.
443    Upgrade,
444    /// A record of a target already at the projection: config and
445    /// receipt only.
446    Adopt,
447}
448
449/// What a landing completed.
450#[derive(Debug)]
451pub struct Landed {
452    /// Every path written, in order, the config and the receipt included.
453    pub completed: Vec<String>,
454    /// Whether the configuration was written, or already held its bytes.
455    pub config_written: bool,
456    /// The receipt as written.
457    pub receipt: Manifest,
458}
459
460/// Land `prepared` into `target`.
461///
462/// Refuse every collision first, open the target once under the lock the
463/// caller holds, write the configuration where its bytes changed, then
464/// each candidate by its decision, then the receipt.
465///
466/// The caller gathers under the same lock where it wants the evidence
467/// and the writes to agree; [`prepare`] itself takes none, so a preview
468/// holds nothing.
469///
470/// # Errors
471///
472/// The collision refusal at exit 73 with nothing written; the lock's
473/// refusals; and [`RkError::Io`] for a write that fails, naming every
474/// path completed before it, with the previous receipt left in place.
475pub fn land(
476    target: &Held,
477    recorded: Option<&Manifest>,
478    prepared: &Prepared,
479    origin: Origin,
480    _lock: &lock::TargetLock,
481) -> Result<Landed, RkError> {
482    if !prepared.collisions.is_empty() {
483        return Err(refusal(target.display(), &prepared.collisions));
484    }
485    let root = &target.root;
486    // The proof's pause: validation is over and the target is held, so a
487    // link or a directory swapped in under the pathname from here on
488    // meets the held descriptor, not the path.
489    held::pause(PAUSE_VAR, "validated", "proceed");
490    // Every destination the landing does not write is read again through
491    // the held directory before the first write: a preserved or matched
492    // file must still stand, and an adopted value must still equal the
493    // candidate, or the landing refuses with the old receipt intact.
494    let unwritten = reverify(root, prepared, origin)?;
495    let mut writer = Writer {
496        root,
497        completed: Vec::new(),
498        stop: std::env::var_os(INTERRUPT_VAR).map(|value| value.to_string_lossy().into_owned()),
499    };
500    // The configuration first, where the resolved answers changed.
501    let config_current = read_relative(root, config::CONFIG_PATH)?;
502    let config_written = config_current.as_deref() != Some(prepared.config.content.as_bytes());
503    if config_written {
504        writer.write(config::CONFIG_PATH, prepared.config.content.as_bytes())?;
505    }
506    let mut files = Vec::new();
507    for candidate in &prepared.projection.candidates {
508        let sha256 = match unwritten.get(&candidate.destination) {
509            Some(digest) => digest.clone(),
510            None => match action_of(prepared, origin, &candidate.destination) {
511                Some(Action::Created | Action::Replaced) => {
512                    writer.write(&candidate.destination, &candidate.bytes)?;
513                    candidate_digest(candidate)
514                }
515                _ => continue,
516            },
517        };
518        files.push(FileRecord {
519            destination: candidate.destination.clone(),
520            kind: candidate.kind,
521            sha256,
522            placement: match candidate.placement {
523                Placement::Whole => manifest::Placement::Whole,
524                Placement::Region { .. } => manifest::Placement::Region,
525            },
526        });
527    }
528    let receipt = receipt(&prepared.params, recorded, origin, files);
529    writer.write(manifest::MANIFEST_PATH, &manifest::render(&receipt)?)?;
530    Ok(Landed {
531        completed: writer.completed,
532        config_written,
533        receipt,
534    })
535}
536
537/// What the landing does with one destination under `origin`: an
538/// adoption preserves everything it verified; a landing follows its
539/// decision, and a candidate without one was a collision the refusal
540/// already named.
541fn action_of(prepared: &Prepared, origin: Origin, destination: &str) -> Option<Action> {
542    match origin {
543        Origin::Adopt => Some(Action::Preserved),
544        Origin::Init | Origin::Upgrade => prepared.decision(destination).map(|d| d.action),
545    }
546}
547
548/// The recorded form of what a destination holds now, read through the
549/// held directory: the whole file, or the marked region alone; `None`
550/// where the file, or the region, is absent.
551fn current_form(root: &File, candidate: &Candidate) -> Result<Option<Vec<u8>>, RkError> {
552    let Some(current) = read_relative(root, &candidate.destination)? else {
553        return Ok(None);
554    };
555    Ok(match candidate.placement {
556        Placement::Whole => Some(current),
557        Placement::Region { begin, end } => {
558            let text = String::from_utf8_lossy(&current);
559            super::extract_block(&text, begin, end).map(|block| block.as_bytes().to_vec())
560        }
561    })
562}
563
564/// Read every destination the landing leaves unwritten again, through
565/// the held directory, and digest it for the receipt: a preserved,
566/// drifted, or matched file must still be present, and a matched or
567/// adopted rendered value must still equal the candidate.
568///
569/// # Errors
570///
571/// A `state-drift` refusal naming the destination that moved since the
572/// decision, with nothing written; and any read failure.
573fn reverify(
574    root: &File,
575    prepared: &Prepared,
576    origin: Origin,
577) -> Result<std::collections::BTreeMap<String, Digest>, RkError> {
578    let mut digests = std::collections::BTreeMap::new();
579    for candidate in &prepared.projection.candidates {
580        let action = action_of(prepared, origin, &candidate.destination);
581        let must_match = match (origin, action) {
582            (Origin::Adopt, _) => candidate.kind == Kind::Rendered,
583            (_, Some(Action::Matched)) => true,
584            (_, Some(Action::Preserved | Action::Drift)) => false,
585            _ => continue,
586        };
587        let Some(current) = current_form(root, candidate)? else {
588            return Err(moved(&candidate.destination, "is no longer present"));
589        };
590        let expected: &[u8] = candidate.region.as_deref().unwrap_or(&candidate.bytes);
591        if must_match && current != expected {
592            return Err(moved(
593                &candidate.destination,
594                "no longer holds the candidate's bytes",
595            ));
596        }
597        digests.insert(candidate.destination.clone(), Digest::of(&current));
598    }
599    Ok(digests)
600}
601
602/// The refusal for a destination that changed between the decision and
603/// the first write.
604fn moved(destination: &str, what: &str) -> RkError {
605    RkError::refusal(
606        Diagnostic::new(
607            Reason::StateDrift,
608            format!(
609                "{destination} {what} since it was validated, and nothing was written; the previous receipt stands"
610            ),
611        )
612        .expected("every destination the landing leaves as it stands to stand still until the receipt is written")
613        .action("re-run once the target is at rest")
614        .target_state("unchanged"),
615    )
616}
617
618/// The digest the receipt carries for a written candidate: the whole
619/// file, or the marked region alone.
620fn candidate_digest(candidate: &Candidate) -> Digest {
621    candidate
622        .region
623        .as_deref()
624        .map_or_else(|| Digest::of(&candidate.bytes), Digest::of)
625}
626
627/// The receipt for this landing.
628fn receipt(
629    params: &Params,
630    recorded: Option<&Manifest>,
631    origin: Origin,
632    files: Vec<FileRecord>,
633) -> Manifest {
634    Manifest {
635        schema_version: manifest::SCHEMA_VERSION,
636        rk_version: env!("CARGO_PKG_VERSION").to_owned(),
637        origin: recorded.map_or_else(
638            || match origin {
639                Origin::Adopt => "adopt".to_owned(),
640                Origin::Init | Origin::Upgrade => "init".to_owned(),
641            },
642            |record| record.origin.clone(),
643        ),
644        tech: params.tech().to_owned(),
645        forge: params.forge().to_owned(),
646        landed_at: recorded.map_or_else(manifest::now, |record| record.landed_at.clone()),
647        parameters: Parameters {
648            repo: params.repo().to_owned(),
649            workflow: params.workflow(),
650            style: params.style(),
651            nix: params.nix(),
652            trunk: params.trunk().to_owned(),
653            line_prefix: params.line_prefix().to_owned(),
654            security_contact: params.security_contact().to_owned(),
655            security_response: params.security_response().to_owned(),
656        },
657        files,
658        pins: crate::registry::pins_for(params.tech())
659            .into_iter()
660            .map(|pin| (pin.name, pin.version))
661            .collect(),
662    }
663}
664
665/// The bytes at a relative path below the held root, read through the
666/// held directory chain, or `None` where nothing stands there.
667fn read_relative(root: &File, relative: &str) -> std::io::Result<Option<Vec<u8>>> {
668    let path = Path::new(relative);
669    let (parent, name) = split(path)?;
670    let dir = match held::hold_dir_existing(root, parent) {
671        Ok(dir) => dir,
672        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
673        Err(error) => return Err(error),
674    };
675    held::read_file(&dir, name)
676}
677
678/// A relative path split into its parent and its file name.
679fn split(path: &Path) -> std::io::Result<(&Path, &OsStr)> {
680    let name = path
681        .file_name()
682        .ok_or_else(|| std::io::Error::other(format!("{} has no file name", path.display())))?;
683    let parent = path.parent().unwrap_or_else(|| Path::new(""));
684    Ok((parent, name))
685}
686
687/// The writes of one landing, each through the held root, with the
688/// completed paths kept for the failure report.
689struct Writer<'a> {
690    root: &'a File,
691    completed: Vec<String>,
692    stop: Option<String>,
693}
694
695impl Writer<'_> {
696    /// Write `bytes` at the relative `destination` through the held
697    /// directory chain and a same-directory temporary file and rename.
698    fn write(&mut self, destination: &str, bytes: &[u8]) -> Result<(), RkError> {
699        let path = Path::new(destination);
700        let (parent, name) = split(path)?;
701        let outcome = held::hold_dir(self.root, parent).and_then(|dir| {
702            if self.stop.as_deref() == Some(destination) {
703                return Err(std::io::Error::other(
704                    "the rename was stopped here for the proof",
705                ));
706            }
707            held::write_file(&dir, name, bytes)
708        });
709        match outcome {
710            Ok(()) => {
711                self.completed.push(destination.to_owned());
712                Ok(())
713            }
714            Err(error) => Err(self.failure(destination, bytes, &error)),
715        }
716    }
717
718    /// The failure of a write that stopped the landing: the destination is
719    /// observed again, because a remote filesystem may have completed a
720    /// rename it reported as failed, and every completed path is named.
721    /// The previous receipt stands; Git holds the diff; a rerun lands the
722    /// rest.
723    fn failure(&self, destination: &str, bytes: &[u8], error: &std::io::Error) -> RkError {
724        let observed = match read_relative(self.root, destination) {
725            Ok(None) => "is absent".to_owned(),
726            Ok(Some(current)) if current == bytes => "holds the candidate bytes whole".to_owned(),
727            Ok(Some(_)) => "holds its previous bytes whole".to_owned(),
728            Err(again) => format!("could not be observed again: {again}"),
729        };
730        let completed = if self.completed.is_empty() {
731            "none".to_owned()
732        } else {
733            self.completed.join(", ")
734        };
735        RkError::Io(std::io::Error::new(
736            error.kind(),
737            format!(
738                "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"
739            ),
740        ))
741    }
742}
743
744#[cfg(test)]
745mod tests {
746    use super::{Action, Held, Origin, Prepared, decide, land, prepare};
747    use crate::landing::manifest::{self, Manifest};
748    use crate::landing::{Params, Style, lock};
749    use crate::projection::{Projection, ProjectionInput, TargetEvidence};
750
751    fn target() -> (tempfile::TempDir, camino::Utf8PathBuf) {
752        let dir = tempfile::tempdir().expect("a scratch target exists");
753        let path = camino::Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("utf-8");
754        (dir, path)
755    }
756
757    fn params() -> Params {
758        Params::for_test("acme/widget", Some(Style::Trunk))
759    }
760
761    fn prepared(target: &camino::Utf8Path, recorded: Option<&Manifest>) -> Prepared {
762        prepare(
763            &Held::open(target).expect("opens"),
764            recorded,
765            &params(),
766            None,
767        )
768        .expect("prepares")
769    }
770
771    fn landed(
772        target: &camino::Utf8Path,
773        recorded: Option<&Manifest>,
774        origin: Origin,
775    ) -> super::Landed {
776        let locks = tempfile::tempdir().expect("a scratch locks directory exists");
777        let lock = lock::acquire_in(locks.path(), target).expect("the target is taken");
778        let held = Held::open(target).expect("opens");
779        land(&held, recorded, &prepared(target, recorded), origin, &lock).expect("lands")
780    }
781
782    /// A fresh target: every candidate is created, the receipt is written
783    /// last at schema 7, and a rerun replaces the rendered files and
784    /// preserves the seeded ones from the receipt alone.
785    #[test]
786    fn a_fresh_landing_creates_and_a_rerun_decides_by_the_receipt() {
787        let (_dir, target) = target();
788        let first = prepared(&target, None);
789        assert!(first.collisions.is_empty(), "{:?}", first.collisions);
790        assert!(
791            first
792                .decisions
793                .iter()
794                .all(|decision| decision.action == Action::Created)
795        );
796        let outcome = landed(&target, None, Origin::Init);
797        assert_eq!(
798            outcome.completed.last().map(String::as_str),
799            Some(manifest::MANIFEST_PATH)
800        );
801        assert_eq!(outcome.receipt.schema_version, 7);
802        let record = manifest::load(&target).expect("loads").expect("exists");
803        let again = prepared(&target, Some(&record));
804        for decision in &again.decisions {
805            let expected = match decision.kind {
806                crate::landing::Kind::Rendered => Action::Replaced,
807                crate::landing::Kind::Seeded | crate::landing::Kind::State => Action::Preserved,
808            };
809            assert_eq!(decision.action, expected, "{}", decision.destination);
810        }
811    }
812
813    /// A whole file the receipt does not name, a non-regular entry, and a
814    /// document with a doubled marker are all collected in one pass, and
815    /// the refusal writes nothing.
816    #[test]
817    fn every_collision_is_collected_and_the_refusal_writes_nothing() {
818        let (_dir, target) = target();
819        std::fs::write(target.join("SECURITY.md"), "ours\n").expect("writes");
820        std::fs::create_dir_all(target.join("release-plz.toml")).expect("creates");
821        std::fs::write(
822            target.join("AGENTS.md"),
823            format!(
824                "{b}\n{e}\n{b}\n{e}\n",
825                b = crate::landing::BLOCK_BEGIN,
826                e = crate::landing::BLOCK_END
827            ),
828        )
829        .expect("writes");
830        let prepared = prepared(&target, None);
831        let paths: Vec<&str> = prepared
832            .collisions
833            .iter()
834            .map(|collision| collision.path.as_str())
835            .collect();
836        assert_eq!(paths, ["AGENTS.md", "SECURITY.md", "release-plz.toml"]);
837        let locks = tempfile::tempdir().expect("a scratch locks directory exists");
838        let lock = lock::acquire_in(locks.path(), &target).expect("the target is taken");
839        let held = Held::open(&target).expect("opens");
840        let refused =
841            land(&held, None, &prepared, Origin::Init, &lock).expect_err("the landing refuses");
842        assert_eq!(refused.exit_code(), 73);
843        assert!(!target.join(".release-kit").exists());
844        assert!(!target.join("dist-workspace.toml").exists());
845    }
846
847    /// A recorded destination the projection stops producing is released:
848    /// on disk, named, and out of the receipt.
849    #[test]
850    fn a_released_destination_stays_and_leaves_the_receipt() {
851        let (_dir, target) = target();
852        landed(&target, None, Origin::Init);
853        let mut record = manifest::load(&target).expect("loads").expect("exists");
854        std::fs::write(target.join("legacy.yml"), "old\n").expect("writes");
855        record.files.push(manifest::FileRecord {
856            destination: "legacy.yml".into(),
857            kind: crate::landing::Kind::Rendered,
858            sha256: crate::digest::Digest::of(b"old\n"),
859            placement: manifest::Placement::Whole,
860        });
861        let (decisions, _) = decide(
862            &Held::open(&target).expect("opens"),
863            Some(&record),
864            &Projection::compute(&ProjectionInput {
865                params: params(),
866                evidence: TargetEvidence::gather(&target, Some(&record)).expect("gathers"),
867            })
868            .expect("projects"),
869        )
870        .expect("decides");
871        let released = decisions
872            .iter()
873            .find(|decision| decision.destination == "legacy.yml")
874            .expect("the released destination is decided");
875        assert_eq!(released.action, Action::Released);
876        let outcome = landed(&target, Some(&record), Origin::Upgrade);
877        assert!(target.join("legacy.yml").is_file());
878        assert!(outcome.receipt.file("legacy.yml").is_none());
879    }
880}