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