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 refusal a licence condition answers, before any write.
408///
409/// A landing that guessed past it would write a workflow whose provider's
410/// terms the target's own licence does not permit, which is a licence
411/// violation this convention does not commit on a target's behalf.
412#[must_use]
413pub fn licence_refusal(reason: &str) -> RkError {
414    RkError::refusal(
415        Diagnostic::new(
416            Reason::StateDrift,
417            format!("the code scanning provider's licence condition is not satisfied, and nothing was written: {reason}"),
418        )
419        .expected("a provider whose terms the target's declared licence permits")
420        .action("pass --code-scanning semgrep, which carries no licence condition, or --code-scanning off")
421        .target_state("unchanged"),
422    )
423}
424
425/// The one refusal for every collision, before any write.
426///
427/// The verbs offer no force flag: an unattributed file becomes landable
428/// through the agent's migration alone, which brings the target to the
429/// projection or records it through `rk adopt`.
430#[must_use]
431pub fn refusal(target: &Utf8Path, collisions: &[Collision]) -> RkError {
432    let listed: Vec<String> = collisions
433        .iter()
434        .map(|collision| format!("{} ({})", collision.path, collision.reason))
435        .collect();
436    RkError::refusal(
437        Diagnostic::new(
438            Reason::StateDrift,
439            format!(
440                "these destinations cannot be landed as they stand, and nothing was written: {}",
441                listed.join("; ")
442            ),
443        )
444        .expected(
445            "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",
446        )
447        .action(format!(
448            "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"
449        ))
450        .target_state("unchanged"),
451    )
452}
453
454/// How a landing came to write its receipt.
455#[derive(Debug, Clone, Copy, PartialEq, Eq)]
456pub enum Origin {
457    /// A first landing: files and receipt.
458    Init,
459    /// A landing over a receipt: files and receipt, the first landing's
460    /// instant and origin preserved.
461    Upgrade,
462    /// A record of a target already at the projection: config and
463    /// receipt only.
464    Adopt,
465}
466
467/// What a landing completed.
468#[derive(Debug)]
469pub struct Landed {
470    /// Every path written, in order, the config and the receipt included.
471    pub completed: Vec<String>,
472    /// Whether the configuration was written, or already held its bytes.
473    pub config_written: bool,
474    /// The receipt as written.
475    pub receipt: Manifest,
476}
477
478/// Land `prepared` into `target`.
479///
480/// Refuse every collision first, open the target once under the lock the
481/// caller holds, write the configuration where its bytes changed, then
482/// each candidate by its decision, then the receipt.
483///
484/// The caller gathers under the same lock where it wants the evidence
485/// and the writes to agree; [`prepare`] itself takes none, so a preview
486/// holds nothing.
487///
488/// # Errors
489///
490/// The collision refusal at exit 73 with nothing written; the lock's
491/// refusals; and [`RkError::Io`] for a write that fails, naming every
492/// path completed before it, with the previous receipt left in place.
493pub fn land(
494    target: &Held,
495    recorded: Option<&Manifest>,
496    prepared: &Prepared,
497    origin: Origin,
498    _lock: &lock::TargetLock,
499) -> Result<Landed, RkError> {
500    if let Some(reason) = prepared.projection.licence_refusal.as_deref() {
501        return Err(licence_refusal(reason));
502    }
503    if !prepared.collisions.is_empty() {
504        return Err(refusal(target.display(), &prepared.collisions));
505    }
506    let root = &target.root;
507    // The proof's pause: validation is over and the target is held, so a
508    // link or a directory swapped in under the pathname from here on
509    // meets the held descriptor, not the path.
510    held::pause(PAUSE_VAR, "validated", "proceed");
511    // Every destination the landing does not write is read again through
512    // the held directory before the first write: a preserved or matched
513    // file must still stand, and an adopted value must still equal the
514    // candidate, or the landing refuses with the old receipt intact.
515    let unwritten = reverify(root, prepared, origin)?;
516    let mut writer = Writer {
517        root,
518        completed: Vec::new(),
519        stop: std::env::var_os(INTERRUPT_VAR).map(|value| value.to_string_lossy().into_owned()),
520    };
521    // The configuration first, where the resolved answers changed.
522    let config_current = read_relative(root, config::CONFIG_PATH)?;
523    let config_written = config_current.as_deref() != Some(prepared.config.content.as_bytes());
524    if config_written {
525        writer.write(config::CONFIG_PATH, prepared.config.content.as_bytes())?;
526    }
527    let mut files = Vec::new();
528    for candidate in &prepared.projection.candidates {
529        let sha256 = match unwritten.get(&candidate.destination) {
530            Some(digest) => digest.clone(),
531            None => match action_of(prepared, origin, &candidate.destination) {
532                Some(Action::Created | Action::Replaced) => {
533                    writer.write(&candidate.destination, &candidate.bytes)?;
534                    candidate_digest(candidate)
535                }
536                _ => continue,
537            },
538        };
539        files.push(FileRecord {
540            destination: candidate.destination.clone(),
541            kind: candidate.kind,
542            sha256,
543            placement: match candidate.placement {
544                Placement::Whole => manifest::Placement::Whole,
545                Placement::Region { .. } => manifest::Placement::Region,
546            },
547        });
548    }
549    let receipt = receipt(&prepared.params, recorded, origin, files);
550    writer.write(manifest::MANIFEST_PATH, &manifest::render(&receipt)?)?;
551    Ok(Landed {
552        completed: writer.completed,
553        config_written,
554        receipt,
555    })
556}
557
558/// What the landing does with one destination under `origin`: an
559/// adoption preserves everything it verified; a landing follows its
560/// decision, and a candidate without one was a collision the refusal
561/// already named.
562fn action_of(prepared: &Prepared, origin: Origin, destination: &str) -> Option<Action> {
563    match origin {
564        Origin::Adopt => Some(Action::Preserved),
565        Origin::Init | Origin::Upgrade => prepared.decision(destination).map(|d| d.action),
566    }
567}
568
569/// The recorded form of what a destination holds now, read through the
570/// held directory: the whole file, or the marked region alone; `None`
571/// where the file, or the region, is absent.
572fn current_form(root: &File, candidate: &Candidate) -> Result<Option<Vec<u8>>, RkError> {
573    let Some(current) = read_relative(root, &candidate.destination)? else {
574        return Ok(None);
575    };
576    Ok(match candidate.placement {
577        Placement::Whole => Some(current),
578        Placement::Region { begin, end } => {
579            let text = String::from_utf8_lossy(&current);
580            super::extract_block(&text, begin, end).map(|block| block.as_bytes().to_vec())
581        }
582    })
583}
584
585/// Read every destination the landing leaves unwritten again, through
586/// the held directory, and digest it for the receipt: a preserved,
587/// drifted, or matched file must still be present, and a matched or
588/// adopted rendered value must still equal the candidate.
589///
590/// # Errors
591///
592/// A `state-drift` refusal naming the destination that moved since the
593/// decision, with nothing written; and any read failure.
594fn reverify(
595    root: &File,
596    prepared: &Prepared,
597    origin: Origin,
598) -> Result<std::collections::BTreeMap<String, Digest>, RkError> {
599    let mut digests = std::collections::BTreeMap::new();
600    for candidate in &prepared.projection.candidates {
601        let action = action_of(prepared, origin, &candidate.destination);
602        let must_match = match (origin, action) {
603            (Origin::Adopt, _) => candidate.kind == Kind::Rendered,
604            (_, Some(Action::Matched)) => true,
605            (_, Some(Action::Preserved | Action::Drift)) => false,
606            _ => continue,
607        };
608        let Some(current) = current_form(root, candidate)? else {
609            return Err(moved(&candidate.destination, "is no longer present"));
610        };
611        let expected: &[u8] = candidate.region.as_deref().unwrap_or(&candidate.bytes);
612        if must_match && current != expected {
613            return Err(moved(
614                &candidate.destination,
615                "no longer holds the candidate's bytes",
616            ));
617        }
618        digests.insert(candidate.destination.clone(), Digest::of(&current));
619    }
620    Ok(digests)
621}
622
623/// The refusal for a destination that changed between the decision and
624/// the first write.
625fn moved(destination: &str, what: &str) -> RkError {
626    RkError::refusal(
627        Diagnostic::new(
628            Reason::StateDrift,
629            format!(
630                "{destination} {what} since it was validated, and nothing was written; the previous receipt stands"
631            ),
632        )
633        .expected("every destination the landing leaves as it stands to stand still until the receipt is written")
634        .action("re-run once the target is at rest")
635        .target_state("unchanged"),
636    )
637}
638
639/// The digest the receipt carries for a written candidate: the whole
640/// file, or the marked region alone.
641fn candidate_digest(candidate: &Candidate) -> Digest {
642    candidate
643        .region
644        .as_deref()
645        .map_or_else(|| Digest::of(&candidate.bytes), Digest::of)
646}
647
648/// The receipt for this landing.
649fn receipt(
650    params: &Params,
651    recorded: Option<&Manifest>,
652    origin: Origin,
653    files: Vec<FileRecord>,
654) -> Manifest {
655    Manifest {
656        schema_version: manifest::SCHEMA_VERSION,
657        rk_version: env!("CARGO_PKG_VERSION").to_owned(),
658        origin: recorded.map_or_else(
659            || match origin {
660                Origin::Adopt => "adopt".to_owned(),
661                Origin::Init | Origin::Upgrade => "init".to_owned(),
662            },
663            |record| record.origin.clone(),
664        ),
665        tech: params.tech().to_owned(),
666        forge: params.forge().to_owned(),
667        landed_at: recorded.map_or_else(manifest::now, |record| record.landed_at.clone()),
668        parameters: Parameters {
669            repo: params.repo().to_owned(),
670            workflow: params.workflow(),
671            style: params.style(),
672            nix: params.nix(),
673            scorecard: params.scorecard(),
674            code_scanning: params.code_scanning(),
675            trunk: params.trunk().to_owned(),
676            line_prefix: params.line_prefix().to_owned(),
677            security_contact: params.security_contact().to_owned(),
678            security_response: params.security_response().to_owned(),
679        },
680        files,
681        pins: crate::registry::pins_for(params.tech())
682            .into_iter()
683            .map(|pin| (pin.name, pin.version))
684            .collect(),
685    }
686}
687
688/// The bytes at a relative path below the held root, read through the
689/// held directory chain, or `None` where nothing stands there.
690fn read_relative(root: &File, relative: &str) -> std::io::Result<Option<Vec<u8>>> {
691    let path = Path::new(relative);
692    let (parent, name) = split(path)?;
693    let dir = match held::hold_dir_existing(root, parent) {
694        Ok(dir) => dir,
695        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
696        Err(error) => return Err(error),
697    };
698    held::read_file(&dir, name)
699}
700
701/// A relative path split into its parent and its file name.
702fn split(path: &Path) -> std::io::Result<(&Path, &OsStr)> {
703    let name = path
704        .file_name()
705        .ok_or_else(|| std::io::Error::other(format!("{} has no file name", path.display())))?;
706    let parent = path.parent().unwrap_or_else(|| Path::new(""));
707    Ok((parent, name))
708}
709
710/// The writes of one landing, each through the held root, with the
711/// completed paths kept for the failure report.
712struct Writer<'a> {
713    root: &'a File,
714    completed: Vec<String>,
715    stop: Option<String>,
716}
717
718impl Writer<'_> {
719    /// Write `bytes` at the relative `destination` through the held
720    /// directory chain and a same-directory temporary file and rename.
721    fn write(&mut self, destination: &str, bytes: &[u8]) -> Result<(), RkError> {
722        let path = Path::new(destination);
723        let (parent, name) = split(path)?;
724        let outcome = held::hold_dir(self.root, parent).and_then(|dir| {
725            if self.stop.as_deref() == Some(destination) {
726                return Err(std::io::Error::other(
727                    "the rename was stopped here for the proof",
728                ));
729            }
730            held::write_file(&dir, name, bytes)
731        });
732        match outcome {
733            Ok(()) => {
734                self.completed.push(destination.to_owned());
735                Ok(())
736            }
737            Err(error) => Err(self.failure(destination, bytes, &error)),
738        }
739    }
740
741    /// The failure of a write that stopped the landing: the destination is
742    /// observed again, because a remote filesystem may have completed a
743    /// rename it reported as failed, and every completed path is named.
744    /// The previous receipt stands; Git holds the diff; a rerun lands the
745    /// rest.
746    fn failure(&self, destination: &str, bytes: &[u8], error: &std::io::Error) -> RkError {
747        let observed = match read_relative(self.root, destination) {
748            Ok(None) => "is absent".to_owned(),
749            Ok(Some(current)) if current == bytes => "holds the candidate bytes whole".to_owned(),
750            Ok(Some(_)) => "holds its previous bytes whole".to_owned(),
751            Err(again) => format!("could not be observed again: {again}"),
752        };
753        let completed = if self.completed.is_empty() {
754            "none".to_owned()
755        } else {
756            self.completed.join(", ")
757        };
758        RkError::Io(std::io::Error::new(
759            error.kind(),
760            format!(
761                "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"
762            ),
763        ))
764    }
765}
766
767#[cfg(test)]
768mod tests {
769    use super::{Action, Held, Origin, Prepared, decide, land, prepare};
770    use crate::landing::manifest::{self, Manifest};
771    use crate::landing::{Params, Style, lock};
772    use crate::projection::{Projection, ProjectionInput, TargetEvidence};
773
774    fn target() -> (tempfile::TempDir, camino::Utf8PathBuf) {
775        let dir = tempfile::tempdir().expect("a scratch target exists");
776        let path = camino::Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("utf-8");
777        (dir, path)
778    }
779
780    fn params() -> Params {
781        Params::for_test("acme/widget", Some(Style::Trunk))
782    }
783
784    fn prepared(target: &camino::Utf8Path, recorded: Option<&Manifest>) -> Prepared {
785        prepare(
786            &Held::open(target).expect("opens"),
787            recorded,
788            &params(),
789            None,
790        )
791        .expect("prepares")
792    }
793
794    fn landed(
795        target: &camino::Utf8Path,
796        recorded: Option<&Manifest>,
797        origin: Origin,
798    ) -> super::Landed {
799        let locks = tempfile::tempdir().expect("a scratch locks directory exists");
800        let lock = lock::acquire_in(locks.path(), target).expect("the target is taken");
801        let held = Held::open(target).expect("opens");
802        land(&held, recorded, &prepared(target, recorded), origin, &lock).expect("lands")
803    }
804
805    /// A fresh target: every candidate is created, the receipt is written
806    /// last at schema 7, and a rerun replaces the rendered files and
807    /// preserves the seeded ones from the receipt alone.
808    #[test]
809    fn a_fresh_landing_creates_and_a_rerun_decides_by_the_receipt() {
810        let (_dir, target) = target();
811        let first = prepared(&target, None);
812        assert!(first.collisions.is_empty(), "{:?}", first.collisions);
813        assert!(
814            first
815                .decisions
816                .iter()
817                .all(|decision| decision.action == Action::Created)
818        );
819        let outcome = landed(&target, None, Origin::Init);
820        assert_eq!(
821            outcome.completed.last().map(String::as_str),
822            Some(manifest::MANIFEST_PATH)
823        );
824        assert_eq!(outcome.receipt.schema_version, 8);
825        let record = manifest::load(&target).expect("loads").expect("exists");
826        let again = prepared(&target, Some(&record));
827        for decision in &again.decisions {
828            let expected = match decision.kind {
829                crate::landing::Kind::Rendered => Action::Replaced,
830                crate::landing::Kind::Seeded | crate::landing::Kind::State => Action::Preserved,
831            };
832            assert_eq!(decision.action, expected, "{}", decision.destination);
833        }
834    }
835
836    /// A whole file the receipt does not name, a non-regular entry, and a
837    /// document with a doubled marker are all collected in one pass, and
838    /// the refusal writes nothing.
839    #[test]
840    fn every_collision_is_collected_and_the_refusal_writes_nothing() {
841        let (_dir, target) = target();
842        std::fs::write(target.join("SECURITY.md"), "ours\n").expect("writes");
843        std::fs::create_dir_all(target.join("release-plz.toml")).expect("creates");
844        std::fs::write(
845            target.join("AGENTS.md"),
846            format!(
847                "{b}\n{e}\n{b}\n{e}\n",
848                b = crate::landing::BLOCK_BEGIN,
849                e = crate::landing::BLOCK_END
850            ),
851        )
852        .expect("writes");
853        let prepared = prepared(&target, None);
854        let paths: Vec<&str> = prepared
855            .collisions
856            .iter()
857            .map(|collision| collision.path.as_str())
858            .collect();
859        assert_eq!(paths, ["AGENTS.md", "SECURITY.md", "release-plz.toml"]);
860        let locks = tempfile::tempdir().expect("a scratch locks directory exists");
861        let lock = lock::acquire_in(locks.path(), &target).expect("the target is taken");
862        let held = Held::open(&target).expect("opens");
863        let refused =
864            land(&held, None, &prepared, Origin::Init, &lock).expect_err("the landing refuses");
865        assert_eq!(refused.exit_code(), 73);
866        assert!(!target.join(".release-kit").exists());
867        assert!(!target.join("dist-workspace.toml").exists());
868    }
869
870    /// A recorded destination the projection stops producing is released:
871    /// on disk, named, and out of the receipt.
872    #[test]
873    fn a_released_destination_stays_and_leaves_the_receipt() {
874        let (_dir, target) = target();
875        landed(&target, None, Origin::Init);
876        let mut record = manifest::load(&target).expect("loads").expect("exists");
877        std::fs::write(target.join("legacy.yml"), "old\n").expect("writes");
878        record.files.push(manifest::FileRecord {
879            destination: "legacy.yml".into(),
880            kind: crate::landing::Kind::Rendered,
881            sha256: crate::digest::Digest::of(b"old\n"),
882            placement: manifest::Placement::Whole,
883        });
884        let (decisions, _) = decide(
885            &Held::open(&target).expect("opens"),
886            Some(&record),
887            &Projection::compute(&ProjectionInput {
888                params: params(),
889                evidence: TargetEvidence::gather(&target, Some(&record)).expect("gathers"),
890            })
891            .expect("projects"),
892        )
893        .expect("decides");
894        let released = decisions
895            .iter()
896            .find(|decision| decision.destination == "legacy.yml")
897            .expect("the released destination is decided");
898        assert_eq!(released.action, Action::Released);
899        let outcome = landed(&target, Some(&record), Origin::Upgrade);
900        assert!(target.join("legacy.yml").is_file());
901        assert!(outcome.receipt.file("legacy.yml").is_none());
902    }
903}